API/src/service/logistics.service.ts

595 lines
19 KiB
TypeScript

import { Config, Inject, Provide, sleep } from '@midwayjs/core';
import { InjectEntityModel, TypeORMDataSourceManager } from '@midwayjs/typeorm';
import { Service } from '../entity/service.entity';
import { In, IsNull, Like, Repository } from 'typeorm';
import { ShippingAddress } from '../entity/shipping_address.entity';
// import { ShipmentBookDTO } from '../dto/logistics.dto';
import { Order } from '../entity/order.entity';
import { Shipment } from '../entity/shipment.entity';
import { ShipmentItem } from '../entity/shipment_item.entity';
import { OrderShipment } from '../entity/order_shipment.entity';
import { QueryServiceDTO, ShipmentBookDTO } from '../dto/logistics.dto';
import {
ErpOrderStatus,
OrderStatus,
ShipmentType,
StockRecordOperationType,
} from '../enums/base.enum';
import { generateUniqueId } from '../utils/helper.util';
import { FreightcomService } from './freightcom.service';
import { StockRecord } from '../entity/stock_record.entity';
import { Stock } from '../entity/stock.entity';
import { plainToClass } from 'class-transformer';
import { WPService } from './wp.service';
import { WpSite } from '../interface';
import { Product } from '../entity/product.entty';
import { ShippingDetailsDTO } from '../dto/freightcom.dto';
import { CanadaPostService } from './canadaPost.service';
import { OrderItem } from '../entity/order_item.entity';
import { OrderSale } from '../entity/order_sale.entity';
@Provide()
export class LogisticsService {
@InjectEntityModel(Service)
serviceModel: Repository<Service>;
@InjectEntityModel(ShippingAddress)
shippingAddressModel: Repository<ShippingAddress>;
@InjectEntityModel(Stock)
stockModel: Repository<Stock>;
@InjectEntityModel(Order)
orderModel: Repository<Order>;
@InjectEntityModel(OrderSale)
orderSaleModel: Repository<OrderSale>;
@InjectEntityModel(Shipment)
shipmentModel: Repository<Shipment>;
@InjectEntityModel(ShipmentItem)
shipmentItemModel: Repository<ShipmentItem>;
@InjectEntityModel(OrderShipment)
orderShipmentModel: Repository<OrderShipment>;
@InjectEntityModel(OrderItem)
orderItem: Repository<OrderItem>;
@Inject()
freightcomService: FreightcomService;
@Inject()
canadaPostService: CanadaPostService;
@Inject()
wpService: WPService;
@Inject()
dataSourceManager: TypeORMDataSourceManager;
@Config('wpSite')
sites: WpSite[];
geSite(id: string): WpSite {
let idx = this.sites.findIndex(item => item.id === id);
return this.sites[idx];
}
async getServiceList(param: QueryServiceDTO) {
const { pageSize, current, carrier_name, isActive } = param;
const where: Record<string, any> = {};
if (carrier_name) where.carrier_name = Like(`%${carrier_name}%`);
if (isActive !== undefined) where.isActive = isActive;
const [items, total] = await this.serviceModel.findAndCount({
where,
skip: (current - 1) * pageSize,
take: pageSize,
});
return { items, total, current, pageSize };
}
async toggleServiceActive(id: string, isActive: boolean) {
const service = await this.serviceModel.findOne({ where: { id } });
if (!service) {
throw new Error('服务商不存在');
}
service.isActive = isActive;
return this.serviceModel.save(service);
}
async getActiveServices() {
const services = await this.serviceModel.find({
where: { isActive: true },
});
if (!services) {
return [];
}
return services.map(service => service.id);
}
async createShippingAddress(shippingAddress: ShippingAddress) {
return await this.shippingAddressModel.save(shippingAddress);
}
async updateShippingAddress(id: number, shippingAddress: ShippingAddress) {
const address = await this.shippingAddressModel.findOneBy({ id });
if (!address) {
throw new Error(`发货地址 ID ${id} 不存在`);
}
await this.shippingAddressModel.update(id, shippingAddress);
return await this.shippingAddressModel.findOneBy({ id });
}
async getShippingAddressList() {
return await this.shippingAddressModel.find();
}
async delShippingAddress(id: number) {
const address = await this.shippingAddressModel.findOneBy({ id });
if (!address) {
throw new Error(`发货地址 ID ${id} 不存在`);
}
const result = await this.shippingAddressModel.delete(id);
return result.affected > 0;
}
// async saveTracking(
// orderId: number,
// shipment: Record<string, any>,
// data: ShipmentBookDTO
// ) {
// const order = await this.orderModel.findOneBy({ id: orderId });
// const orderTracking = this.orderTrackingModel.save({
// orderId: String(orderId),
// siteId: order.siteId,
// externalOrderId: order.externalOrderId,
// });
// }
async getRateList(details: ShippingDetailsDTO) {
details.destination.address.country = 'CA';
details.origin.address.country = 'CA';
const { request_id } = await this.freightcomService.getRateEstimate(
details
);
const rateRequest = {
'customer-number': this.canadaPostService.customerNumber,
'parcel-characteristics': {
weight: details?.packaging_properties?.packages?.reduce(
(cur, next) => cur + (next?.measurements?.weight?.value || 0),
0
),
},
'origin-postal-code': details.origin.address.postal_code.replace(
/\s/g,
''
),
destination: {
domestic: {
'postal-code': details.destination.address.postal_code.replace(
/\s/g,
''
),
},
},
};
const canadaPostRates = await this.canadaPostService.getRates(rateRequest);
await sleep(3000);
const rates = await this.freightcomService.getRates(request_id);
return [...rates, ...canadaPostRates];
}
async createShipment(orderId: number, data: ShipmentBookDTO, userId: number) {
const order = await this.orderModel.findOneBy({ id: orderId });
if (!order) {
throw new Error('订单不存在');
}
if (
order.orderStatus !== ErpOrderStatus.PROCESSING &&
order.orderStatus !== ErpOrderStatus.PENDING_RESHIPMENT
) {
throw new Error('订单状态不正确 ');
}
// for (const item of data?.sales) {
// const stock = await this.stockModel.findOne({
// where: {
// stockPointId: data.stockPointId,
// productSku: item.sku,
// },
// });
// if (!stock || stock.quantity < item.quantity)
// throw new Error(item.name + '库存不足');
// }
let shipment: Shipment;
if (data.service_type === ShipmentType.FREIGHTCOM) {
const uuid = generateUniqueId();
data.details.reference_codes = [String(orderId)];
const { id } = await this.freightcomService.createShipment({
unique_id: uuid,
payment_method_id: data.payment_method_id,
service_id: data.service_id,
details: data.details,
});
const service = await this.serviceModel.findOneBy({
id: data.service_id,
});
shipment = {
id,
unique_id: uuid,
tracking_provider: service?.carrier_name || '',
};
} else if (data.service_type === ShipmentType.CANADAPOST) {
const shipmentRequest = {
'transmit-shipment': true,
'requested-shipping-point':
data.details.origin.address.postal_code.replace(/\s/g, ''),
'delivery-spec': {
'service-code': data.service_id,
sender: {
name: data.details.origin.name,
company: data.details.origin.name,
'contact-phone': data.details.origin.phone_number.number,
'address-details': {
'address-line-1': data.details.origin.address.address_line_1,
city: data.details.origin.address.city,
'prov-state': data.details.origin.address.region,
'postal-zip-code':
data.details.origin.address.postal_code.replace(/\s/g, ''),
'country-code': data.details.origin.address.country,
},
},
destination: {
name: data.details.destination.contact_name,
company: data.details.destination.name,
'address-details': {
'address-line-1': data.details.destination.address.address_line_1,
city: data.details.destination.address.city,
'prov-state': data.details.destination.address.region,
'postal-zip-code':
data.details.destination.address.postal_code.replace(/\s/g, ''),
'country-code': data.details.destination.address.country,
},
},
'parcel-characteristics': {
weight: data.details.packaging_properties.packages?.reduce(
(cur, next) => cur + (next?.measurements?.weight?.value || 0),
0
),
},
preferences: {
'show-packing-instructions': true,
},
'settlement-info': {
'contract-id': this.canadaPostService.contractId,
'intended-method-of-payment': 'CreditCard',
},
},
};
shipment = await this.canadaPostService.createShipment(shipmentRequest);
}
shipment.type = data.service_type;
const dataSource = this.dataSourceManager.getDataSource('default');
return dataSource.transaction(async manager => {
const productRepo = manager.getRepository(Product);
const shipmentRepo = manager.getRepository(Shipment);
const shipmentItemRepo = manager.getRepository(ShipmentItem);
const orderShipmentRepo = manager.getRepository(OrderShipment);
const stockRecordRepo = manager.getRepository(StockRecord);
const stockRepo = manager.getRepository(Stock);
const orderRepo = manager.getRepository(Order);
await shipmentRepo.save(shipment);
await this.getShipment(shipment.id);
const shipmentItems = [];
for (const item of data?.sales) {
const product = await productRepo.findOne({ where: { sku: item.sku } });
shipmentItems.push({
shipment_id: shipment.id,
productId: product.id,
name: product.name,
sku: item.sku,
quantity: item.quantity,
});
const stock = await stockRepo.findOne({
where: {
stockPointId: data.stockPointId,
productSku: item.sku,
},
});
stock.quantity -= item.quantity;
await stockRepo.save(stock);
await stockRecordRepo.save({
stockPointId: data.stockPointId,
productSku: item.sku,
operationType: StockRecordOperationType.OUT,
quantityChange: item.quantity,
operatorId: userId,
note: `订单${[orderId, ...data.orderIds].join(',')} 发货`,
});
}
await shipmentItemRepo.save(shipmentItems);
await orderShipmentRepo.save({
order_id: orderId,
shipment_id: shipment.id,
stockPointId: data.stockPointId,
});
for (const orderId of data?.orderIds) {
await orderShipmentRepo.save({
order_id: orderId,
shipment_id: shipment.id,
stockPointId: data.stockPointId,
});
const order = await orderRepo.findOneBy({ id: orderId });
order.orderStatus = ErpOrderStatus.COMPLETED;
order.status = OrderStatus.COMPLETED;
await orderRepo.save(order);
}
order.orderStatus = ErpOrderStatus.COMPLETED;
order.status = OrderStatus.COMPLETED;
await orderRepo.save(order);
});
}
async syncShipment() {
try {
const shipments = await this.shipmentModel.find({
where: {
primary_tracking_number: IsNull(),
},
});
if (!shipments) return;
for (const shipment of shipments) {
await this.getShipment(shipment.id);
}
} catch (error) {
console.log('syncShipment', error);
}
}
async syncShipmentStatus() {
const shipments = await this.shipmentModel.find({
where: {
state: In([
'draft',
'waiting-for-transit',
'waiting-for-scheduling',
'in-transit',
]),
},
});
if (!shipments) return;
for (const item of shipments) {
try {
let res;
if (item.type === ShipmentType.FREIGHTCOM) {
res = await this.freightcomService.getShipment(item.id);
} else if (item.type === ShipmentType.CANADAPOST) {
res = await this.canadaPostService.getShipment(
item.primary_tracking_number
);
res.shipment.id = item.id;
}
if (!res) return;
const shipment = plainToClass(Shipment, res.shipment);
this.shipmentModel.save(shipment);
} catch (error) {
console.log('syncShipmentStatus error');
}
}
}
async getShipment(id: string) {
const orderShipments = await this.orderShipmentModel.find({
where: { shipment_id: id },
});
if (!orderShipments || orderShipments.length === 0) return;
const oldShipment = await this.shipmentModel.findOneBy({ id });
let res;
if (oldShipment.type === ShipmentType.FREIGHTCOM) {
res = await this.freightcomService.getShipment(id);
} else if (oldShipment.type === ShipmentType.CANADAPOST) {
res = await this.canadaPostService.getShipment(
oldShipment.primary_tracking_number
);
res.shipment.id = oldShipment.id;
}
if (!res) return;
const shipment = plainToClass(Shipment, res.shipment);
await this.shipmentModel.save(shipment);
for (const orderShipment of orderShipments) {
const order = await this.orderModel.findOneBy({
id: orderShipment.order_id,
});
const site = this.geSite(order.siteId);
await this.wpService.updateOrder(site, order.externalOrderId, {
status: OrderStatus.COMPLETED,
});
await this.wpService.createShipment(site, order.externalOrderId, {
tracking_number: shipment.primary_tracking_number,
tracking_provider: shipment?.rate?.carrier_name,
});
}
}
async delShipment(id: string, userId: number) {
const shipment = await this.shipmentModel.findOneBy({ id });
if (!shipment) throw new Error('物流不存在');
if (shipment.type === ShipmentType.FREIGHTCOM) {
await this.freightcomService.cancelShipment(shipment.id);
} else if (shipment.type === ShipmentType.CANADAPOST) {
await this.canadaPostService.cancelShipment(shipment.id);
}
const dataSource = this.dataSourceManager.getDataSource('default');
return dataSource.transaction(async manager => {
const shipmentRepo = manager.getRepository(Shipment);
const shipmentItemRepo = manager.getRepository(ShipmentItem);
const orderShipmentRepo = manager.getRepository(OrderShipment);
const stockRecordRepo = manager.getRepository(StockRecord);
const stockRepo = manager.getRepository(Stock);
const orderRepo = manager.getRepository(Order);
const orderShipments = await orderShipmentRepo.findBy({
shipment_id: id,
});
const shipmentItems = await shipmentItemRepo.findBy({ shipment_id: id });
await shipmentRepo.delete({ id });
await shipmentItemRepo.delete({ shipment_id: id });
await orderShipmentRepo.delete({ shipment_id: id });
for (const item of shipmentItems) {
const stock = await stockRepo.findOne({
where: {
stockPointId: orderShipments[0].stockPointId,
productSku: item.sku,
},
});
stock.quantity += item.quantity;
await stockRepo.save(stock);
await stockRecordRepo.save({
stockPointId: orderShipments[0].stockPointId,
productSku: item.sku,
operationType: StockRecordOperationType.IN,
quantityChange: item.quantity,
operatorId: userId,
note: `订单${orderShipments.map(v => v.order_id).join(',')} 取消发货`,
});
}
await orderRepo.update(
{ id: In(orderShipments.map(v => v.order_id)) },
{
orderStatus: ErpOrderStatus.PENDING_RESHIPMENT,
}
);
});
}
async getTrackingNumber(number: string) {
const orders = await this.orderModel.find({
where: {
externalOrderId: Like(`%${number}%`),
},
});
const siteMap = new Map(this.sites.map(site => [site.id, site.siteName]));
return orders.map(order => ({
...order,
siteName: siteMap.get(order.siteId) || '',
}));
}
async getListByTrackingId(id: string) {
const qb = `
SELECT
oi.name,
oi.quantity,
CASE
WHEN oi.externalVariationId != 0 THEN v.constitution
ELSE p.constitution
END AS constitution
FROM order_item oi
LEFT JOIN wp_product p ON oi.siteId=p.siteId AND oi.externalProductId=p.externalProductId
LEFT JOIN variation v ON oi.siteId=v.siteId AND oi.externalVariationId=v.externalVariationId
WHERE oi.orderId=?
`;
const saleItem = await this.orderSaleModel.query(qb, [id]);
const allSkus = new Set<string>();
for (const item of saleItem) {
if (!item.constitution) continue;
try {
item.constitution.forEach(c => allSkus.add(c.sku));
} catch (e) {
console.warn('Invalid constitution JSON:', item.constitution);
}
}
console.log(allSkus);
const skuList = Array.from(allSkus);
let skuNameMap = new Map<string, string>();
if (skuList.length > 0) {
const placeholders = skuList.map(() => '?').join(', ');
const productRows = await this.orderSaleModel.query(
`SELECT sku, name FROM product WHERE sku IN (${placeholders})`,
skuList
);
skuNameMap = new Map(productRows.map(p => [p.sku, p.name]));
}
for (const item of saleItem) {
if (!item.constitution) continue;
try {
item.constitution = item.constitution.map(c => ({
...c,
name: skuNameMap.get(c.sku) || null,
}));
} catch (e) {
item.constitution = [];
}
}
return saleItem;
}
async getList(param: Record<string, any>) {
const {
pageSize = 10,
current = 1,
primary_tracking_number,
stockPointId,
} = param;
console.log(pageSize, current, primary_tracking_number, stockPointId);
const offset = pageSize * (current - 1);
const values: any[] = [];
let whereClause = 'WHERE 1=1';
if (primary_tracking_number) {
whereClause += ' AND s.primary_tracking_number LIKE ?';
values.push(`%${primary_tracking_number}%`);
}
if (stockPointId) {
whereClause += ' AND os.stockPointId = ?';
values.push(stockPointId);
}
const sql = `
SELECT s.*, sp.name
FROM shipment s
LEFT JOIN order_shipment os ON s.id = os.shipment_id
LEFT JOIN stock_point sp ON os.stockPointId = sp.id
${whereClause}
GROUP BY s.id
ORDER BY s.createdAt DESC
LIMIT ?, ?
`;
values.push(offset, Number(pageSize));
const items = await this.serviceModel.query(sql, values);
// 单独计算总数
const countSql = `
SELECT COUNT(DISTINCT s.id) as total
FROM shipment s
LEFT JOIN order_shipment os ON s.id = os.shipment_id
${whereClause}
`;
const countResult = await this.serviceModel.query(
countSql,
values.slice(0, values.length - 2)
);
const total = countResult[0]?.total || 0;
return { items, total, current, pageSize };
}
}