feat(M08-B): 补任务免清洁
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user