feat(M09-B): 完成清洁规则与照片验收闭环
This commit is contained in:
@@ -9,6 +9,8 @@ export const cleaningTaskStatuses = [
|
||||
export type CleaningTaskStatus = typeof cleaningTaskStatuses[number];
|
||||
export type CleaningSettlementStatus = 'DRAFT' | 'CONFIRMED' | 'PAID' | 'CANCELLED';
|
||||
export type CleaningSettlementPayoutState = 'NONE' | 'SUCCESS' | 'FAIL' | 'PROCESSING' | 'WAIT_USER_CONFIRM';
|
||||
export type CleaningTemplateScope = 'TENANT' | 'STORE' | 'ROOM';
|
||||
export type CleaningExemptPolicy = 'DISABLED' | 'BEFORE_START' | 'ANY_ACTIVE';
|
||||
|
||||
export class CleaningTaskError extends Error {
|
||||
constructor(public readonly code: string) { super(code); }
|
||||
@@ -36,8 +38,16 @@ interface CleaningTaskRow extends RowDataPacket {
|
||||
priority: number;
|
||||
rewardCents: number;
|
||||
requirement: string;
|
||||
cleaningTemplateId: string | null;
|
||||
cleaningTemplateVersion: number;
|
||||
photoRequired: number | boolean;
|
||||
minPhotoCount: number;
|
||||
maxPhotoCount: number;
|
||||
exemptPolicy: CleaningExemptPolicy;
|
||||
photoUrlsJson: string | string[] | null;
|
||||
rejectReason: string;
|
||||
reworkCount: number;
|
||||
photoRevision: number;
|
||||
claimedAt: Date | null;
|
||||
startedAt: Date | null;
|
||||
submittedAt: Date | null;
|
||||
@@ -68,10 +78,55 @@ interface CleaningTaskEventRow extends RowDataPacket {
|
||||
note: string;
|
||||
createdAt: Date;
|
||||
}
|
||||
interface CleaningTaskSubmissionRow extends RowDataPacket {
|
||||
id: string;
|
||||
taskId: string;
|
||||
revision: number;
|
||||
status: 'SUBMITTED' | 'ACCEPTED' | 'REJECTED';
|
||||
photoUrlsJson: string | string[];
|
||||
note: string;
|
||||
rejectReason: string;
|
||||
submittedBy: string;
|
||||
reviewedBy: string | null;
|
||||
submittedAt: Date;
|
||||
reviewedAt: Date | null;
|
||||
}
|
||||
interface CountRow extends RowDataPacket { total: number }
|
||||
interface StatusCountRow extends RowDataPacket { status: CleaningTaskStatus; total: number }
|
||||
interface AmountRow extends RowDataPacket { amount: number | null }
|
||||
interface CurrentStatusRow extends RowDataPacket { status: CleaningTaskStatus; cleanerUserId: string | null }
|
||||
interface CurrentTaskRuleRow extends CurrentStatusRow {
|
||||
storeId: string;
|
||||
photoRequired: number | boolean;
|
||||
minPhotoCount: number;
|
||||
maxPhotoCount: number;
|
||||
exemptPolicy: CleaningExemptPolicy;
|
||||
reworkCount: number;
|
||||
}
|
||||
interface CleaningTemplateRow extends RowDataPacket {
|
||||
id: string;
|
||||
scopeKey: string;
|
||||
scopeType: CleaningTemplateScope;
|
||||
storeId: string | null;
|
||||
roomId: string | null;
|
||||
name: string;
|
||||
requirement: string;
|
||||
photoRequired: number | boolean;
|
||||
minPhotoCount: number;
|
||||
maxPhotoCount: number;
|
||||
exemptPolicy: CleaningExemptPolicy;
|
||||
version: number;
|
||||
status: 'ACTIVE' | 'DISABLED';
|
||||
updatedAt: Date;
|
||||
}
|
||||
interface PhotoOwnershipRow extends RowDataPacket {
|
||||
id: string;
|
||||
publicUrl: string;
|
||||
}
|
||||
interface ExpiredPhotoRow extends RowDataPacket {
|
||||
id: string;
|
||||
storagePath: string;
|
||||
}
|
||||
interface ReclaimRow extends CurrentStatusRow { id: string }
|
||||
interface FinishedOrderRow extends RowDataPacket {
|
||||
id: string;
|
||||
@@ -167,6 +222,72 @@ interface ManagerDailyTrendRow extends RowDataPacket {
|
||||
export class CleaningTaskRepository {
|
||||
constructor(private readonly pool: MySqlPool) {}
|
||||
|
||||
async listTemplates(input: CleaningActor) {
|
||||
this.assertCleaner(input.access, 'read');
|
||||
const [rows] = await this.pool.execute<CleaningTemplateRow[]>(
|
||||
`SELECT t.id, t.scope_key AS scopeKey, t.scope_type AS scopeType,
|
||||
t.store_id AS storeId, t.room_id AS roomId, t.name, t.requirement,
|
||||
t.photo_required AS photoRequired, t.min_photo_count AS minPhotoCount,
|
||||
t.max_photo_count AS maxPhotoCount, t.exempt_policy AS exemptPolicy,
|
||||
t.version, t.status, t.updated_at AS updatedAt
|
||||
FROM qipai_cleaning_templates t
|
||||
WHERE t.tenant_id = ?
|
||||
AND (t.scope_type = 'TENANT' OR ${storeScopeSql(input.access, 't.store_id')})
|
||||
ORDER BY FIELD(t.scope_type, 'TENANT', 'STORE', 'ROOM'), t.scope_key`,
|
||||
[input.tenantId]
|
||||
);
|
||||
return rows.map(publicTemplate);
|
||||
}
|
||||
|
||||
async upsertTemplate(input: CleaningActor & {
|
||||
scopeType: CleaningTemplateScope;
|
||||
scopeId?: string;
|
||||
name: string;
|
||||
requirement: string;
|
||||
photoRequired: boolean;
|
||||
minPhotoCount: number;
|
||||
maxPhotoCount: number;
|
||||
exemptPolicy: CleaningExemptPolicy;
|
||||
status: 'ACTIVE' | 'DISABLED';
|
||||
}) {
|
||||
this.assertCleaner(input.access, 'write');
|
||||
if (input.maxPhotoCount < 1 || input.maxPhotoCount > 9
|
||||
|| input.minPhotoCount < 0 || input.minPhotoCount > input.maxPhotoCount
|
||||
|| (input.photoRequired && input.minPhotoCount < 1)
|
||||
|| (!input.photoRequired && input.minPhotoCount !== 0)) {
|
||||
throw new CleaningTaskError('CLEANING_TEMPLATE_PHOTO_RULE_INVALID');
|
||||
}
|
||||
const scope = await this.resolveTemplateScope(input, input.scopeType, input.scopeId);
|
||||
return this.transaction(async (connection) => {
|
||||
const [result] = await connection.execute<ResultSetHeader>(
|
||||
`INSERT INTO qipai_cleaning_templates
|
||||
(tenant_id, scope_key, scope_type, store_id, room_id, name, requirement,
|
||||
photo_required, min_photo_count, max_photo_count, exempt_policy, status,
|
||||
created_by, updated_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
id = LAST_INSERT_ID(id), name = VALUES(name), requirement = VALUES(requirement),
|
||||
photo_required = VALUES(photo_required), min_photo_count = VALUES(min_photo_count),
|
||||
max_photo_count = VALUES(max_photo_count), exempt_policy = VALUES(exempt_policy),
|
||||
status = VALUES(status), updated_by = VALUES(updated_by), version = version + 1`,
|
||||
[input.tenantId, scope.scopeKey, input.scopeType, scope.storeId, scope.roomId,
|
||||
input.name.slice(0, 128), input.requirement.slice(0, 512), input.photoRequired ? 1 : 0,
|
||||
input.minPhotoCount, input.maxPhotoCount, input.exemptPolicy, input.status,
|
||||
input.userId, input.userId]
|
||||
);
|
||||
const templateId = String(result.insertId);
|
||||
await connection.execute(
|
||||
`INSERT INTO qipai_audit_logs
|
||||
(tenant_id, actor_type, actor_id, action, resource_type, resource_id, trace_id, metadata)
|
||||
VALUES (?, 'USER', ?, 'CLEANING_TEMPLATE_UPSERT', 'CLEANING_TEMPLATE', ?, ?,
|
||||
JSON_OBJECT('scopeType', ?, 'scopeKey', ?, 'appliesToExistingTasks', FALSE))`,
|
||||
[input.tenantId, input.userId, templateId, input.traceId,
|
||||
input.scopeType, scope.scopeKey]
|
||||
);
|
||||
return this.getTemplate(connection, input.tenantId, templateId);
|
||||
});
|
||||
}
|
||||
|
||||
async listHall(input: CleaningActor & { page: number; pageSize: number; status?: CleaningTaskStatus }) {
|
||||
this.assertCleaner(input.access, 'read');
|
||||
const status = input.status ?? 'WAITING';
|
||||
@@ -263,10 +384,9 @@ export class CleaningTaskRepository {
|
||||
}
|
||||
|
||||
async rework(input: CleaningActor & { taskId: string }) {
|
||||
return this.moveMine(input, 'REJECTED', 'STARTED', 'REWORK', 'started_at', '', {
|
||||
photo_urls_json: JSON.stringify([]),
|
||||
reject_reason: ''
|
||||
});
|
||||
return this.moveMine(
|
||||
input, 'REJECTED', 'STARTED', 'REWORK', 'started_at', '', { incrementRework: true }
|
||||
);
|
||||
}
|
||||
|
||||
async assign(input: CleaningActor & { taskId: string; cleanerUserId: string; note?: string }) {
|
||||
@@ -289,7 +409,8 @@ export class CleaningTaskRepository {
|
||||
);
|
||||
await connection.execute(
|
||||
`UPDATE qipai_cleaning_tasks
|
||||
SET status = 'CLAIMED', cleaner_user_id = ?, claimed_at = UTC_TIMESTAMP(3),
|
||||
SET rework_count = rework_count + IF(status = 'REJECTED', 1, 0),
|
||||
status = 'CLAIMED', cleaner_user_id = ?, claimed_at = UTC_TIMESTAMP(3),
|
||||
started_at = NULL, submitted_at = NULL, completed_at = NULL, settled_at = NULL,
|
||||
cancelled_at = NULL, photo_urls_json = JSON_ARRAY(), reject_reason = ''
|
||||
WHERE tenant_id = ? AND id = ?`,
|
||||
@@ -304,9 +425,28 @@ export class CleaningTaskRepository {
|
||||
}
|
||||
|
||||
async complete(input: CleaningActor & { taskId: string; note?: string }) {
|
||||
return this.moveManaged(
|
||||
input, 'SUBMITTED', 'COMPLETED', 'COMPLETE', 'completed_at', input.note ?? ''
|
||||
);
|
||||
this.assertCleaner(input.access, 'write');
|
||||
await this.assertStoreVisible(input, input.taskId);
|
||||
await this.transaction(async (connection) => {
|
||||
const [result] = await connection.execute<ResultSetHeader>(
|
||||
`UPDATE qipai_cleaning_tasks
|
||||
SET status = 'COMPLETED', completed_at = UTC_TIMESTAMP(3)
|
||||
WHERE tenant_id = ? AND id = ? AND status = 'SUBMITTED' 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_submissions
|
||||
SET status = 'ACCEPTED', reviewed_by = ?, reviewed_at = UTC_TIMESTAMP(3)
|
||||
WHERE tenant_id = ? AND task_id = ? AND status = 'SUBMITTED'`,
|
||||
[input.userId, input.tenantId, input.taskId]
|
||||
);
|
||||
await this.ensureLeadMember(connection, input.tenantId, input.taskId);
|
||||
await this.recordEventWithConnection(
|
||||
connection, input, input.taskId, 'SUBMITTED', 'COMPLETED', 'COMPLETE', input.note ?? ''
|
||||
);
|
||||
});
|
||||
return this.getTask(input, input.taskId);
|
||||
}
|
||||
|
||||
async reject(input: CleaningActor & { taskId: string; reason: string }) {
|
||||
@@ -320,6 +460,12 @@ export class CleaningTaskRepository {
|
||||
[input.reason.slice(0, 512), input.tenantId, input.taskId]
|
||||
);
|
||||
if (result.affectedRows !== 1) throw new CleaningTaskError('CLEANING_TASK_STATUS_CONFLICT');
|
||||
await connection.execute(
|
||||
`UPDATE qipai_cleaning_task_submissions
|
||||
SET status = 'REJECTED', reject_reason = ?, reviewed_by = ?, reviewed_at = UTC_TIMESTAMP(3)
|
||||
WHERE tenant_id = ? AND task_id = ? AND status = 'SUBMITTED'`,
|
||||
[input.reason.slice(0, 512), input.userId, input.tenantId, input.taskId]
|
||||
);
|
||||
await this.recordEventWithConnection(
|
||||
connection, input, input.taskId, 'SUBMITTED', 'REJECTED', 'REJECT', input.reason
|
||||
);
|
||||
@@ -331,8 +477,11 @@ export class CleaningTaskRepository {
|
||||
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
|
||||
const [currentRows] = await connection.execute<CurrentTaskRuleRow[]>(
|
||||
`SELECT status, cleaner_user_id AS cleanerUserId, store_id AS storeId,
|
||||
photo_required AS photoRequired, min_photo_count AS minPhotoCount,
|
||||
max_photo_count AS maxPhotoCount, exempt_policy AS exemptPolicy,
|
||||
rework_count AS reworkCount
|
||||
FROM qipai_cleaning_tasks
|
||||
WHERE tenant_id = ? AND id = ? AND deleted_at IS NULL
|
||||
FOR UPDATE`,
|
||||
@@ -340,6 +489,12 @@ export class CleaningTaskRepository {
|
||||
);
|
||||
const current = currentRows[0];
|
||||
if (!current) throw new CleaningTaskError('CLEANING_TASK_NOT_FOUND');
|
||||
const allowedStatuses = current.exemptPolicy === 'ANY_ACTIVE'
|
||||
? ['WAITING', 'CLAIMED', 'STARTED', 'SUBMITTED', 'REJECTED']
|
||||
: current.exemptPolicy === 'BEFORE_START' ? ['WAITING', 'CLAIMED'] : [];
|
||||
if (!allowedStatuses.includes(current.status)) {
|
||||
throw new CleaningTaskError('CLEANING_EXEMPT_POLICY_DENIED');
|
||||
}
|
||||
const [result] = await connection.execute<ResultSetHeader>(
|
||||
`UPDATE qipai_cleaning_tasks
|
||||
SET status = 'EXEMPT',
|
||||
@@ -348,10 +503,8 @@ export class CleaningTaskRepository {
|
||||
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]
|
||||
WHERE tenant_id = ? AND id = ? AND status = ? AND deleted_at IS NULL`,
|
||||
[input.tenantId, input.taskId, current.status]
|
||||
);
|
||||
if (result.affectedRows !== 1) throw new CleaningTaskError('CLEANING_TASK_STATUS_CONFLICT');
|
||||
await connection.execute(
|
||||
@@ -395,6 +548,24 @@ export class CleaningTaskRepository {
|
||||
return rows.map(publicTaskEvent);
|
||||
}
|
||||
|
||||
async listSubmissions(input: CleaningActor & { taskId: string }) {
|
||||
this.assertCleaner(input.access, 'read');
|
||||
await this.assertStoreVisible(input, input.taskId);
|
||||
const [rows] = await this.pool.execute<CleaningTaskSubmissionRow[]>(
|
||||
`SELECT s.id, s.task_id AS taskId, s.revision, s.status,
|
||||
s.photo_urls_json AS photoUrlsJson, s.note,
|
||||
s.reject_reason AS rejectReason, s.submitted_by AS submittedBy,
|
||||
s.reviewed_by AS reviewedBy, s.submitted_at AS submittedAt,
|
||||
s.reviewed_at AS reviewedAt
|
||||
FROM qipai_cleaning_task_submissions s
|
||||
WHERE s.tenant_id = ? AND s.task_id = ?
|
||||
ORDER BY s.revision DESC, s.id DESC
|
||||
LIMIT 20`,
|
||||
[input.tenantId, input.taskId]
|
||||
);
|
||||
return rows.map(publicTaskSubmission);
|
||||
}
|
||||
|
||||
async addMember(input: CleaningActor & {
|
||||
taskId: string; cleanerUserId: string; rewardCents: number; note?: string;
|
||||
}) {
|
||||
@@ -825,26 +996,68 @@ export class CleaningTaskRepository {
|
||||
|
||||
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');
|
||||
await this.transaction(async (connection) => {
|
||||
const [rows] = await connection.execute<CurrentStatusRow[]>(
|
||||
`SELECT status, cleaner_user_id AS cleanerUserId
|
||||
const [rows] = await connection.execute<CurrentTaskRuleRow[]>(
|
||||
`SELECT status, cleaner_user_id AS cleanerUserId, store_id AS storeId,
|
||||
photo_required AS photoRequired, min_photo_count AS minPhotoCount,
|
||||
max_photo_count AS maxPhotoCount, exempt_policy AS exemptPolicy,
|
||||
rework_count AS reworkCount
|
||||
FROM qipai_cleaning_tasks
|
||||
WHERE tenant_id = ? AND id = ? AND cleaner_user_id = ? AND deleted_at IS NULL
|
||||
FOR UPDATE`,
|
||||
[input.tenantId, input.taskId, input.userId]
|
||||
);
|
||||
const current = rows[0];
|
||||
if (!current || !['STARTED', 'REJECTED'].includes(current.status)) {
|
||||
if (!current || current.status !== 'STARTED') {
|
||||
throw new CleaningTaskError('CLEANING_TASK_STATUS_CONFLICT');
|
||||
}
|
||||
const photoUrls = [...new Set(input.photoUrls)];
|
||||
if (photoUrls.length !== input.photoUrls.length
|
||||
|| photoUrls.length < Number(current.minPhotoCount)
|
||||
|| photoUrls.length > Number(current.maxPhotoCount)
|
||||
|| (current.photoRequired && photoUrls.length === 0)) {
|
||||
throw new CleaningTaskError('CLEANING_PHOTO_COUNT_INVALID');
|
||||
}
|
||||
let ownedPhotos: PhotoOwnershipRow[] = [];
|
||||
if (photoUrls.length > 0) {
|
||||
const placeholders = photoUrls.map(() => '?').join(',');
|
||||
const [photoRows] = await connection.execute<PhotoOwnershipRow[]>(
|
||||
`SELECT id, public_url AS publicUrl
|
||||
FROM qipai_cleaning_task_photos
|
||||
WHERE tenant_id = ? AND task_id = ? AND uploaded_by = ?
|
||||
AND status = 'PENDING' AND deleted_at IS NULL
|
||||
AND public_url IN (${placeholders})
|
||||
FOR UPDATE`,
|
||||
[input.tenantId, input.taskId, input.userId, ...photoUrls]
|
||||
);
|
||||
ownedPhotos = photoRows;
|
||||
if (ownedPhotos.length !== photoUrls.length) {
|
||||
throw new CleaningTaskError('CLEANING_PHOTO_OWNERSHIP_INVALID');
|
||||
}
|
||||
}
|
||||
const revision = Number(current.reworkCount) + 1;
|
||||
await connection.execute(
|
||||
`UPDATE qipai_cleaning_tasks
|
||||
SET status = 'SUBMITTED', submitted_at = UTC_TIMESTAMP(3),
|
||||
photo_urls_json = ?, reject_reason = ''
|
||||
WHERE tenant_id = ? AND id = ?`,
|
||||
[JSON.stringify(input.photoUrls.slice(0, 9)), input.tenantId, input.taskId]
|
||||
[JSON.stringify(photoUrls), input.tenantId, input.taskId]
|
||||
);
|
||||
await connection.execute(
|
||||
`INSERT INTO qipai_cleaning_task_submissions
|
||||
(tenant_id, task_id, revision, status, photo_urls_json, note, submitted_by)
|
||||
VALUES (?, ?, ?, 'SUBMITTED', ?, ?, ?)`,
|
||||
[input.tenantId, input.taskId, revision, JSON.stringify(photoUrls),
|
||||
(input.note ?? '').slice(0, 512), input.userId]
|
||||
);
|
||||
if (ownedPhotos.length > 0) {
|
||||
await connection.execute(
|
||||
`UPDATE qipai_cleaning_task_photos
|
||||
SET status = 'ATTACHED', attached_revision = ?, retention_until = '9999-12-31 23:59:59.999'
|
||||
WHERE tenant_id = ? AND task_id = ? AND id IN (${ownedPhotos.map(() => '?').join(',')})`,
|
||||
[revision, input.tenantId, input.taskId, ...ownedPhotos.map((photo) => photo.id)]
|
||||
);
|
||||
}
|
||||
await this.recordEventWithConnection(
|
||||
connection, input, input.taskId, current.status, 'SUBMITTED', 'SUBMIT', input.note ?? ''
|
||||
);
|
||||
@@ -854,14 +1067,106 @@ export class CleaningTaskRepository {
|
||||
|
||||
async assertCanUploadPhoto(input: CleaningActor & { taskId: string }) {
|
||||
this.assertCleaner(input.access, 'write');
|
||||
const [rows] = await this.pool.execute<CurrentStatusRow[]>(
|
||||
`SELECT status, cleaner_user_id AS cleanerUserId
|
||||
const [rows] = await this.pool.execute<CurrentTaskRuleRow[]>(
|
||||
`SELECT status, cleaner_user_id AS cleanerUserId, store_id AS storeId,
|
||||
photo_required AS photoRequired, min_photo_count AS minPhotoCount,
|
||||
max_photo_count AS maxPhotoCount, exempt_policy AS exemptPolicy,
|
||||
rework_count AS reworkCount
|
||||
FROM qipai_cleaning_tasks
|
||||
WHERE tenant_id = ? AND id = ? AND cleaner_user_id = ? AND status IN ('STARTED', 'REJECTED')
|
||||
WHERE tenant_id = ? AND id = ? AND cleaner_user_id = ? AND status = 'STARTED'
|
||||
AND deleted_at IS NULL`,
|
||||
[input.tenantId, input.taskId, input.userId]
|
||||
);
|
||||
if (!rows[0]) throw new CleaningTaskError('CLEANING_TASK_STATUS_CONFLICT');
|
||||
return { storeId: String(rows[0].storeId), maxPhotoCount: Number(rows[0].maxPhotoCount) };
|
||||
}
|
||||
|
||||
async recordPhotoUpload(input: CleaningActor & { taskId: string; image: {
|
||||
storagePath: string;
|
||||
publicUrl: string;
|
||||
mimeType: string;
|
||||
byteSize: number;
|
||||
width: number;
|
||||
height: number;
|
||||
checksumSha256: string;
|
||||
} }) {
|
||||
this.assertCleaner(input.access, 'write');
|
||||
return this.transaction(async (connection) => {
|
||||
const [rows] = await connection.execute<CurrentTaskRuleRow[]>(
|
||||
`SELECT status, cleaner_user_id AS cleanerUserId, store_id AS storeId,
|
||||
photo_required AS photoRequired, min_photo_count AS minPhotoCount,
|
||||
max_photo_count AS maxPhotoCount, exempt_policy AS exemptPolicy,
|
||||
rework_count AS reworkCount
|
||||
FROM qipai_cleaning_tasks
|
||||
WHERE tenant_id = ? AND id = ? AND cleaner_user_id = ?
|
||||
AND status = 'STARTED' AND deleted_at IS NULL
|
||||
FOR UPDATE`,
|
||||
[input.tenantId, input.taskId, input.userId]
|
||||
);
|
||||
const task = rows[0];
|
||||
if (!task) throw new CleaningTaskError('CLEANING_TASK_STATUS_CONFLICT');
|
||||
const [counts] = await connection.execute<CountRow[]>(
|
||||
`SELECT COUNT(*) AS total FROM qipai_cleaning_task_photos
|
||||
WHERE tenant_id = ? AND task_id = ? AND uploaded_by = ?
|
||||
AND status = 'PENDING' AND deleted_at IS NULL`,
|
||||
[input.tenantId, input.taskId, input.userId]
|
||||
);
|
||||
if (Number(counts[0]?.total ?? 0) >= Number(task.maxPhotoCount)) {
|
||||
throw new CleaningTaskError('CLEANING_PHOTO_COUNT_INVALID');
|
||||
}
|
||||
const [result] = await connection.execute<ResultSetHeader>(
|
||||
`INSERT INTO qipai_cleaning_task_photos
|
||||
(tenant_id, task_id, store_id, uploaded_by, storage_path, public_url,
|
||||
mime_type, byte_size, width, height, checksum_sha256, status, retention_until)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'PENDING',
|
||||
TIMESTAMPADD(DAY, 7, UTC_TIMESTAMP(3)))`,
|
||||
[input.tenantId, input.taskId, task.storeId, input.userId,
|
||||
input.image.storagePath, input.image.publicUrl, input.image.mimeType,
|
||||
input.image.byteSize, input.image.width, input.image.height, input.image.checksumSha256]
|
||||
);
|
||||
return { id: String(result.insertId), ...input.image };
|
||||
});
|
||||
}
|
||||
|
||||
async claimExpiredPhotoUploads(input: CleaningActor & { limit: number }) {
|
||||
this.assertCleaner(input.access, 'write');
|
||||
const safeLimit = Math.max(1, Math.min(100, Math.trunc(input.limit)));
|
||||
return this.transaction(async (connection) => {
|
||||
const [rows] = await connection.execute<ExpiredPhotoRow[]>(
|
||||
`SELECT p.id, p.storage_path AS storagePath
|
||||
FROM qipai_cleaning_task_photos p
|
||||
WHERE p.tenant_id = ? AND p.status IN ('PENDING', 'ORPHANED')
|
||||
AND p.retention_until <= UTC_TIMESTAMP(3) AND p.deleted_at IS NULL
|
||||
AND ${storeScopeSql(input.access, 'p.store_id')}
|
||||
ORDER BY p.retention_until ASC, p.id ASC
|
||||
LIMIT ${safeLimit} FOR UPDATE SKIP LOCKED`,
|
||||
[input.tenantId]
|
||||
);
|
||||
if (rows.length > 0) {
|
||||
await connection.execute(
|
||||
`UPDATE qipai_cleaning_task_photos
|
||||
SET status = 'ORPHANED', retention_until = TIMESTAMPADD(MINUTE, 5, UTC_TIMESTAMP(3))
|
||||
WHERE tenant_id = ? AND id IN (${rows.map(() => '?').join(',')})`,
|
||||
[input.tenantId, ...rows.map((row) => row.id)]
|
||||
);
|
||||
}
|
||||
return rows.map((row) => ({ id: String(row.id), storagePath: row.storagePath }));
|
||||
});
|
||||
}
|
||||
|
||||
async markPhotoUploadsDeleted(input: CleaningActor & { photoIds: string[] }) {
|
||||
this.assertCleaner(input.access, 'write');
|
||||
if (input.photoIds.length === 0) return { deleted: 0 };
|
||||
const uniqueIds = [...new Set(input.photoIds)];
|
||||
const [result] = await this.pool.execute<ResultSetHeader>(
|
||||
`UPDATE qipai_cleaning_task_photos p
|
||||
SET p.deleted_at = UTC_TIMESTAMP(3), p.retention_until = UTC_TIMESTAMP(3)
|
||||
WHERE p.tenant_id = ? AND p.status = 'ORPHANED' AND p.deleted_at IS NULL
|
||||
AND p.id IN (${uniqueIds.map(() => '?').join(',')})
|
||||
AND ${storeScopeSql(input.access, 'p.store_id')}`,
|
||||
[input.tenantId, ...uniqueIds]
|
||||
);
|
||||
return { deleted: result.affectedRows };
|
||||
}
|
||||
|
||||
async createForFinishedOrder(
|
||||
@@ -877,23 +1182,45 @@ export class CleaningTaskRepository {
|
||||
);
|
||||
const order = orders[0];
|
||||
if (!order) throw new CleaningTaskError('CLEANING_ORDER_NOT_FINISHED');
|
||||
const [templates] = await connection.execute<CleaningTemplateRow[]>(
|
||||
`SELECT id, scope_key AS scopeKey, scope_type AS scopeType,
|
||||
store_id AS storeId, room_id AS roomId, name, requirement,
|
||||
photo_required AS photoRequired, min_photo_count AS minPhotoCount,
|
||||
max_photo_count AS maxPhotoCount, exempt_policy AS exemptPolicy,
|
||||
version, status, updated_at AS updatedAt
|
||||
FROM qipai_cleaning_templates
|
||||
WHERE tenant_id = ? AND status = 'ACTIVE'
|
||||
AND ((scope_type = 'ROOM' AND room_id = ?)
|
||||
OR (scope_type = 'STORE' AND store_id = ?)
|
||||
OR scope_type = 'TENANT')
|
||||
ORDER BY FIELD(scope_type, 'ROOM', 'STORE', 'TENANT')
|
||||
LIMIT 1`,
|
||||
[input.tenantId, order.roomId, order.storeId]
|
||||
);
|
||||
const template = templates[0];
|
||||
const taskNo = `CLN-${order.orderNo}`;
|
||||
const [result] = await connection.execute<ResultSetHeader>(
|
||||
`INSERT IGNORE INTO qipai_cleaning_tasks
|
||||
(tenant_id, store_id, room_id, order_id, task_no, status, priority,
|
||||
reward_cents, requirement, photo_urls_json)
|
||||
VALUES (?, ?, ?, ?, ?, 'WAITING', 5, 0, '订单结束后保洁', JSON_ARRAY())`,
|
||||
[input.tenantId, order.storeId, order.roomId, input.orderId, taskNo]
|
||||
reward_cents, requirement, cleaning_template_id, cleaning_template_version,
|
||||
photo_required, min_photo_count, max_photo_count, exempt_policy, photo_urls_json)
|
||||
VALUES (?, ?, ?, ?, ?, 'WAITING', 5, 0, ?, ?, ?, ?, ?, ?, ?, JSON_ARRAY())`,
|
||||
[input.tenantId, order.storeId, order.roomId, input.orderId, taskNo,
|
||||
template?.requirement || '订单结束后清洁', template?.id ?? null,
|
||||
Number(template?.version ?? 0), template ? (template.photoRequired ? 1 : 0) : 1,
|
||||
Number(template?.minPhotoCount ?? 1), Number(template?.maxPhotoCount ?? 9),
|
||||
template?.exemptPolicy ?? 'ANY_ACTIVE']
|
||||
);
|
||||
if (result.affectedRows === 1) {
|
||||
await connection.execute(
|
||||
`INSERT IGNORE INTO qipai_cleaning_task_events
|
||||
(tenant_id, task_id, from_status, to_status, action, actor_id, trace_id, note, metadata)
|
||||
SELECT tenant_id, id, NULL, 'WAITING', 'AUTO_CREATE', ?, ?, '订单结束自动创建保洁任务',
|
||||
JSON_OBJECT('orderId', ?)
|
||||
JSON_OBJECT('orderId', ?, 'cleaningTemplateId', ?, 'cleaningTemplateVersion', ?)
|
||||
FROM qipai_cleaning_tasks
|
||||
WHERE tenant_id = ? AND order_id = ?`,
|
||||
[input.actorId, input.traceId, input.orderId, input.tenantId, input.orderId]
|
||||
[input.actorId, input.traceId, input.orderId, template?.id ?? null,
|
||||
Number(template?.version ?? 0), input.tenantId, input.orderId]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1106,7 +1433,14 @@ export class CleaningTaskRepository {
|
||||
t.room_id AS roomId, r.name AS roomName, r.room_no AS roomNo,
|
||||
t.order_id AS orderId, o.order_no AS orderNo, t.status,
|
||||
t.cleaner_user_id AS cleanerUserId, t.priority, t.reward_cents AS rewardCents,
|
||||
t.requirement, t.photo_urls_json AS photoUrlsJson, t.reject_reason AS rejectReason,
|
||||
t.requirement, t.cleaning_template_id AS cleaningTemplateId,
|
||||
t.cleaning_template_version AS cleaningTemplateVersion,
|
||||
t.photo_required AS photoRequired, t.min_photo_count AS minPhotoCount,
|
||||
t.max_photo_count AS maxPhotoCount, t.exempt_policy AS exemptPolicy,
|
||||
t.photo_urls_json AS photoUrlsJson, t.reject_reason AS rejectReason,
|
||||
t.rework_count AS reworkCount,
|
||||
COALESCE((SELECT MAX(sub.revision) FROM qipai_cleaning_task_submissions sub
|
||||
WHERE sub.tenant_id = t.tenant_id AND sub.task_id = t.id), 0) AS photoRevision,
|
||||
t.claimed_at AS claimedAt, t.started_at AS startedAt,
|
||||
t.submitted_at AS submittedAt, t.completed_at AS completedAt,
|
||||
(SELECT COUNT(*) FROM qipai_cleaning_task_members m
|
||||
@@ -1175,7 +1509,7 @@ export class CleaningTaskRepository {
|
||||
action: string,
|
||||
timestampColumn: 'started_at' | 'submitted_at',
|
||||
note: string,
|
||||
extra?: { photo_urls_json?: string; reject_reason?: string }
|
||||
extra?: { photo_urls_json?: string; reject_reason?: string; incrementRework?: boolean }
|
||||
) {
|
||||
this.assertCleaner(input.access, 'write');
|
||||
const extraAssignments: string[] = [];
|
||||
@@ -1188,6 +1522,7 @@ export class CleaningTaskRepository {
|
||||
extraAssignments.push('reject_reason = ?');
|
||||
extraParams.push(extra.reject_reason);
|
||||
}
|
||||
if (extra?.incrementRework) extraAssignments.push('rework_count = rework_count + 1');
|
||||
const setExtra = extraAssignments.length > 0 ? `, ${extraAssignments.join(', ')}` : '';
|
||||
await this.transaction(async (connection) => {
|
||||
const [result] = await connection.execute<ResultSetHeader>(
|
||||
@@ -1280,6 +1615,61 @@ export class CleaningTaskRepository {
|
||||
);
|
||||
}
|
||||
|
||||
private async getTemplate(
|
||||
connection: Pick<MySqlPool, 'execute'> | PoolConnection,
|
||||
tenantId: string,
|
||||
templateId: string
|
||||
) {
|
||||
const [rows] = await connection.execute<CleaningTemplateRow[]>(
|
||||
`SELECT id, scope_key AS scopeKey, scope_type AS scopeType,
|
||||
store_id AS storeId, room_id AS roomId, name, requirement,
|
||||
photo_required AS photoRequired, min_photo_count AS minPhotoCount,
|
||||
max_photo_count AS maxPhotoCount, exempt_policy AS exemptPolicy,
|
||||
version, status, updated_at AS updatedAt
|
||||
FROM qipai_cleaning_templates
|
||||
WHERE tenant_id = ? AND id = ?`,
|
||||
[tenantId, templateId]
|
||||
);
|
||||
if (!rows[0]) throw new CleaningTaskError('CLEANING_TEMPLATE_NOT_FOUND');
|
||||
return publicTemplate(rows[0]);
|
||||
}
|
||||
|
||||
private async resolveTemplateScope(
|
||||
input: CleaningActor,
|
||||
scopeType: CleaningTemplateScope,
|
||||
scopeId?: string
|
||||
) {
|
||||
if (scopeType === 'TENANT') {
|
||||
if (scopeId || (!input.access.capabilities.includes('tenant.manage')
|
||||
&& !input.access.roles.includes('PLATFORM_ADMIN'))) {
|
||||
throw new CleaningTaskError('CLEANING_TEMPLATE_SCOPE_FORBIDDEN');
|
||||
}
|
||||
return { scopeKey: 'TENANT', storeId: null, roomId: null };
|
||||
}
|
||||
if (!scopeId) throw new CleaningTaskError('CLEANING_TEMPLATE_SCOPE_INVALID');
|
||||
if (scopeType === 'STORE') {
|
||||
const [rows] = await this.pool.execute<RowDataPacket[]>(
|
||||
`SELECT id FROM qipai_stores
|
||||
WHERE tenant_id = ? AND id = ? AND deleted_at IS NULL`,
|
||||
[input.tenantId, scopeId]
|
||||
);
|
||||
if (!rows[0] || !canAccessStore(input.access, scopeId)) {
|
||||
throw new CleaningTaskError('CLEANING_TEMPLATE_SCOPE_FORBIDDEN');
|
||||
}
|
||||
return { scopeKey: `STORE:${scopeId}`, storeId: scopeId, roomId: null };
|
||||
}
|
||||
const [rows] = await this.pool.execute<RowDataPacket[]>(
|
||||
`SELECT store_id AS storeId FROM qipai_rooms
|
||||
WHERE tenant_id = ? AND id = ? AND deleted_at IS NULL`,
|
||||
[input.tenantId, scopeId]
|
||||
);
|
||||
const storeId = rows[0]?.storeId ? String(rows[0].storeId) : '';
|
||||
if (!storeId || !canAccessStore(input.access, storeId)) {
|
||||
throw new CleaningTaskError('CLEANING_TEMPLATE_SCOPE_FORBIDDEN');
|
||||
}
|
||||
return { scopeKey: `ROOM:${scopeId}`, storeId, roomId: scopeId };
|
||||
}
|
||||
|
||||
private async assertStoreVisible(input: CleaningActor, taskId: string) {
|
||||
if (input.access.capabilities.includes('tenant.manage') || input.access.roles.includes('PLATFORM_ADMIN')) return;
|
||||
const [rows] = await this.pool.execute<RowDataPacket[]>(
|
||||
@@ -1417,6 +1807,12 @@ export class CleaningTaskRepository {
|
||||
}
|
||||
}
|
||||
|
||||
function canAccessStore(access: AccessProfile, storeId: string) {
|
||||
return access.capabilities.includes('tenant.manage')
|
||||
|| access.roles.includes('PLATFORM_ADMIN')
|
||||
|| access.storeIds.includes(storeId);
|
||||
}
|
||||
|
||||
function storeScopeSql(access: AccessProfile, storeExpression: string) {
|
||||
if (access.capabilities.includes('tenant.manage') || access.roles.includes('PLATFORM_ADMIN')) {
|
||||
return '1 = 1';
|
||||
@@ -1441,8 +1837,16 @@ function publicTask(row: CleaningTaskRow) {
|
||||
priority: Number(row.priority),
|
||||
rewardCents: Number(row.rewardCents),
|
||||
requirement: row.requirement,
|
||||
cleaningTemplateId: row.cleaningTemplateId === null ? null : String(row.cleaningTemplateId),
|
||||
cleaningTemplateVersion: Number(row.cleaningTemplateVersion),
|
||||
photoRequired: Boolean(row.photoRequired),
|
||||
minPhotoCount: Number(row.minPhotoCount),
|
||||
maxPhotoCount: Number(row.maxPhotoCount),
|
||||
exemptPolicy: row.exemptPolicy,
|
||||
photoUrls: parseJsonArray(row.photoUrlsJson),
|
||||
rejectReason: row.rejectReason,
|
||||
reworkCount: Number(row.reworkCount),
|
||||
photoRevision: Number(row.photoRevision),
|
||||
claimedAt: row.claimedAt,
|
||||
startedAt: row.startedAt,
|
||||
submittedAt: row.submittedAt,
|
||||
@@ -1453,6 +1857,26 @@ function publicTask(row: CleaningTaskRow) {
|
||||
};
|
||||
}
|
||||
|
||||
function publicTemplate(row: CleaningTemplateRow) {
|
||||
return {
|
||||
id: String(row.id),
|
||||
scopeKey: row.scopeKey,
|
||||
scopeType: row.scopeType,
|
||||
storeId: row.storeId === null ? null : String(row.storeId),
|
||||
roomId: row.roomId === null ? null : String(row.roomId),
|
||||
name: row.name,
|
||||
requirement: row.requirement,
|
||||
photoRequired: Boolean(row.photoRequired),
|
||||
minPhotoCount: Number(row.minPhotoCount),
|
||||
maxPhotoCount: Number(row.maxPhotoCount),
|
||||
exemptPolicy: row.exemptPolicy,
|
||||
version: Number(row.version),
|
||||
status: row.status,
|
||||
updatedAt: row.updatedAt,
|
||||
appliesToExistingTasks: false
|
||||
};
|
||||
}
|
||||
|
||||
function publicTaskMember(row: CleaningTaskMemberRow) {
|
||||
return {
|
||||
id: String(row.id),
|
||||
@@ -1481,6 +1905,22 @@ function publicTaskEvent(row: CleaningTaskEventRow) {
|
||||
};
|
||||
}
|
||||
|
||||
function publicTaskSubmission(row: CleaningTaskSubmissionRow) {
|
||||
return {
|
||||
id: String(row.id),
|
||||
taskId: String(row.taskId),
|
||||
revision: Number(row.revision),
|
||||
status: row.status,
|
||||
photoUrls: parseJsonArray(row.photoUrlsJson),
|
||||
note: row.note,
|
||||
rejectReason: row.rejectReason,
|
||||
submittedBy: String(row.submittedBy),
|
||||
reviewedBy: row.reviewedBy === null ? null : String(row.reviewedBy),
|
||||
submittedAt: row.submittedAt,
|
||||
reviewedAt: row.reviewedAt
|
||||
};
|
||||
}
|
||||
|
||||
function publicSettlement(row: SettlementRow) {
|
||||
return {
|
||||
id: String(row.id),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
import { mkdir, unlink, writeFile } from 'node:fs/promises';
|
||||
import { extname, resolve, sep } from 'node:path';
|
||||
import sharp from 'sharp';
|
||||
|
||||
@@ -33,18 +33,33 @@ export class MediaStorage {
|
||||
if (input.body.length === 0 || input.body.length > 8 * 1024 * 1024) {
|
||||
throw new MediaValidationError('IMAGE_SIZE_INVALID');
|
||||
}
|
||||
if (!['image/jpeg', 'image/png', 'image/webp'].includes(input.contentType)) {
|
||||
const declaredFormat = {
|
||||
'image/jpeg': 'jpeg',
|
||||
'image/png': 'png',
|
||||
'image/webp': 'webp'
|
||||
}[input.contentType];
|
||||
if (!declaredFormat) {
|
||||
throw new MediaValidationError('IMAGE_TYPE_INVALID');
|
||||
}
|
||||
if (!['.jpg', '.jpeg', '.png', '.webp'].includes(extname(input.originalName).toLowerCase())) {
|
||||
const declaredExtension = {
|
||||
'.jpg': 'jpeg',
|
||||
'.jpeg': 'jpeg',
|
||||
'.png': 'png',
|
||||
'.webp': 'webp'
|
||||
}[extname(input.originalName).toLowerCase()];
|
||||
if (!declaredExtension) {
|
||||
throw new MediaValidationError('IMAGE_EXTENSION_INVALID');
|
||||
}
|
||||
if (declaredExtension !== declaredFormat) {
|
||||
throw new MediaValidationError('IMAGE_TYPE_MISMATCH');
|
||||
}
|
||||
let result: Buffer;
|
||||
let metadata: sharp.Metadata;
|
||||
try {
|
||||
const source = sharp(input.body, { failOn: 'warning', limitInputPixels: 40_000_000 });
|
||||
metadata = await source.metadata();
|
||||
if (!metadata.width || !metadata.height) throw new Error('missing dimensions');
|
||||
if (metadata.format !== declaredFormat) throw new Error('declared image type mismatch');
|
||||
result = await source
|
||||
.rotate()
|
||||
.resize({ width: 1920, height: 1920, fit: 'inside', withoutEnlargement: true })
|
||||
@@ -74,4 +89,17 @@ export class MediaStorage {
|
||||
checksumSha256: createHash('sha256').update(result).digest('hex')
|
||||
};
|
||||
}
|
||||
|
||||
async deleteImage(storagePath: string): Promise<void> {
|
||||
const safeRoot = resolve(this.root);
|
||||
const target = resolve(safeRoot, storagePath);
|
||||
if (target === safeRoot || !target.startsWith(`${safeRoot}${sep}`)) {
|
||||
throw new MediaValidationError('IMAGE_PATH_INVALID');
|
||||
}
|
||||
try {
|
||||
await unlink(target);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,7 +53,8 @@ const migrationFiles: Record<MigrationDirection, readonly string[]> = {
|
||||
'database/migrations/2026081001_m08c_staff_management_access.up.sql',
|
||||
'database/migrations/2026081002_m08d_content_asset_scope.up.sql',
|
||||
'database/migrations/2026081003_m08d_franchise_leads.up.sql',
|
||||
'database/migrations/2026081004_m08d_admin_password_auth.up.sql'
|
||||
'database/migrations/2026081004_m08d_admin_password_auth.up.sql',
|
||||
'database/migrations/2026081005_m09b_cleaning_rules.up.sql'
|
||||
],
|
||||
verify: [
|
||||
'database/migrations/2026061601_m01b_core_schema.verify.sql',
|
||||
@@ -88,9 +89,11 @@ const migrationFiles: Record<MigrationDirection, readonly string[]> = {
|
||||
'database/migrations/2026081001_m08c_staff_management_access.verify.sql',
|
||||
'database/migrations/2026081002_m08d_content_asset_scope.verify.sql',
|
||||
'database/migrations/2026081003_m08d_franchise_leads.verify.sql',
|
||||
'database/migrations/2026081004_m08d_admin_password_auth.verify.sql'
|
||||
'database/migrations/2026081004_m08d_admin_password_auth.verify.sql',
|
||||
'database/migrations/2026081005_m09b_cleaning_rules.verify.sql'
|
||||
],
|
||||
down: [
|
||||
'database/migrations/2026081005_m09b_cleaning_rules.down.sql',
|
||||
'database/migrations/2026081004_m08d_admin_password_auth.down.sql',
|
||||
'database/migrations/2026081003_m08d_franchise_leads.down.sql',
|
||||
'database/migrations/2026081002_m08d_content_asset_scope.down.sql',
|
||||
|
||||
@@ -60,12 +60,17 @@ export class OrderDeviceAutomationService {
|
||||
}
|
||||
if (payload.event === 'ORDER_ROOM_CHANGED') {
|
||||
if (payload.previousRoomId && payload.previousRoomId !== order.roomId) {
|
||||
await this.cancelRoomDevices(order, payload.traceId, payload.previousRoomId);
|
||||
if (!await this.hasActiveRoomOrder(order.tenantId, payload.previousRoomId, order.id)) {
|
||||
await this.cancelRoomDevices(order, payload.traceId, payload.previousRoomId);
|
||||
}
|
||||
}
|
||||
await this.startOrderDevices(order, payload.traceId);
|
||||
return { orderId: order.id, action: 'ROOM_CHANGED' };
|
||||
}
|
||||
|
||||
if (await this.hasActiveRoomOrder(order.tenantId, order.roomId, order.id)) {
|
||||
return { orderId: order.id, skipped: true, reason: 'ROOM_HAS_ACTIVE_ORDER' };
|
||||
}
|
||||
await this.cancelRoomDevices(order, payload.traceId, order.roomId);
|
||||
return { orderId: order.id, action: 'CANCELLED' };
|
||||
}
|
||||
@@ -112,6 +117,17 @@ export class OrderDeviceAutomationService {
|
||||
};
|
||||
}
|
||||
|
||||
private async hasActiveRoomOrder(tenantId: string, roomId: string, excludedOrderId: string) {
|
||||
const [rows] = await this.pool.execute<RowDataPacket[]>(
|
||||
`SELECT id FROM qipai_orders
|
||||
WHERE tenant_id = ? AND room_id = ? AND id <> ?
|
||||
AND status IN ('PAID', 'RESERVED', 'IN_PROGRESS') AND deleted_at IS NULL
|
||||
LIMIT 1`,
|
||||
[tenantId, roomId, excludedOrderId]
|
||||
);
|
||||
return Boolean(rows[0]);
|
||||
}
|
||||
|
||||
private async loadOrder(tenantId: string, orderId: string) {
|
||||
const [rows] = await this.pool.execute<OrderRow[]>(
|
||||
`SELECT id, tenant_id AS tenantId, store_id AS storeId, room_id AS roomId,
|
||||
|
||||
@@ -52,6 +52,26 @@ const memberRemoveSchema = z.object({
|
||||
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 cleaningTemplateSchema = z.object({
|
||||
scopeType: z.enum(['TENANT', 'STORE', 'ROOM']),
|
||||
scopeId: z.string().regex(/^[1-9]\d{0,19}$/).optional(),
|
||||
name: z.string().trim().min(1).max(128),
|
||||
requirement: z.string().trim().max(512).default(''),
|
||||
photoRequired: z.boolean().default(true),
|
||||
minPhotoCount: z.coerce.number().int().min(0).max(9).default(1),
|
||||
maxPhotoCount: z.coerce.number().int().min(1).max(9).default(9),
|
||||
exemptPolicy: z.enum(['DISABLED', 'BEFORE_START', 'ANY_ACTIVE']).default('ANY_ACTIVE'),
|
||||
status: z.enum(['ACTIVE', 'DISABLED']).default('ACTIVE')
|
||||
}).strict().superRefine((value, context) => {
|
||||
if ((value.scopeType === 'TENANT') !== !value.scopeId) {
|
||||
context.addIssue({ code: z.ZodIssueCode.custom, path: ['scopeId'], message: 'scope mismatch' });
|
||||
}
|
||||
if (value.minPhotoCount > value.maxPhotoCount
|
||||
|| (value.photoRequired && value.minPhotoCount < 1)
|
||||
|| (!value.photoRequired && value.minPhotoCount !== 0)) {
|
||||
context.addIssue({ code: z.ZodIssueCode.custom, path: ['minPhotoCount'], message: 'photo rule mismatch' });
|
||||
}
|
||||
});
|
||||
const settlementStatusSchema = z.enum(['DRAFT', 'CONFIRMED', 'PAID', 'CANCELLED']);
|
||||
const settlementPayoutStateSchema = z.enum([
|
||||
'NONE', 'SUCCESS', 'FAIL', 'PROCESSING', 'WAIT_USER_CONFIRM'
|
||||
@@ -101,14 +121,19 @@ const reclaimSchema = z.object({
|
||||
olderThanMinutes: z.coerce.number().int().min(5).max(1440).default(60),
|
||||
limit: z.coerce.number().int().min(1).max(100).default(20)
|
||||
});
|
||||
const photoCleanupSchema = z.object({
|
||||
limit: z.coerce.number().int().min(1).max(100).default(20)
|
||||
}).strict();
|
||||
|
||||
export interface CleaningRouteOptions {
|
||||
repository: Pick<CleaningTaskRepository,
|
||||
'listHall' | 'listMine' | 'listManage' | 'claim' | 'start' | 'rework' | 'submit'
|
||||
| 'assign' | 'complete' | 'reject' | 'exempt' | 'listMembers' | 'listEvents' | 'addMember' | 'removeMember' | 'settlementCandidates'
|
||||
| 'assign' | 'complete' | 'reject' | 'exempt' | 'listMembers' | 'listEvents' | 'listSubmissions' | 'addMember' | 'removeMember' | 'settlementCandidates'
|
||||
| 'listSettlements' | 'getSettlementDetail' | 'generateSettlement' | 'confirmSettlement' | 'markSettlementPaid'
|
||||
| 'recordSettlementPayoutFailure' | 'reclaimTimeouts'
|
||||
| 'assertCanUploadPhoto' | 'stats' | 'managerStatistics'>;
|
||||
| 'assertCanUploadPhoto' | 'recordPhotoUpload' | 'claimExpiredPhotoUploads' | 'markPhotoUploadsDeleted'
|
||||
| 'listTemplates' | 'upsertTemplate'
|
||||
| 'stats' | 'managerStatistics'>;
|
||||
mediaStorage?: MediaStorage;
|
||||
payoutService?: Pick<CleaningPayoutService,
|
||||
'preflightWechatTransfer' | 'executeWechatTransfer' | 'syncWechatTransfer'
|
||||
@@ -130,6 +155,28 @@ export async function registerCleaningRoutes(
|
||||
);
|
||||
}
|
||||
|
||||
app.get('/admin-api/cleaning/templates', async (request, reply) => {
|
||||
const actor = await requireActor(request, reply, options, 'read');
|
||||
if (!actor) return;
|
||||
return handle(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.repository.listTemplates(actor),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
|
||||
app.put('/admin-api/cleaning/templates', async (request, reply) => {
|
||||
const actor = await requireActor(request, reply, options, 'write');
|
||||
if (!actor) return;
|
||||
const body = cleaningTemplateSchema.safeParse(request.body ?? {});
|
||||
if (!body.success) return invalid(reply, request.traceId);
|
||||
return handle(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.repository.upsertTemplate({ ...actor, ...body.data }),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
|
||||
app.get('/app-api/cleaning/tasks/hall', async (request, reply) => {
|
||||
const actor = await requireActor(request, reply, options, 'read');
|
||||
if (!actor) return;
|
||||
@@ -291,6 +338,18 @@ export async function registerCleaningRoutes(
|
||||
}));
|
||||
});
|
||||
|
||||
app.get('/admin-api/cleaning/tasks/:taskId/submissions', async (request, reply) => {
|
||||
const actor = await requireActor(request, reply, options, 'read');
|
||||
if (!actor) return;
|
||||
const params = paramsSchema.safeParse(request.params);
|
||||
if (!params.success) return invalid(reply, request.traceId);
|
||||
return handle(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.repository.listSubmissions({ ...actor, taskId: params.data.taskId }),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
|
||||
app.post('/admin-api/cleaning/tasks/:taskId/members', async (request, reply) => {
|
||||
const actor = await requireActor(request, reply, options, 'write');
|
||||
if (!actor) return;
|
||||
@@ -366,14 +425,25 @@ export async function registerCleaningRoutes(
|
||||
return invalid(reply, request.traceId);
|
||||
}
|
||||
return handle(reply, request.traceId, async () => {
|
||||
await options.repository.assertCanUploadPhoto({ ...actor, taskId: params.data.taskId });
|
||||
const context = await options.repository.assertCanUploadPhoto({
|
||||
...actor, taskId: params.data.taskId
|
||||
});
|
||||
const image = await options.mediaStorage!.storeImage({
|
||||
tenantId: actor.tenantId,
|
||||
storeId: context.storeId,
|
||||
originalName,
|
||||
contentType: singleHeader(request.headers['x-image-content-type']) ?? '',
|
||||
body: request.body as Buffer
|
||||
});
|
||||
return reply.status(201).send({ code: 0, data: image, traceId: request.traceId });
|
||||
try {
|
||||
const recorded = await options.repository.recordPhotoUpload({
|
||||
...actor, taskId: params.data.taskId, image
|
||||
});
|
||||
return reply.status(201).send({ code: 0, data: recorded, traceId: request.traceId });
|
||||
} catch (error) {
|
||||
await options.mediaStorage!.deleteImage(image.storagePath);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -473,6 +543,41 @@ export async function registerCleaningRoutes(
|
||||
}));
|
||||
});
|
||||
|
||||
app.post('/admin-api/cleaning/photos/cleanup', async (request, reply) => {
|
||||
if (!options.mediaStorage) return reply.status(501).send({
|
||||
code: 'CLEANING_PHOTO_CLEANUP_UNAVAILABLE',
|
||||
message: 'Cleaning photo storage is not configured.',
|
||||
traceId: request.traceId
|
||||
});
|
||||
const actor = await requireActor(request, reply, options, 'write');
|
||||
if (!actor) return;
|
||||
const body = photoCleanupSchema.safeParse(request.body ?? {});
|
||||
if (!body.success) return invalid(reply, request.traceId);
|
||||
return handle(reply, request.traceId, async () => {
|
||||
const claimed = await options.repository.claimExpiredPhotoUploads({
|
||||
...actor, limit: body.data.limit
|
||||
});
|
||||
const deletedIds: string[] = [];
|
||||
const failedIds: string[] = [];
|
||||
for (const photo of claimed) {
|
||||
try {
|
||||
await options.mediaStorage!.deleteImage(photo.storagePath);
|
||||
deletedIds.push(photo.id);
|
||||
} catch {
|
||||
failedIds.push(photo.id);
|
||||
}
|
||||
}
|
||||
const result = await options.repository.markPhotoUploadsDeleted({
|
||||
...actor, photoIds: deletedIds
|
||||
});
|
||||
return {
|
||||
code: 0,
|
||||
data: { claimed: claimed.length, deleted: result.deleted, failedIds },
|
||||
traceId: request.traceId
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/admin-api/cleaning/settlements', async (request, reply) => {
|
||||
const actor = await requireActor(request, reply, options, 'write');
|
||||
if (!actor) return;
|
||||
@@ -679,8 +784,7 @@ async function handle(reply: FastifyReply, traceId: string, work: () => Promise<
|
||||
&& !(error instanceof CleaningPayoutError)
|
||||
&& !(error instanceof WechatPayError)) throw error;
|
||||
const code = error.code;
|
||||
const statusCode = code === 'CLEANING_TASK_FORBIDDEN'
|
||||
|| code === 'CLEANING_SETTLEMENT_FORBIDDEN' ? 403 : 409;
|
||||
const statusCode = code.endsWith('_FORBIDDEN') ? 403 : 409;
|
||||
return reply.status(statusCode).send({
|
||||
code,
|
||||
message: 'The cleaning task request cannot be completed.',
|
||||
|
||||
@@ -77,6 +77,25 @@ const app = await buildApp({
|
||||
},
|
||||
async assertCanUploadPhoto(input) {
|
||||
calls.push(['assertCanUploadPhoto', input]);
|
||||
return { storeId: '11', maxPhotoCount: 3 };
|
||||
},
|
||||
async recordPhotoUpload(input) {
|
||||
calls.push(['recordPhotoUpload', input]);
|
||||
return { id: '701', ...input.image };
|
||||
},
|
||||
async listTemplates(input) {
|
||||
calls.push(['listTemplates', input]);
|
||||
return [{
|
||||
id: '801', scopeKey: 'STORE:11', scopeType: 'STORE', storeId: '11', roomId: null,
|
||||
name: '门店清洁', requirement: '桌面与地面', photoRequired: true,
|
||||
minPhotoCount: 2, maxPhotoCount: 3, exemptPolicy: 'BEFORE_START',
|
||||
version: 2, status: 'ACTIVE', appliesToExistingTasks: false
|
||||
}];
|
||||
},
|
||||
async upsertTemplate(input) {
|
||||
calls.push(['upsertTemplate', input]);
|
||||
return { id: '801', scopeKey: `STORE:${input.scopeId}`, ...input,
|
||||
storeId: input.scopeId, roomId: null, version: 3, appliesToExistingTasks: false };
|
||||
},
|
||||
async submit(input) {
|
||||
calls.push(['submit', input]);
|
||||
@@ -116,6 +135,15 @@ const app = await buildApp({
|
||||
createdAt: new Date()
|
||||
}];
|
||||
},
|
||||
async listSubmissions(input) {
|
||||
calls.push(['listSubmissions', input]);
|
||||
return [{
|
||||
id: '9101', taskId: input.taskId, revision: 2, status: 'REJECTED',
|
||||
photoUrls: ['https://api.txyundm.cn/uploads/old.webp'], note: 'first',
|
||||
rejectReason: 'photo is unclear', submittedBy: '31', reviewedBy: '31',
|
||||
submittedAt: new Date(), reviewedAt: new Date()
|
||||
}];
|
||||
},
|
||||
async addMember(input) {
|
||||
calls.push(['addMember', input]);
|
||||
return [member('LEAD', '31', 500), member('ASSIST', input.cleanerUserId, input.rewardCents)];
|
||||
@@ -171,6 +199,14 @@ const app = await buildApp({
|
||||
calls.push(['reclaimTimeouts', input]);
|
||||
return { reclaimed: 2, taskIds: ['101', '102'] };
|
||||
},
|
||||
async claimExpiredPhotoUploads(input) {
|
||||
calls.push(['claimExpiredPhotoUploads', input]);
|
||||
return [{ id: '701', storagePath: 'tenants/7/stores/11/orphan.webp' }];
|
||||
},
|
||||
async markPhotoUploadsDeleted(input) {
|
||||
calls.push(['markPhotoUploadsDeleted', input]);
|
||||
return { deleted: input.photoIds.length };
|
||||
},
|
||||
async stats(input) {
|
||||
calls.push(['stats', input]);
|
||||
return {
|
||||
@@ -281,19 +317,44 @@ const app = await buildApp({
|
||||
async storeImage(input) {
|
||||
calls.push(['storeImage', input]);
|
||||
return {
|
||||
storagePath: 'tenants/7/shared/cleaning.webp',
|
||||
publicUrl: 'https://api.txyundm.cn/uploads/tenants/7/shared/cleaning.webp',
|
||||
storagePath: 'tenants/7/stores/11/cleaning.webp',
|
||||
publicUrl: 'https://api.txyundm.cn/uploads/tenants/7/stores/11/cleaning.webp',
|
||||
mimeType: 'image/webp',
|
||||
byteSize: input.body.length,
|
||||
width: 640,
|
||||
height: 480,
|
||||
checksumSha256: 'abc'
|
||||
};
|
||||
},
|
||||
async deleteImage(storagePath) {
|
||||
calls.push(['deleteImage', storagePath]);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const templates = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/admin-api/cleaning/templates',
|
||||
headers: { authorization: `Bearer ${token}` }
|
||||
});
|
||||
assert.equal(templates.statusCode, 200);
|
||||
assert.equal(templates.json().data[0].scopeKey, 'STORE:11');
|
||||
|
||||
const updatedTemplate = await app.inject({
|
||||
method: 'PUT',
|
||||
url: '/admin-api/cleaning/templates',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: {
|
||||
scopeType: 'STORE', scopeId: '11', name: '门店清洁', requirement: '桌面与地面',
|
||||
photoRequired: true, minPhotoCount: 2, maxPhotoCount: 3,
|
||||
exemptPolicy: 'BEFORE_START', status: 'ACTIVE'
|
||||
}
|
||||
});
|
||||
assert.equal(updatedTemplate.statusCode, 200);
|
||||
assert.equal(updatedTemplate.json().data.appliesToExistingTasks, false);
|
||||
assert.equal(calls.at(-1)[0], 'upsertTemplate');
|
||||
|
||||
const hall = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/app-api/cleaning/tasks/hall?page=1&pageSize=10',
|
||||
@@ -396,9 +457,11 @@ const photo = await app.inject({
|
||||
payload: Buffer.from('fake-image')
|
||||
});
|
||||
assert.equal(photo.statusCode, 201);
|
||||
assert.equal(photo.json().data.publicUrl, 'https://api.txyundm.cn/uploads/tenants/7/shared/cleaning.webp');
|
||||
assert.equal(calls.at(-2)[0], 'assertCanUploadPhoto');
|
||||
assert.equal(calls.at(-1)[0], 'storeImage');
|
||||
assert.equal(photo.json().data.publicUrl, 'https://api.txyundm.cn/uploads/tenants/7/stores/11/cleaning.webp');
|
||||
assert.equal(calls.at(-3)[0], 'assertCanUploadPhoto');
|
||||
assert.equal(calls.at(-2)[0], 'storeImage');
|
||||
assert.equal(calls.at(-2)[1].storeId, '11');
|
||||
assert.equal(calls.at(-1)[0], 'recordPhotoUpload');
|
||||
|
||||
const submit = await app.inject({
|
||||
method: 'POST',
|
||||
@@ -498,6 +561,16 @@ assert.equal(calls.at(-1)[0], 'listEvents');
|
||||
assert.equal(calls.at(-1)[1].taskId, '101');
|
||||
assert.equal(events.json().data[0].action, 'SUBMIT');
|
||||
|
||||
const submissions = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/admin-api/cleaning/tasks/101/submissions',
|
||||
headers: { authorization: `Bearer ${token}` }
|
||||
});
|
||||
assert.equal(submissions.statusCode, 200);
|
||||
assert.equal(calls.at(-1)[0], 'listSubmissions');
|
||||
assert.equal(submissions.json().data[0].revision, 2);
|
||||
assert.equal(submissions.json().data[0].photoUrls[0], 'https://api.txyundm.cn/uploads/old.webp');
|
||||
|
||||
const addedMember = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/admin-api/cleaning/tasks/101/members',
|
||||
@@ -664,6 +737,19 @@ assert.equal(reclaimed.statusCode, 200);
|
||||
assert.equal(calls.at(-1)[0], 'reclaimTimeouts');
|
||||
assert.equal(calls.at(-1)[1].olderThanMinutes, 30);
|
||||
|
||||
const cleanedPhotos = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/admin-api/cleaning/photos/cleanup',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: { limit: 10 }
|
||||
});
|
||||
assert.equal(cleanedPhotos.statusCode, 200);
|
||||
assert.deepEqual(cleanedPhotos.json().data, { claimed: 1, deleted: 1, failedIds: [] });
|
||||
assert.equal(calls.at(-3)[0], 'claimExpiredPhotoUploads');
|
||||
assert.equal(calls.at(-2)[0], 'deleteImage');
|
||||
assert.equal(calls.at(-1)[0], 'markPhotoUploadsDeleted');
|
||||
assert.deepEqual(calls.at(-1)[1].photoIds, ['701']);
|
||||
|
||||
const stats = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/app-api/cleaning/stats',
|
||||
|
||||
@@ -21,7 +21,18 @@ try {
|
||||
assert.equal(image.mimeType, 'image/webp');
|
||||
assert.ok(image.width <= 1920);
|
||||
assert.match(image.storagePath, /^tenants\/7\/stores\/11\/.+\.webp$/);
|
||||
assert.ok((await readFile(join(root, ...image.storagePath.split('/')))).length > 0);
|
||||
const storedPath = join(root, ...image.storagePath.split('/'));
|
||||
const storedBytes = await readFile(storedPath);
|
||||
assert.ok(storedBytes.length > 0);
|
||||
const storedMetadata = await sharp(storedBytes).metadata();
|
||||
assert.equal(storedMetadata.exif, undefined);
|
||||
assert.equal(storedMetadata.icc, undefined);
|
||||
await assert.rejects(
|
||||
() => storage.storeImage({
|
||||
tenantId: '7', originalName: 'spoofed.jpg', contentType: 'image/jpeg', body: png
|
||||
}),
|
||||
(error) => error instanceof MediaValidationError && error.code === 'IMAGE_DECODE_FAILED'
|
||||
);
|
||||
await assert.rejects(
|
||||
() => storage.storeImage({
|
||||
tenantId: '7', originalName: 'bad.txt', contentType: 'text/plain',
|
||||
@@ -29,6 +40,12 @@ try {
|
||||
}),
|
||||
(error) => error instanceof MediaValidationError && error.code === 'IMAGE_TYPE_INVALID'
|
||||
);
|
||||
await storage.deleteImage(image.storagePath);
|
||||
await assert.rejects(() => readFile(storedPath), (error) => error.code === 'ENOENT');
|
||||
await assert.rejects(
|
||||
() => storage.deleteImage('../outside.webp'),
|
||||
(error) => error instanceof MediaValidationError && error.code === 'IMAGE_PATH_INVALID'
|
||||
);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
@@ -108,6 +108,9 @@ const franchiseVerifySql = read('database/migrations/2026081003_m08d_franchise_l
|
||||
const adminAuthUpSql = read('database/migrations/2026081004_m08d_admin_password_auth.up.sql');
|
||||
const adminAuthDownSql = read('database/migrations/2026081004_m08d_admin_password_auth.down.sql');
|
||||
const adminAuthVerifySql = read('database/migrations/2026081004_m08d_admin_password_auth.verify.sql');
|
||||
const cleaningRulesUpSql = read('database/migrations/2026081005_m09b_cleaning_rules.up.sql');
|
||||
const cleaningRulesDownSql = read('database/migrations/2026081005_m09b_cleaning_rules.down.sql');
|
||||
const cleaningRulesVerifySql = read('database/migrations/2026081005_m09b_cleaning_rules.verify.sql');
|
||||
|
||||
const coreTables = [
|
||||
'qipai_schema_migrations',
|
||||
@@ -489,7 +492,22 @@ assert.match(adminAuthUpSql, /uq_qipai_admin_credentials_login/);
|
||||
assert.match(adminAuthUpSql, /'2026081004'/);
|
||||
assert.match(adminAuthDownSql, /DROP TABLE IF EXISTS qipai_admin_credentials/);
|
||||
assert.match(adminAuthVerifySql, /idx_qipai_auth_sessions_refresh/);
|
||||
for (const table of [
|
||||
'qipai_cleaning_templates', 'qipai_cleaning_task_photos', 'qipai_cleaning_task_submissions'
|
||||
]) {
|
||||
assert.match(cleaningRulesUpSql, new RegExp(`CREATE TABLE IF NOT EXISTS ${table}`));
|
||||
assert.match(cleaningRulesDownSql, new RegExp(`DROP TABLE IF EXISTS ${table}`));
|
||||
assert.match(cleaningRulesVerifySql, new RegExp(`'${table}'`));
|
||||
}
|
||||
for (const column of [
|
||||
'cleaning_template_id', 'photo_required', 'min_photo_count', 'max_photo_count',
|
||||
'exempt_policy', 'rework_count'
|
||||
]) assert.match(cleaningRulesUpSql, new RegExp(`ADD COLUMN ${column}`));
|
||||
assert.match(cleaningRulesUpSql, /uq_qipai_cleaning_submission_revision/);
|
||||
assert.match(cleaningRulesUpSql, /retention_until DATETIME\(3\) NOT NULL/);
|
||||
assert.match(cleaningRulesDownSql, /DROP FOREIGN KEY fk_qipai_cleaning_tasks_template/);
|
||||
assert.match(cleaningRulesVerifySql, /'2026081005'/);
|
||||
assert.match(franchiseDownSql, /DROP TABLE IF EXISTS qipai_franchise_follow_ups/);
|
||||
assert.match(franchiseVerifySql, /idx_qipai_franchise_follow_up_history/);
|
||||
|
||||
console.log('PASS: M01-B through M08-D migration contracts are present.');
|
||||
console.log('PASS: M01-B through M09-B migration contracts are present.');
|
||||
|
||||
@@ -43,7 +43,8 @@ assert.match(plan.file, /2026062729_m08b_cleaning_transfer_state\.up\.sql/);
|
||||
assert.match(plan.file, /2026081001_m08c_staff_management_access\.up\.sql/);
|
||||
assert.match(plan.file, /2026081002_m08d_content_asset_scope\.up\.sql/);
|
||||
assert.match(plan.file, /2026081003_m08d_franchise_leads\.up\.sql/);
|
||||
assert.match(plan.file, /2026081004_m08d_admin_password_auth\.up\.sql$/);
|
||||
assert.match(plan.file, /2026081004_m08d_admin_password_auth\.up\.sql/);
|
||||
assert.match(plan.file, /2026081005_m09b_cleaning_rules\.up\.sql$/);
|
||||
assert.match(plan.checksum, /^[a-f0-9]{64}$/);
|
||||
assert.ok(plan.statements.length >= 11);
|
||||
|
||||
@@ -51,7 +52,8 @@ const verifyPlan = await loadMigrationPlan('verify');
|
||||
assert.match(verifyPlan.statements[90], /^SELECT column_name/);
|
||||
assert.match(verifyPlan.statements[91], /^SELECT index_name/);
|
||||
assert.match(verifyPlan.file, /2026081003_m08d_franchise_leads\.verify\.sql/);
|
||||
assert.match(verifyPlan.file, /2026081004_m08d_admin_password_auth\.verify\.sql$/);
|
||||
assert.match(verifyPlan.file, /2026081004_m08d_admin_password_auth\.verify\.sql/);
|
||||
assert.match(verifyPlan.file, /2026081005_m09b_cleaning_rules\.verify\.sql$/);
|
||||
|
||||
const calls = [];
|
||||
const fakePool = {
|
||||
|
||||
@@ -60,6 +60,9 @@ const expectedTables = [
|
||||
'qipai_async_tasks',
|
||||
'qipai_audit_logs',
|
||||
'qipai_auth_sessions',
|
||||
'qipai_cleaning_task_photos',
|
||||
'qipai_cleaning_task_submissions',
|
||||
'qipai_cleaning_templates',
|
||||
'qipai_collection_accounts',
|
||||
'qipai_device_alerts',
|
||||
'qipai_device_channels',
|
||||
@@ -139,13 +142,13 @@ async function readMigrationVersions(pool) {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT version, name
|
||||
FROM qipai_schema_migrations
|
||||
WHERE version IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
WHERE version IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ORDER BY version`,
|
||||
['2026061601', '2026061802', '2026061803', '2026061804',
|
||||
'2026061805', '2026061806', '2026061807', '2026061808', '2026061809',
|
||||
'2026061810', '2026061811', '2026062012', '2026062013', '2026062014',
|
||||
'2026062015', '2026062216', '2026062217', '2026062218', '2026062219',
|
||||
'2026062220', '2026081002', '2026081003', '2026081004']
|
||||
'2026062220', '2026081002', '2026081003', '2026081004', '2026081005']
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
@@ -1833,7 +1836,7 @@ async function assertSystemOperations(pool, context) {
|
||||
assert.equal(logs.items[0].metadata.nested.safe, 'visible');
|
||||
const overview = await repository.getSystemOverview(context.tenantId);
|
||||
assert.equal(overview.tenant.id, context.tenantId);
|
||||
assert.equal(overview.latestMigration.version, '2026081004');
|
||||
assert.equal(overview.latestMigration.version, '2026081005');
|
||||
assert.ok(overview.counts.userCount > 0);
|
||||
await repository.updateTenant(actor, context.tenantId, {
|
||||
name: overview.tenant.name, timezone: overview.tenant.timezone
|
||||
@@ -2528,6 +2531,19 @@ async function assertCleaningTaskTransactions(pool, context) {
|
||||
[context.tenantId, taskId, userId, memberRole, rewardCents, removedAt, settledAt]
|
||||
);
|
||||
};
|
||||
const recordPhoto = async (taskId, userId, suffix) => repository.recordPhotoUpload({
|
||||
...cleanerActor(userId, `m09b-photo-${suffix}`),
|
||||
taskId,
|
||||
image: {
|
||||
storagePath: `tenants/${context.tenantId}/stores/${storeId}/${suffix}.webp`,
|
||||
publicUrl: `https://api.txyundm.cn/uploads/tenants/${context.tenantId}/stores/${storeId}/${suffix}.webp`,
|
||||
mimeType: 'image/webp',
|
||||
byteSize: 1024,
|
||||
width: 1280,
|
||||
height: 720,
|
||||
checksumSha256: suffix.padEnd(64, '0').slice(0, 64)
|
||||
}
|
||||
});
|
||||
|
||||
const rollbackTaskId = await insertTask();
|
||||
const rollbackTrace = `m09a-event-failure-${rollbackTaskId}`;
|
||||
@@ -2650,9 +2666,10 @@ async function assertCleaningTaskTransactions(pool, context) {
|
||||
await repository.start({
|
||||
...cleanerActor(firstCleanerId, 'm09a-start'), taskId: lifecycleTaskId
|
||||
});
|
||||
const firstPhoto = await recordPhoto(lifecycleTaskId, firstCleanerId, 'm09b-first');
|
||||
await repository.submit({
|
||||
...cleanerActor(firstCleanerId, 'm09a-submit-first'), taskId: lifecycleTaskId,
|
||||
photoUrls: ['https://api.txyundm.cn/uploads/m09a-first.webp'], note: 'first submit'
|
||||
photoUrls: [firstPhoto.publicUrl], note: 'first submit'
|
||||
});
|
||||
await repository.reject({
|
||||
...managerActor('m09a-reject'), taskId: lifecycleTaskId, reason: 'needs rework'
|
||||
@@ -2666,9 +2683,10 @@ async function assertCleaningTaskTransactions(pool, context) {
|
||||
await repository.rework({
|
||||
...cleanerActor(firstCleanerId, 'm09a-rework'), taskId: lifecycleTaskId
|
||||
});
|
||||
const secondPhoto = await recordPhoto(lifecycleTaskId, firstCleanerId, 'm09b-second');
|
||||
await repository.submit({
|
||||
...cleanerActor(firstCleanerId, 'm09a-submit-second'), taskId: lifecycleTaskId,
|
||||
photoUrls: ['https://api.txyundm.cn/uploads/m09a-second.webp'], note: 'second submit'
|
||||
photoUrls: [secondPhoto.publicUrl], note: 'second submit'
|
||||
});
|
||||
const completeInput = {
|
||||
...managerActor('m09a-complete'), taskId: lifecycleTaskId, note: 'accepted'
|
||||
@@ -2688,6 +2706,74 @@ async function assertCleaningTaskTransactions(pool, context) {
|
||||
status: lifecycleRows[0].status,
|
||||
completedSet: Number(lifecycleRows[0].completedSet)
|
||||
}, { status: 'COMPLETED', completedSet: 1 });
|
||||
const [submissionRows] = await pool.query(
|
||||
`SELECT revision, status, JSON_UNQUOTE(JSON_EXTRACT(photo_urls_json, '$[0]')) AS photoUrl,
|
||||
reject_reason AS rejectReason
|
||||
FROM qipai_cleaning_task_submissions
|
||||
WHERE tenant_id = ? AND task_id = ? ORDER BY revision`,
|
||||
[context.tenantId, lifecycleTaskId]
|
||||
);
|
||||
assert.deepEqual(submissionRows.map((row) => ({
|
||||
revision: Number(row.revision), status: row.status,
|
||||
photoUrl: row.photoUrl, rejectReason: row.rejectReason
|
||||
})), [
|
||||
{ revision: 2, status: 'REJECTED', photoUrl: firstPhoto.publicUrl, rejectReason: 'needs rework' },
|
||||
{ revision: 3, status: 'ACCEPTED', photoUrl: secondPhoto.publicUrl, rejectReason: '' }
|
||||
]);
|
||||
const [photoRows] = await pool.query(
|
||||
`SELECT public_url AS publicUrl, status, attached_revision AS attachedRevision
|
||||
FROM qipai_cleaning_task_photos
|
||||
WHERE tenant_id = ? AND task_id = ? ORDER BY attached_revision`,
|
||||
[context.tenantId, lifecycleTaskId]
|
||||
);
|
||||
assert.deepEqual(photoRows.map((row) => ({
|
||||
publicUrl: row.publicUrl, status: row.status, revision: Number(row.attachedRevision)
|
||||
})), [
|
||||
{ publicUrl: firstPhoto.publicUrl, status: 'ATTACHED', revision: 2 },
|
||||
{ publicUrl: secondPhoto.publicUrl, status: 'ATTACHED', revision: 3 }
|
||||
]);
|
||||
const submissionHistory = await repository.listSubmissions({
|
||||
...managerActor('m09b-submission-history'), taskId: lifecycleTaskId
|
||||
});
|
||||
assert.deepEqual(submissionHistory.map((submission) => ({
|
||||
revision: submission.revision,
|
||||
status: submission.status,
|
||||
photoUrl: submission.photoUrls[0],
|
||||
rejectReason: submission.rejectReason
|
||||
})), [
|
||||
{ revision: 3, status: 'ACCEPTED', photoUrl: secondPhoto.publicUrl, rejectReason: '' },
|
||||
{ revision: 2, status: 'REJECTED', photoUrl: firstPhoto.publicUrl, rejectReason: 'needs rework' }
|
||||
]);
|
||||
|
||||
const cleanupTaskId = await insertTask({
|
||||
status: 'STARTED', cleanerUserId: firstCleanerId,
|
||||
claimedAt: new Date(), startedAt: new Date()
|
||||
});
|
||||
const expiredPhoto = await recordPhoto(cleanupTaskId, firstCleanerId, 'm09b-expired');
|
||||
await pool.query(
|
||||
`UPDATE qipai_cleaning_task_photos SET retention_until = TIMESTAMPADD(DAY, -1, UTC_TIMESTAMP(3))
|
||||
WHERE tenant_id = ? AND id = ?`,
|
||||
[context.tenantId, expiredPhoto.id]
|
||||
);
|
||||
const expiredClaims = await repository.claimExpiredPhotoUploads({
|
||||
...managerActor('m09b-photo-cleanup-claim'), limit: 10
|
||||
});
|
||||
assert.deepEqual(expiredClaims, [{ id: expiredPhoto.id, storagePath: expiredPhoto.storagePath }]);
|
||||
assert.deepEqual(
|
||||
await repository.markPhotoUploadsDeleted({
|
||||
...managerActor('m09b-photo-cleanup-delete'), photoIds: [expiredPhoto.id]
|
||||
}),
|
||||
{ deleted: 1 }
|
||||
);
|
||||
const [deletedPhotoRows] = await pool.query(
|
||||
`SELECT status, deleted_at IS NOT NULL AS deleted
|
||||
FROM qipai_cleaning_task_photos WHERE tenant_id = ? AND id = ?`,
|
||||
[context.tenantId, expiredPhoto.id]
|
||||
);
|
||||
assert.deepEqual(
|
||||
{ status: deletedPhotoRows[0].status, deleted: Number(deletedPhotoRows[0].deleted) },
|
||||
{ status: 'ORPHANED', deleted: 1 }
|
||||
);
|
||||
const [lifecycleEventRows] = await pool.query(
|
||||
`SELECT action FROM qipai_cleaning_task_events
|
||||
WHERE tenant_id = ? AND task_id = ? ORDER BY id`,
|
||||
@@ -2782,6 +2868,26 @@ async function assertCleaningTaskTransactions(pool, context) {
|
||||
assert.equal(settledMemberRows[0].removedAt, null);
|
||||
assert.equal(Number(settledMemberRows[0].settled), 1);
|
||||
|
||||
const deniedExemptTaskId = await insertTask();
|
||||
await pool.query(
|
||||
`UPDATE qipai_cleaning_tasks SET exempt_policy = 'DISABLED'
|
||||
WHERE tenant_id = ? AND id = ?`,
|
||||
[context.tenantId, deniedExemptTaskId]
|
||||
);
|
||||
await assert.rejects(
|
||||
() => repository.exempt({
|
||||
...managerActor('m09b-exempt-denied'), taskId: deniedExemptTaskId, note: 'not allowed'
|
||||
}),
|
||||
(error) => error instanceof CleaningTaskError && error.code === 'CLEANING_EXEMPT_POLICY_DENIED'
|
||||
);
|
||||
|
||||
const template = await repository.upsertTemplate({
|
||||
...managerActor('m09b-template-v1'), scopeType: 'TENANT', name: 'M09-B tenant default',
|
||||
requirement: '两张照片并在开始前决定免清洁', photoRequired: true,
|
||||
minPhotoCount: 2, maxPhotoCount: 3, exemptPolicy: 'BEFORE_START', status: 'ACTIVE'
|
||||
});
|
||||
assert.equal(template.version, 1);
|
||||
|
||||
const [orderResult] = await pool.query(
|
||||
`INSERT INTO qipai_orders
|
||||
(tenant_id, store_id, room_id, order_no, status, start_at, end_at,
|
||||
@@ -2812,7 +2918,10 @@ async function assertCleaningTaskTransactions(pool, context) {
|
||||
orderConnection.release();
|
||||
}
|
||||
const [generatedRows] = await pool.query(
|
||||
`SELECT t.status,
|
||||
`SELECT t.status, t.cleaning_template_id AS cleaningTemplateId,
|
||||
t.cleaning_template_version AS cleaningTemplateVersion,
|
||||
t.photo_required AS photoRequired, t.min_photo_count AS minPhotoCount,
|
||||
t.max_photo_count AS maxPhotoCount, t.exempt_policy AS exemptPolicy,
|
||||
(SELECT COUNT(*) FROM qipai_cleaning_task_events e
|
||||
WHERE e.tenant_id = t.tenant_id AND e.task_id = t.id
|
||||
AND e.action = 'AUTO_CREATE') AS eventCount
|
||||
@@ -2822,10 +2931,34 @@ async function assertCleaningTaskTransactions(pool, context) {
|
||||
);
|
||||
assert.equal(generatedRows.length, 1);
|
||||
assert.equal(generatedRows[0].status, 'WAITING');
|
||||
assert.equal(String(generatedRows[0].cleaningTemplateId), template.id);
|
||||
assert.equal(Number(generatedRows[0].cleaningTemplateVersion), 1);
|
||||
assert.equal(Number(generatedRows[0].photoRequired), 1);
|
||||
assert.equal(Number(generatedRows[0].minPhotoCount), 2);
|
||||
assert.equal(Number(generatedRows[0].maxPhotoCount), 3);
|
||||
assert.equal(generatedRows[0].exemptPolicy, 'BEFORE_START');
|
||||
assert.equal(Number(generatedRows[0].eventCount), 1);
|
||||
|
||||
const updatedTemplate = await repository.upsertTemplate({
|
||||
...managerActor('m09b-template-v2'), scopeType: 'TENANT', name: 'M09-B tenant default',
|
||||
requirement: '新任务无需照片', photoRequired: false,
|
||||
minPhotoCount: 0, maxPhotoCount: 2, exemptPolicy: 'DISABLED', status: 'ACTIVE'
|
||||
});
|
||||
assert.equal(updatedTemplate.version, 2);
|
||||
const [snapshotRows] = await pool.query(
|
||||
`SELECT cleaning_template_version AS version, min_photo_count AS minPhotoCount,
|
||||
exempt_policy AS exemptPolicy
|
||||
FROM qipai_cleaning_tasks WHERE tenant_id = ? AND order_id = ?`,
|
||||
[context.tenantId, finishedOrderId]
|
||||
);
|
||||
assert.deepEqual({
|
||||
version: Number(snapshotRows[0].version),
|
||||
minPhotoCount: Number(snapshotRows[0].minPhotoCount),
|
||||
exemptPolicy: snapshotRows[0].exemptPolicy
|
||||
}, { version: 1, minPhotoCount: 2, exemptPolicy: 'BEFORE_START' });
|
||||
|
||||
console.log(
|
||||
'PASS: M09-A cleaning claims, lifecycle events, rollback, reassignment, timeout reclaim and order generation are transactional.'
|
||||
'PASS: M09-A/M09-B cleaning transactions, template snapshots, photo revisions and exemption rules are consistent.'
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2875,7 +3008,8 @@ try {
|
||||
{ version: '2026062220', name: 'm06c_iot_messages' },
|
||||
{ version: '2026081002', name: 'm08d_content_asset_scope' },
|
||||
{ version: '2026081003', name: 'm08d_franchise_leads' },
|
||||
{ version: '2026081004', name: 'm08d_admin_password_auth' }
|
||||
{ version: '2026081004', name: 'm08d_admin_password_auth' },
|
||||
{ version: '2026081005', name: 'm09b_cleaning_rules' }
|
||||
]);
|
||||
await assertTaskDurability(pool);
|
||||
const loginContext = await assertPlatformTenantIsolation(pool);
|
||||
@@ -2904,7 +3038,7 @@ try {
|
||||
await executeMigrationPlan(pool, plans.down);
|
||||
assert.deepEqual(await readCoreTables(pool), []);
|
||||
await assertLegacyCompatibility(pool);
|
||||
console.log('PASS: down removed all M01-B through M06-C migration tables.');
|
||||
console.log('PASS: down removed all M01-B through M09-B migration tables.');
|
||||
|
||||
await executeMigrationPlan(pool, plans.up);
|
||||
await executeMigrationPlan(pool, plans.verify);
|
||||
@@ -2932,7 +3066,8 @@ try {
|
||||
{ version: '2026062220', name: 'm06c_iot_messages' },
|
||||
{ version: '2026081002', name: 'm08d_content_asset_scope' },
|
||||
{ version: '2026081003', name: 'm08d_franchise_leads' },
|
||||
{ version: '2026081004', name: 'm08d_admin_password_auth' }
|
||||
{ version: '2026081004', name: 'm08d_admin_password_auth' },
|
||||
{ version: '2026081005', name: 'm09b_cleaning_rules' }
|
||||
]);
|
||||
await assertLegacyCompatibility(pool);
|
||||
console.log('PASS: second up and verify restored the schema.');
|
||||
@@ -3069,7 +3204,11 @@ try {
|
||||
'idempotent cleaning completion trace',
|
||||
'SKIP LOCKED cleaning timeout reclaim batch',
|
||||
'settled cleaning member preservation',
|
||||
'finished order creates one cleaning task'
|
||||
'finished order creates one cleaning task',
|
||||
'cleaning template snapshot remains stable after config version change',
|
||||
'rejected and accepted cleaning photo revisions remain distinguishable',
|
||||
'expired unattached cleaning photo retention cleanup',
|
||||
'task-level cleaning exemption policy denial'
|
||||
]
|
||||
}, null, 2));
|
||||
} finally {
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
import { DeviceControlError } from '../dist/devices/device-control-service.js';
|
||||
|
||||
const calls = [];
|
||||
let activeRoomOrder = false;
|
||||
let currentOrder = {
|
||||
id: 31,
|
||||
tenantId: 7,
|
||||
@@ -17,6 +18,11 @@ let currentOrder = {
|
||||
};
|
||||
const service = new OrderDeviceAutomationService({
|
||||
async execute(sql, params) {
|
||||
if (sql.includes('id <> ?')) {
|
||||
assert.equal(params[0], '7');
|
||||
assert.equal(params[2], '31');
|
||||
return [activeRoomOrder ? [{ id: 32 }] : [], []];
|
||||
}
|
||||
if (sql.includes('FROM qipai_orders')) {
|
||||
assert.equal(params[0], '7');
|
||||
assert.equal(params[1], '31');
|
||||
@@ -91,6 +97,17 @@ await service.handleTask(task({
|
||||
assert.equal(calls.at(-2)[0], 'cancelTask');
|
||||
assert.equal(calls.at(-1)[2].on, false);
|
||||
|
||||
currentOrder = { ...currentOrder, status: 'FINISHED' };
|
||||
activeRoomOrder = true;
|
||||
const callCountBeforeProtectedFinish = calls.length;
|
||||
const protectedFinish = await service.handleTask(task({
|
||||
tenantId: '7', orderId: '31', event: 'ORDER_FINISHED', traceId: 'm09b-finish'
|
||||
}));
|
||||
assert.equal(protectedFinish.skipped, true);
|
||||
assert.equal(protectedFinish.reason, 'ROOM_HAS_ACTIVE_ORDER');
|
||||
assert.equal(calls.length, callCountBeforeProtectedFinish);
|
||||
activeRoomOrder = false;
|
||||
|
||||
currentOrder = { ...currentOrder, roomId: 52, status: 'IN_PROGRESS' };
|
||||
const changed = await service.handleTask(task({
|
||||
tenantId: '7',
|
||||
|
||||
Reference in New Issue
Block a user