feat(M08-B): 补任务免清洁

This commit is contained in:
Codex
2026-07-07 15:08:32 +08:00
parent 2a4db54b94
commit 8ffb940bdb
12 changed files with 145 additions and 17 deletions
@@ -292,6 +292,52 @@ export class CleaningTaskRepository {
return this.getTask(input, input.taskId);
}
async exempt(input: CleaningActor & { taskId: string; note?: string }) {
this.assertCleaner(input.access, 'write');
await this.assertStoreVisible(input, input.taskId);
await this.transaction(async (connection) => {
const [currentRows] = await connection.execute<CurrentStatusRow[]>(
`SELECT status, cleaner_user_id AS cleanerUserId
FROM qipai_cleaning_tasks
WHERE tenant_id = ? AND id = ? AND deleted_at IS NULL
FOR UPDATE`,
[input.tenantId, input.taskId]
);
const current = currentRows[0];
if (!current) throw new CleaningTaskError('CLEANING_TASK_NOT_FOUND');
const [result] = await connection.execute<ResultSetHeader>(
`UPDATE qipai_cleaning_tasks
SET status = 'EXEMPT',
cleaner_user_id = NULL,
reward_cents = 0,
reject_reason = '',
completed_at = NULL,
settled_at = NULL
WHERE tenant_id = ? AND id = ?
AND status IN ('WAITING', 'CLAIMED', 'STARTED', 'SUBMITTED', 'REJECTED')
AND deleted_at IS NULL`,
[input.tenantId, input.taskId]
);
if (result.affectedRows !== 1) throw new CleaningTaskError('CLEANING_TASK_STATUS_CONFLICT');
await connection.execute(
`UPDATE qipai_cleaning_task_members
SET removed_at = UTC_TIMESTAMP(3), reward_cents = 0
WHERE tenant_id = ? AND task_id = ? AND removed_at IS NULL AND settled_at IS NULL`,
[input.tenantId, input.taskId]
);
await this.recordEventWithConnection(
connection,
input,
input.taskId,
current.status,
'EXEMPT',
'EXEMPT',
input.note ?? '免清洁'
);
});
return this.getTask(input, input.taskId);
}
async listMembers(input: CleaningActor & { taskId: string }) {
this.assertCleaner(input.access, 'read');
await this.assertStoreVisible(input, input.taskId);
+19 -1
View File
@@ -51,6 +51,7 @@ const memberRemoveSchema = z.object({
}).strict();
const completeSchema = z.object({ note: z.string().trim().max(512).optional() }).strict();
const rejectSchema = z.object({ reason: z.string().trim().min(1).max(512) }).strict();
const exemptSchema = z.object({ note: z.string().trim().max(512).optional() }).strict();
const settlementStatusSchema = z.enum(['DRAFT', 'CONFIRMED', 'PAID', 'CANCELLED']);
const settlementPayoutStateSchema = z.enum([
'NONE', 'SUCCESS', 'FAIL', 'PROCESSING', 'WAIT_USER_CONFIRM'
@@ -104,7 +105,7 @@ const reclaimSchema = z.object({
export interface CleaningRouteOptions {
repository: Pick<CleaningTaskRepository,
'listHall' | 'listMine' | 'listManage' | 'claim' | 'start' | 'rework' | 'submit'
| 'assign' | 'complete' | 'reject' | 'listMembers' | 'listEvents' | 'addMember' | 'removeMember' | 'settlementCandidates'
| 'assign' | 'complete' | 'reject' | 'exempt' | 'listMembers' | 'listEvents' | 'addMember' | 'removeMember' | 'settlementCandidates'
| 'listSettlements' | 'getSettlementDetail' | 'generateSettlement' | 'confirmSettlement' | 'markSettlementPaid'
| 'recordSettlementPayoutFailure' | 'reclaimTimeouts'
| 'assertCanUploadPhoto' | 'stats' | 'managerStatistics'>;
@@ -418,6 +419,23 @@ export async function registerCleaningRoutes(
}));
});
app.post('/admin-api/cleaning/tasks/:taskId/exempt', async (request, reply) => {
const actor = await requireActor(request, reply, options, 'write');
if (!actor) return;
const params = paramsSchema.safeParse(request.params);
const body = exemptSchema.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.exempt({
...actor,
taskId: params.data.taskId,
note: body.data.note
}),
traceId: request.traceId
}));
});
app.post('/admin-api/cleaning/reclaim-timeouts', async (request, reply) => {
const actor = await requireActor(request, reply, options, 'write');
if (!actor) return;
+15
View File
@@ -94,6 +94,10 @@ const app = await buildApp({
calls.push(['reject', input]);
return { ...task('REJECTED'), rejectReason: input.reason };
},
async exempt(input) {
calls.push(['exempt', input]);
return { ...task('EXEMPT'), rewardCents: 0 };
},
async listMembers(input) {
calls.push(['listMembers', input]);
return [member('LEAD', '31', 500), member('ASSIST', '33', 100)];
@@ -410,6 +414,17 @@ assert.equal(reject.statusCode, 200);
assert.equal(calls.at(-1)[0], 'reject');
assert.equal(calls.at(-1)[1].reason, 'photo is unclear');
const exempt = await app.inject({
method: 'POST',
url: '/admin-api/cleaning/tasks/101/exempt',
headers: { authorization: `Bearer ${token}` },
payload: { note: 'no cleaning required' }
});
assert.equal(exempt.statusCode, 200);
assert.equal(exempt.json().data.status, 'EXEMPT');
assert.equal(calls.at(-1)[0], 'exempt');
assert.equal(calls.at(-1)[1].note, 'no cleaning required');
const members = await app.inject({
method: 'GET',
url: '/admin-api/cleaning/tasks/101/members',