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