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; 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; 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 { 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 { 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; 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; 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])); } }