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(
|
||||
|
||||
@@ -56,6 +56,10 @@ const app = await buildApp({
|
||||
calls.push(['listMine', input]);
|
||||
return { items: [task('CLAIMED')], total: 1, page: input.page, pageSize: input.pageSize };
|
||||
},
|
||||
async listManage(input) {
|
||||
calls.push(['listManage', input]);
|
||||
return { items: [task(input.status || 'SUBMITTED')], total: 1, page: input.page, pageSize: input.pageSize };
|
||||
},
|
||||
async claim(input) {
|
||||
calls.push(['claim', input]);
|
||||
return task('CLAIMED');
|
||||
@@ -75,6 +79,22 @@ const app = await buildApp({
|
||||
calls.push(['submit', input]);
|
||||
return { ...task('SUBMITTED'), photoUrls: input.photoUrls };
|
||||
},
|
||||
async assign(input) {
|
||||
calls.push(['assign', input]);
|
||||
return { ...task('CLAIMED'), cleanerUserId: input.cleanerUserId };
|
||||
},
|
||||
async complete(input) {
|
||||
calls.push(['complete', input]);
|
||||
return task('COMPLETED');
|
||||
},
|
||||
async reject(input) {
|
||||
calls.push(['reject', input]);
|
||||
return { ...task('REJECTED'), rejectReason: input.reason };
|
||||
},
|
||||
async settlementCandidates(input) {
|
||||
calls.push(['settlementCandidates', input]);
|
||||
return { items: [task('COMPLETED')], total: 1, page: input.page, pageSize: input.pageSize };
|
||||
},
|
||||
async stats(input) {
|
||||
calls.push(['stats', input]);
|
||||
return { byStatus: { SUBMITTED: 2 }, pendingSettlementCents: 1200 };
|
||||
@@ -116,6 +136,15 @@ assert.equal(mine.statusCode, 200);
|
||||
assert.equal(calls.at(-1)[0], 'listMine');
|
||||
assert.equal(calls.at(-1)[1].status, 'CLAIMED');
|
||||
|
||||
const manageList = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/admin-api/cleaning/tasks?status=SUBMITTED',
|
||||
headers: { authorization: `Bearer ${token}` }
|
||||
});
|
||||
assert.equal(manageList.statusCode, 200);
|
||||
assert.equal(calls.at(-1)[0], 'listManage');
|
||||
assert.equal(calls.at(-1)[1].status, 'SUBMITTED');
|
||||
|
||||
const claim = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/app-api/cleaning/tasks/101/claim',
|
||||
@@ -166,6 +195,43 @@ const submit = await app.inject({
|
||||
assert.equal(submit.statusCode, 200);
|
||||
assert.deepEqual(calls.at(-1)[1].photoUrls, ['https://api.txyundm.cn/uploads/cleaning/101.jpg']);
|
||||
|
||||
const assign = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/admin-api/cleaning/tasks/101/assign',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: { cleanerUserId: '33', note: 'manual dispatch' }
|
||||
});
|
||||
assert.equal(assign.statusCode, 200);
|
||||
assert.equal(calls.at(-1)[0], 'assign');
|
||||
assert.equal(calls.at(-1)[1].cleanerUserId, '33');
|
||||
|
||||
const complete = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/admin-api/cleaning/tasks/101/complete',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: { note: 'ok' }
|
||||
});
|
||||
assert.equal(complete.statusCode, 200);
|
||||
assert.equal(calls.at(-1)[0], 'complete');
|
||||
|
||||
const reject = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/admin-api/cleaning/tasks/101/reject',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: { reason: 'photo is unclear' }
|
||||
});
|
||||
assert.equal(reject.statusCode, 200);
|
||||
assert.equal(calls.at(-1)[0], 'reject');
|
||||
assert.equal(calls.at(-1)[1].reason, 'photo is unclear');
|
||||
|
||||
const settlement = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/admin-api/cleaning/settlement-candidates?page=1&pageSize=10',
|
||||
headers: { authorization: `Bearer ${token}` }
|
||||
});
|
||||
assert.equal(settlement.statusCode, 200);
|
||||
assert.equal(calls.at(-1)[0], 'settlementCandidates');
|
||||
|
||||
const stats = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/app-api/cleaning/stats',
|
||||
|
||||
Reference in New Issue
Block a user