物流对账单

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,10 +1,12 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { WINERY_SETTLEMENT_RATE } from '@dukang/shared-types';
import { DEFAULT_XFX_LOGISTICS_PRICING, WINERY_SETTLEMENT_RATE } from '@dukang/shared-types';
import { calcLogisticsFeeByBottles, type LogisticsPricingRule } from '@dukang/domain';
import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import { AnalyticsService } from '../analytics/analytics.service';
import { PartnerCityService } from '../city-scope/partner-city.service';
import { FulfillmentProviderService } from '../fulfillment/fulfillment-provider.service';
function generateBillNo(prefix: string) {
return `${prefix}${Date.now()}${Math.floor(Math.random() * 900 + 100)}`;
@@ -37,6 +39,7 @@ export class SettlementService {
private readonly prisma: PrismaService,
private readonly analyticsService: AnalyticsService,
private readonly partnerCityService: PartnerCityService,
private readonly fulfillmentProviderService: FulfillmentProviderService,
) {}
// ─── Store payout (line) ─────────────────────────────
@@ -1129,4 +1132,512 @@ export class SettlementService {
}
return where;
}
// ─── Logistics bills (按承运商月结) ───────────────────
async generatePreviousMonthLogisticsBills(anchor = new Date()) {
const prev = new Date(anchor.getFullYear(), anchor.getMonth() - 1, 1);
return this.generateAllLogisticsBills({
year: prev.getFullYear(),
month: prev.getMonth() + 1,
});
}
async generateAllLogisticsBills(body: { year: number; month: number }) {
const providers = await this.prisma.fulfillmentProvider.findMany({
where: { status: 'ACTIVE' },
orderBy: { code: 'asc' },
});
const results: Array<{
providerId: string;
providerCode: string;
providerName: string;
ok: boolean;
skipped?: boolean;
message?: string;
billId?: string;
}> = [];
for (const p of providers) {
try {
const bill = await this.generateLogisticsBill({
providerId: p.id.toString(),
year: body.year,
month: body.month,
});
results.push({
providerId: p.id.toString(),
providerCode: p.code,
providerName: p.name,
ok: true,
skipped: Boolean(bill.skipped),
message: bill.reason,
billId: bill.bill?.id?.toString?.() ?? bill.bill?.id,
});
} catch (e) {
results.push({
providerId: p.id.toString(),
providerCode: p.code,
providerName: p.name,
ok: false,
message: e instanceof Error ? e.message : '失败',
});
}
}
return {
total: providers.length,
success: results.filter((r) => r.ok).length,
failed: results.filter((r) => !r.ok).length,
results,
};
}
async generateLogisticsBill(body: { providerId: string; year: number; month: number }) {
const fulfillmentProviderId = BigInt(body.providerId);
const provider = await this.prisma.fulfillmentProvider.findUnique({
where: { id: fulfillmentProviderId },
});
if (!provider) throw new NotFoundException('仓配承运商不存在');
const periodStart = new Date(body.year, body.month - 1, 1);
const periodEnd = new Date(body.year, body.month, 0, 23, 59, 59, 999);
const existing = await this.prisma.logisticsBill.findUnique({
where: {
fulfillmentProviderId_periodStart: { fulfillmentProviderId, periodStart },
},
});
if (existing?.status === 'PAID') {
return {
skipped: true,
reason: '该月账单已结算',
bill: serializeBigInt(existing),
};
}
const pricing =
this.fulfillmentProviderService.parsePricingRules(provider.pricingRulesJson) ??
(provider.code.toUpperCase() === 'XFX' || provider.code.toUpperCase() === 'XIAOFEIXIA'
? { ...DEFAULT_XFX_LOGISTICS_PRICING }
: null);
if (!pricing) {
throw new BadRequestException(`承运商 ${provider.name} 未配置计价标准`);
}
const deliveries = await this.prisma.orderDelivery.findMany({
where: {
fulfillmentProviderId,
OR: [
{ shippingAt: { gte: periodStart, lte: periodEnd } },
{
shippingAt: null,
outWarehouseAt: { gte: periodStart, lte: periodEnd },
},
],
},
include: {
order: {
select: {
id: true,
orderNo: true,
quantity: true,
deliveryType: true,
payStatus: true,
},
},
},
orderBy: [{ shippingAt: 'asc' }, { outWarehouseAt: 'asc' }],
});
const eligible = deliveries.filter(
(d) =>
d.order.payStatus === 'PAID' &&
(d.order.deliveryType === 'LOCAL' || d.order.deliveryType === 'CROSS_CITY'),
);
if (eligible.length === 0 && !existing) {
return { skipped: true, reason: '无发货订单', bill: null };
}
const items = eligible.map((d) => {
const qty = d.order.quantity;
const fee = calcLogisticsFeeByBottles(qty, pricing as LogisticsPricingRule);
const shippedAt = d.shippingAt ?? d.outWarehouseAt ?? periodStart;
return {
orderId: d.order.id,
orderNo: d.order.orderNo,
quantity: qty,
logisticsAmount: fee,
shippedAt,
};
});
const orderCount = items.length;
const bottleCount = items.reduce((s, i) => s + i.quantity, 0);
const logisticsAmount = round2(items.reduce((s, i) => s + i.logisticsAmount, 0));
const settlementMethod = provider.settlementMethod;
const pricingSnapshotJson = JSON.stringify(pricing);
const bill = await this.prisma.$transaction(async (tx) => {
const header = existing
? await tx.logisticsBill.update({
where: { id: existing.id },
data: {
periodEnd,
orderCount,
bottleCount,
logisticsAmount,
settlementMethod,
pricingSnapshotJson,
status: 'UNPAID',
paidAt: null,
},
})
: await tx.logisticsBill.create({
data: {
billNo: generateBillNo('LB'),
fulfillmentProviderId,
periodStart,
periodEnd,
orderCount,
bottleCount,
logisticsAmount,
settlementMethod,
pricingSnapshotJson,
status: 'UNPAID',
},
});
if (existing) {
await tx.logisticsBillItem.deleteMany({ where: { logisticsBillId: header.id } });
}
if (items.length > 0) {
await tx.logisticsBillItem.createMany({
data: items.map((i) => ({
logisticsBillId: header.id,
orderId: i.orderId,
orderNo: i.orderNo,
quantity: i.quantity,
logisticsAmount: i.logisticsAmount,
shippedAt: i.shippedAt,
})),
});
}
// 充值模式:余额充足则自动扣款并标记已结算
if (settlementMethod === 'PREPAID' && logisticsAmount > 0) {
const deducted = await this.fulfillmentProviderService.deductPrepaid(
fulfillmentProviderId,
logisticsAmount,
header.id,
tx,
);
if (deducted.ok) {
return tx.logisticsBill.update({
where: { id: header.id },
data: { status: 'PAID', paidAt: new Date() },
});
}
}
return header;
});
return {
skipped: false,
bill: serializeBigInt(bill),
};
}
async listAdminLogisticsBills(query: {
page?: number;
pageSize?: number;
status?: string;
providerId?: string;
year?: number;
month?: number;
}) {
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 20;
const where = this.buildLogisticsBillWhere(query);
const [rawItems, total, aggregates] = await Promise.all([
this.prisma.logisticsBill.findMany({
where,
orderBy: { periodStart: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
include: {
fulfillmentProvider: {
select: {
id: true,
code: true,
name: true,
settlementMethod: true,
prepaidBalance: true,
bankAccountName: true,
bankName: true,
bankAccountNo: true,
},
},
},
}),
this.prisma.logisticsBill.count({ where }),
this.prisma.logisticsBill.aggregate({
where,
_sum: { logisticsAmount: true, bottleCount: true, orderCount: true },
_count: true,
}),
]);
const items = rawItems.map((b) => ({
...b,
providerCode: b.fulfillmentProvider.code,
providerName: b.fulfillmentProvider.name,
pricingSnapshot: this.fulfillmentProviderService.parsePricingRules(b.pricingSnapshotJson),
}));
const summary = {
count: aggregates._count,
logisticsAmount: Number(aggregates._sum.logisticsAmount ?? 0),
bottleCount: Number(aggregates._sum.bottleCount ?? 0),
totalAmount: Number(aggregates._sum.logisticsAmount ?? 0),
};
return serializeBigInt({ items, total, page, pageSize, summary });
}
async listLogisticsProviderSummary(query: { year?: number; month?: number }) {
const year = query.year ?? new Date().getFullYear();
const month = query.month ?? new Date().getMonth() + 1;
const periodStart = new Date(year, month - 1, 1);
const periodEnd = new Date(year, month, 0, 23, 59, 59, 999);
const providers = await this.prisma.fulfillmentProvider.findMany({
orderBy: { code: 'asc' },
});
const rows = [];
for (const p of providers) {
const pricing =
this.fulfillmentProviderService.parsePricingRules(p.pricingRulesJson) ??
(p.code.toUpperCase() === 'XFX' || p.code.toUpperCase() === 'XIAOFEIXIA'
? { ...DEFAULT_XFX_LOGISTICS_PRICING }
: null);
const deliveries = await this.prisma.orderDelivery.findMany({
where: {
fulfillmentProviderId: p.id,
OR: [
{ shippingAt: { gte: periodStart, lte: periodEnd } },
{
shippingAt: null,
outWarehouseAt: { gte: periodStart, lte: periodEnd },
},
],
},
include: {
order: { select: { quantity: true, payStatus: true, deliveryType: true } },
},
});
const eligible = deliveries.filter(
(d) =>
d.order.payStatus === 'PAID' &&
(d.order.deliveryType === 'LOCAL' || d.order.deliveryType === 'CROSS_CITY'),
);
const orderCount = eligible.length;
const bottleCount = eligible.reduce((s, d) => s + d.order.quantity, 0);
let logisticsAmount = 0;
if (pricing) {
logisticsAmount = round2(
eligible.reduce(
(s, d) => s + calcLogisticsFeeByBottles(d.order.quantity, pricing as LogisticsPricingRule),
0,
),
);
}
const bill = await this.prisma.logisticsBill.findUnique({
where: {
fulfillmentProviderId_periodStart: {
fulfillmentProviderId: p.id,
periodStart,
},
},
});
rows.push({
providerId: p.id.toString(),
providerCode: p.code,
providerName: p.name,
settlementMethod: p.settlementMethod,
prepaidBalance: Number(p.prepaidBalance),
bankAccountName: p.bankAccountName,
bankName: p.bankName,
bankAccountNo: p.bankAccountNo,
pricingRules: pricing,
orderCount,
bottleCount,
logisticsAmount,
billId: bill?.id?.toString() ?? null,
billStatus: bill?.status ?? null,
billAmount: bill ? Number(bill.logisticsAmount) : null,
});
}
return { year, month, items: rows };
}
async getAdminLogisticsBill(id: bigint) {
const bill = await this.prisma.logisticsBill.findUnique({
where: { id },
include: {
fulfillmentProvider: true,
items: { orderBy: { shippedAt: 'desc' } },
},
});
if (!bill) throw new NotFoundException('物流对账单不存在');
return serializeBigInt({
...bill,
providerCode: bill.fulfillmentProvider.code,
providerName: bill.fulfillmentProvider.name,
pricingSnapshot: this.fulfillmentProviderService.parsePricingRules(bill.pricingSnapshotJson),
});
}
async confirmLogisticsBill(id: bigint) {
const bill = await this.prisma.logisticsBill.findUnique({ where: { id } });
if (!bill) throw new NotFoundException('物流对账单不存在');
if (bill.status !== 'UNPAID') throw new BadRequestException('仅未结算账单可确认');
if (bill.settlementMethod === 'PREPAID') {
const deducted = await this.prisma.$transaction(async (tx) => {
const result = await this.fulfillmentProviderService.deductPrepaid(
bill.fulfillmentProviderId,
Number(bill.logisticsAmount),
bill.id,
tx,
);
if (!result.ok) {
throw new BadRequestException(
`充值余额不足(当前 ¥${result.balance.toFixed(2)},应付 ¥${Number(bill.logisticsAmount).toFixed(2)}`,
);
}
return tx.logisticsBill.update({
where: { id },
data: { status: 'PAID', paidAt: new Date() },
});
});
return serializeBigInt(deducted);
}
const updated = await this.prisma.logisticsBill.update({
where: { id },
data: { status: 'PAID', paidAt: new Date() },
});
return serializeBigInt(updated);
}
async batchConfirmLogisticsBills(ids: string[]) {
const results: Array<{ id: string; ok: boolean; message?: string }> = [];
for (const id of ids) {
try {
await this.confirmLogisticsBill(BigInt(id));
results.push({ id, ok: true });
} catch (e) {
results.push({ id, ok: false, message: e instanceof Error ? e.message : '失败' });
}
}
return results;
}
async exportAdminLogisticsBills(query: {
status?: string;
providerId?: string;
year?: number;
month?: number;
}) {
const where = this.buildLogisticsBillWhere(query);
const bills = await this.prisma.logisticsBill.findMany({
where,
include: {
fulfillmentProvider: { select: { code: true, name: true } },
items: true,
},
orderBy: { periodStart: 'desc' },
});
const header = [
'账单号',
'承运商编码',
'承运商名称',
'账期起',
'账期止',
'结算方式',
'订单号',
'瓶数',
'物流费',
'发货时间',
'账单状态',
].join(',');
const rows: string[] = [];
for (const b of bills) {
if (b.items.length === 0) {
rows.push(
[
csvEscape(b.billNo),
csvEscape(b.fulfillmentProvider.code),
csvEscape(b.fulfillmentProvider.name),
b.periodStart.toISOString().slice(0, 10),
b.periodEnd.toISOString().slice(0, 10),
b.settlementMethod,
'',
b.bottleCount,
Number(b.logisticsAmount),
'',
b.status,
].join(','),
);
continue;
}
for (const item of b.items) {
rows.push(
[
csvEscape(b.billNo),
csvEscape(b.fulfillmentProvider.code),
csvEscape(b.fulfillmentProvider.name),
b.periodStart.toISOString().slice(0, 10),
b.periodEnd.toISOString().slice(0, 10),
b.settlementMethod,
csvEscape(item.orderNo),
item.quantity,
Number(item.logisticsAmount),
item.shippedAt.toISOString().slice(0, 19).replace('T', ' '),
b.status,
].join(','),
);
}
}
return { csv: `\uFEFF${[header, ...rows].join('\n')}`, count: rows.length };
}
private buildLogisticsBillWhere(query: {
status?: string;
providerId?: string;
year?: number;
month?: number;
}): Prisma.LogisticsBillWhereInput {
const where: Prisma.LogisticsBillWhereInput = {};
if (query.status === 'UNPAID' || query.status === 'PAID') where.status = query.status;
if (query.providerId) where.fulfillmentProviderId = BigInt(query.providerId);
if (query.year && query.month) {
const periodStart = new Date(query.year, query.month - 1, 1);
const periodEnd = new Date(query.year, query.month, 0, 23, 59, 59, 999);
where.periodStart = { gte: periodStart, lte: periodEnd };
}
return where;
}
}