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'; @Provide() export class LogisticsService { @InjectEntityModel(Service) serviceModel: Repository; @InjectEntityModel(ShippingAddress) shippingAddressModel: Repository; @InjectEntityModel(Stock) stockModel: Repository; @InjectEntityModel(Order) orderModel: Repository; @InjectEntityModel(StockPoint) stockPointModel: Repository @InjectEntityModel(OrderSale) orderSaleModel: Repository; @InjectEntityModel(Shipment) shipmentModel: Repository; @InjectEntityModel(ShipmentItem) shipmentItemModel: Repository; @InjectEntityModel(OrderShipment) orderShipmentModel: Repository; @InjectEntityModel(OrderItem) orderItem: Repository; @Inject() freightcomService: FreightcomService; @Inject() canadaPostService: CanadaPostService; @Inject() uniExpressService: UniExpressService; @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 = {}; 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, // 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) { try { const shipment:Shipment = await this.shipmentModel.findOneBy({id: shipmentId}); const order:Order = await this.orderModel.findOneBy({id: shipment.order_id}); // todo 同步到wooccommerce删除运单信息 const site = await this.geSite(order.siteId); await this.wpService.deleteShipment(site, order.externalOrderId, shipment.tracking_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); // 同步订单状态到woocommerce if (order.status === OrderStatus.COMPLETED) { await this.wpService.updateOrder(site, order.externalOrderId, { status: OrderStatus.PROCESSING, }); order.status = OrderStatus.PROCESSING; } order.orderStatus = ErpOrderStatus.PROCESSING; await orderRepo.save(order); }).catch(error => { transactionError = error; }); if (transactionError !== undefined) { throw new Error(`数据库同步错误: ${transactionError.message}`); } 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, // todo,待确认 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 }); 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, // todo,待确认 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; } 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: 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(); 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(); 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) { const { pageSize = 10, current = 1, primary_tracking_number, stockPointId, } = param; 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} 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 }; } }