API/src/service/logistics.service.ts

696 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 { 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';
import { UniExpressService } from './uni_express.service';
import { StockPoint } from '../entity/stock_point.entity';
import { OrderService } from './order.service';
@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(StockPoint)
stockPointModel: Repository<StockPoint>
@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()
uniExpressService: UniExpressService;
@Inject()
wpService: WPService;
@Inject()
orderService: OrderService;
@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 updateShipmentState(shipment: Shipment) {
try {
const data = await this.uniExpressService.getOrderStatus(shipment.return_tracking_number);
shipment.state = data.data[0].state;
if (shipment.state in [203, 215, 216, 230]) { // todo,写常数
shipment.finished = true;
}
this.shipmentModel.save(shipment);
return shipment.state;
} catch (error) {
throw new Error(`更新运单状态失败 ${error.message}`);
}
}
async updateShipmentStateById(id: number) {
const shipment:Shipment = await this.shipmentModel.findOneBy({ id : id });
return this.updateShipmentState(shipment);
}
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 getShipmentLabel(shipmentId) {
try {
const shipment:Shipment = await this.shipmentModel.findOneBy({id: shipmentId});
if (!shipment) {
throw new Error('运单不存在');
}
return await this.uniExpressService.getLabel(shipment.return_tracking_number);
} catch (e) {
throw new Error('获取运单失败');
}
}
async removeShipment(shipmentId: number) {
try {
const shipment:Shipment = await this.shipmentModel.findOneBy({id: shipmentId});
if (shipment.state !== '190') { // todo写常数
throw new Error('订单当前状态无法删除');
}
const order:Order = await this.orderModel.findOneBy({id: shipment.order_id});
const dataSource = this.dataSourceManager.getDataSource('default');
let transactionError = undefined;
await dataSource.transaction(async manager => {
const orderRepo = manager.getRepository(Order);
const shipmentRepo = manager.getRepository(Shipment);
order.shipmentId = null;
orderRepo.save(order);
shipmentRepo.remove(shipment);
const res = await this.uniExpressService.deleteShipment(shipment.return_tracking_number);
console.log('res', res.data); // todo
await orderRepo.save(order);
}).catch(error => {
transactionError = error;
});
if (transactionError !== undefined) {
throw new Error(`数据库同步错误: ${transactionError.message}`);
}
try {
// 同步订单状态到woocommerce
const site = await this.geSite(order.siteId);
if (order.status === OrderStatus.COMPLETED) {
await this.wpService.updateOrder(site, order.externalOrderId, {
status: OrderStatus.PROCESSING,
});
order.status = OrderStatus.PROCESSING;
}
order.orderStatus = ErpOrderStatus.PROCESSING;
this.orderModel.save(order);
// todo 同步到wooccommerce删除运单信息
await this.wpService.deleteShipment(site, order.externalOrderId, shipment.tracking_id);
} catch (error) {
console.log('同步到woocommerce失败', error);
return true;
}
return true;
} catch {
throw new Error('删除运单失败');
}
}
async getShipmentFee(data: ShipmentBookDTO) {
try {
const stock_point = await this.stockPointModel.findOneBy({ id: data.stockPointId});
const reqBody = {
sender: data.details.origin.contact_name,
start_phone: data.details.origin.phone_number,
start_postal_code: data.details.origin.address.postal_code.replace(/\s/g, ''),
pickup_address: data.details.origin.address.address_line_1,
pickup_warehouse: stock_point.upStreamStockPointId,
shipper_country_code: data.details.origin.address.country,
receiver: data.details.destination.contact_name,
city: data.details.destination.address.city,
province: data.details.destination.address.region,
country: data.details.destination.address.country,
postal_code: data.details.destination.address.postal_code.replace(/\s/g, ''),
delivery_address: data.details.destination.address.address_line_1,
receiver_phone: data.details.destination.phone_number.number,
receiver_email: data.details.destination.email_addresses,
// item_description: data.sales, // todo: 货品信息
length: data.details.packaging_properties.packages[0].measurements.cuboid.l,
width: data.details.packaging_properties.packages[0].measurements.cuboid.w,
height: data.details.packaging_properties.packages[0].measurements.cuboid.h,
dimension_uom: data.details.packaging_properties.packages[0].measurements.cuboid.unit,
weight: data.details.packaging_properties.packages[0].measurements.weight.value,
weight_uom: data.details.packaging_properties.packages[0].measurements.weight.unit,
currency: 'CAD',
}
const resShipmentFee = await this.uniExpressService.getRates(reqBody);
return resShipmentFee.data.totalAfterTax * 100;
} catch (e) {
throw e;
}
}
async createShipment(orderId: number, data: ShipmentBookDTO, userId: number) {
const order = await this.orderModel.findOneBy({ id: orderId });
const { sales } = data;
if (!order) {
throw new Error('订单不存在');
}
if (
order.orderStatus !== ErpOrderStatus.PROCESSING &&
order.orderStatus !== ErpOrderStatus.PENDING_RESHIPMENT
) {
throw new Error('订单状态不正确 ');
}
let resShipmentOrder;
try {
const stock_point = await this.stockPointModel.findOneBy({ id: data.stockPointId});
const reqBody = {
sender: data.details.origin.contact_name,
start_phone: data.details.origin.phone_number,
start_postal_code: data.details.origin.address.postal_code.replace(/\s/g, ''),
pickup_address: data.details.origin.address.address_line_1,
pickup_warehouse: stock_point.upStreamStockPointId,
shipper_country_code: data.details.origin.address.country,
receiver: data.details.destination.contact_name,
city: data.details.destination.address.city,
province: data.details.destination.address.region,
country: data.details.destination.address.country,
postal_code: data.details.destination.address.postal_code.replace(/\s/g, ''),
delivery_address: data.details.destination.address.address_line_1,
receiver_phone: data.details.destination.phone_number.number,
receiver_email: data.details.destination.email_addresses,
// item_description: data.sales, // todo: 货品信息
length: data.details.packaging_properties.packages[0].measurements.cuboid.l,
width: data.details.packaging_properties.packages[0].measurements.cuboid.w,
height: data.details.packaging_properties.packages[0].measurements.cuboid.h,
dimension_uom: data.details.packaging_properties.packages[0].measurements.cuboid.unit,
weight: data.details.packaging_properties.packages[0].measurements.weight.value,
weight_uom: data.details.packaging_properties.packages[0].measurements.weight.unit,
currency: 'CAD',
custom_field: {
'order_id': order.externalOrderId
}
}
// 添加运单
resShipmentOrder = await this.uniExpressService.createShipment(reqBody);
// 记录物流信息,并将订单状态转到完成
if (resShipmentOrder.status === 'SUCCESS') {
order.orderStatus = ErpOrderStatus.COMPLETED;
} else {
throw new Error('运单生成失败');
}
const dataSource = this.dataSourceManager.getDataSource('default');
let transactionError = undefined;
let shipmentId = undefined;
await dataSource.transaction(async manager => {
const orderRepo = manager.getRepository(Order);
const shipmentRepo = manager.getRepository(Shipment);
const tracking_provider = 'UniUni'; // todo: id未确定后写进常数
// 同步物流信息到woocommerce
const site = await this.geSite(order.siteId);
const res = await this.wpService.createShipment(site, order.externalOrderId, {
tracking_number: resShipmentOrder.data.tno,
tracking_provider: tracking_provider,
});
if (order.orderStatus === ErpOrderStatus.COMPLETED) {
const shipment = await shipmentRepo.save({
tracking_provider: tracking_provider,
tracking_id: res.data.tracking_id,
unique_id: resShipmentOrder.data.uni_order_sn,
stockPointId: String(data.stockPointId), // todo
state: resShipmentOrder.data.uni_status_code,
return_tracking_number: resShipmentOrder.data.tno,
fee: data.details.shipmentFee,
order: order
});
order.shipmentId = shipment.id;
shipmentId = shipment.id;
}
// 同步订单状态到woocommerce
if (order.status !== OrderStatus.COMPLETED) {
await this.wpService.updateOrder(site, order.externalOrderId, {
status: OrderStatus.COMPLETED,
});
order.status = OrderStatus.COMPLETED;
}
order.orderStatus = ErpOrderStatus.COMPLETED;
await orderRepo.save(order);
}).catch(error => {
transactionError = error
});
if (transactionError !== undefined) {
console.log('err', transactionError);
throw transactionError;
}
// 更新产品发货信息
this.orderService.updateOrderSales(order.id, sales);
return { data: {
shipmentId
} };
} catch(error) {
if (resShipmentOrder.status === 'SUCCESS') {
await this.uniExpressService.deleteShipment(resShipmentOrder.data.tno);
}
throw new Error(`上游请求错误:${error}`);
}
}
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: number) {
const orderShipments:OrderShipment[] = 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: number, 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: number) {
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,
return_tracking_number,
stockPointId,
externalOrderId,
} = param;
const offset = pageSize * (current - 1);
const values: any[] = [];
let whereClause = 'WHERE 1=1';
if (return_tracking_number) {
whereClause += ' AND s.return_tracking_number LIKE ?';
values.push(`%${return_tracking_number}%`);
}
if (stockPointId) {
whereClause += ' AND sp.id = ?';
values.push(stockPointId);
}
// todo增加订单号搜索
if (externalOrderId) {
whereClause += ' AND o.externalOrderId = ?';
values.push(externalOrderId);
}
const sql = `
SELECT s.*, sp.name, o.externalOrderId, o.siteId
FROM shipment s
LEFT JOIN \`order\` o ON s.order_id = o.id
LEFT JOIN stock_point sp ON s.stock_point_id = sp.id
${whereClause}
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\` o ON s.order_id = o.id
LEFT JOIN stock_point sp ON s.stock_point_id = sp.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 };
}
}