feat(M08-B): 增加保洁验收和指派管理

This commit is contained in:
Codex
2026-06-26 15:42:30 +08:00
parent c8ddc8dd00
commit 59ce3eca53
9 changed files with 312 additions and 13 deletions
@@ -85,6 +85,21 @@ export class CleaningTaskRepository {
return this.listByWhere(where, params, input.page, input.pageSize, 't.updated_at DESC, t.id DESC');
}
async listManage(input: CleaningActor & { page: number; pageSize: number; status?: CleaningTaskStatus }) {
this.assertCleaner(input.access, 'read');
const where = [
't.tenant_id = ?',
't.deleted_at IS NULL',
storeScopeSql(input.access, 't.store_id')
];
const params: Array<string | number> = [input.tenantId];
if (input.status) {
where.push('t.status = ?');
params.push(input.status);
}
return this.listByWhere(where, params, input.page, input.pageSize, 't.updated_at DESC, t.id DESC');
}
async claim(input: CleaningActor & { taskId: string }) {
this.assertCleaner(input.access, 'write');
await this.assertStoreVisible(input, input.taskId);
@@ -111,6 +126,53 @@ export class CleaningTaskRepository {
});
}
async assign(input: CleaningActor & { taskId: string; cleanerUserId: string; note?: string }) {
this.assertCleaner(input.access, 'write');
await this.assertStoreVisible(input, input.taskId);
await this.assertCleanerUserForTask(input.tenantId, input.taskId, input.cleanerUserId);
const [result] = await this.pool.execute<ResultSetHeader>(
`UPDATE qipai_cleaning_tasks
SET status = 'CLAIMED', cleaner_user_id = ?, claimed_at = COALESCE(claimed_at, UTC_TIMESTAMP(3)),
reject_reason = ''
WHERE tenant_id = ? AND id = ? AND status IN ('WAITING', 'CLAIMED', 'REJECTED')
AND deleted_at IS NULL`,
[input.cleanerUserId, input.tenantId, input.taskId]
);
if (result.affectedRows !== 1) throw new CleaningTaskError('CLEANING_TASK_ASSIGN_CONFLICT');
await this.recordEvent(input, input.taskId, 'WAITING', 'CLAIMED', 'ASSIGN', input.note ?? '');
return this.getTask(input, input.taskId);
}
async complete(input: CleaningActor & { taskId: string; note?: string }) {
return this.moveManaged(input, 'SUBMITTED', 'COMPLETED', 'COMPLETE', 'completed_at', input.note ?? '');
}
async reject(input: CleaningActor & { taskId: string; reason: string }) {
this.assertCleaner(input.access, 'write');
await this.assertStoreVisible(input, input.taskId);
const [result] = await this.pool.execute<ResultSetHeader>(
`UPDATE qipai_cleaning_tasks
SET status = 'REJECTED', reject_reason = ?
WHERE tenant_id = ? AND id = ? AND status = 'SUBMITTED' AND deleted_at IS NULL`,
[input.reason.slice(0, 512), input.tenantId, input.taskId]
);
if (result.affectedRows !== 1) throw new CleaningTaskError('CLEANING_TASK_STATUS_CONFLICT');
await this.recordEvent(input, input.taskId, 'SUBMITTED', 'REJECTED', 'REJECT', input.reason);
return this.getTask(input, input.taskId);
}
async settlementCandidates(input: CleaningActor & { page: number; pageSize: number }) {
this.assertCleaner(input.access, 'read');
const where = [
't.tenant_id = ?',
't.deleted_at IS NULL',
"t.status = 'COMPLETED'",
't.settled_at IS NULL',
storeScopeSql(input.access, 't.store_id')
];
return this.listByWhere(where, [input.tenantId], input.page, input.pageSize, 't.completed_at ASC, t.id ASC');
}
async submit(input: CleaningActor & { taskId: string; photoUrls: string[]; note?: string }) {
this.assertCleaner(input.access, 'write');
if (input.photoUrls.length === 0) throw new CleaningTaskError('CLEANING_PHOTO_REQUIRED');
@@ -247,6 +309,34 @@ export class CleaningTaskRepository {
return task;
}
private async getTask(input: CleaningActor, taskId: string) {
const result = await this.listManage({ ...input, page: 1, pageSize: 50 });
const task = result.items.find((item) => item.id === taskId);
if (!task) throw new CleaningTaskError('CLEANING_TASK_NOT_FOUND');
return task;
}
private async moveManaged(
input: CleaningActor & { taskId: string },
from: CleaningTaskStatus,
to: CleaningTaskStatus,
action: string,
timestampColumn: 'completed_at',
note: string
) {
this.assertCleaner(input.access, 'write');
await this.assertStoreVisible(input, input.taskId);
const [result] = await this.pool.execute<ResultSetHeader>(
`UPDATE qipai_cleaning_tasks
SET status = ?, ${timestampColumn} = UTC_TIMESTAMP(3)
WHERE tenant_id = ? AND id = ? AND status = ? AND deleted_at IS NULL`,
[to, input.tenantId, input.taskId, from]
);
if (result.affectedRows !== 1) throw new CleaningTaskError('CLEANING_TASK_STATUS_CONFLICT');
await this.recordEvent(input, input.taskId, from, to, action, note);
return this.getTask(input, input.taskId);
}
private async moveMine(
input: CleaningActor & { taskId: string },
from: CleaningTaskStatus,
@@ -293,6 +383,24 @@ export class CleaningTaskRepository {
}
}
private async assertCleanerUserForTask(tenantId: string, taskId: string, cleanerUserId: string) {
const [rows] = await this.pool.execute<RowDataPacket[]>(
`SELECT 1
FROM qipai_cleaning_tasks t
INNER JOIN qipai_users u ON u.tenant_id = t.tenant_id AND u.id = ?
AND u.status = 'ACTIVE' AND u.deleted_at IS NULL
INNER JOIN qipai_user_roles ur ON ur.tenant_id = u.tenant_id AND ur.user_id = u.id
INNER JOIN qipai_roles r ON r.tenant_id = ur.tenant_id AND r.id = ur.role_id
AND r.code = 'CLEANER' AND r.status = 'ACTIVE' AND r.deleted_at IS NULL
INNER JOIN qipai_user_store_scopes ss ON ss.tenant_id = u.tenant_id
AND ss.user_id = u.id AND ss.store_id = t.store_id
WHERE t.tenant_id = ? AND t.id = ? AND t.deleted_at IS NULL
LIMIT 1`,
[cleanerUserId, tenantId, taskId]
);
if (!rows[0]) throw new CleaningTaskError('CLEANING_ASSIGNEE_INVALID');
}
private async recordEvent(
input: CleaningActor,
taskId: string,
+85 -1
View File
@@ -21,10 +21,18 @@ const submitSchema = z.object({
photoUrls: z.array(z.string().url()).max(9).default([]),
note: z.string().trim().max(512).optional()
});
const assignSchema = z.object({
cleanerUserId: z.string().regex(/^[1-9]\d{0,19}$/),
note: z.string().trim().max(512).optional()
}).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();
export interface CleaningRouteOptions {
repository: Pick<CleaningTaskRepository,
'listHall' | 'listMine' | 'claim' | 'start' | 'rework' | 'submit' | 'assertCanUploadPhoto' | 'stats'>;
'listHall' | 'listMine' | 'listManage' | 'claim' | 'start' | 'rework' | 'submit'
| 'assign' | 'complete' | 'reject' | 'settlementCandidates'
| 'assertCanUploadPhoto' | 'stats'>;
mediaStorage?: MediaStorage;
authRepository: Pick<AuthRepository, 'validateSession'>;
accessControl: { getAccessProfile(tenantId: string, userId: string): Promise<AccessProfile> };
@@ -77,6 +85,30 @@ export async function registerCleaningRoutes(
}));
});
app.get('/admin-api/cleaning/tasks', async (request, reply) => {
const actor = await requireActor(request, reply, options, 'read');
if (!actor) return;
const query = listSchema.safeParse(request.query);
if (!query.success) return invalid(reply, request.traceId);
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.listManage({ ...actor, ...query.data }),
traceId: request.traceId
}));
});
app.get('/admin-api/cleaning/settlement-candidates', async (request, reply) => {
const actor = await requireActor(request, reply, options, 'read');
if (!actor) return;
const query = listSchema.omit({ status: true }).safeParse(request.query);
if (!query.success) return invalid(reply, request.traceId);
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.settlementCandidates({ ...actor, ...query.data }),
traceId: request.traceId
}));
});
app.post('/app-api/cleaning/tasks/:taskId/claim', async (request, reply) => {
const actor = await requireActor(request, reply, options, 'write');
if (!actor) return;
@@ -89,6 +121,24 @@ export async function registerCleaningRoutes(
}));
});
app.post('/admin-api/cleaning/tasks/:taskId/assign', async (request, reply) => {
const actor = await requireActor(request, reply, options, 'write');
if (!actor) return;
const params = paramsSchema.safeParse(request.params);
const body = assignSchema.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.assign({
...actor,
taskId: params.data.taskId,
cleanerUserId: body.data.cleanerUserId,
note: body.data.note
}),
traceId: request.traceId
}));
});
app.post('/app-api/cleaning/tasks/:taskId/start', async (request, reply) => {
const actor = await requireActor(request, reply, options, 'write');
if (!actor) return;
@@ -155,6 +205,40 @@ export async function registerCleaningRoutes(
traceId: request.traceId
}));
});
app.post('/admin-api/cleaning/tasks/:taskId/complete', async (request, reply) => {
const actor = await requireActor(request, reply, options, 'write');
if (!actor) return;
const params = paramsSchema.safeParse(request.params);
const body = completeSchema.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.complete({
...actor,
taskId: params.data.taskId,
note: body.data.note
}),
traceId: request.traceId
}));
});
app.post('/admin-api/cleaning/tasks/:taskId/reject', async (request, reply) => {
const actor = await requireActor(request, reply, options, 'write');
if (!actor) return;
const params = paramsSchema.safeParse(request.params);
const body = rejectSchema.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.reject({
...actor,
taskId: params.data.taskId,
reason: body.data.reason
}),
traceId: request.traceId
}));
});
}
async function requireActor(