物流对账单
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user