|
|
|
@@ -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),
|
|
|
|
|