595 lines
19 KiB
TypeScript
595 lines
19 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 { 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<Service>;
|
|
|
|
@InjectEntityModel(ShippingAddress)
|
|
shippingAddressModel: Repository<ShippingAddress>;
|
|
|
|
@InjectEntityModel(Stock)
|
|
stockModel: Repository<Stock>;
|
|
|
|
@InjectEntityModel(Order)
|
|
orderModel: Repository<Order>;
|
|
|
|
@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()
|
|
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 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<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;
|
|
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 };
|
|
}
|
|
}
|