feat: multi-module iteration
This commit is contained in:
@@ -0,0 +1,441 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import {
|
||||
FulfillmentProviderStatus,
|
||||
FulfillmentProviderType,
|
||||
LogisticsSettlementMethod,
|
||||
Prisma,
|
||||
} from '@prisma/client';
|
||||
import {
|
||||
DEFAULT_XFX_LOGISTICS_PRICING,
|
||||
isXfxProviderCode,
|
||||
type LogisticsPricingRuleDto,
|
||||
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;
|
||||
name: string;
|
||||
type: FulfillmentProviderType;
|
||||
status?: FulfillmentProviderStatus;
|
||||
configJson?: string;
|
||||
capabilitiesJson?: string;
|
||||
xiaofeixiaConfig?: Partial<XiaofeixiaProviderConfig>;
|
||||
bankAccountName?: string | null;
|
||||
bankName?: string | null;
|
||||
bankBranch?: string | null;
|
||||
bankAccountNo?: string | null;
|
||||
settlementMethod?: LogisticsSettlementMethod | string;
|
||||
pricingRules?: LogisticsPricingRuleDto | null;
|
||||
};
|
||||
|
||||
export type UpdateFulfillmentProviderInput = Partial<CreateFulfillmentProviderInput>;
|
||||
|
||||
type Capabilities = {
|
||||
createShipment?: boolean;
|
||||
getTrack?: boolean;
|
||||
callback?: boolean;
|
||||
cancel?: boolean;
|
||||
};
|
||||
|
||||
const DEFAULT_XFX_API_URL = 'https://beta.51xiaoju.cn/app/api/interface.do';
|
||||
|
||||
@Injectable()
|
||||
export class FulfillmentProviderService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async listActiveApiProviders() {
|
||||
const rows = await this.prisma.fulfillmentProvider.findMany({
|
||||
where: { status: 'ACTIVE', type: 'API' },
|
||||
orderBy: { name: 'asc' },
|
||||
});
|
||||
return rows.map((row) => this.toDto(row));
|
||||
}
|
||||
|
||||
async listAll() {
|
||||
const rows = await this.prisma.fulfillmentProvider.findMany({
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
return rows.map((row) => this.toDto(row));
|
||||
}
|
||||
|
||||
async getById(id: bigint) {
|
||||
const row = await this.prisma.fulfillmentProvider.findUnique({ where: { id } });
|
||||
if (!row) throw new NotFoundException('仓配承运商不存在');
|
||||
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)) {
|
||||
throw new BadRequestException('承运商编码仅支持大写字母、数字和下划线');
|
||||
}
|
||||
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 pricingRulesJson = this.resolvePricingRulesJson(
|
||||
code,
|
||||
input.pricingRules,
|
||||
isXfxProviderCode(code) ? DEFAULT_XFX_LOGISTICS_PRICING : null,
|
||||
);
|
||||
const settlementMethod = this.parseSettlementMethod(input.settlementMethod) ?? 'PREPAID';
|
||||
|
||||
const row = await this.prisma.fulfillmentProvider.create({
|
||||
data: {
|
||||
code,
|
||||
name: input.name.trim(),
|
||||
type: input.type,
|
||||
status: input.status ?? 'ACTIVE',
|
||||
configJson,
|
||||
capabilitiesJson:
|
||||
input.capabilitiesJson?.trim() ||
|
||||
(isXfxProviderCode(code)
|
||||
? JSON.stringify({
|
||||
createShipment: true,
|
||||
getTrack: true,
|
||||
callback: true,
|
||||
cancel: true,
|
||||
})
|
||||
: null),
|
||||
bankAccountName: this.normOptional(input.bankAccountName),
|
||||
bankName: this.normOptional(input.bankName),
|
||||
bankBranch: this.normOptional(input.bankBranch),
|
||||
bankAccountNo: this.normOptional(input.bankAccountNo),
|
||||
settlementMethod,
|
||||
pricingRulesJson,
|
||||
},
|
||||
});
|
||||
return this.toDto(row);
|
||||
}
|
||||
|
||||
async update(id: bigint, input: UpdateFulfillmentProviderInput) {
|
||||
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 pricingRulesJson =
|
||||
input.pricingRules !== undefined
|
||||
? this.resolvePricingRulesJson(code, input.pricingRules, null)
|
||||
: undefined;
|
||||
|
||||
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 } : {}),
|
||||
...(configJson !== undefined ? { configJson } : {}),
|
||||
...(input.capabilitiesJson !== undefined
|
||||
? { capabilitiesJson: input.capabilitiesJson?.trim() || null }
|
||||
: {}),
|
||||
...(input.bankAccountName !== undefined
|
||||
? { bankAccountName: this.normOptional(input.bankAccountName) }
|
||||
: {}),
|
||||
...(input.bankName !== undefined ? { bankName: this.normOptional(input.bankName) } : {}),
|
||||
...(input.bankBranch !== undefined ? { bankBranch: this.normOptional(input.bankBranch) } : {}),
|
||||
...(input.bankAccountNo !== undefined
|
||||
? { bankAccountNo: this.normOptional(input.bankAccountNo) }
|
||||
: {}),
|
||||
...(input.settlementMethod !== undefined
|
||||
? { settlementMethod: this.parseSettlementMethod(input.settlementMethod)! }
|
||||
: {}),
|
||||
...(pricingRulesJson !== undefined ? { pricingRulesJson } : {}),
|
||||
},
|
||||
});
|
||||
return this.toDto(row);
|
||||
}
|
||||
|
||||
/** 充值(结算模块可复用) */
|
||||
async rechargePrepaid(providerId: bigint, amount: number, remark?: string) {
|
||||
if (!(amount > 0)) throw new BadRequestException('充值金额须大于 0');
|
||||
const rounded = Math.round(amount * 100) / 100;
|
||||
|
||||
const result = await this.prisma.$transaction(async (tx) => {
|
||||
const row = await tx.fulfillmentProvider.findUnique({ where: { id: providerId } });
|
||||
if (!row) throw new NotFoundException('仓配承运商不存在');
|
||||
const balanceAfter = Math.round((Number(row.prepaidBalance) + rounded) * 100) / 100;
|
||||
const updated = await tx.fulfillmentProvider.update({
|
||||
where: { id: providerId },
|
||||
data: { prepaidBalance: balanceAfter },
|
||||
});
|
||||
const ledger = await tx.logisticsPrepaidLedger.create({
|
||||
data: {
|
||||
fulfillmentProviderId: providerId,
|
||||
type: 'RECHARGE',
|
||||
amount: rounded,
|
||||
balanceAfter,
|
||||
remark: remark?.trim() || '充值',
|
||||
},
|
||||
});
|
||||
return { provider: updated, ledger };
|
||||
});
|
||||
|
||||
return serializeBigInt({
|
||||
provider: this.toDto(result.provider),
|
||||
ledger: result.ledger,
|
||||
});
|
||||
}
|
||||
|
||||
/** 账单扣减充值余额;余额不足返回 false */
|
||||
async deductPrepaid(
|
||||
providerId: bigint,
|
||||
amount: number,
|
||||
logisticsBillId: bigint,
|
||||
tx?: Prisma.TransactionClient,
|
||||
): Promise<{ ok: true; balanceAfter: number } | { ok: false; balance: number }> {
|
||||
const client = tx ?? this.prisma;
|
||||
const rounded = Math.round(amount * 100) / 100;
|
||||
const row = await client.fulfillmentProvider.findUnique({ where: { id: providerId } });
|
||||
if (!row) throw new NotFoundException('仓配承运商不存在');
|
||||
const balance = Number(row.prepaidBalance);
|
||||
if (balance + 1e-9 < rounded) {
|
||||
return { ok: false, balance };
|
||||
}
|
||||
const balanceAfter = Math.round((balance - rounded) * 100) / 100;
|
||||
await client.fulfillmentProvider.update({
|
||||
where: { id: providerId },
|
||||
data: { prepaidBalance: balanceAfter },
|
||||
});
|
||||
await client.logisticsPrepaidLedger.create({
|
||||
data: {
|
||||
fulfillmentProviderId: providerId,
|
||||
type: 'DEDUCT',
|
||||
amount: rounded,
|
||||
balanceAfter,
|
||||
logisticsBillId,
|
||||
remark: '物流月账单扣款',
|
||||
},
|
||||
});
|
||||
return { ok: true, balanceAfter };
|
||||
}
|
||||
|
||||
parseCapabilities(raw: string | null): Capabilities | null {
|
||||
if (!raw) return null;
|
||||
try {
|
||||
return JSON.parse(raw) as Capabilities;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
parsePricingRules(raw: string | null): LogisticsPricingRuleDto | null {
|
||||
if (!raw) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as Partial<LogisticsPricingRuleDto>;
|
||||
if (!parsed || typeof parsed !== 'object') return null;
|
||||
const baseBottles = Number(parsed.baseBottles);
|
||||
const baseFee = Number(parsed.baseFee);
|
||||
const extraBottleFee = Number(parsed.extraBottleFee);
|
||||
if (!(baseBottles > 0) || !(baseFee >= 0) || !(extraBottleFee >= 0)) return null;
|
||||
const rule: LogisticsPricingRuleDto = { baseBottles, baseFee, extraBottleFee };
|
||||
if (parsed.boxBottles != null && Number(parsed.boxBottles) > 0) {
|
||||
rule.boxBottles = Number(parsed.boxBottles);
|
||||
}
|
||||
if (parsed.boxFee != null && Number(parsed.boxFee) >= 0) {
|
||||
rule.boxFee = Number(parsed.boxFee);
|
||||
}
|
||||
return rule;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private resolvePricingRulesJson(
|
||||
code: string,
|
||||
input: LogisticsPricingRuleDto | null | undefined,
|
||||
fallback: LogisticsPricingRuleDto | null,
|
||||
): string | null {
|
||||
if (input === null) return null;
|
||||
const rule = input ?? fallback;
|
||||
if (!rule) return isXfxProviderCode(code) ? JSON.stringify(DEFAULT_XFX_LOGISTICS_PRICING) : null;
|
||||
if (!(rule.baseBottles > 0)) throw new BadRequestException('计价标准:起送瓶数须大于 0');
|
||||
if (!(rule.baseFee >= 0)) throw new BadRequestException('计价标准:起送费用无效');
|
||||
if (!(rule.extraBottleFee >= 0)) throw new BadRequestException('计价标准:加瓶费用无效');
|
||||
return JSON.stringify({
|
||||
baseBottles: Number(rule.baseBottles),
|
||||
baseFee: Number(rule.baseFee),
|
||||
extraBottleFee: Number(rule.extraBottleFee),
|
||||
...(rule.boxBottles != null ? { boxBottles: Number(rule.boxBottles) } : {}),
|
||||
...(rule.boxFee != null ? { boxFee: Number(rule.boxFee) } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
private parseSettlementMethod(
|
||||
raw?: LogisticsSettlementMethod | string | null,
|
||||
): LogisticsSettlementMethod | null {
|
||||
if (raw == null || raw === '') return null;
|
||||
if (raw === 'PREPAID' || raw === 'MONTHLY_CREDIT') return raw;
|
||||
throw new BadRequestException('结算方式仅支持 PREPAID / MONTHLY_CREDIT');
|
||||
}
|
||||
|
||||
private normOptional(v?: string | null) {
|
||||
if (v === undefined) return undefined;
|
||||
if (v == null) return null;
|
||||
const t = String(v).trim();
|
||||
return t || 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;
|
||||
name: string;
|
||||
type: string;
|
||||
status: string;
|
||||
configJson: string | null;
|
||||
capabilitiesJson: string | null;
|
||||
bankAccountName?: string | null;
|
||||
bankName?: string | null;
|
||||
bankBranch?: string | null;
|
||||
bankAccountNo?: string | null;
|
||||
settlementMethod?: string;
|
||||
pricingRulesJson?: string | null;
|
||||
prepaidBalance?: Prisma.Decimal | number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}) {
|
||||
return serializeBigInt({
|
||||
id: row.id.toString(),
|
||||
code: row.code,
|
||||
name: row.name,
|
||||
type: row.type,
|
||||
status: row.status,
|
||||
capabilities: this.parseCapabilities(row.capabilitiesJson),
|
||||
hasConfig: Boolean(row.configJson),
|
||||
xiaofeixiaConfig: isXfxProviderCode(row.code)
|
||||
? this.toPublicXiaofeixiaConfig(row.configJson)
|
||||
: null,
|
||||
bankAccountName: row.bankAccountName ?? null,
|
||||
bankName: row.bankName ?? null,
|
||||
bankBranch: row.bankBranch ?? null,
|
||||
bankAccountNo: row.bankAccountNo ?? null,
|
||||
settlementMethod: row.settlementMethod ?? 'PREPAID',
|
||||
pricingRules: this.parsePricingRules(row.pricingRulesJson ?? null),
|
||||
prepaidBalance: Number(row.prepaidBalance ?? 0),
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Module, forwardRef } from '@nestjs/common';
|
||||
import { IntegrationsModule } from '../../integrations/integrations.module';
|
||||
import { TradeModule } from '../trade/trade.module';
|
||||
import { FulfillmentProviderService } from './fulfillment-provider.service';
|
||||
import { FulfillmentService } from './fulfillment.service';
|
||||
|
||||
@Module({
|
||||
imports: [IntegrationsModule, forwardRef(() => TradeModule)],
|
||||
providers: [FulfillmentProviderService, FulfillmentService],
|
||||
exports: [FulfillmentProviderService, FulfillmentService],
|
||||
})
|
||||
export class FulfillmentModule {}
|
||||
@@ -0,0 +1,369 @@
|
||||
import { BadRequestException, Injectable, Logger, NotFoundException, Inject, forwardRef } from '@nestjs/common';
|
||||
import type { CityWarehouse, FulfillmentProvider, Order } from '@prisma/client';
|
||||
import { isXfxProviderCode } from '@dukang/shared-types';
|
||||
import {
|
||||
BOTTLES_PER_BOX,
|
||||
XFX_AUTO_DISPATCH_MAX_BOXES,
|
||||
calcOrderBoxCount,
|
||||
shouldHoldAutoCourierDispatch,
|
||||
} from '@dukang/domain';
|
||||
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;
|
||||
trackingNo: string;
|
||||
manualQueryUrl?: string;
|
||||
};
|
||||
|
||||
export type HqLogisticsShipInput = ManualShipInput;
|
||||
|
||||
export const FULFILLMENT_HOLD_LARGE_ORDER = 'LARGE_ORDER_GE_10_BOXES';
|
||||
|
||||
@Injectable()
|
||||
export class FulfillmentService {
|
||||
private readonly logger = new Logger(FulfillmentService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly courier: CourierService,
|
||||
private readonly fulfillmentProviderService: FulfillmentProviderService,
|
||||
@Inject(forwardRef(() => TradeService))
|
||||
private readonly tradeService: TradeService,
|
||||
) {}
|
||||
|
||||
async dispatchAfterPay(orderId: bigint) {
|
||||
const order = await this.prisma.order.findUnique({
|
||||
where: { id: orderId },
|
||||
include: { delivery: true },
|
||||
});
|
||||
if (!order || order.payStatus !== 'PAID') return;
|
||||
|
||||
if (order.deliveryType === 'CROSS_CITY') {
|
||||
await this.ensureDeliveryRecord(orderId, 'MANUAL');
|
||||
return;
|
||||
}
|
||||
|
||||
const warehouse = await this.resolveWarehouseForLocalOrder(order.cityId);
|
||||
if (!warehouse) {
|
||||
await this.ensureDeliveryRecord(orderId, 'MANUAL');
|
||||
return;
|
||||
}
|
||||
|
||||
await this.prisma.order.update({
|
||||
where: { id: orderId },
|
||||
data: { fulfillmentWarehouseId: warehouse.id },
|
||||
});
|
||||
|
||||
if (warehouse.fulfillmentMode === 'MANUAL') {
|
||||
await this.ensureDeliveryRecord(orderId, 'MANUAL');
|
||||
return;
|
||||
}
|
||||
|
||||
if (warehouse.fulfillmentMode === 'API_AUTO' && warehouse.fulfillmentProviderId) {
|
||||
const provider = await this.prisma.fulfillmentProvider.findUnique({
|
||||
where: { id: warehouse.fulfillmentProviderId },
|
||||
});
|
||||
if (!provider || provider.status !== 'ACTIVE' || provider.type !== 'API') {
|
||||
await this.ensureDeliveryRecord(orderId, 'MANUAL');
|
||||
return;
|
||||
}
|
||||
|
||||
// 大单拦截:≥10 箱不自动推小飞侠,待总部确认后推单或自配送
|
||||
if (shouldHoldAutoCourierDispatch(order.quantity)) {
|
||||
const boxes = calcOrderBoxCount(order.quantity);
|
||||
this.logger.warn(
|
||||
`大单拦截自动推单:${order.orderNo} quantity=${order.quantity} bottles≈${boxes}箱(阈值 ${XFX_AUTO_DISPATCH_MAX_BOXES}箱/${BOTTLES_PER_BOX}瓶)`,
|
||||
);
|
||||
await this.prisma.order.update({
|
||||
where: { id: orderId },
|
||||
data: {
|
||||
fulfillmentHold: true,
|
||||
fulfillmentHoldReason: FULFILLMENT_HOLD_LARGE_ORDER,
|
||||
},
|
||||
});
|
||||
await this.ensureDeliveryRecord(orderId, 'MANUAL', provider.id);
|
||||
await this.prisma.logThirdParty.create({
|
||||
data: {
|
||||
provider: 'XFX',
|
||||
scene: 'ORDER_DISPATCH_HOLD',
|
||||
refType: 'ORDER',
|
||||
refId: order.id,
|
||||
status: 'PENDING',
|
||||
errorMessage: `大单拦截:${order.quantity}瓶(约${boxes}箱),需总部确认后推小飞侠或自配送`.slice(
|
||||
0,
|
||||
512,
|
||||
),
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await this.dispatchApiAuto(order, warehouse, provider);
|
||||
}
|
||||
}
|
||||
|
||||
async clearFulfillmentHold(orderId: bigint) {
|
||||
await this.prisma.order.update({
|
||||
where: { id: orderId },
|
||||
data: { fulfillmentHold: false, fulfillmentHoldReason: null },
|
||||
});
|
||||
}
|
||||
|
||||
async dispatchApiAuto(order: Order, warehouse: CityWarehouse, provider: FulfillmentProvider) {
|
||||
if (!isXfxProviderCode(provider.code)) {
|
||||
this.logger.warn(`承运商 ${provider.code} 自动推单尚未实现,订单 ${order.orderNo} 保持待发货`);
|
||||
await this.ensureDeliveryRecord(order.id, 'MANUAL', provider.id);
|
||||
return;
|
||||
}
|
||||
|
||||
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 },
|
||||
},
|
||||
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}`,
|
||||
},
|
||||
{ xiaofeixia: xfxConfig },
|
||||
);
|
||||
|
||||
const now = new Date();
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
const delivery = await tx.orderDelivery.findUnique({ where: { orderId: order.id } });
|
||||
const data = {
|
||||
provider: 'XFX' as const,
|
||||
fulfillmentProviderId: provider.id,
|
||||
trackingNo: result.trackingNumber,
|
||||
providerOrderNo: String(result.providerShipmentId),
|
||||
shippingAt: now,
|
||||
};
|
||||
if (delivery) {
|
||||
await tx.orderDelivery.update({ where: { orderId: order.id }, data });
|
||||
} else {
|
||||
await tx.orderDelivery.create({ data: { orderId: order.id, ...data } });
|
||||
}
|
||||
await tx.order.update({
|
||||
where: { id: order.id },
|
||||
data: { fulfillmentHold: false, fulfillmentHoldReason: null },
|
||||
});
|
||||
await tx.logThirdParty.create({
|
||||
data: {
|
||||
provider: 'XFX',
|
||||
scene: 'ORDER_DISPATCH',
|
||||
refType: 'ORDER',
|
||||
refId: order.id,
|
||||
externalNo: result.trackingNumber,
|
||||
status: 'SUCCESS',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
await this.tradeService.applyStatusTransition(order.id, order.status, 'SHIPPING', 'WAREHOUSE_AUTO');
|
||||
} 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);
|
||||
}
|
||||
}
|
||||
|
||||
async shipManualByWarehouse(orderId: bigint, warehouseIds: bigint[], input: ManualShipInput) {
|
||||
const order = await this.prisma.order.findFirst({
|
||||
where: { id: orderId, fulfillmentWarehouseId: { in: warehouseIds } },
|
||||
include: { delivery: true },
|
||||
});
|
||||
if (!order) throw new NotFoundException('订单不存在或无权操作');
|
||||
if (!['PENDING_SHIP', 'OUT_WAREHOUSE'].includes(order.status)) {
|
||||
throw new BadRequestException('当前订单状态不可发货');
|
||||
}
|
||||
|
||||
const queryUrl =
|
||||
input.manualQueryUrl?.trim() ||
|
||||
(await this.buildQueryUrlFromTemplate(order.fulfillmentWarehouseId, input.trackingNo));
|
||||
|
||||
return this.applyManualShip(order, {
|
||||
logisticsCompany: input.logisticsCompany.trim(),
|
||||
trackingNo: input.trackingNo.trim(),
|
||||
manualQueryUrl: queryUrl,
|
||||
operator: 'WAREHOUSE_MANUAL',
|
||||
});
|
||||
}
|
||||
|
||||
async shipHqLogistics(orderId: bigint, input: HqLogisticsShipInput) {
|
||||
const order = await this.prisma.order.findUnique({
|
||||
where: { id: orderId },
|
||||
include: { delivery: true },
|
||||
});
|
||||
if (!order) throw new NotFoundException('订单不存在');
|
||||
|
||||
// HQ 可对任意待发货单填快递单号(含仓配单手动填单)
|
||||
if (!['PENDING_SHIP', 'OUT_WAREHOUSE'].includes(order.status)) {
|
||||
throw new BadRequestException('当前订单状态不可发货');
|
||||
}
|
||||
if (order.delivery?.trackingNo) throw new BadRequestException('该订单已有运单号');
|
||||
|
||||
return this.applyManualShip(order, {
|
||||
logisticsCompany: input.logisticsCompany.trim(),
|
||||
trackingNo: input.trackingNo.trim(),
|
||||
manualQueryUrl: input.manualQueryUrl?.trim(),
|
||||
operator: 'HQ_LOGISTICS',
|
||||
provider: 'LOGISTICS',
|
||||
});
|
||||
}
|
||||
|
||||
async getOrderTrack(orderId: bigint) {
|
||||
const order = await this.prisma.order.findUnique({
|
||||
where: { id: orderId },
|
||||
include: { delivery: true },
|
||||
});
|
||||
if (!order?.delivery) {
|
||||
return { nodes: [], manualQueryUrl: null };
|
||||
}
|
||||
|
||||
if (order.delivery.provider === 'XFX' && (order.delivery.trackingNo || order.orderNo)) {
|
||||
try {
|
||||
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,
|
||||
provider: order.delivery.provider,
|
||||
trackingNo: order.delivery.trackingNo,
|
||||
logisticsCompany: order.delivery.logisticsCompany,
|
||||
};
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
nodes: [],
|
||||
manualQueryUrl: order.delivery.manualQueryUrl,
|
||||
provider: order.delivery.provider,
|
||||
trackingNo: order.delivery.trackingNo,
|
||||
logisticsCompany: order.delivery.logisticsCompany,
|
||||
};
|
||||
}
|
||||
|
||||
private async applyManualShip(
|
||||
order: Order & { delivery: { trackingNo: string | null } | null },
|
||||
input: ManualShipInput & { operator: string; provider?: 'MANUAL' | 'LOGISTICS' },
|
||||
) {
|
||||
const now = new Date();
|
||||
const provider = input.provider ?? 'MANUAL';
|
||||
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
const delivery = await tx.orderDelivery.findUnique({ where: { orderId: order.id } });
|
||||
const data = {
|
||||
provider,
|
||||
logisticsCompany: input.logisticsCompany,
|
||||
trackingNo: input.trackingNo,
|
||||
manualQueryUrl: input.manualQueryUrl || null,
|
||||
shippingAt: now,
|
||||
};
|
||||
if (delivery) {
|
||||
await tx.orderDelivery.update({ where: { orderId: order.id }, data });
|
||||
} else {
|
||||
await tx.orderDelivery.create({ data: { orderId: order.id, ...data } });
|
||||
}
|
||||
await tx.order.update({
|
||||
where: { id: order.id },
|
||||
data: { fulfillmentHold: false, fulfillmentHoldReason: null },
|
||||
});
|
||||
});
|
||||
|
||||
await this.tradeService.applyStatusTransition(order.id, order.status, 'SHIPPING', input.operator);
|
||||
return this.prisma.order.findUnique({
|
||||
where: { id: order.id },
|
||||
include: { delivery: true, fulfillmentWarehouse: true },
|
||||
});
|
||||
}
|
||||
|
||||
private async resolveWarehouseForLocalOrder(cityId: bigint) {
|
||||
return this.prisma.cityWarehouse.findFirst({
|
||||
where: { cityId, status: 'ACTIVE' },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
}
|
||||
|
||||
private async ensureDeliveryRecord(
|
||||
orderId: bigint,
|
||||
provider: 'MANUAL' | 'LOGISTICS' | 'XFX',
|
||||
fulfillmentProviderId?: bigint,
|
||||
) {
|
||||
const existing = await this.prisma.orderDelivery.findUnique({ where: { orderId } });
|
||||
if (existing) return;
|
||||
await this.prisma.orderDelivery.create({
|
||||
data: {
|
||||
orderId,
|
||||
provider,
|
||||
...(fulfillmentProviderId ? { fulfillmentProviderId } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async logDispatchFailure(order: Order, provider: FulfillmentProvider, error: string) {
|
||||
await this.prisma.logThirdParty.create({
|
||||
data: {
|
||||
provider: 'XFX',
|
||||
scene: 'ORDER_DISPATCH',
|
||||
refType: 'ORDER',
|
||||
refId: order.id,
|
||||
status: 'FAILED',
|
||||
errorMessage: `[${provider.code}] ${error}`.slice(0, 512),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async buildQueryUrlFromTemplate(warehouseId: bigint | null, trackingNo: string) {
|
||||
if (!warehouseId) return undefined;
|
||||
const wh = await this.prisma.cityWarehouse.findUnique({ where: { id: warehouseId } });
|
||||
const tpl = wh?.manualQueryUrlTemplate;
|
||||
if (!tpl) return undefined;
|
||||
return tpl.replace(/\{trackingNo\}/g, encodeURIComponent(trackingNo));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user