feat(settlement): 合伙人账单生成后直接同步并支持打款凭证

去掉总部发送与合伙人确认,财务打款后上传凭证,合伙人即可查看状态。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-26 13:04:03 +08:00
parent cb63d382ad
commit 550697c346
15 changed files with 256 additions and 541 deletions
+1
View File
@@ -1387,6 +1387,7 @@ model PartnerBill {
totalAmount Decimal @map("total_amount") @db.Decimal(10, 2)
status PartnerBillStatus @default(PENDING_REVIEW)
paymentRef String? @map("payment_ref") @db.VarChar(128)
paymentProofUrls Json? @map("payment_proof_urls")
confirmedAt DateTime? @map("confirmed_at") @db.DateTime(3)
sentAt DateTime? @map("sent_at") @db.DateTime(3)
paidAt DateTime? @map("paid_at") @db.DateTime(3)
@@ -118,7 +118,7 @@ async function main() {
},
});
// ─── 2. 合伙人月账单(上月待确认 + 本月已打款样例)───
// ─── 2. 合伙人月账单(上月未打款 + 更早已打款样例)───
const partnerBillAwait = await prisma.partnerBill.upsert({
where: {
partnerAccountId_periodStart: {
@@ -134,7 +134,7 @@ async function main() {
orderCommission: 860,
redeemCommission: 420,
totalAmount: 1280,
status: 'AWAITING_CONFIRM',
status: 'UNPAID',
sentAt: yesterday,
},
update: {
@@ -142,7 +142,7 @@ async function main() {
orderCommission: 860,
redeemCommission: 420,
totalAmount: 1280,
status: 'AWAITING_CONFIRM',
status: 'UNPAID',
sentAt: yesterday,
paidAt: null,
rejectReason: null,
@@ -0,0 +1,13 @@
-- 应付大于 0 的合伙人账单直接同步为未打款(去掉发送 / 合伙人确认 / 驳回)。
-- 零元账单保持 PENDING_REVIEW,不同步给合伙人。
-- 打款凭证照片列与门店账单一致。
ALTER TABLE `partner_bill`
ADD COLUMN `payment_proof_urls` JSON NULL AFTER `payment_ref`;
UPDATE `partner_bill`
SET `status` = 'UNPAID',
`reject_reason` = NULL,
`sent_at` = COALESCE(`sent_at`, CURRENT_TIMESTAMP(3))
WHERE `total_amount` > 0
AND `status` IN ('PENDING_REVIEW', 'AWAITING_CONFIRM', 'REJECTED');
@@ -185,6 +185,7 @@ export class AdminDashboardService {
? this.prisma.partnerBill.count({
where: {
status: 'PENDING_REVIEW',
totalAmount: { lte: 0 },
...(cityFilter ? { partnerAccount: { cityId: cityFilter } } : {}),
},
})
@@ -97,16 +97,6 @@ export class SettlementController {
billDetail(@CurrentUser() user: AuthUser, @Param('id') id: string) {
return this.settlementService.getPartnerBill(user.actorId, BigInt(id));
}
@Post('bills/batch-confirm')
batchConfirm(@CurrentUser() user: AuthUser, @Body() body: { ids: string[] }) {
return this.settlementService.batchPartnerConfirmBills(user.actorId, body.ids ?? []);
}
@Post('bills/:id/confirm')
confirm(@CurrentUser() user: AuthUser, @Param('id') id: string) {
return this.settlementService.partnerConfirmBill(user.actorId, BigInt(id));
}
}
@Controller('shop/payouts')
@@ -432,17 +422,6 @@ export class AdminPartnerBillController {
});
}
@Post('batch-send')
@HqOperation({
action: HqOperationAction.PARTNER_BILL_BATCH_SEND,
refType: 'PARTNER_BILL',
batch: true,
includeBody: true,
})
batchSend(@Body() body: { ids: string[] }) {
return this.settlementService.batchSendPartnerBills(body.ids ?? []);
}
@Post('batch-mark-paid')
@HqOperation({
action: HqOperationAction.PARTNER_BILL_BATCH_MARK_PAID,
@@ -450,8 +429,8 @@ export class AdminPartnerBillController {
batch: true,
includeBody: true,
})
batchMarkPaid(@Body() body: { ids: string[] }) {
return this.settlementService.batchMarkPartnerBillsPaid(body.ids ?? []);
batchMarkPaid(@Body() body: { ids: string[]; paymentRef?: string; paymentProofUrls?: string[] }) {
return this.settlementService.batchMarkPartnerBillsPaid(body.ids ?? [], body);
}
@Get(':id')
@@ -459,26 +438,6 @@ export class AdminPartnerBillController {
return this.settlementService.getAdminPartnerBill(BigInt(id));
}
@Post(':id/send')
@HqOperation({
action: HqOperationAction.PARTNER_BILL_SEND,
refType: 'PARTNER_BILL',
refIdParam: 'id',
})
send(@Param('id') id: string) {
return this.settlementService.sendPartnerBill(BigInt(id));
}
@Post(':id/confirm')
@HqOperation({
action: HqOperationAction.PARTNER_BILL_CONFIRM,
refType: 'PARTNER_BILL',
refIdParam: 'id',
})
confirm(@Param('id') id: string) {
return this.settlementService.confirmPartnerBill(BigInt(id));
}
@Post(':id/mark-paid')
@HqOperation({
action: HqOperationAction.PARTNER_BILL_MARK_PAID,
@@ -486,23 +445,12 @@ export class AdminPartnerBillController {
refIdParam: 'id',
includeBody: true,
})
markPaid(@Param('id') id: string, @Body() body: { paymentRef?: string }) {
markPaid(
@Param('id') id: string,
@Body() body: { paymentRef?: string; paymentProofUrls?: string[] },
) {
return this.settlementService.markPartnerBillPaid(BigInt(id), body);
}
@Post(':id/reject')
@HqOperation({
action: HqOperationAction.PARTNER_BILL_REJECT,
refType: 'PARTNER_BILL',
refIdParam: 'id',
includeBody: true,
})
reject(@Param('id') id: string, @Body() body: { reason?: string }) {
if (!body?.reason?.trim()) {
throw new BadRequestException('请填写驳回理由');
}
return this.settlementService.rejectPartnerBill(BigInt(id), body.reason);
}
}
@Controller('admin/winery-bills')
@@ -119,6 +119,16 @@ function withPartnerBillDates<T extends { periodStart: Date; periodEnd: Date }>(
};
}
function mapPartnerBillView<T extends { periodStart: Date; periodEnd: Date; paymentProofUrls?: unknown }>(row: T) {
const { paymentProofUrls, ...rest } = row;
return {
...withPartnerBillDates(rest),
paymentProofUrls: parsePaymentProofUrls(paymentProofUrls),
};
}
const PARTNER_BILL_PUBLISH_STATUSES = ['PENDING_REVIEW', 'AWAITING_CONFIRM', 'REJECTED'] as const;
function resolvePartnerWeekPeriod(weekStartYmd: string): {
periodStart: Date;
periodEnd: Date;
@@ -284,6 +294,12 @@ export class SettlementService implements OnModuleInit {
} catch (e) {
this.logger.warn(`Winery bill date align skipped: ${e instanceof Error ? e.message : e}`);
}
try {
const published = await this.publishOutstandingPartnerBills();
if (published > 0) this.logger.log(`Partner bills published to partners: ${published}`);
} catch (e) {
this.logger.warn(`Partner bill publish skipped: ${e instanceof Error ? e.message : e}`);
}
}
/**
@@ -1722,12 +1738,33 @@ export class SettlementService implements OnModuleInit {
// ─── Partner bills ───────────────────────────────────
/** 应付大于 0 且仍停在审核/待确认/驳回的账单,直接改为未打款并同步合伙人。 */
private async publishOutstandingPartnerBills(): Promise<number> {
const alreadySent = await this.prisma.partnerBill.updateMany({
where: {
totalAmount: { gt: 0 },
status: { in: [...PARTNER_BILL_PUBLISH_STATUSES] },
sentAt: { not: null },
},
data: { status: 'UNPAID', rejectReason: null },
});
const fresh = await this.prisma.partnerBill.updateMany({
where: {
totalAmount: { gt: 0 },
status: { in: [...PARTNER_BILL_PUBLISH_STATUSES] },
},
data: { status: 'UNPAID', sentAt: new Date(), rejectReason: null },
});
return alreadySent.count + fresh.count;
}
async listPartnerBills(partnerAccountId: bigint) {
await this.publishOutstandingPartnerBills();
const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId);
const bills = await this.prisma.partnerBill.findMany({
where: {
partnerAccountId: primary.id,
status: { in: ['AWAITING_CONFIRM', 'UNPAID', 'PAID', 'REJECTED'] },
status: { in: ['UNPAID', 'PAID'] },
totalAmount: { gt: 0 },
},
orderBy: { createdAt: 'desc' },
@@ -1737,7 +1774,7 @@ export class SettlementService implements OnModuleInit {
eventName: 'partner_bill_view',
extraJson: { count: bills.length },
});
return serializeBigInt(bills.map(withPartnerBillDates));
return serializeBigInt(bills.map(mapPartnerBillView));
}
getPartnerSettlementCycle(anchor = new Date()) {
@@ -1793,6 +1830,7 @@ export class SettlementService implements OnModuleInit {
}
async getPartnerBill(partnerAccountId: bigint, billId: bigint) {
await this.publishOutstandingPartnerBills();
const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId);
const bill = await this.prisma.partnerBill.findFirst({
where: { id: billId, partnerAccountId: primary.id },
@@ -1800,6 +1838,9 @@ export class SettlementService implements OnModuleInit {
});
if (!bill) throw new NotFoundException('账单不存在');
if (Number(bill.totalAmount) <= 0) throw new NotFoundException('账单不存在');
if (bill.status !== 'UNPAID' && bill.status !== 'PAID') {
throw new NotFoundException('账单不存在');
}
this.analyticsService.trackPartnerOneSafe(partnerAccountId, 'PARTNER_H5', {
partnerAccountId: primary.id,
eventName: 'partner_bill_detail_view',
@@ -1809,7 +1850,7 @@ export class SettlementService implements OnModuleInit {
});
const { items, ...header } = bill;
return serializeBigInt({
...withPartnerBillDates(header),
...mapPartnerBillView(header),
partnerId: header.partnerAccountId.toString(),
...splitPartnerBillItems(items),
});
@@ -1824,6 +1865,7 @@ export class SettlementService implements OnModuleInit {
month?: number;
weekStartYmd?: string;
}) {
await this.publishOutstandingPartnerBills();
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 20;
const where = this.buildPartnerBillWhere(query);
@@ -1857,7 +1899,7 @@ export class SettlementService implements OnModuleInit {
]);
const items = rawItems.map((b) => ({
...withPartnerBillDates(b),
...mapPartnerBillView(b),
partner: b.partnerAccount,
}));
@@ -1973,6 +2015,7 @@ export class SettlementService implements OnModuleInit {
}
async getAdminPartnerBill(id: bigint) {
await this.publishOutstandingPartnerBills();
const bill = await this.prisma.partnerBill.findUnique({
where: { id },
include: { partnerAccount: true, items: { orderBy: { occurredAt: 'asc' } } },
@@ -1980,7 +2023,7 @@ export class SettlementService implements OnModuleInit {
if (!bill) throw new NotFoundException('账单不存在');
const { items, ...header } = bill;
return serializeBigInt({
...withPartnerBillDates(header),
...mapPartnerBillView(header),
partnerId: header.partnerAccountId.toString(),
...splitPartnerBillItems(items),
});
@@ -2000,8 +2043,8 @@ export class SettlementService implements OnModuleInit {
periodStart: { gte: periodStart, lt: endExclusive },
},
});
if (existing && existing.status !== 'PENDING_REVIEW') {
throw new BadRequestException('该账期账单已进入审核流程,不可重复生成');
if (existing?.status === 'PAID') {
throw new BadRequestException('该账期账单已打款,不可重复生成');
}
if (!primary.cityId) {
@@ -2070,6 +2113,8 @@ export class SettlementService implements OnModuleInit {
const totalAmount = round2(orderCommission + redeemCommission);
const itemRows = [...orderRows, ...redeemRows];
const publish = totalAmount > 0;
const sentAt = publish ? (existing?.sentAt ?? new Date()) : null;
const bill = await this.prisma.$transaction(async (tx) => {
const header = existing
@@ -2081,7 +2126,9 @@ export class SettlementService implements OnModuleInit {
totalAmount,
periodStart,
periodEnd,
status: 'PENDING_REVIEW',
status: publish ? 'UNPAID' : 'PENDING_REVIEW',
sentAt,
rejectReason: null,
},
})
: await tx.partnerBill.create({
@@ -2093,7 +2140,8 @@ export class SettlementService implements OnModuleInit {
orderCommission: round2(orderCommission),
redeemCommission: round2(redeemCommission),
totalAmount,
status: 'PENDING_REVIEW',
status: publish ? 'UNPAID' : 'PENDING_REVIEW',
sentAt,
},
});
@@ -2155,128 +2203,14 @@ export class SettlementService implements OnModuleInit {
});
}
/** HQ:发送给合伙人(待审核 → 待合伙人确认) */
async sendPartnerBill(id: bigint) {
const bill = await this.prisma.partnerBill.findUnique({ where: { id } });
if (!bill) throw new NotFoundException('账单不存在');
if (Number(bill.totalAmount) <= 0) {
throw new BadRequestException('零元账单不同步给合伙人');
}
if (bill.status !== 'PENDING_REVIEW' && bill.status !== 'REJECTED') {
throw new BadRequestException('仅待审核或已驳回账单可发送');
}
const updated = await this.prisma.partnerBill.update({
where: { id },
data: {
status: 'AWAITING_CONFIRM',
sentAt: new Date(),
rejectReason: null,
},
});
return serializeBigInt(updated);
}
async batchSendPartnerBills(ids: string[]) {
const results: Array<{ id: string; ok: boolean; message?: string }> = [];
for (const id of ids) {
try {
await this.sendPartnerBill(BigInt(id));
results.push({ id, ok: true });
} catch (e) {
results.push({ id, ok: false, message: e instanceof Error ? e.message : '失败' });
}
}
return results;
}
/** @deprecated HQ 代确认:待审核/待确认 → 未打款 */
async confirmPartnerBill(id: bigint) {
const bill = await this.prisma.partnerBill.findUnique({ where: { id } });
if (!bill) throw new NotFoundException('账单不存在');
if (bill.status !== 'PENDING_REVIEW' && bill.status !== 'AWAITING_CONFIRM' && bill.status !== 'REJECTED') {
throw new BadRequestException('当前状态不可确认');
}
const updated = await this.prisma.partnerBill.update({
where: { id },
data: {
status: 'UNPAID',
confirmedAt: new Date(),
sentAt: bill.sentAt ?? new Date(),
rejectReason: null,
},
});
return serializeBigInt(updated);
}
/** 合伙人确认:待合伙人确认 → 未打款 */
async partnerConfirmBill(partnerAccountId: bigint, billId: bigint) {
const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId);
const bill = await this.prisma.partnerBill.findUnique({ where: { id: billId } });
if (!bill || bill.partnerAccountId !== primary.id) {
throw new NotFoundException('账单不存在');
}
if (bill.status !== 'AWAITING_CONFIRM') {
throw new BadRequestException('当前账单不可确认');
}
const updated = await this.prisma.partnerBill.update({
where: { id: billId },
data: {
status: 'UNPAID',
confirmedAt: new Date(),
rejectReason: null,
},
});
this.analyticsService.trackPartnerOneSafe(partnerAccountId, 'PARTNER_H5', {
partnerAccountId: primary.id,
eventName: 'partner_bill_confirm',
refType: 'PARTNER_BILL',
refId: billId,
extraJson: { billNo: bill.billNo },
});
return serializeBigInt(updated);
}
async batchPartnerConfirmBills(partnerAccountId: bigint, ids: string[]) {
const results: Array<{ id: string; ok: boolean; message?: string }> = [];
for (const id of ids) {
try {
await this.partnerConfirmBill(partnerAccountId, BigInt(id));
results.push({ id, ok: true });
} catch (e) {
results.push({ id, ok: false, message: e instanceof Error ? e.message : '失败' });
}
}
return results;
}
async rejectPartnerBill(id: bigint, reason: string) {
const rejectReason = reason.trim();
if (!rejectReason) throw new BadRequestException('请填写驳回理由');
if (rejectReason.length > 500) throw new BadRequestException('驳回理由不能超过 500 字');
const bill = await this.prisma.partnerBill.findUnique({ where: { id } });
if (!bill) throw new NotFoundException('账单不存在');
if (bill.status !== 'AWAITING_CONFIRM' && bill.status !== 'UNPAID') {
throw new BadRequestException('仅待合伙人确认或未打款账单可驳回');
}
const updated = await this.prisma.partnerBill.update({
where: { id },
data: {
status: 'REJECTED',
rejectReason,
},
});
return serializeBigInt(updated);
}
async markPartnerBillPaid(id: bigint, dto: { paymentRef?: string } = {}) {
async markPartnerBillPaid(
id: bigint,
dto: { paymentRef?: string; paymentProofUrls?: string[] } = {},
) {
await this.publishOutstandingPartnerBills();
const bill = await this.prisma.partnerBill.findUnique({ where: { id } });
if (!bill) throw new NotFoundException('账单不存在');
if (Number(bill.totalAmount) <= 0) throw new BadRequestException('零元账单无需打款');
if (bill.status !== 'UNPAID') throw new BadRequestException('仅未打款账单可标记打款');
const updated = await this.prisma.partnerBill.update({
@@ -2286,17 +2220,21 @@ export class SettlementService implements OnModuleInit {
paidAt: new Date(),
rejectReason: null,
paymentRef: dto.paymentRef?.trim() || null,
paymentProofUrls: paymentProofUrlsInput(dto.paymentProofUrls),
},
});
return serializeBigInt(updated);
return serializeBigInt(mapPartnerBillView(updated));
}
async batchMarkPartnerBillsPaid(ids: string[]) {
async batchMarkPartnerBillsPaid(
ids: string[],
dto: { paymentRef?: string; paymentProofUrls?: string[] } = {},
) {
const results: Array<{ id: string; ok: boolean; message?: string }> = [];
for (const id of ids) {
try {
await this.markPartnerBillPaid(BigInt(id));
await this.markPartnerBillPaid(BigInt(id), dto);
results.push({ id, ok: true });
} catch (e) {
results.push({ id, ok: false, message: e instanceof Error ? e.message : '失败' });
@@ -2363,7 +2301,18 @@ export class SettlementService implements OnModuleInit {
{ key: '合计应付', header: '合计应付', value: (r) => Number(r.totalAmount) },
{ key: '收款户名', header: '收款户名', value: (r) => r.partnerAccount.bankAccountName ?? '' },
{ key: '收款账号', header: '收款账号', value: (r) => r.partnerAccount.bankAccountNo ?? '' },
{ key: '状态', header: '状态', value: (r) => r.status },
{
key: '状态',
header: '状态',
value: (r) =>
({
PENDING_REVIEW: '待审核',
AWAITING_CONFIRM: '待合伙人确认',
UNPAID: '未打款',
PAID: '已打款',
REJECTED: '已驳回',
})[r.status] ?? r.status,
},
{ key: '登录手机', header: '登录手机', value: (r) => r.partnerAccount.phone ?? '' },
{ key: '开户行', header: '开户行', value: (r) => r.partnerAccount.bankBranch ?? '' },
{
@@ -2382,6 +2331,11 @@ export class SettlementService implements OnModuleInit {
value: (r) => (r.paidAt ? shanghaiYmd(r.paidAt) : ''),
},
{ key: '打款凭证', header: '打款凭证', value: (r) => r.paymentRef ?? '' },
{
key: '打款凭证照片',
header: '打款凭证照片',
value: (r) => parsePaymentProofUrls(r.paymentProofUrls).join(' '),
},
{ key: '驳回理由', header: '驳回理由', value: (r) => r.rejectReason ?? '' },
];