Merge branch 'dev' of git.yqidian.com:jacy/dukang into dev-v4.0.10

This commit is contained in:
developer_liu
2026-09-02 09:50:08 +08:00
33 changed files with 893 additions and 176 deletions
@@ -0,0 +1,4 @@
-- v4.0.9:关联码已扫码计数
ALTER TABLE `partner_account`
ADD COLUMN `assoc_scan_count` INT NOT NULL DEFAULT 0 AFTER `assoc_qrcode_resource_id`;
+1
View File
@@ -1208,6 +1208,7 @@ model PartnerAccount {
managedWarehouseId BigInt? @unique @map("managed_warehouse_id") @db.UnsignedBigInt
assocQrcodeId String? @unique @map("assoc_qrcode_id") @db.VarChar(64)
assocQrcodeResourceId BigInt? @map("assoc_qrcode_resource_id") @db.UnsignedBigInt
assocScanCount Int @default(0) @map("assoc_scan_count")
activityPosterId BigInt? @map("activity_poster_id") @db.UnsignedBigInt
/// 测试合伙人账号
isTest Boolean @default(false) @map("is_test")
@@ -7,7 +7,8 @@ import { AlertService } from '../common/alert/alert.service';
/**
* 财务对账单定时任务(Asia/Shanghai
* - 每日 08:00:酒厂 T+3 期账单(每 3 天出一期,纳入上期 3 天已完成已付订单)+ 近 45 日出账周期核对补漏 + 门店日账单
* - 每月 1 日 08:00:合伙人上一自然账单 + 物流承运商上一自然月对账
* - 每周一 08:00:合伙人上一自然账单
* - 每月 1 日 08:00:物流承运商上一自然月对账
* - 工作日 18:05:门店提现 T+0 审完预警(FIN-003
*/
@Injectable()
@@ -75,11 +76,11 @@ export class SettlementScheduler {
}
}
@Cron('0 8 1 * *', { timeZone: 'Asia/Shanghai' })
async handleMonthlyPartnerBills() {
this.logger.log('Monthly partner bills job start');
@Cron('0 8 * * 1', { timeZone: 'Asia/Shanghai' })
async handleWeeklyPartnerBills() {
this.logger.log('Weekly partner bills job start');
try {
const result = await this.settlementService.generatePreviousMonthPartnerBills();
const result = await this.settlementService.generatePreviousWeekPartnerBills();
this.logger.log(
`Partner bills: total=${result.total} success=${result.success} failed=${result.failed}`,
);
@@ -87,9 +88,9 @@ export class SettlementScheduler {
this.alert.notify({
level: 'P0',
category: 'settlement',
title: '合伙人账单部分失败',
title: '合伙人账单部分失败',
detail: `total=${result.total} success=${result.success} failed=${result.failed}`,
dedupeKey: `job_partner_bill|${new Date().toISOString().slice(0, 7)}`,
dedupeKey: `job_partner_bill|${new Date().toISOString().slice(0, 10)}`,
});
}
} catch (e) {
@@ -97,11 +98,16 @@ export class SettlementScheduler {
this.alert.notify({
level: 'P0',
category: 'settlement',
title: '合伙人账单任务失败',
title: '合伙人账单任务失败',
detail: e instanceof Error ? e.message : String(e),
dedupeKey: `job_partner_bill|${new Date().toISOString().slice(0, 7)}`,
dedupeKey: `job_partner_bill|${new Date().toISOString().slice(0, 10)}`,
});
}
}
@Cron('0 8 1 * *', { timeZone: 'Asia/Shanghai' })
async handleMonthlyPartnerBills() {
this.logger.log('Monthly logistics bills job start');
try {
const logistics = await this.settlementService.generatePreviousMonthLogisticsBills();
this.logger.log(
@@ -115,7 +115,7 @@ export class PartnerStaffService {
permissions,
isPrimary: 0,
parentAccountId: parent.id,
status: 'DISABLED',
status: 'ACTIVE',
},
});
@@ -1,5 +1,5 @@
import { BadRequestException, Body, Controller, Get, Param, Post, Put, Query, UseGuards } from '@nestjs/common';
import { IsOptional, IsString } from 'class-validator';
import { BadRequestException, Body, Controller, ForbiddenException, Get, Param, Post, Put, Query, UseGuards } from '@nestjs/common';
import { IsNotEmpty, IsOptional, IsString, MaxLength } from 'class-validator';
import { parseShanghaiYmd } from '@dukang/domain';
import { DEFAULT_PARTNER_STORE_STAFF_PERMISSIONS } from '@dukang/shared-types';
import { SettlementService } from './settlement.service';
@@ -20,11 +20,38 @@ class UpdatePartnerMeDto {
name?: string;
}
class UpdatePartnerBankDto {
@IsString()
@IsNotEmpty({ message: '请填写收款人' })
@MaxLength(64)
bankAccountName!: string;
@IsString()
@IsNotEmpty({ message: '请填写银行账号' })
@MaxLength(32)
bankAccountNo!: string;
@IsString()
@IsNotEmpty({ message: '请填写开户行名称' })
@MaxLength(128)
bankBranch!: string;
}
@Controller('partner/settlement')
@UseGuards(JwtAuthGuard, PartnerPrimaryGuard)
export class SettlementController {
constructor(private readonly settlementService: SettlementService) {}
@Get('cycle')
cycle() {
return this.settlementService.getPartnerSettlementCycle();
}
@Get('preview')
preview(@CurrentUser() user: AuthUser) {
return this.settlementService.getPartnerSettlementPreview(user.actorId);
}
@Get('bills')
bills(@CurrentUser() user: AuthUser) {
return this.settlementService.listPartnerBills(user.actorId);
@@ -325,7 +352,9 @@ export class AdminPartnerBillController {
refIdField: 'id',
includeBody: true,
})
generate(@Body() body: { partnerId: string; year: number; month: number }) {
generate(@Body() body: { partnerId: string; weekStartYmd: string }) {
if (!body?.weekStartYmd) throw new BadRequestException('请提供 weekStartYmd');
if (!body?.partnerId) throw new BadRequestException('请提供 partnerId');
return this.settlementService.generatePartnerBill(body);
}
@@ -336,7 +365,8 @@ export class AdminPartnerBillController {
batch: true,
includeBody: true,
})
generateAll(@Body() body: { year: number; month: number }) {
generateAll(@Body() body: { weekStartYmd: string }) {
if (!body?.weekStartYmd) throw new BadRequestException('请提供 weekStartYmd');
return this.settlementService.generateAllPartnerBills(body);
}
@@ -349,6 +379,7 @@ export class AdminPartnerBillController {
partnerId: query.partnerId,
year: query.year ? Number(query.year) : undefined,
month: query.month ? Number(query.month) : undefined,
weekStartYmd: query.weekStartYmd,
});
}
@@ -359,6 +390,7 @@ export class AdminPartnerBillController {
status: query.status,
year: query.year ? Number(query.year) : undefined,
month: query.month ? Number(query.month) : undefined,
weekStartYmd: query.weekStartYmd,
});
}
@@ -618,6 +650,29 @@ export class PartnerMeController {
return this.buildPartnerMe(user.actorId);
}
@Put('bank')
async updateBank(@CurrentUser() user: AuthUser, @Body() dto: UpdatePartnerBankDto) {
const account = await this.prisma.partnerAccount.findUniqueOrThrow({
where: { id: user.actorId },
});
if (account.isPrimary !== 1) {
throw new ForbiddenException('仅主账号可修改收款账户');
}
const bankAccountName = dto.bankAccountName.trim();
const bankAccountNo = dto.bankAccountNo.replace(/\s+/g, '');
const bankBranch = dto.bankBranch.trim();
if (!bankAccountName) throw new BadRequestException('请填写收款人');
if (!/^\d{8,32}$/.test(bankAccountNo)) {
throw new BadRequestException('请填写正确的银行账号');
}
if (!bankBranch) throw new BadRequestException('请填写开户行名称');
await this.prisma.partnerAccount.update({
where: { id: account.id },
data: { bankAccountName, bankAccountNo, bankBranch },
});
return this.buildPartnerMe(user.actorId);
}
private async buildPartnerMe(actorId: bigint) {
let account = await this.prisma.partnerAccount.findUniqueOrThrow({
where: { id: actorId },
@@ -661,6 +716,14 @@ export class PartnerMeController {
wxAvatarUrl: account.wxAvatarUrl ?? undefined,
managedWarehouseId: primary.managedWarehouseId?.toString() ?? null,
hasWarehouseAccess,
bankAccount:
account.isPrimary === 1
? {
bankAccountName: account.bankAccountName,
bankAccountNo: account.bankAccountNo,
bankBranch: account.bankBranch,
}
: undefined,
};
}
}
@@ -18,15 +18,20 @@ import {
parseShanghaiYmd,
pickPayoutsForWithdrawAmount,
previousShanghaiMonth,
previousShanghaiWeek,
resolveSettlementRate,
shanghaiBillPeriodYmds,
shanghaiMonthLastInstant,
shanghaiMonthRange,
shanghaiPeriodYmds,
shanghaiPeriodYmdsForWeek,
shanghaiT1DayWindow,
shanghaiWeekRange,
shanghaiWineryPeriodWindow,
shanghaiYearMonth,
shanghaiYmd,
startOfShanghaiDay,
nextPartnerBillIssueAt,
sumUnbilledPayoutAmount,
validateStoreWithdraw,
wineryIssueDateFromCompletedAt,
@@ -80,7 +85,39 @@ function paymentProofUrlsInput(urls?: string[]): Prisma.InputJsonValue | typeof
}
function withShanghaiPeriod<T extends { periodStart: Date; periodEnd: Date }>(row: T) {
return { ...row, ...shanghaiPeriodYmds(row.periodStart) };
return { ...row, ...shanghaiBillPeriodYmds(row.periodStart, row.periodEnd) };
}
function resolvePartnerWeekPeriod(weekStartYmd: string): {
periodStart: Date;
periodEnd: Date;
endExclusive: Date;
} {
const ymd = String(weekStartYmd || '').trim();
if (!/^\d{4}-\d{2}-\d{2}$/.test(ymd)) {
throw new BadRequestException('请提供周起始日 weekStartYmdYYYY-MM-DD');
}
const start = parseShanghaiYmd(ymd);
const week = shanghaiWeekRange(start);
if (shanghaiYmd(week.start) !== ymd) {
throw new BadRequestException('账期起始日须为周一');
}
return {
periodStart: week.start,
periodEnd: new Date(week.endExclusive.getTime() - 1),
endExclusive: week.endExclusive,
};
}
function partnerSettlementCycleDto(anchor = new Date()) {
const { start } = shanghaiWeekRange(anchor);
const ymds = shanghaiPeriodYmdsForWeek(start);
return {
periodStart: ymds.periodStart,
periodEnd: ymds.periodEnd,
nextIssueAt: nextPartnerBillIssueAt(anchor).toISOString(),
cycleLabel: '每周一 08:00 出账',
};
}
function toPartnerBillItemDto(row: {
@@ -1619,6 +1656,7 @@ export class SettlementService implements OnModuleInit {
where: {
partnerAccountId: primary.id,
status: { in: ['AWAITING_CONFIRM', 'UNPAID', 'PAID', 'REJECTED'] },
totalAmount: { gt: 0 },
},
orderBy: { createdAt: 'desc' },
});
@@ -1630,6 +1668,58 @@ export class SettlementService implements OnModuleInit {
return serializeBigInt(bills.map(withShanghaiPeriod));
}
getPartnerSettlementCycle(anchor = new Date()) {
return partnerSettlementCycleDto(anchor);
}
async getPartnerSettlementPreview(partnerAccountId: bigint) {
const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId);
const { start } = shanghaiWeekRange();
const now = new Date();
const periodEnd = now.getTime() < start.getTime() ? start : now;
const redeemCommissionRate = Number(primary.redeemCommissionRate ?? 0.03);
const orders = await this.prisma.order.findMany({
where: {
partnerAccountIdAtPay: primary.id,
payStatus: 'PAID',
paidAt: { gte: start, lte: periodEnd },
},
select: { payAmount: true, orderCommissionRateAtPay: true },
});
const orderCommission = round2(
orders.reduce((sum, o) => {
const rate = o.orderCommissionRateAtPay != null ? Number(o.orderCommissionRateAtPay) : 0;
return sum + Number(o.payAmount) * rate;
}, 0),
);
const stores = await this.prisma.store.findMany({
where: { partnerAccountId: primary.id },
select: { id: true },
});
const storeIds = stores.map((s) => s.id);
const redeems = !storeIds.length
? []
: await this.prisma.redeemRecord.findMany({
where: {
storeId: { in: storeIds },
createdAt: { gte: start, lte: periodEnd },
},
select: { amount: true },
});
const redeemCommission = round2(
redeems.reduce((sum, r) => sum + Number(r.amount) * redeemCommissionRate, 0),
);
return {
...partnerSettlementCycleDto(),
prepaymentAmount: round2(orderCommission + redeemCommission),
orderCommissionEstimate: orderCommission,
redeemCommissionEstimate: redeemCommission,
};
}
async getPartnerBill(partnerAccountId: bigint, billId: bigint) {
const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId);
const bill = await this.prisma.partnerBill.findFirst({
@@ -1637,6 +1727,7 @@ export class SettlementService implements OnModuleInit {
include: { items: { orderBy: { occurredAt: 'asc' } } },
});
if (!bill) throw new NotFoundException('账单不存在');
if (Number(bill.totalAmount) <= 0) throw new NotFoundException('账单不存在');
this.analyticsService.trackPartnerOneSafe(partnerAccountId, 'PARTNER_H5', {
partnerAccountId: primary.id,
eventName: 'partner_bill_detail_view',
@@ -1659,6 +1750,7 @@ export class SettlementService implements OnModuleInit {
partnerId?: string;
year?: number;
month?: number;
weekStartYmd?: string;
}) {
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 20;
@@ -1712,18 +1804,22 @@ export class SettlementService implements OnModuleInit {
partnerId?: string;
year?: number;
month?: number;
weekStartYmd?: string;
}): Prisma.PartnerBillWhereInput {
const where: Prisma.PartnerBillWhereInput = {};
if (query.status) where.status = query.status as Prisma.EnumPartnerBillStatusFilter['equals'];
if (query.partnerId) where.partnerAccountId = BigInt(query.partnerId);
if (query.year && query.month) {
if (query.weekStartYmd) {
const { periodStart, endExclusive } = resolvePartnerWeekPeriod(query.weekStartYmd);
where.periodStart = { gte: periodStart, lt: endExclusive };
} else if (query.year && query.month) {
const { start, endExclusive } = shanghaiMonthRange(query.year, query.month);
where.periodStart = { gte: start, lt: endExclusive };
}
return where;
}
async generateAllPartnerBills(body: { year: number; month: number }) {
async generateAllPartnerBills(body: { weekStartYmd: string }) {
const partners = await this.prisma.partnerAccount.findMany({
where: { isPrimary: 1, status: 'ACTIVE' },
select: {
@@ -1752,8 +1848,7 @@ export class SettlementService implements OnModuleInit {
const bill = await this.generatePartnerBill(
{
partnerId: p.id.toString(),
year: body.year,
month: body.month,
weekStartYmd: body.weekStartYmd,
},
{ notify: false },
);
@@ -1781,10 +1876,7 @@ export class SettlementService implements OnModuleInit {
});
}
}
this.notifyPartnerBillDigest(
`${body.year}-${String(body.month).padStart(2, '0')}`,
created,
);
this.notifyPartnerBillDigest(body.weekStartYmd, created);
return {
total: partners.length,
success: results.filter((r) => r.ok).length,
@@ -1793,10 +1885,15 @@ export class SettlementService implements OnModuleInit {
};
}
/** 每月 1 日任务:生成上一自然账单 */
/** 每周一任务:生成上一自然账单 */
async generatePreviousWeekPartnerBills(anchor = new Date()) {
const prev = previousShanghaiWeek(anchor);
return this.generateAllPartnerBills({ weekStartYmd: shanghaiYmd(prev.start) });
}
/** @deprecated 月账任务已改为周账 */
async generatePreviousMonthPartnerBills(anchor = new Date()) {
const prev = previousShanghaiMonth(anchor);
return this.generateAllPartnerBills({ year: prev.year, month: prev.month });
return this.generatePreviousWeekPartnerBills(anchor);
}
async getAdminPartnerBill(id: bigint) {
@@ -1814,13 +1911,12 @@ export class SettlementService implements OnModuleInit {
}
async generatePartnerBill(
body: { partnerId: string; year: number; month: number },
body: { partnerId: string; weekStartYmd: string },
opts?: { notify?: boolean },
) {
const partnerAccountId = BigInt(body.partnerId);
const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId);
const { start: periodStart, endExclusive } = shanghaiMonthRange(body.year, body.month);
const periodEnd = shanghaiMonthLastInstant(body.year, body.month);
const { periodStart, periodEnd, endExclusive } = resolvePartnerWeekPeriod(body.weekStartYmd);
const existing = await this.prisma.partnerBill.findFirst({
where: {
@@ -1829,7 +1925,7 @@ export class SettlementService implements OnModuleInit {
},
});
if (existing && existing.status !== 'PENDING_REVIEW') {
throw new BadRequestException('该账单已进入审核流程,不可重复生成');
throw new BadRequestException('该账期账单已进入审核流程,不可重复生成');
}
if (!primary.cityId) {
@@ -1952,7 +2048,7 @@ export class SettlementService implements OnModuleInit {
});
cityName = city?.name?.trim() || '—';
}
this.notifyPartnerBillDigest(`${body.year}-${String(body.month).padStart(2, '0')}`, [
this.notifyPartnerBillDigest(body.weekStartYmd, [
{
cityName,
partnerLabel: formatPartnerDashLabel(primary.companyName, primary.name),
@@ -1981,6 +2077,9 @@ export class SettlementService implements OnModuleInit {
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('仅待审核或已驳回账单可发送');
}
@@ -2129,6 +2228,7 @@ export class SettlementService implements OnModuleInit {
status?: string;
year?: number;
month?: number;
weekStartYmd?: string;
}) {
const where = this.buildPartnerBillWhere(query);
const bills = await this.prisma.partnerBill.findMany({
@@ -2172,8 +2272,8 @@ export class SettlementService implements OnModuleInit {
csvEscape(b.billNo),
csvEscape(b.partnerAccount.companyName ?? ''),
csvEscape(b.partnerAccount.phone ?? ''),
shanghaiPeriodYmds(b.periodStart).periodStart,
shanghaiPeriodYmds(b.periodStart).periodEnd,
shanghaiBillPeriodYmds(b.periodStart, b.periodEnd).periodStart,
shanghaiBillPeriodYmds(b.periodStart, b.periodEnd).periodEnd,
Number(b.orderCommission),
Number(b.redeemCommission),
Number(b.totalAmount),
@@ -1,8 +1,11 @@
import { Body, Controller, Get, Param, Post, Put, Query, Res, UseGuards } from '@nestjs/common';
import type { Response } from 'express';
import { IsOptional, IsString, MaxLength } from 'class-validator';
import { Transform } from 'class-transformer';
import { IsBoolean, IsOptional, IsString, MaxLength } from 'class-validator';
import { JwtAuthGuard, AuthUser } from '../../common/guards/jwt-auth.guard';
import { OptionalJwtAuthGuard } from '../../common/guards/optional-jwt-auth.guard';
import { PartnerPrimaryGuard } from '../../common/guards/partner-primary.guard';
import { PartnerPermissionGuard } from '../../common/guards/partner-permission.guard';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { PartnerAssocService } from './partner-assoc.service';
@@ -16,6 +19,17 @@ class BindPartnerAssocDto {
partnerId?: string;
}
class TouchPartnerAssocDto extends BindPartnerAssocDto {
@IsOptional()
@Transform(({ value }) => {
if (value === false || value === 'false' || value === 0 || value === '0') return false;
if (value === true || value === 'true' || value === 1 || value === '1') return true;
return undefined;
})
@IsBoolean()
countScan?: boolean;
}
class PartnerAssocRemarkDto {
@IsOptional()
@IsString()
@@ -23,6 +37,21 @@ class PartnerAssocRemarkDto {
remark?: string | null;
}
@Controller('user/partner-assoc')
export class UserPartnerAssocPublicController {
constructor(private readonly assoc: PartnerAssocService) {}
@Post('touch')
@UseGuards(OptionalJwtAuthGuard)
touch(@Body() dto: TouchPartnerAssocDto) {
return this.assoc.touchScan({
scene: dto.scene,
partnerId: dto.partnerId,
countScan: dto.countScan,
});
}
}
@Controller('user/partner-assoc')
@UseGuards(JwtAuthGuard)
export class UserPartnerAssocController {
@@ -35,7 +64,7 @@ export class UserPartnerAssocController {
}
@Controller('partner/assoc')
@UseGuards(JwtAuthGuard, PartnerPrimaryGuard)
@UseGuards(JwtAuthGuard, PartnerPermissionGuard)
export class PartnerAssocController {
constructor(private readonly assoc: PartnerAssocService) {}
@@ -92,6 +92,32 @@ export class PartnerAssocService {
};
}
async touchScan(input: { scene?: string; partnerId?: string; countScan?: boolean }) {
const partnerIdRaw = parseAssocScene(input.scene) || input.partnerId?.trim();
if (!partnerIdRaw || !/^\d+$/.test(partnerIdRaw)) {
throw new BadRequestException('关联码无效');
}
const primary = await this.partnerCityService.resolvePrimaryAccount(BigInt(partnerIdRaw));
if (primary.isPrimary !== 1 || primary.status !== 'ACTIVE') {
throw new BadRequestException('合伙人不存在或已停用');
}
const shouldCountScan = input.countScan !== false;
let scanCount = primary.assocScanCount ?? 0;
if (shouldCountScan) {
const updated = await this.prisma.partnerAccount.update({
where: { id: primary.id },
data: { assocScanCount: { increment: 1 } },
select: { assocScanCount: true },
});
scanCount = updated.assocScanCount;
}
return {
partnerId: primary.id.toString(),
scanCounted: shouldCountScan,
scanCount,
};
}
async unbindUser(userId: bigint) {
const user = await this.prisma.user.findUnique({ where: { id: userId } });
if (!user) throw new NotFoundException('用户不存在');
@@ -149,9 +175,10 @@ export class PartnerAssocService {
partnerId: primary.id.toString(),
qrcodeUrl: ensured.qrcodeUrl,
userCount,
scanCount: primary.assocScanCount ?? 0,
companyName: primary.companyName,
name: primary.name,
activityPosterId,
activityPosterId: partnerAccountId === primary.id ? activityPosterId : null,
};
}
@@ -35,6 +35,7 @@ import {
PartnerAssocController,
PartnerCommissionController,
UserPartnerAssocController,
UserPartnerAssocPublicController,
} from './partner-assoc.controller';
@Module({
@@ -62,6 +63,7 @@ import {
PartnerStoreInfoChangeController,
ShopStoreInfoChangeController,
AdminStoreInfoChangeController,
UserPartnerAssocPublicController,
UserPartnerAssocController,
PartnerAssocController,
PartnerCommissionController,