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; @InjectEntityModel(ShippingAddress) shippingAddressModel: Repository; @InjectEntityModel(Stock) stockModel: Repository; @InjectEntityModel(Order) orderModel: 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() 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 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(); 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; 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 }; } }