feat(M08-B): 补保洁结算发放记录
This commit is contained in:
@@ -84,8 +84,12 @@ interface SettlementRow extends RowDataPacket {
|
||||
totalRewardCents: number;
|
||||
periodStart: Date | null;
|
||||
periodEnd: Date | null;
|
||||
paidBy: string | null;
|
||||
confirmedAt: Date | null;
|
||||
paidAt: Date | null;
|
||||
payoutChannel: string;
|
||||
payoutReference: string;
|
||||
payoutError: string;
|
||||
note: string;
|
||||
createdAt: Date;
|
||||
}
|
||||
@@ -342,7 +346,9 @@ export class CleaningTaskRepository {
|
||||
s.store_id AS storeId, st.name AS storeName, s.status,
|
||||
s.task_count AS taskCount, s.total_reward_cents AS totalRewardCents,
|
||||
s.period_start AS periodStart, s.period_end AS periodEnd,
|
||||
s.confirmed_at AS confirmedAt, s.paid_at AS paidAt,
|
||||
s.paid_by AS paidBy, s.confirmed_at AS confirmedAt, s.paid_at AS paidAt,
|
||||
s.payout_channel AS payoutChannel, s.payout_reference AS payoutReference,
|
||||
s.payout_error AS payoutError,
|
||||
s.note, s.created_at AS createdAt
|
||||
FROM qipai_cleaning_settlements s
|
||||
INNER JOIN qipai_users u ON u.tenant_id = s.tenant_id AND u.id = s.cleaner_user_id
|
||||
@@ -464,6 +470,53 @@ export class CleaningTaskRepository {
|
||||
});
|
||||
}
|
||||
|
||||
async markSettlementPaid(input: CleaningActor & {
|
||||
settlementId: string; payoutChannel: string; payoutReference: string; note?: string;
|
||||
}) {
|
||||
this.assertSettlement(input.access, 'write');
|
||||
return this.transaction(async (connection) => {
|
||||
const [result] = await connection.execute<ResultSetHeader>(
|
||||
`UPDATE qipai_cleaning_settlements s
|
||||
SET s.status = 'PAID', s.paid_by = ?, s.paid_at = UTC_TIMESTAMP(3),
|
||||
s.payout_channel = ?, s.payout_reference = ?, s.payout_error = '',
|
||||
s.note = CASE WHEN ? = '' THEN s.note ELSE ? END
|
||||
WHERE s.tenant_id = ? AND s.id = ? AND s.status = 'CONFIRMED' AND s.deleted_at IS NULL
|
||||
AND ${storeScopeSql(input.access, 'COALESCE(s.store_id, 0)')}`,
|
||||
[
|
||||
input.userId, input.payoutChannel, input.payoutReference,
|
||||
input.note ?? '', input.note ?? '', input.tenantId, input.settlementId
|
||||
]
|
||||
);
|
||||
if (result.affectedRows !== 1) throw new CleaningTaskError('CLEANING_SETTLEMENT_STATUS_CONFLICT');
|
||||
return this.getSettlement(connection, input.tenantId, input.settlementId);
|
||||
});
|
||||
}
|
||||
|
||||
async recordSettlementPayoutFailure(input: CleaningActor & {
|
||||
settlementId: string; payoutChannel?: string; payoutReference?: string; error: string; note?: string;
|
||||
}) {
|
||||
this.assertSettlement(input.access, 'write');
|
||||
return this.transaction(async (connection) => {
|
||||
const [result] = await connection.execute<ResultSetHeader>(
|
||||
`UPDATE qipai_cleaning_settlements s
|
||||
SET s.payout_channel = CASE WHEN ? = '' THEN s.payout_channel ELSE ? END,
|
||||
s.payout_reference = CASE WHEN ? = '' THEN s.payout_reference ELSE ? END,
|
||||
s.payout_error = ?,
|
||||
s.note = CASE WHEN ? = '' THEN s.note ELSE ? END
|
||||
WHERE s.tenant_id = ? AND s.id = ? AND s.status = 'CONFIRMED' AND s.deleted_at IS NULL
|
||||
AND ${storeScopeSql(input.access, 'COALESCE(s.store_id, 0)')}`,
|
||||
[
|
||||
input.payoutChannel ?? '', input.payoutChannel ?? '',
|
||||
input.payoutReference ?? '', input.payoutReference ?? '',
|
||||
input.error.slice(0, 512), input.note ?? '', input.note ?? '',
|
||||
input.tenantId, input.settlementId
|
||||
]
|
||||
);
|
||||
if (result.affectedRows !== 1) throw new CleaningTaskError('CLEANING_SETTLEMENT_STATUS_CONFLICT');
|
||||
return this.getSettlement(connection, input.tenantId, input.settlementId);
|
||||
});
|
||||
}
|
||||
|
||||
async reclaimTimeouts(input: CleaningActor & { olderThanMinutes: number; limit: number }) {
|
||||
this.assertCleaner(input.access, 'write');
|
||||
const [rows] = await this.pool.execute<RowDataPacket[]>(
|
||||
@@ -866,7 +919,9 @@ export class CleaningTaskRepository {
|
||||
s.store_id AS storeId, st.name AS storeName, s.status,
|
||||
s.task_count AS taskCount, s.total_reward_cents AS totalRewardCents,
|
||||
s.period_start AS periodStart, s.period_end AS periodEnd,
|
||||
s.confirmed_at AS confirmedAt, s.paid_at AS paidAt,
|
||||
s.paid_by AS paidBy, s.confirmed_at AS confirmedAt, s.paid_at AS paidAt,
|
||||
s.payout_channel AS payoutChannel, s.payout_reference AS payoutReference,
|
||||
s.payout_error AS payoutError,
|
||||
s.note, s.created_at AS createdAt
|
||||
FROM qipai_cleaning_settlements s
|
||||
INNER JOIN qipai_users u ON u.tenant_id = s.tenant_id AND u.id = s.cleaner_user_id
|
||||
@@ -957,8 +1012,12 @@ function publicSettlement(row: SettlementRow) {
|
||||
totalRewardCents: Number(row.totalRewardCents),
|
||||
periodStart: row.periodStart,
|
||||
periodEnd: row.periodEnd,
|
||||
paidBy: row.paidBy === null ? null : String(row.paidBy),
|
||||
confirmedAt: row.confirmedAt,
|
||||
paidAt: row.paidAt,
|
||||
payoutChannel: row.payoutChannel,
|
||||
payoutReference: row.payoutReference,
|
||||
payoutError: row.payoutError,
|
||||
note: row.note,
|
||||
createdAt: row.createdAt
|
||||
};
|
||||
|
||||
@@ -47,7 +47,8 @@ const migrationFiles: Record<MigrationDirection, readonly string[]> = {
|
||||
'database/migrations/2026062524_m08a_recharge_wechat.up.sql',
|
||||
'database/migrations/2026062525_m08b_cleaner_tasks.up.sql',
|
||||
'database/migrations/2026062626_m08b_cleaning_settlements.up.sql',
|
||||
'database/migrations/2026062627_m08b_cleaning_collaboration.up.sql'
|
||||
'database/migrations/2026062627_m08b_cleaning_collaboration.up.sql',
|
||||
'database/migrations/2026062728_m08b_cleaning_payouts.up.sql'
|
||||
],
|
||||
verify: [
|
||||
'database/migrations/2026061601_m01b_core_schema.verify.sql',
|
||||
@@ -76,9 +77,11 @@ const migrationFiles: Record<MigrationDirection, readonly string[]> = {
|
||||
'database/migrations/2026062524_m08a_recharge_wechat.verify.sql',
|
||||
'database/migrations/2026062525_m08b_cleaner_tasks.verify.sql',
|
||||
'database/migrations/2026062626_m08b_cleaning_settlements.verify.sql',
|
||||
'database/migrations/2026062627_m08b_cleaning_collaboration.verify.sql'
|
||||
'database/migrations/2026062627_m08b_cleaning_collaboration.verify.sql',
|
||||
'database/migrations/2026062728_m08b_cleaning_payouts.verify.sql'
|
||||
],
|
||||
down: [
|
||||
'database/migrations/2026062728_m08b_cleaning_payouts.down.sql',
|
||||
'database/migrations/2026062627_m08b_cleaning_collaboration.down.sql',
|
||||
'database/migrations/2026062626_m08b_cleaning_settlements.down.sql',
|
||||
'database/migrations/2026062525_m08b_cleaner_tasks.down.sql',
|
||||
@@ -247,7 +250,8 @@ export async function executeMigrationPlan(
|
||||
5, 10, 7, 3, 1,
|
||||
2, 8, 4, 3, 1,
|
||||
2, 9, 5, 2, 1,
|
||||
1, 7, 5, 1
|
||||
1, 7, 5, 1,
|
||||
4, 1, 1, 1
|
||||
][index] ?? 1;
|
||||
if (!Array.isArray(result) || result.length < minimumRows) {
|
||||
throw new Error(
|
||||
|
||||
@@ -54,6 +54,17 @@ const settlementGenerateSchema = z.object({
|
||||
const settlementConfirmSchema = z.object({
|
||||
note: z.string().trim().max(512).optional()
|
||||
}).strict();
|
||||
const settlementPaidSchema = z.object({
|
||||
payoutChannel: z.string().trim().min(1).max(32),
|
||||
payoutReference: z.string().trim().min(1).max(128),
|
||||
note: z.string().trim().max(512).optional()
|
||||
}).strict();
|
||||
const settlementPayoutFailureSchema = z.object({
|
||||
payoutChannel: z.string().trim().max(32).optional(),
|
||||
payoutReference: z.string().trim().max(128).optional(),
|
||||
error: z.string().trim().min(1).max(512),
|
||||
note: z.string().trim().max(512).optional()
|
||||
}).strict();
|
||||
const reclaimSchema = z.object({
|
||||
olderThanMinutes: z.coerce.number().int().min(5).max(1440).default(60),
|
||||
limit: z.coerce.number().int().min(1).max(100).default(20)
|
||||
@@ -63,7 +74,8 @@ export interface CleaningRouteOptions {
|
||||
repository: Pick<CleaningTaskRepository,
|
||||
'listHall' | 'listMine' | 'listManage' | 'claim' | 'start' | 'rework' | 'submit'
|
||||
| 'assign' | 'complete' | 'reject' | 'listMembers' | 'addMember' | 'removeMember' | 'settlementCandidates'
|
||||
| 'listSettlements' | 'generateSettlement' | 'confirmSettlement' | 'reclaimTimeouts'
|
||||
| 'listSettlements' | 'generateSettlement' | 'confirmSettlement' | 'markSettlementPaid'
|
||||
| 'recordSettlementPayoutFailure' | 'reclaimTimeouts'
|
||||
| 'assertCanUploadPhoto' | 'stats'>;
|
||||
mediaStorage?: MediaStorage;
|
||||
authRepository: Pick<AuthRepository, 'validateSession'>;
|
||||
@@ -373,6 +385,45 @@ export async function registerCleaningRoutes(
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
|
||||
app.post('/admin-api/cleaning/settlements/:settlementId/paid', async (request, reply) => {
|
||||
const actor = await requireActor(request, reply, options, 'write');
|
||||
if (!actor) return;
|
||||
const params = settlementParamsSchema.safeParse(request.params);
|
||||
const body = settlementPaidSchema.safeParse(request.body ?? {});
|
||||
if (!params.success || !body.success) return invalid(reply, request.traceId);
|
||||
return handle(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.repository.markSettlementPaid({
|
||||
...actor,
|
||||
settlementId: params.data.settlementId,
|
||||
payoutChannel: body.data.payoutChannel,
|
||||
payoutReference: body.data.payoutReference,
|
||||
note: body.data.note
|
||||
}),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
|
||||
app.post('/admin-api/cleaning/settlements/:settlementId/payout-failure', async (request, reply) => {
|
||||
const actor = await requireActor(request, reply, options, 'write');
|
||||
if (!actor) return;
|
||||
const params = settlementParamsSchema.safeParse(request.params);
|
||||
const body = settlementPayoutFailureSchema.safeParse(request.body ?? {});
|
||||
if (!params.success || !body.success) return invalid(reply, request.traceId);
|
||||
return handle(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.repository.recordSettlementPayoutFailure({
|
||||
...actor,
|
||||
settlementId: params.data.settlementId,
|
||||
payoutChannel: body.data.payoutChannel,
|
||||
payoutReference: body.data.payoutReference,
|
||||
error: body.data.error,
|
||||
note: body.data.note
|
||||
}),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
async function requireActor(
|
||||
|
||||
@@ -122,6 +122,26 @@ const app = await buildApp({
|
||||
calls.push(['confirmSettlement', input]);
|
||||
return settlementResponse('CONFIRMED');
|
||||
},
|
||||
async markSettlementPaid(input) {
|
||||
calls.push(['markSettlementPaid', input]);
|
||||
return {
|
||||
...settlementResponse('PAID'),
|
||||
paidBy: input.userId,
|
||||
paidAt: new Date(),
|
||||
payoutChannel: input.payoutChannel,
|
||||
payoutReference: input.payoutReference,
|
||||
payoutError: ''
|
||||
};
|
||||
},
|
||||
async recordSettlementPayoutFailure(input) {
|
||||
calls.push(['recordSettlementPayoutFailure', input]);
|
||||
return {
|
||||
...settlementResponse('CONFIRMED'),
|
||||
payoutChannel: input.payoutChannel || '',
|
||||
payoutReference: input.payoutReference || '',
|
||||
payoutError: input.error
|
||||
};
|
||||
},
|
||||
async reclaimTimeouts(input) {
|
||||
calls.push(['reclaimTimeouts', input]);
|
||||
return { reclaimed: 2, taskIds: ['101', '102'] };
|
||||
@@ -322,6 +342,37 @@ assert.equal(confirmedSettlement.statusCode, 200);
|
||||
assert.equal(calls.at(-1)[0], 'confirmSettlement');
|
||||
assert.equal(calls.at(-1)[1].settlementId, '501');
|
||||
|
||||
const failedPayout = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/admin-api/cleaning/settlements/501/payout-failure',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: {
|
||||
payoutChannel: 'WECHAT_TRANSFER',
|
||||
payoutReference: 'wx-failed-001',
|
||||
error: 'insufficient merchant balance',
|
||||
note: 'retry later'
|
||||
}
|
||||
});
|
||||
assert.equal(failedPayout.statusCode, 200);
|
||||
assert.equal(calls.at(-1)[0], 'recordSettlementPayoutFailure');
|
||||
assert.equal(calls.at(-1)[1].settlementId, '501');
|
||||
assert.equal(calls.at(-1)[1].error, 'insufficient merchant balance');
|
||||
|
||||
const paidSettlement = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/admin-api/cleaning/settlements/501/paid',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: {
|
||||
payoutChannel: 'WECHAT_TRANSFER',
|
||||
payoutReference: 'wx-paid-001',
|
||||
note: 'paid'
|
||||
}
|
||||
});
|
||||
assert.equal(paidSettlement.statusCode, 200);
|
||||
assert.equal(paidSettlement.json().data.status, 'PAID');
|
||||
assert.equal(paidSettlement.json().data.payoutReference, 'wx-paid-001');
|
||||
assert.equal(calls.at(-1)[0], 'markSettlementPaid');
|
||||
|
||||
const reclaimed = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/admin-api/cleaning/reclaim-timeouts',
|
||||
@@ -383,8 +434,12 @@ function settlementResponse(status) {
|
||||
totalRewardCents: 1200,
|
||||
periodStart: new Date(),
|
||||
periodEnd: new Date(),
|
||||
paidBy: status === 'PAID' ? '31' : null,
|
||||
confirmedAt: status === 'CONFIRMED' ? new Date() : null,
|
||||
paidAt: null,
|
||||
paidAt: status === 'PAID' ? new Date() : null,
|
||||
payoutChannel: '',
|
||||
payoutReference: '',
|
||||
payoutError: '',
|
||||
note: ''
|
||||
};
|
||||
}
|
||||
|
||||
@@ -90,6 +90,9 @@ const cleaningSettlementVerifySql = read('database/migrations/2026062626_m08b_cl
|
||||
const cleaningCollaborationUpSql = read('database/migrations/2026062627_m08b_cleaning_collaboration.up.sql');
|
||||
const cleaningCollaborationDownSql = read('database/migrations/2026062627_m08b_cleaning_collaboration.down.sql');
|
||||
const cleaningCollaborationVerifySql = read('database/migrations/2026062627_m08b_cleaning_collaboration.verify.sql');
|
||||
const cleaningPayoutUpSql = read('database/migrations/2026062728_m08b_cleaning_payouts.up.sql');
|
||||
const cleaningPayoutDownSql = read('database/migrations/2026062728_m08b_cleaning_payouts.down.sql');
|
||||
const cleaningPayoutVerifySql = read('database/migrations/2026062728_m08b_cleaning_payouts.verify.sql');
|
||||
|
||||
const coreTables = [
|
||||
'qipai_schema_migrations',
|
||||
@@ -426,4 +429,15 @@ assert.match(cleaningCollaborationDownSql, /ADD UNIQUE KEY uq_qipai_cleaning_set
|
||||
assert.match(cleaningCollaborationVerifySql, /'qipai_cleaning_task_members'/);
|
||||
assert.match(cleaningCollaborationVerifySql, /'cleaner_user_id'/);
|
||||
|
||||
assert.match(cleaningPayoutUpSql, /ADD COLUMN paid_by BIGINT UNSIGNED NULL/);
|
||||
assert.match(cleaningPayoutUpSql, /ADD COLUMN payout_channel VARCHAR\(32\) NOT NULL DEFAULT ''/);
|
||||
assert.match(cleaningPayoutUpSql, /ADD COLUMN payout_reference VARCHAR\(128\) NOT NULL DEFAULT ''/);
|
||||
assert.match(cleaningPayoutUpSql, /ADD COLUMN payout_error VARCHAR\(512\) NOT NULL DEFAULT ''/);
|
||||
assert.match(cleaningPayoutUpSql, /fk_qipai_cleaning_settlements_paid_by/);
|
||||
assert.match(cleaningPayoutUpSql, /idx_qipai_cleaning_settlement_payout/);
|
||||
assert.match(cleaningPayoutDownSql, /DROP FOREIGN KEY fk_qipai_cleaning_settlements_paid_by/);
|
||||
assert.match(cleaningPayoutDownSql, /DROP COLUMN payout_error/);
|
||||
assert.match(cleaningPayoutVerifySql, /'payout_reference'/);
|
||||
assert.match(cleaningPayoutVerifySql, /'2026062728'/);
|
||||
|
||||
console.log('PASS: M01-B through M08-B migration contracts are present.');
|
||||
|
||||
@@ -37,7 +37,8 @@ assert.match(plan.file, /2026062422_m07b_recharge_plans\.up\.sql/);
|
||||
assert.match(plan.file, /2026062524_m08a_recharge_wechat\.up\.sql/);
|
||||
assert.match(plan.file, /2026062525_m08b_cleaner_tasks\.up\.sql/);
|
||||
assert.match(plan.file, /2026062626_m08b_cleaning_settlements\.up\.sql/);
|
||||
assert.match(plan.file, /2026062627_m08b_cleaning_collaboration\.up\.sql$/);
|
||||
assert.match(plan.file, /2026062627_m08b_cleaning_collaboration\.up\.sql/);
|
||||
assert.match(plan.file, /2026062728_m08b_cleaning_payouts\.up\.sql$/);
|
||||
assert.match(plan.checksum, /^[a-f0-9]{64}$/);
|
||||
assert.ok(plan.statements.length >= 11);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user