物流对账单

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
+90 -10
View File
@@ -226,6 +226,18 @@ enum FinancePayStatus {
PAID
}
// 物流承运商结算方式:前期充值,后期挂账月结
enum LogisticsSettlementMethod {
PREPAID
MONTHLY_CREDIT
}
enum LogisticsPrepaidLedgerType {
RECHARGE
DEDUCT
ADJUST
}
enum StoreStatus {
OPEN
PAUSED
@@ -674,22 +686,90 @@ model CommonCity {
}
model FulfillmentProvider {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
code String @unique @db.VarChar(32)
name String @db.VarChar(128)
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
code String @unique @db.VarChar(32)
name String @db.VarChar(128)
type FulfillmentProviderType
status FulfillmentProviderStatus @default(ACTIVE)
configJson String? @map("config_json") @db.Text
capabilitiesJson String? @map("capabilities_json") @db.Text
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
status FulfillmentProviderStatus @default(ACTIVE)
configJson String? @map("config_json") @db.Text
capabilitiesJson String? @map("capabilities_json") @db.Text
bankAccountName String? @map("bank_account_name") @db.VarChar(64)
bankName String? @map("bank_name") @db.VarChar(64)
bankBranch String? @map("bank_branch") @db.VarChar(128)
bankAccountNo String? @map("bank_account_no") @db.VarChar(64)
settlementMethod LogisticsSettlementMethod @default(PREPAID) @map("settlement_method")
pricingRulesJson String? @map("pricing_rules_json") @db.Text
prepaidBalance Decimal @default(0) @map("prepaid_balance") @db.Decimal(12, 2)
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
warehouses CityWarehouse[]
deliveries OrderDelivery[]
warehouses CityWarehouse[]
deliveries OrderDelivery[]
logisticsBills LogisticsBill[]
prepaidLedgers LogisticsPrepaidLedger[]
@@map("common_fulfillment_provider")
}
// 物流对账月账单(按承运商汇总)
model LogisticsBill {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
billNo String @unique @map("bill_no") @db.VarChar(32)
fulfillmentProviderId BigInt @map("fulfillment_provider_id") @db.UnsignedBigInt
periodStart DateTime @map("period_start") @db.DateTime(3)
periodEnd DateTime @map("period_end") @db.DateTime(3)
orderCount Int @default(0) @map("order_count")
bottleCount Int @default(0) @map("bottle_count")
logisticsAmount Decimal @map("logistics_amount") @db.Decimal(12, 2)
settlementMethod LogisticsSettlementMethod @map("settlement_method")
pricingSnapshotJson String? @map("pricing_snapshot_json") @db.Text
status FinancePayStatus @default(UNPAID)
paidAt DateTime? @map("paid_at") @db.DateTime(3)
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
fulfillmentProvider FulfillmentProvider @relation(fields: [fulfillmentProviderId], references: [id], onDelete: Restrict)
items LogisticsBillItem[]
prepaidLedgers LogisticsPrepaidLedger[]
@@unique([fulfillmentProviderId, periodStart])
@@index([status, periodStart])
@@map("logistics_bill")
}
model LogisticsBillItem {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
logisticsBillId BigInt @map("logistics_bill_id") @db.UnsignedBigInt
orderId BigInt @map("order_id") @db.UnsignedBigInt
orderNo String @map("order_no") @db.VarChar(32)
quantity Int
logisticsAmount Decimal @map("logistics_amount") @db.Decimal(10, 2)
shippedAt DateTime @map("shipped_at") @db.DateTime(3)
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
logisticsBill LogisticsBill @relation(fields: [logisticsBillId], references: [id], onDelete: Cascade)
@@unique([logisticsBillId, orderId])
@@index([logisticsBillId])
@@map("logistics_bill_item")
}
model LogisticsPrepaidLedger {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
fulfillmentProviderId BigInt @map("fulfillment_provider_id") @db.UnsignedBigInt
type LogisticsPrepaidLedgerType
amount Decimal @db.Decimal(12, 2)
balanceAfter Decimal @map("balance_after") @db.Decimal(12, 2)
logisticsBillId BigInt? @map("logistics_bill_id") @db.UnsignedBigInt
remark String? @db.VarChar(256)
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
fulfillmentProvider FulfillmentProvider @relation(fields: [fulfillmentProviderId], references: [id], onDelete: Restrict)
logisticsBill LogisticsBill? @relation(fields: [logisticsBillId], references: [id], onDelete: SetNull)
@@index([fulfillmentProviderId, createdAt])
@@map("logistics_prepaid_ledger")
}
model CityWarehouse {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
cityId BigInt @map("city_id") @db.UnsignedBigInt
+8
View File
@@ -183,6 +183,14 @@ async function main() {
name: '小飞侠',
type: 'API',
status: 'ACTIVE',
settlementMethod: 'PREPAID',
pricingRulesJson: JSON.stringify({
baseBottles: 2,
baseFee: 6,
extraBottleFee: 2,
boxBottles: 6,
boxFee: 14,
}),
capabilitiesJson: JSON.stringify({
createShipment: true,
getTrack: true,
@@ -67,6 +67,9 @@ export const HqOperationAction = {
PARTNER_BILL_REJECT: 'PARTNER_BILL_REJECT',
WINERY_BILL_CONFIRM: 'WINERY_BILL_CONFIRM',
WINERY_BILL_BATCH_CONFIRM: 'WINERY_BILL_BATCH_CONFIRM',
LOGISTICS_BILL_CONFIRM: 'LOGISTICS_BILL_CONFIRM',
LOGISTICS_BILL_BATCH_CONFIRM: 'LOGISTICS_BILL_BATCH_CONFIRM',
LOGISTICS_PROVIDER_RECHARGE: 'LOGISTICS_PROVIDER_RECHARGE',
REDEEM_DEBUG_CREATE_TOKEN: 'REDEEM_DEBUG_CREATE_TOKEN',
REDEEM_DEBUG_CONFIRM: 'REDEEM_DEBUG_CONFIRM',
PROMO_CODE_CREATE: 'PROMO_CODE_CREATE',
@@ -163,6 +166,9 @@ export const HQ_OPERATION_ACTION_LABELS: Record<string, string> = {
[HqOperationAction.PARTNER_BILL_REJECT]: '驳回合伙人打款申请',
[HqOperationAction.WINERY_BILL_CONFIRM]: '酒厂对账单确认打款',
[HqOperationAction.WINERY_BILL_BATCH_CONFIRM]: '批量酒厂对账单打款',
[HqOperationAction.LOGISTICS_BILL_CONFIRM]: '物流对账单确认结算',
[HqOperationAction.LOGISTICS_BILL_BATCH_CONFIRM]: '批量物流对账单结算',
[HqOperationAction.LOGISTICS_PROVIDER_RECHARGE]: '物流承运商充值',
[HqOperationAction.REDEEM_DEBUG_CREATE_TOKEN]: '核销调试-生成码',
[HqOperationAction.REDEEM_DEBUG_CONFIRM]: '核销调试-确认核销',
[HqOperationAction.PROMO_CODE_CREATE]: '创建推广码',
@@ -5,7 +5,7 @@ import { SettlementService } from '../modules/settlement/settlement.service';
/**
* 财务对账单定时任务(Asia/Shanghai
* - 每日 08:00:酒厂日账单 + 门店日账单(统计昨日 00:00~今日 00:00
* - 每月 1 日 08:00:合伙人上一自然月账单
* - 每月 1 日 08:00:合伙人上一自然月账单 + 物流承运商上一自然月对账
*/
@Injectable()
export class SettlementScheduler {
@@ -41,5 +41,13 @@ export class SettlementScheduler {
} catch (e) {
this.logger.error('Partner bill job failed', e instanceof Error ? e.stack : e);
}
try {
const logistics = await this.settlementService.generatePreviousMonthLogisticsBills();
this.logger.log(
`Logistics bills: total=${logistics.total} success=${logistics.success} failed=${logistics.failed}`,
);
} catch (e) {
this.logger.error('Logistics bill job failed', e instanceof Error ? e.stack : e);
}
}
}
@@ -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(),
});
@@ -5,6 +5,7 @@ import { HqOperationAction } from '../../common/hq-operation/hq-operation.consta
import { FulfillmentProviderService } from '../fulfillment/fulfillment-provider.service';
import {
CreateFulfillmentProviderDto,
RechargeFulfillmentProviderDto,
UpdateFulfillmentProviderDto,
} from './dto/admin-mutate.dto';
import type { FulfillmentProviderStatus, FulfillmentProviderType } from '@prisma/client';
@@ -40,6 +41,12 @@ export class AdminFulfillmentProvidersController {
configJson: dto.configJson,
capabilitiesJson: dto.capabilitiesJson,
xiaofeixiaConfig: dto.xiaofeixiaConfig,
bankAccountName: dto.bankAccountName,
bankName: dto.bankName,
bankBranch: dto.bankBranch,
bankAccountNo: dto.bankAccountNo,
settlementMethod: dto.settlementMethod,
pricingRules: dto.pricingRules,
});
}
@@ -63,6 +70,23 @@ export class AdminFulfillmentProvidersController {
configJson: dto.configJson,
capabilitiesJson: dto.capabilitiesJson,
xiaofeixiaConfig: dto.xiaofeixiaConfig,
bankAccountName: dto.bankAccountName,
bankName: dto.bankName,
bankBranch: dto.bankBranch,
bankAccountNo: dto.bankAccountNo,
settlementMethod: dto.settlementMethod,
pricingRules: dto.pricingRules,
});
}
@Post(':id/recharge')
@HqOperation({
action: HqOperationAction.LOGISTICS_PROVIDER_RECHARGE,
refType: 'FULFILLMENT_PROVIDER',
refIdParam: 'id',
includeBody: true,
})
recharge(@Param('id') id: string, @Body() dto: RechargeFulfillmentProviderDto) {
return this.service.rechargePrepaid(BigInt(id), Number(dto.amount), dto.remark);
}
}
@@ -593,6 +593,36 @@ export class CreateFulfillmentProviderDto {
signType?: 'MD5' | 'HMAC-SHA256';
appId?: string;
};
@IsOptional()
@IsString()
bankAccountName?: string | null;
@IsOptional()
@IsString()
bankName?: string | null;
@IsOptional()
@IsString()
bankBranch?: string | null;
@IsOptional()
@IsString()
bankAccountNo?: string | null;
@IsOptional()
@IsIn(['PREPAID', 'MONTHLY_CREDIT'])
settlementMethod?: 'PREPAID' | 'MONTHLY_CREDIT';
@IsOptional()
@IsObject()
pricingRules?: {
baseBottles: number;
baseFee: number;
extraBottleFee: number;
boxBottles?: number;
boxFee?: number;
} | null;
}
export class UpdateFulfillmentProviderDto {
@@ -625,6 +655,45 @@ export class UpdateFulfillmentProviderDto {
signType?: 'MD5' | 'HMAC-SHA256';
appId?: string;
};
@IsOptional()
@IsString()
bankAccountName?: string | null;
@IsOptional()
@IsString()
bankName?: string | null;
@IsOptional()
@IsString()
bankBranch?: string | null;
@IsOptional()
@IsString()
bankAccountNo?: string | null;
@IsOptional()
@IsIn(['PREPAID', 'MONTHLY_CREDIT'])
settlementMethod?: 'PREPAID' | 'MONTHLY_CREDIT';
@IsOptional()
@IsObject()
pricingRules?: {
baseBottles: number;
baseFee: number;
extraBottleFee: number;
boxBottles?: number;
boxFee?: number;
} | null;
}
export class RechargeFulfillmentProviderDto {
@IsNumber()
amount: number;
@IsOptional()
@IsString()
remark?: string;
}
export class ManualShipOrderDto {
@@ -373,6 +373,86 @@ export class AdminWineryBillController {
}
}
@Controller('admin/logistics-bills')
@UseGuards(HqAuthGuard)
export class AdminLogisticsBillController {
constructor(private readonly settlementService: SettlementService) {}
@Get()
list(@Query() query: Record<string, string>) {
return this.settlementService.listAdminLogisticsBills({
page: query.page ? Number(query.page) : 1,
pageSize: query.pageSize ? Number(query.pageSize) : 20,
status: query.status,
providerId: query.providerId,
year: query.year ? Number(query.year) : undefined,
month: query.month ? Number(query.month) : undefined,
});
}
@Get('provider-summary')
providerSummary(@Query() query: Record<string, string>) {
return this.settlementService.listLogisticsProviderSummary({
year: query.year ? Number(query.year) : undefined,
month: query.month ? Number(query.month) : undefined,
});
}
@Get('export')
export(@Query() query: Record<string, string>) {
return this.settlementService.exportAdminLogisticsBills({
status: query.status,
providerId: query.providerId,
year: query.year ? Number(query.year) : undefined,
month: query.month ? Number(query.month) : undefined,
});
}
@Post('generate')
generate(@Body() body: { year: number; month: number; providerId?: string }) {
if (!body?.year || !body?.month) {
throw new BadRequestException('请指定年月');
}
if (body.providerId) {
return this.settlementService.generateLogisticsBill({
providerId: body.providerId,
year: body.year,
month: body.month,
});
}
return this.settlementService.generateAllLogisticsBills({
year: body.year,
month: body.month,
});
}
@Post('batch-confirm')
@HqOperation({
action: HqOperationAction.LOGISTICS_BILL_BATCH_CONFIRM,
refType: 'LOGISTICS_BILL',
batch: true,
includeBody: true,
})
batchConfirm(@Body() body: { ids: string[] }) {
return this.settlementService.batchConfirmLogisticsBills(body.ids ?? []);
}
@Get(':id')
detail(@Param('id') id: string) {
return this.settlementService.getAdminLogisticsBill(BigInt(id));
}
@Post(':id/confirm')
@HqOperation({
action: HqOperationAction.LOGISTICS_BILL_CONFIRM,
refType: 'LOGISTICS_BILL',
refIdParam: 'id',
})
confirm(@Param('id') id: string) {
return this.settlementService.confirmLogisticsBill(BigInt(id));
}
}
@Controller('partner/me')
@UseGuards(JwtAuthGuard)
export class PartnerMeController {
@@ -2,8 +2,10 @@ import { Module } from '@nestjs/common';
import { IamModule } from '../iam/iam.module';
import { AnalyticsModule } from '../analytics/analytics.module';
import { CityScopeModule } from '../city-scope/city-scope.module';
import { FulfillmentModule } from '../fulfillment/fulfillment.module';
import { SettlementService } from './settlement.service';
import {
AdminLogisticsBillController,
AdminPartnerBillController,
AdminStoreBillController,
AdminStorePayoutController,
@@ -14,7 +16,7 @@ import {
} from './settlement.controller';
@Module({
imports: [IamModule, AnalyticsModule, CityScopeModule],
imports: [IamModule, AnalyticsModule, CityScopeModule, FulfillmentModule],
controllers: [
SettlementController,
PartnerMeController,
@@ -23,6 +25,7 @@ import {
AdminStoreBillController,
AdminPartnerBillController,
AdminWineryBillController,
AdminLogisticsBillController,
],
providers: [SettlementService],
exports: [SettlementService],
@@ -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;
}
}