Files
dukang/server/dukang-api/src/modules/fulfillment/fulfillment-provider.service.ts
T
jacy 797b20979d feat(mini-user): v3.5.10 同城配送提示可配置并支持换行
承运商 HTML 按收货市下发;textarea 回车在 C 端转成换行。含门店去掉分享按钮与小程序企微客服。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 14:29:48 +08:00

531 lines
19 KiB
TypeScript

import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import {
FulfillmentProviderStatus,
FulfillmentProviderType,
LogisticsSettlementMethod,
Prisma,
} from '@prisma/client';
import {
DEFAULT_XFX_LOGISTICS_PRICING,
isXfxProviderCode,
sanitizeDeliveryHintHtml,
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;
deliveryHintHtml?: string | 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,
deliveryHintHtml: sanitizeDeliveryHintHtml(input.deliveryHintHtml),
},
});
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 } : {}),
...(input.deliveryHintHtml !== undefined
? { deliveryHintHtml: sanitizeDeliveryHintHtml(input.deliveryHintHtml) }
: {}),
},
});
return this.toDto(row);
}
async remove(id: bigint) {
const current = await this.prisma.fulfillmentProvider.findUnique({ where: { id } });
if (!current) throw new NotFoundException('仓配承运商不存在');
const [billCount, ledgerCount] = await Promise.all([
this.prisma.logisticsBill.count({ where: { fulfillmentProviderId: id } }),
this.prisma.logisticsPrepaidLedger.count({ where: { fulfillmentProviderId: id } }),
]);
if (billCount > 0) {
throw new BadRequestException(`该承运商已有 ${billCount} 笔物流对账单,不能删除`);
}
if (ledgerCount > 0) {
throw new BadRequestException('该承运商已有充值/扣款流水,不能删除');
}
await this.prisma.$transaction(async (tx) => {
await tx.cityWarehouse.updateMany({
where: { fulfillmentProviderId: id },
data: { fulfillmentProviderId: null, fulfillmentMode: 'MANUAL' },
});
await tx.orderDelivery.updateMany({
where: { fulfillmentProviderId: id },
data: { fulfillmentProviderId: null },
});
await tx.fulfillmentProvider.delete({ where: { id } });
});
return { ok: true, id: id.toString() };
}
/** 充值(结算模块可复用) */
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;
deliveryHintHtml?: string | null;
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),
deliveryHintHtml: sanitizeDeliveryHintHtml(row.deliveryHintHtml ?? null),
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
});
}
async listLocalDeliveries(filter?: { cityCode?: string; cityName?: string }) {
const cityCode = filter?.cityCode?.trim() || '';
const cityName = filter?.cityName?.trim() || '';
const cities = await this.prisma.commonCity.findMany({
where: {
status: 'ACTIVE',
...(cityCode ? { code: cityCode } : {}),
...(cityName && !cityCode
? { name: { in: this.cityNameAliases(cityName) } }
: {}),
},
include: {
warehouses: {
where: { status: 'ACTIVE' },
include: { fulfillmentProvider: true },
orderBy: { createdAt: 'asc' },
},
},
orderBy: { name: 'asc' },
});
return cities.map((city) => {
const preferred =
city.warehouses.find((w) => w.fulfillmentMode === 'API_AUTO' && w.fulfillmentProviderId) ??
city.warehouses.find((w) => w.fulfillmentProviderId) ??
null;
const provider = preferred?.fulfillmentProvider ?? null;
return {
city: { id: city.id.toString(), code: city.code, name: city.name },
warehouse: preferred
? {
id: preferred.id.toString(),
name: preferred.name,
fulfillmentMode: preferred.fulfillmentMode,
}
: null,
provider: provider
? { id: provider.id.toString(), code: provider.code, name: provider.name }
: null,
hintHtml: sanitizeDeliveryHintHtml(provider?.deliveryHintHtml ?? null),
};
});
}
private cityNameAliases(name: string): string[] {
const raw = name.trim();
if (!raw) return [];
const noSuffix = raw.replace(/市$/, '');
const withSuffix = raw.endsWith('市') ? raw : `${raw}市`;
return Array.from(new Set([raw, noSuffix, withSuffix]));
}
}