物流对账单

This commit is contained in:
2026-07-26 09:51:33 +08:00
parent 2b7ef65cce
commit 19cb312465
21 changed files with 1909 additions and 27 deletions
@@ -1,7 +1,14 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { FulfillmentProviderStatus, FulfillmentProviderType } from '@prisma/client';
import {
FulfillmentProviderStatus,
FulfillmentProviderType,
LogisticsSettlementMethod,
Prisma,
} from '@prisma/client';
import {
DEFAULT_XFX_LOGISTICS_PRICING,
isXfxProviderCode,
type LogisticsPricingRuleDto,
type XiaofeixiaProviderConfig,
type XiaofeixiaProviderConfigPublic,
} from '@dukang/shared-types';
@@ -17,6 +24,12 @@ export type CreateFulfillmentProviderInput = {
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>;
@@ -106,6 +119,13 @@ export class FulfillmentProviderService {
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,
@@ -123,6 +143,12 @@ export class FulfillmentProviderService {
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);
@@ -142,6 +168,11 @@ export class FulfillmentProviderService {
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: {
@@ -152,11 +183,87 @@ export class FulfillmentProviderService {
...(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 {
@@ -183,6 +290,63 @@ export class FulfillmentProviderService {
}
}
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,
@@ -242,6 +406,13 @@ export class FulfillmentProviderService {
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;
}) {
@@ -256,6 +427,13 @@ export class FulfillmentProviderService {
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(),
});