@@ -1,7 +1,13 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { FulfillmentProviderStatus, FulfillmentProviderType } from '@prisma/client';
|
||||
import {
|
||||
isXfxProviderCode,
|
||||
type XiaofeixiaProviderConfig,
|
||||
type XiaofeixiaProviderConfigPublic,
|
||||
} from '@dukang/shared-types';
|
||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||||
import type { XiaofeixiaConfig, XiaofeixiaSignType } from '../../integrations/courier/courier.config';
|
||||
|
||||
export type CreateFulfillmentProviderInput = {
|
||||
code: string;
|
||||
@@ -10,6 +16,7 @@ export type CreateFulfillmentProviderInput = {
|
||||
status?: FulfillmentProviderStatus;
|
||||
configJson?: string;
|
||||
capabilitiesJson?: string;
|
||||
xiaofeixiaConfig?: Partial<XiaofeixiaProviderConfig>;
|
||||
};
|
||||
|
||||
export type UpdateFulfillmentProviderInput = Partial<CreateFulfillmentProviderInput>;
|
||||
@@ -21,6 +28,8 @@ type Capabilities = {
|
||||
cancel?: boolean;
|
||||
};
|
||||
|
||||
const DEFAULT_XFX_API_URL = 'https://beta.51xiaoju.cn/app/api/interface.do';
|
||||
|
||||
@Injectable()
|
||||
export class FulfillmentProviderService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
@@ -46,6 +55,44 @@ export class FulfillmentProviderService {
|
||||
return this.toDto(row);
|
||||
}
|
||||
|
||||
/** 供推单使用:解析完整小飞侠凭证(含 apiKey) */
|
||||
async resolveXiaofeixiaConfig(providerId: bigint): Promise<XiaofeixiaConfig> {
|
||||
const row = await this.prisma.fulfillmentProvider.findUnique({ where: { id: providerId } });
|
||||
if (!row) throw new NotFoundException('仓配承运商不存在');
|
||||
if (!isXfxProviderCode(row.code)) {
|
||||
throw new BadRequestException('该承运商不是小飞侠');
|
||||
}
|
||||
const cfg = this.parseXiaofeixiaConfig(row.configJson);
|
||||
if (!cfg?.mchId || !cfg?.apiKey || !cfg?.apiUrl) {
|
||||
throw new BadRequestException('小飞侠仓配配置不完整,请在仓配管理中填写 API 地址、商户号与 API Key');
|
||||
}
|
||||
return {
|
||||
apiUrl: cfg.apiUrl,
|
||||
mchId: cfg.mchId,
|
||||
apiKey: cfg.apiKey,
|
||||
signType: this.resolveSignType(cfg.signType),
|
||||
appId: cfg.appId || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/** 取第一个启用的小飞侠承运商配置(联调/兼容) */
|
||||
async resolveDefaultXiaofeixiaConfig(): Promise<XiaofeixiaConfig | null> {
|
||||
const row = await this.prisma.fulfillmentProvider.findFirst({
|
||||
where: {
|
||||
status: 'ACTIVE',
|
||||
type: 'API',
|
||||
code: { in: ['XFX', 'XIAOFEIXIA'] },
|
||||
},
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
});
|
||||
if (!row?.configJson) return null;
|
||||
try {
|
||||
return await this.resolveXiaofeixiaConfig(row.id);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async create(input: CreateFulfillmentProviderInput) {
|
||||
const code = input.code.trim().toUpperCase();
|
||||
if (!/^[A-Z0-9_]+$/.test(code)) {
|
||||
@@ -54,28 +101,54 @@ export class FulfillmentProviderService {
|
||||
const existing = await this.prisma.fulfillmentProvider.findUnique({ where: { code } });
|
||||
if (existing) throw new BadRequestException('承运商编码已存在');
|
||||
|
||||
const configJson = this.resolveConfigJsonForWrite(code, null, input);
|
||||
if (isXfxProviderCode(code) && input.type === 'API') {
|
||||
this.assertXiaofeixiaConfigComplete(configJson, true);
|
||||
}
|
||||
|
||||
const row = await this.prisma.fulfillmentProvider.create({
|
||||
data: {
|
||||
code,
|
||||
name: input.name.trim(),
|
||||
type: input.type,
|
||||
status: input.status ?? 'ACTIVE',
|
||||
configJson: input.configJson?.trim() || null,
|
||||
capabilitiesJson: input.capabilitiesJson?.trim() || null,
|
||||
configJson,
|
||||
capabilitiesJson:
|
||||
input.capabilitiesJson?.trim() ||
|
||||
(isXfxProviderCode(code)
|
||||
? JSON.stringify({
|
||||
createShipment: true,
|
||||
getTrack: true,
|
||||
callback: true,
|
||||
cancel: true,
|
||||
})
|
||||
: null),
|
||||
},
|
||||
});
|
||||
return this.toDto(row);
|
||||
}
|
||||
|
||||
async update(id: bigint, input: UpdateFulfillmentProviderInput) {
|
||||
await this.getById(id);
|
||||
const current = await this.prisma.fulfillmentProvider.findUnique({ where: { id } });
|
||||
if (!current) throw new NotFoundException('仓配承运商不存在');
|
||||
|
||||
const code = current.code;
|
||||
const configJson =
|
||||
input.xiaofeixiaConfig !== undefined || input.configJson !== undefined
|
||||
? this.resolveConfigJsonForWrite(code, current.configJson, input)
|
||||
: undefined;
|
||||
|
||||
if (configJson !== undefined && isXfxProviderCode(code) && (input.type ?? current.type) === 'API') {
|
||||
this.assertXiaofeixiaConfigComplete(configJson, false);
|
||||
}
|
||||
|
||||
const row = await this.prisma.fulfillmentProvider.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(input.name !== undefined ? { name: input.name.trim() } : {}),
|
||||
...(input.type !== undefined ? { type: input.type } : {}),
|
||||
...(input.status !== undefined ? { status: input.status } : {}),
|
||||
...(input.configJson !== undefined ? { configJson: input.configJson?.trim() || null } : {}),
|
||||
...(configJson !== undefined ? { configJson } : {}),
|
||||
...(input.capabilitiesJson !== undefined
|
||||
? { capabilitiesJson: input.capabilitiesJson?.trim() || null }
|
||||
: {}),
|
||||
@@ -93,6 +166,74 @@ export class FulfillmentProviderService {
|
||||
}
|
||||
}
|
||||
|
||||
parseXiaofeixiaConfig(raw: string | null): XiaofeixiaProviderConfig | null {
|
||||
if (!raw) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as Partial<XiaofeixiaProviderConfig>;
|
||||
if (!parsed || typeof parsed !== 'object') return null;
|
||||
return {
|
||||
apiUrl: String(parsed.apiUrl ?? '').trim(),
|
||||
mchId: String(parsed.mchId ?? '').trim(),
|
||||
apiKey: String(parsed.apiKey ?? '').trim(),
|
||||
signType: parsed.signType === 'HMAC-SHA256' ? 'HMAC-SHA256' : 'MD5',
|
||||
appId: parsed.appId ? String(parsed.appId).trim() : undefined,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private resolveConfigJsonForWrite(
|
||||
code: string,
|
||||
existingRaw: string | null,
|
||||
input: CreateFulfillmentProviderInput | UpdateFulfillmentProviderInput,
|
||||
): string | null {
|
||||
if (isXfxProviderCode(code) && input.xiaofeixiaConfig) {
|
||||
const existing = this.parseXiaofeixiaConfig(existingRaw);
|
||||
const next: XiaofeixiaProviderConfig = {
|
||||
apiUrl: (input.xiaofeixiaConfig.apiUrl ?? existing?.apiUrl ?? DEFAULT_XFX_API_URL).trim(),
|
||||
mchId: (input.xiaofeixiaConfig.mchId ?? existing?.mchId ?? '').trim(),
|
||||
apiKey: (input.xiaofeixiaConfig.apiKey || existing?.apiKey || '').trim(),
|
||||
signType:
|
||||
input.xiaofeixiaConfig.signType === 'HMAC-SHA256'
|
||||
? 'HMAC-SHA256'
|
||||
: input.xiaofeixiaConfig.signType === 'MD5'
|
||||
? 'MD5'
|
||||
: existing?.signType ?? 'MD5',
|
||||
appId: (input.xiaofeixiaConfig.appId ?? existing?.appId)?.trim() || undefined,
|
||||
};
|
||||
return JSON.stringify(next);
|
||||
}
|
||||
if (input.configJson !== undefined) {
|
||||
return input.configJson?.trim() || null;
|
||||
}
|
||||
return existingRaw;
|
||||
}
|
||||
|
||||
private assertXiaofeixiaConfigComplete(configJson: string | null, requireApiKey: boolean) {
|
||||
const cfg = this.parseXiaofeixiaConfig(configJson);
|
||||
if (!cfg?.apiUrl) throw new BadRequestException('请填写小飞侠 API 地址');
|
||||
if (!cfg.mchId) throw new BadRequestException('请填写小飞侠商户号');
|
||||
if (requireApiKey && !cfg.apiKey) throw new BadRequestException('请填写小飞侠 API Key');
|
||||
if (!requireApiKey && !cfg.apiKey) throw new BadRequestException('小飞侠 API Key 缺失,请重新填写');
|
||||
}
|
||||
|
||||
private toPublicXiaofeixiaConfig(raw: string | null): XiaofeixiaProviderConfigPublic | null {
|
||||
const cfg = this.parseXiaofeixiaConfig(raw);
|
||||
if (!cfg) return null;
|
||||
return {
|
||||
apiUrl: cfg.apiUrl || DEFAULT_XFX_API_URL,
|
||||
mchId: cfg.mchId,
|
||||
signType: cfg.signType === 'HMAC-SHA256' ? 'HMAC-SHA256' : 'MD5',
|
||||
appId: cfg.appId,
|
||||
hasApiKey: Boolean(cfg.apiKey),
|
||||
};
|
||||
}
|
||||
|
||||
private resolveSignType(raw?: string): XiaofeixiaSignType {
|
||||
return raw?.toUpperCase() === 'HMAC-SHA256' ? 'HMAC-SHA256' : 'MD5';
|
||||
}
|
||||
|
||||
private toDto(row: {
|
||||
id: bigint;
|
||||
code: string;
|
||||
@@ -112,6 +253,9 @@ export class FulfillmentProviderService {
|
||||
status: row.status,
|
||||
capabilities: this.parseCapabilities(row.capabilitiesJson),
|
||||
hasConfig: Boolean(row.configJson),
|
||||
xiaofeixiaConfig: isXfxProviderCode(row.code)
|
||||
? this.toPublicXiaofeixiaConfig(row.configJson)
|
||||
: null,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
});
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { BadRequestException, Injectable, Logger, NotFoundException, Inject, forwardRef } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import type { CityWarehouse, FulfillmentProvider, Order } from '@prisma/client';
|
||||
import { isXfxProviderCode } from '@dukang/shared-types';
|
||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
import { CourierService } from '../../integrations/courier/courier.service';
|
||||
import { CourierPayMode } from '../../integrations/courier/courier.types';
|
||||
import type { XiaofeixiaConfig } from '../../integrations/courier/courier.config';
|
||||
import { TradeService } from '../trade/trade.service';
|
||||
import { FulfillmentProviderService } from './fulfillment-provider.service';
|
||||
|
||||
export type ManualShipInput = {
|
||||
logisticsCompany: string;
|
||||
@@ -14,16 +16,14 @@ export type ManualShipInput = {
|
||||
|
||||
export type HqLogisticsShipInput = ManualShipInput;
|
||||
|
||||
const XFX_CODES = new Set(['XFX', 'XIAOFEIXIA']);
|
||||
|
||||
@Injectable()
|
||||
export class FulfillmentService {
|
||||
private readonly logger = new Logger(FulfillmentService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly config: ConfigService,
|
||||
private readonly courier: CourierService,
|
||||
private readonly fulfillmentProviderService: FulfillmentProviderService,
|
||||
@Inject(forwardRef(() => TradeService))
|
||||
private readonly tradeService: TradeService,
|
||||
) {}
|
||||
@@ -69,38 +69,50 @@ export class FulfillmentService {
|
||||
}
|
||||
|
||||
async dispatchApiAuto(order: Order, warehouse: CityWarehouse, provider: FulfillmentProvider) {
|
||||
if (!XFX_CODES.has(provider.code)) {
|
||||
if (!isXfxProviderCode(provider.code)) {
|
||||
this.logger.warn(`承运商 ${provider.code} 自动推单尚未实现,订单 ${order.orderNo} 保持待发货`);
|
||||
await this.ensureDeliveryRecord(order.id, 'MANUAL', provider.id);
|
||||
return;
|
||||
}
|
||||
|
||||
const defaults = this.getShipDefaults();
|
||||
const fromLng = warehouse.lng != null ? Number(warehouse.lng) : defaults.fromLng;
|
||||
const fromLat = warehouse.lat != null ? Number(warehouse.lat) : defaults.fromLat;
|
||||
let xfxConfig: XiaofeixiaConfig;
|
||||
try {
|
||||
xfxConfig = await this.fulfillmentProviderService.resolveXiaofeixiaConfig(provider.id);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
await this.logDispatchFailure(order, provider, message);
|
||||
await this.ensureDeliveryRecord(order.id, 'MANUAL', provider.id);
|
||||
return;
|
||||
}
|
||||
|
||||
const fromLng = warehouse.lng != null ? Number(warehouse.lng) : 113.665;
|
||||
const fromLat = warehouse.lat != null ? Number(warehouse.lat) : 34.757;
|
||||
|
||||
try {
|
||||
const result = await this.courier.createShipment({
|
||||
outNumber: order.orderNo,
|
||||
from: {
|
||||
name: warehouse.contactName,
|
||||
mobile: warehouse.contactPhone,
|
||||
address: warehouse.address,
|
||||
addressDetail: warehouse.name,
|
||||
coordinate: { lng: fromLng, lat: fromLat },
|
||||
const result = await this.courier.createShipment(
|
||||
{
|
||||
outNumber: order.orderNo,
|
||||
from: {
|
||||
name: warehouse.contactName,
|
||||
mobile: warehouse.contactPhone,
|
||||
address: warehouse.address,
|
||||
addressDetail: warehouse.name,
|
||||
coordinate: { lng: fromLng, lat: fromLat },
|
||||
},
|
||||
to: {
|
||||
name: order.receiverName,
|
||||
mobile: order.receiverPhone,
|
||||
address: `${order.receiverProvince}${order.receiverCity}${order.receiverDistrict}`,
|
||||
addressDetail: order.receiverAddress,
|
||||
},
|
||||
goodsName: order.productName,
|
||||
goodsNum: order.quantity,
|
||||
weight: 2,
|
||||
payMode: CourierPayMode.SENDER,
|
||||
remark: `仓配自动发货 ${order.orderNo}`,
|
||||
},
|
||||
to: {
|
||||
name: order.receiverName,
|
||||
mobile: order.receiverPhone,
|
||||
address: `${order.receiverProvince}${order.receiverCity}${order.receiverDistrict}`,
|
||||
addressDetail: order.receiverAddress,
|
||||
},
|
||||
goodsName: order.productName,
|
||||
goodsNum: order.quantity,
|
||||
weight: defaults.weight,
|
||||
payMode: defaults.payMode,
|
||||
remark: `仓配自动发货 ${order.orderNo}`,
|
||||
});
|
||||
{ xiaofeixia: xfxConfig },
|
||||
);
|
||||
|
||||
const now = new Date();
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
@@ -196,10 +208,20 @@ export class FulfillmentService {
|
||||
|
||||
if (order.delivery.provider === 'XFX' && (order.delivery.trackingNo || order.orderNo)) {
|
||||
try {
|
||||
const nodes = await this.courier.getTrack({
|
||||
trackingNumber: order.delivery.trackingNo ?? undefined,
|
||||
outNumber: order.orderNo,
|
||||
});
|
||||
const options = order.delivery.fulfillmentProviderId
|
||||
? {
|
||||
xiaofeixia: await this.fulfillmentProviderService.resolveXiaofeixiaConfig(
|
||||
order.delivery.fulfillmentProviderId,
|
||||
),
|
||||
}
|
||||
: undefined;
|
||||
const nodes = await this.courier.getTrack(
|
||||
{
|
||||
trackingNumber: order.delivery.trackingNo ?? undefined,
|
||||
outNumber: order.orderNo,
|
||||
},
|
||||
options,
|
||||
);
|
||||
return {
|
||||
nodes,
|
||||
manualQueryUrl: order.delivery.manualQueryUrl,
|
||||
@@ -208,7 +230,7 @@ export class FulfillmentService {
|
||||
logisticsCompany: order.delivery.logisticsCompany,
|
||||
};
|
||||
} catch {
|
||||
// fall through to manual fields
|
||||
// fall through
|
||||
}
|
||||
}
|
||||
|
||||
@@ -294,13 +316,4 @@ export class FulfillmentService {
|
||||
if (!tpl) return undefined;
|
||||
return tpl.replace(/\{trackingNo\}/g, encodeURIComponent(trackingNo));
|
||||
}
|
||||
|
||||
private getShipDefaults() {
|
||||
return {
|
||||
fromLng: Number(this.config.get<string>('SHIP_FROM_LNG') || 113.665),
|
||||
fromLat: Number(this.config.get<string>('SHIP_FROM_LAT') || 34.757),
|
||||
weight: 2,
|
||||
payMode: CourierPayMode.SENDER,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user