588 lines
20 KiB
TypeScript
588 lines
20 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 { 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 };
|
||
}
|
||
}
|