feat(M08-B): 增加保洁验收和指派管理
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user