API/src/service/logistics.service.ts

588 lines
20 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';
@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()
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 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 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, // todo, (只能一个包)
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',
}
// 添加运单
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;
await dataSource.transaction(async manager => {
const orderRepo = manager.getRepository(Order);
const shipmentRepo = manager.getRepository(Shipment);
if (order.orderStatus === ErpOrderStatus.COMPLETED) {
const shipment = await shipmentRepo.save({
tracking_provider: 'uniuni-express', // todo: 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_id: orderId
});
order.shipmentId = shipment.id;
// 同步物流信息到woocommerce
const site = await this.geSite(order.siteId);
await this.wpService.createShipment(site, order.externalOrderId, {
tracking_number: shipment.return_tracking_number,
tracking_provider: shipment?.tracking_provider,
});
}
await orderRepo.save(order);
}).catch(error => {
transactionError = error
});
if (transactionError !== undefined) {
console.log('err', transactionError);
throw transactionError;
}
return { data: {
resShipmentOrder
} };
} 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<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;
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 };
}
}