import type { PoolConnection, ResultSetHeader, RowDataPacket } from 'mysql2/promise'; import type { AccessProfile } from '../auth/rbac-repository.js'; import type { MySqlPool } from '../db/mysql.js'; export const cleaningTaskStatuses = [ 'WAITING', 'CLAIMED', 'STARTED', 'SUBMITTED', 'COMPLETED', 'REJECTED', 'EXEMPT', 'SETTLED', 'CANCELLED' ] as const; export type CleaningTaskStatus = typeof cleaningTaskStatuses[number]; export type CleaningSettlementStatus = 'DRAFT' | 'CONFIRMED' | 'PAID' | 'CANCELLED'; export class CleaningTaskError extends Error { constructor(public readonly code: string) { super(code); } } export interface CleaningActor { tenantId: string; userId: string; access: AccessProfile; traceId: string; } interface CleaningTaskRow extends RowDataPacket { id: string; taskNo: string; storeId: string; storeName: string; roomId: string; roomName: string; roomNo: string; orderId: string | null; orderNo: string | null; status: CleaningTaskStatus; cleanerUserId: string | null; priority: number; rewardCents: number; requirement: string; photoUrlsJson: string | string[] | null; rejectReason: string; claimedAt: Date | null; startedAt: Date | null; submittedAt: Date | null; completedAt: Date | null; memberCount: number; createdAt: Date; updatedAt: Date; } interface CleaningTaskMemberRow extends RowDataPacket { id: string; taskId: string; userId: string; nickname: string; memberRole: 'LEAD' | 'ASSIST'; rewardCents: number; joinedAt: Date; removedAt: Date | null; settledAt: 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 FinishedOrderRow extends RowDataPacket { id: string; orderNo: string; storeId: string; roomId: string; } interface SettlementCandidateRow extends RowDataPacket { id: string; storeId: string; cleanerUserId: string; rewardCents: number; completedAt: Date | null; } interface SettlementRow extends RowDataPacket { id: string; settlementNo: string; cleanerUserId: string; cleanerName: string; storeId: string | null; storeName: string | null; status: CleaningSettlementStatus; taskCount: number; totalRewardCents: number; periodStart: Date | null; periodEnd: Date | null; paidBy: string | null; confirmedAt: Date | null; paidAt: Date | null; payoutChannel: string; payoutReference: string; payoutState: string; payoutPackageInfo: string; payoutError: string; note: string; createdAt: Date; } interface SettlementItemRow extends RowDataPacket { id: string; settlementId: string; taskId: string; taskNo: string; orderNo: string | null; storeName: string; roomName: string; roomNo: string; cleanerUserId: string; cleanerName: string; rewardCents: number; completedAt: Date | null; createdAt: Date; } export class CleaningTaskRepository { constructor(private readonly pool: MySqlPool) {} async listHall(input: CleaningActor & { page: number; pageSize: number; status?: CleaningTaskStatus }) { this.assertCleaner(input.access, 'read'); const status = input.status ?? 'WAITING'; const where = [ 't.tenant_id = ?', 't.deleted_at IS NULL', 't.status = ?', storeScopeSql(input.access, 't.store_id') ]; const params: Array = [input.tenantId, status]; return this.listByWhere(where, params, input.page, input.pageSize, 't.priority ASC, t.created_at ASC'); } async listMine(input: CleaningActor & { page: number; pageSize: number; status?: CleaningTaskStatus }) { this.assertCleaner(input.access, 'read'); const where = [ 't.tenant_id = ?', 't.deleted_at IS NULL', `(t.cleaner_user_id = ? OR EXISTS ( SELECT 1 FROM qipai_cleaning_task_members mm WHERE mm.tenant_id = t.tenant_id AND mm.task_id = t.id AND mm.user_id = ? AND mm.removed_at IS NULL ))` ]; const params: Array = [input.tenantId, input.userId, input.userId]; if (input.status) { where.push('t.status = ?'); params.push(input.status); } return this.listByWhere(where, params, input.page, input.pageSize, 't.updated_at DESC, t.id DESC'); } async listManage(input: CleaningActor & { page: number; pageSize: number; status?: CleaningTaskStatus }) { this.assertCleaner(input.access, 'read'); const where = [ 't.tenant_id = ?', 't.deleted_at IS NULL', storeScopeSql(input.access, 't.store_id') ]; const params: Array = [input.tenantId]; if (input.status) { where.push('t.status = ?'); params.push(input.status); } return this.listByWhere(where, params, input.page, input.pageSize, 't.updated_at DESC, t.id DESC'); } async claim(input: CleaningActor & { taskId: string }) { this.assertCleaner(input.access, 'write'); await this.assertStoreVisible(input, input.taskId); const [result] = await this.pool.execute( `UPDATE qipai_cleaning_tasks SET status = 'CLAIMED', cleaner_user_id = ?, claimed_at = UTC_TIMESTAMP(3) WHERE tenant_id = ? AND id = ? AND status = 'WAITING' AND cleaner_user_id IS NULL AND deleted_at IS NULL`, [input.userId, input.tenantId, input.taskId] ); if (result.affectedRows !== 1) throw new CleaningTaskError('CLEANING_TASK_NOT_CLAIMABLE'); await this.ensureLeadMember(this.pool, input.tenantId, input.taskId); await this.recordEvent(input, input.taskId, 'WAITING', 'CLAIMED', 'CLAIM', ''); return this.getMineTask(input, input.taskId); } async start(input: CleaningActor & { taskId: string }) { return this.moveMine(input, 'CLAIMED', 'STARTED', 'START', 'started_at', ''); } async rework(input: CleaningActor & { taskId: string }) { return this.moveMine(input, 'REJECTED', 'STARTED', 'REWORK', 'started_at', '', { photo_urls_json: JSON.stringify([]), reject_reason: '' }); } async assign(input: CleaningActor & { taskId: string; cleanerUserId: string; note?: string }) { this.assertCleaner(input.access, 'write'); await this.assertStoreVisible(input, input.taskId); await this.assertCleanerUserForTask(input.tenantId, input.taskId, input.cleanerUserId); const [result] = await this.pool.execute( `UPDATE qipai_cleaning_tasks SET status = 'CLAIMED', cleaner_user_id = ?, claimed_at = COALESCE(claimed_at, UTC_TIMESTAMP(3)), reject_reason = '' WHERE tenant_id = ? AND id = ? AND status IN ('WAITING', 'CLAIMED', 'REJECTED') AND deleted_at IS NULL`, [input.cleanerUserId, input.tenantId, input.taskId] ); if (result.affectedRows !== 1) throw new CleaningTaskError('CLEANING_TASK_ASSIGN_CONFLICT'); await this.ensureLeadMember(this.pool, input.tenantId, input.taskId); await this.recordEvent(input, input.taskId, 'WAITING', 'CLAIMED', 'ASSIGN', input.note ?? ''); return this.getTask(input, input.taskId); } async complete(input: CleaningActor & { taskId: string; note?: string }) { const task = await this.moveManaged(input, 'SUBMITTED', 'COMPLETED', 'COMPLETE', 'completed_at', input.note ?? ''); await this.ensureLeadMember(this.pool, input.tenantId, input.taskId); return task; } async reject(input: CleaningActor & { taskId: string; reason: string }) { this.assertCleaner(input.access, 'write'); await this.assertStoreVisible(input, input.taskId); const [result] = await this.pool.execute( `UPDATE qipai_cleaning_tasks SET status = 'REJECTED', reject_reason = ? WHERE tenant_id = ? AND id = ? AND status = 'SUBMITTED' AND deleted_at IS NULL`, [input.reason.slice(0, 512), input.tenantId, input.taskId] ); if (result.affectedRows !== 1) throw new CleaningTaskError('CLEANING_TASK_STATUS_CONFLICT'); await this.recordEvent(input, input.taskId, 'SUBMITTED', 'REJECTED', 'REJECT', input.reason); return this.getTask(input, input.taskId); } async listMembers(input: CleaningActor & { taskId: string }) { this.assertCleaner(input.access, 'read'); await this.assertStoreVisible(input, input.taskId); return this.getMembers(input.tenantId, input.taskId); } async addMember(input: CleaningActor & { taskId: string; cleanerUserId: string; rewardCents: number; note?: string; }) { this.assertCleaner(input.access, 'write'); await this.assertStoreVisible(input, input.taskId); await this.assertCleanerUserForTask(input.tenantId, input.taskId, input.cleanerUserId); return this.transaction(async (connection) => { const [tasks] = await connection.execute( `SELECT t.id, t.task_no AS taskNo, t.store_id AS storeId, '' AS storeName, t.room_id AS roomId, '' AS roomName, '' AS roomNo, t.order_id AS orderId, NULL 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.claimed_at AS claimedAt, t.started_at AS startedAt, t.submitted_at AS submittedAt, t.completed_at AS completedAt, 0 AS memberCount, t.created_at AS createdAt, t.updated_at AS updatedAt FROM qipai_cleaning_tasks t WHERE t.tenant_id = ? AND t.id = ? AND t.deleted_at IS NULL FOR UPDATE`, [input.tenantId, input.taskId] ); const task = tasks[0]; if (!task) throw new CleaningTaskError('CLEANING_TASK_NOT_FOUND'); if (!task.cleanerUserId) throw new CleaningTaskError('CLEANING_TASK_ASSIGNEE_REQUIRED'); if (String(task.cleanerUserId) === input.cleanerUserId) { throw new CleaningTaskError('CLEANING_MEMBER_DUPLICATE_LEAD'); } if (!['CLAIMED', 'STARTED', 'SUBMITTED', 'COMPLETED'].includes(task.status)) { throw new CleaningTaskError('CLEANING_TASK_STATUS_CONFLICT'); } const [existingMembers] = await connection.execute( `SELECT m.id, m.task_id AS taskId, m.user_id AS userId, '' AS nickname, m.member_role AS memberRole, m.reward_cents AS rewardCents, m.joined_at AS joinedAt, m.removed_at AS removedAt, m.settled_at AS settledAt FROM qipai_cleaning_task_members m WHERE m.tenant_id = ? AND m.task_id = ? AND m.user_id = ? FOR UPDATE`, [input.tenantId, input.taskId, input.cleanerUserId] ); if (existingMembers[0]?.settledAt) throw new CleaningTaskError('CLEANING_MEMBER_SETTLED'); await this.ensureLeadMember(connection, input.tenantId, input.taskId); await connection.execute( `INSERT INTO qipai_cleaning_task_members (tenant_id, task_id, user_id, member_role, reward_cents, joined_at, removed_at, settled_at) VALUES (?, ?, ?, 'ASSIST', ?, UTC_TIMESTAMP(3), NULL, NULL) ON DUPLICATE KEY UPDATE member_role = 'ASSIST', reward_cents = VALUES(reward_cents), removed_at = NULL, settled_at = NULL, updated_at = UTC_TIMESTAMP(3)`, [input.tenantId, input.taskId, input.cleanerUserId, input.rewardCents] ); await this.syncLeadReward(connection, input.tenantId, input.taskId); await this.recordEventWithConnection( connection, input, input.taskId, task.status, task.status, 'ADD_MEMBER', input.note ?? '' ); return this.getMembers(input.tenantId, input.taskId, connection); }); } async removeMember(input: CleaningActor & { taskId: string; cleanerUserId: string; note?: string }) { this.assertCleaner(input.access, 'write'); await this.assertStoreVisible(input, input.taskId); return this.transaction(async (connection) => { const [tasks] = await connection.execute( `SELECT status, cleaner_user_id AS cleanerUserId FROM qipai_cleaning_tasks WHERE tenant_id = ? AND id = ? AND deleted_at IS NULL FOR UPDATE`, [input.tenantId, input.taskId] ); const task = tasks[0]; if (!task) throw new CleaningTaskError('CLEANING_TASK_NOT_FOUND'); if (String(task.cleanerUserId) === input.cleanerUserId) { throw new CleaningTaskError('CLEANING_MEMBER_LEAD_NOT_REMOVABLE'); } const [result] = await connection.execute( `UPDATE qipai_cleaning_task_members SET removed_at = UTC_TIMESTAMP(3), reward_cents = 0, updated_at = UTC_TIMESTAMP(3) WHERE tenant_id = ? AND task_id = ? AND user_id = ? AND member_role = 'ASSIST' AND removed_at IS NULL AND settled_at IS NULL`, [input.tenantId, input.taskId, input.cleanerUserId] ); if (result.affectedRows !== 1) throw new CleaningTaskError('CLEANING_MEMBER_NOT_FOUND'); await this.syncLeadReward(connection, input.tenantId, input.taskId); await this.recordEventWithConnection( connection, input, input.taskId, task.status, task.status, 'REMOVE_MEMBER', input.note ?? '' ); return this.getMembers(input.tenantId, input.taskId, connection); }); } async settlementCandidates(input: CleaningActor & { page: number; pageSize: number }) { this.assertCleaner(input.access, 'read'); const where = [ 't.tenant_id = ?', 't.deleted_at IS NULL', "t.status = 'COMPLETED'", 't.settled_at IS NULL', storeScopeSql(input.access, 't.store_id') ]; return this.listByWhere(where, [input.tenantId], input.page, input.pageSize, 't.completed_at ASC, t.id ASC'); } async listSettlements(input: CleaningActor & { page: number; pageSize: number; status?: CleaningSettlementStatus; }) { this.assertSettlement(input.access, 'read'); const where = [ 's.tenant_id = ?', 's.deleted_at IS NULL', storeScopeSql(input.access, 'COALESCE(s.store_id, 0)') ]; const params: Array = [input.tenantId]; if (input.status) { where.push('s.status = ?'); params.push(input.status); } const whereSql = where.join(' AND '); const offset = (input.page - 1) * input.pageSize; const [counts] = await this.pool.execute( `SELECT COUNT(*) AS total FROM qipai_cleaning_settlements s WHERE ${whereSql}`, params ); const [rows] = await this.pool.execute( `SELECT s.id, s.settlement_no AS settlementNo, s.cleaner_user_id AS cleanerUserId, u.nickname AS cleanerName, s.store_id AS storeId, st.name AS storeName, s.status, s.task_count AS taskCount, s.total_reward_cents AS totalRewardCents, s.period_start AS periodStart, s.period_end AS periodEnd, s.paid_by AS paidBy, s.confirmed_at AS confirmedAt, s.paid_at AS paidAt, s.payout_channel AS payoutChannel, s.payout_reference AS payoutReference, s.payout_state AS payoutState, s.payout_package_info AS payoutPackageInfo, s.payout_error AS payoutError, s.note, s.created_at AS createdAt FROM qipai_cleaning_settlements s INNER JOIN qipai_users u ON u.tenant_id = s.tenant_id AND u.id = s.cleaner_user_id LEFT JOIN qipai_stores st ON st.tenant_id = s.tenant_id AND st.id = s.store_id WHERE ${whereSql} ORDER BY s.created_at DESC, s.id DESC LIMIT ? OFFSET ?`, [...params, input.pageSize, offset] ); return { items: rows.map(publicSettlement), total: Number(counts[0]?.total ?? 0), page: input.page, pageSize: input.pageSize }; } async getSettlementDetail(input: CleaningActor & { settlementId: string }) { this.assertSettlement(input.access, 'read'); const settlement = await this.getSettlementScoped(input); const [items] = await this.pool.execute( `SELECT i.id, i.settlement_id AS settlementId, i.task_id AS taskId, t.task_no AS taskNo, o.order_no AS orderNo, st.name AS storeName, r.name AS roomName, r.room_no AS roomNo, i.cleaner_user_id AS cleanerUserId, u.nickname AS cleanerName, i.reward_cents AS rewardCents, t.completed_at AS completedAt, i.created_at AS createdAt FROM qipai_cleaning_settlement_items i INNER JOIN qipai_cleaning_settlements s ON s.tenant_id = i.tenant_id AND s.id = i.settlement_id AND s.deleted_at IS NULL INNER JOIN qipai_cleaning_tasks t ON t.tenant_id = i.tenant_id AND t.id = i.task_id INNER JOIN qipai_stores st ON st.tenant_id = t.tenant_id AND st.id = t.store_id INNER JOIN qipai_rooms r ON r.tenant_id = t.tenant_id AND r.id = t.room_id INNER JOIN qipai_users u ON u.tenant_id = i.tenant_id AND u.id = i.cleaner_user_id LEFT JOIN qipai_orders o ON o.tenant_id = t.tenant_id AND o.id = t.order_id WHERE i.tenant_id = ? AND i.settlement_id = ? ORDER BY t.completed_at ASC, i.id ASC`, [input.tenantId, input.settlementId] ); return { settlement, items: items.map(publicSettlementItem) }; } async generateSettlement(input: CleaningActor & { cleanerUserId: string; storeId?: string; note?: string; }) { this.assertSettlement(input.access, 'write'); return this.transaction(async (connection) => { await connection.execute( `INSERT IGNORE INTO qipai_cleaning_task_members (tenant_id, task_id, user_id, member_role, reward_cents, joined_at) SELECT t.tenant_id, t.id, t.cleaner_user_id, 'LEAD', t.reward_cents, COALESCE(t.claimed_at, t.completed_at, UTC_TIMESTAMP(3)) FROM qipai_cleaning_tasks t WHERE t.tenant_id = ? AND t.cleaner_user_id = ? AND t.status = 'COMPLETED' AND t.deleted_at IS NULL AND t.cleaner_user_id IS NOT NULL`, [input.tenantId, input.cleanerUserId] ); const scopeSql = storeScopeSql(input.access, 't.store_id'); const params: Array = [input.tenantId, input.cleanerUserId]; const storeFilter = input.storeId ? 'AND t.store_id = ?' : ''; if (input.storeId) params.push(input.storeId); const [tasks] = await connection.execute( `SELECT t.id, t.store_id AS storeId, m.user_id AS cleanerUserId, m.reward_cents AS rewardCents, t.completed_at AS completedAt FROM qipai_cleaning_tasks t INNER JOIN qipai_cleaning_task_members m ON m.tenant_id = t.tenant_id AND m.task_id = t.id AND m.user_id = ? AND m.removed_at IS NULL AND m.settled_at IS NULL AND m.reward_cents > 0 WHERE t.tenant_id = ? AND t.status = 'COMPLETED' AND t.deleted_at IS NULL ${storeFilter} AND ${scopeSql} ORDER BY t.completed_at ASC, t.id ASC FOR UPDATE`, [input.cleanerUserId, input.tenantId, ...params.slice(2)] ); if (tasks.length === 0) throw new CleaningTaskError('CLEANING_SETTLEMENT_EMPTY'); const storeIds = Array.from(new Set(tasks.map((task) => String(task.storeId)))); const totalRewardCents = tasks.reduce((sum, task) => sum + Number(task.rewardCents), 0); const periodStart = minDate(tasks.map((task) => task.completedAt)); const periodEnd = maxDate(tasks.map((task) => task.completedAt)); const settlementNo = `CLS-${Date.now()}-${input.cleanerUserId}`; const [created] = await connection.execute( `INSERT INTO qipai_cleaning_settlements (tenant_id, settlement_no, cleaner_user_id, store_id, status, task_count, total_reward_cents, period_start, period_end, generated_by, note) VALUES (?, ?, ?, ?, 'DRAFT', ?, ?, ?, ?, ?, ?)`, [ input.tenantId, settlementNo, input.cleanerUserId, storeIds.length === 1 ? storeIds[0] : null, tasks.length, totalRewardCents, periodStart, periodEnd, input.userId, input.note ?? '' ] ); const settlementId = String(created.insertId); for (const task of tasks) { await connection.execute( `INSERT INTO qipai_cleaning_settlement_items (tenant_id, settlement_id, task_id, cleaner_user_id, reward_cents) VALUES (?, ?, ?, ?, ?)`, [input.tenantId, settlementId, task.id, input.cleanerUserId, Number(task.rewardCents)] ); } await connection.execute( `UPDATE qipai_cleaning_task_members SET settled_at = UTC_TIMESTAMP(3) WHERE tenant_id = ? AND user_id = ? AND task_id IN (${tasks.map(() => '?').join(',')})`, [input.tenantId, input.cleanerUserId, ...tasks.map((task) => task.id)] ); await connection.execute( `UPDATE qipai_cleaning_tasks t SET t.status = 'SETTLED', t.settled_at = UTC_TIMESTAMP(3) WHERE t.tenant_id = ? AND t.id IN (${tasks.map(() => '?').join(',')}) AND NOT EXISTS ( SELECT 1 FROM qipai_cleaning_task_members pending WHERE pending.tenant_id = t.tenant_id AND pending.task_id = t.id AND pending.removed_at IS NULL AND pending.reward_cents > 0 AND pending.settled_at IS NULL )`, [input.tenantId, ...tasks.map((task) => task.id)] ); for (const task of tasks) { await this.recordEventWithConnection( connection, input, String(task.id), 'COMPLETED', 'COMPLETED', 'SETTLE_MEMBER', settlementNo ); } return this.getSettlement(connection, input.tenantId, settlementId); }); } async confirmSettlement(input: CleaningActor & { settlementId: string; note?: string }) { this.assertSettlement(input.access, 'write'); return this.transaction(async (connection) => { const [result] = await connection.execute( `UPDATE qipai_cleaning_settlements s SET s.status = 'CONFIRMED', s.confirmed_by = ?, s.confirmed_at = UTC_TIMESTAMP(3), s.note = CASE WHEN ? = '' THEN s.note ELSE ? END WHERE s.tenant_id = ? AND s.id = ? AND s.status = 'DRAFT' AND s.deleted_at IS NULL AND ${storeScopeSql(input.access, 'COALESCE(s.store_id, 0)')}`, [input.userId, input.note ?? '', input.note ?? '', input.tenantId, input.settlementId] ); if (result.affectedRows !== 1) throw new CleaningTaskError('CLEANING_SETTLEMENT_STATUS_CONFLICT'); return this.getSettlement(connection, input.tenantId, input.settlementId); }); } async markSettlementPaid(input: CleaningActor & { settlementId: string; payoutChannel: string; payoutReference: string; note?: string; }) { this.assertSettlement(input.access, 'write'); return this.transaction(async (connection) => { const [result] = await connection.execute( `UPDATE qipai_cleaning_settlements s SET s.status = 'PAID', s.paid_by = ?, s.paid_at = UTC_TIMESTAMP(3), s.payout_channel = ?, s.payout_reference = ?, s.payout_state = 'SUCCESS', s.payout_package_info = '', s.payout_error = '', s.note = CASE WHEN ? = '' THEN s.note ELSE ? END WHERE s.tenant_id = ? AND s.id = ? AND s.status = 'CONFIRMED' AND s.deleted_at IS NULL AND ${storeScopeSql(input.access, 'COALESCE(s.store_id, 0)')}`, [ input.userId, input.payoutChannel, input.payoutReference, input.note ?? '', input.note ?? '', input.tenantId, input.settlementId ] ); if (result.affectedRows !== 1) throw new CleaningTaskError('CLEANING_SETTLEMENT_STATUS_CONFLICT'); return this.getSettlement(connection, input.tenantId, input.settlementId); }); } async recordSettlementPayoutFailure(input: CleaningActor & { settlementId: string; payoutChannel?: string; payoutReference?: string; error: string; note?: string; }) { this.assertSettlement(input.access, 'write'); return this.transaction(async (connection) => { const [result] = await connection.execute( `UPDATE qipai_cleaning_settlements s SET s.payout_channel = CASE WHEN ? = '' THEN s.payout_channel ELSE ? END, s.payout_reference = CASE WHEN ? = '' THEN s.payout_reference ELSE ? END, s.payout_state = 'FAIL', s.payout_package_info = '', s.payout_error = ?, s.note = CASE WHEN ? = '' THEN s.note ELSE ? END WHERE s.tenant_id = ? AND s.id = ? AND s.status = 'CONFIRMED' AND s.deleted_at IS NULL AND ${storeScopeSql(input.access, 'COALESCE(s.store_id, 0)')}`, [ input.payoutChannel ?? '', input.payoutChannel ?? '', input.payoutReference ?? '', input.payoutReference ?? '', input.error.slice(0, 512), input.note ?? '', input.note ?? '', input.tenantId, input.settlementId ] ); if (result.affectedRows !== 1) throw new CleaningTaskError('CLEANING_SETTLEMENT_STATUS_CONFLICT'); return this.getSettlement(connection, input.tenantId, input.settlementId); }); } async recordSettlementPayoutPending(input: CleaningActor & { settlementId: string; payoutChannel: string; payoutReference: string; payoutState: string; payoutPackageInfo?: string; note?: string; }) { this.assertSettlement(input.access, 'write'); return this.transaction(async (connection) => { const [result] = await connection.execute( `UPDATE qipai_cleaning_settlements s SET s.payout_channel = ?, s.payout_reference = ?, s.payout_state = ?, s.payout_package_info = ?, s.payout_error = '', s.note = CASE WHEN ? = '' THEN s.note ELSE ? END WHERE s.tenant_id = ? AND s.id = ? AND s.status = 'CONFIRMED' AND s.deleted_at IS NULL AND ${storeScopeSql(input.access, 'COALESCE(s.store_id, 0)')}`, [ input.payoutChannel, input.payoutReference, input.payoutState.slice(0, 32), (input.payoutPackageInfo ?? '').slice(0, 1024), input.note ?? '', input.note ?? '', input.tenantId, input.settlementId ] ); if (result.affectedRows !== 1) throw new CleaningTaskError('CLEANING_SETTLEMENT_STATUS_CONFLICT'); return this.getSettlement(connection, input.tenantId, input.settlementId); }); } async reclaimTimeouts(input: CleaningActor & { olderThanMinutes: number; limit: number }) { this.assertCleaner(input.access, 'write'); const [rows] = await this.pool.execute( `SELECT id FROM qipai_cleaning_tasks t WHERE t.tenant_id = ? AND t.status IN ('CLAIMED', 'STARTED') AND t.updated_at < TIMESTAMPADD(MINUTE, -?, UTC_TIMESTAMP(3)) AND t.deleted_at IS NULL AND ${storeScopeSql(input.access, 't.store_id')} ORDER BY t.updated_at ASC, t.id ASC LIMIT ?`, [input.tenantId, input.olderThanMinutes, input.limit] ); const ids = rows.map((row) => String(row.id)); if (ids.length === 0) return { reclaimed: 0, taskIds: [] as string[] }; await this.pool.execute( `UPDATE qipai_cleaning_tasks SET status = 'WAITING', cleaner_user_id = NULL, claimed_at = NULL, started_at = NULL, reject_reason = '' WHERE tenant_id = ? AND id IN (${ids.map(() => '?').join(',')})`, [input.tenantId, ...ids] ); for (const taskId of ids) { await this.recordEvent(input, taskId, 'CLAIMED', 'WAITING', 'TIMEOUT_RECLAIM', ''); } return { reclaimed: ids.length, taskIds: ids }; } 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'); const [rows] = await this.pool.execute( `SELECT status, cleaner_user_id AS cleanerUserId FROM qipai_cleaning_tasks WHERE tenant_id = ? AND id = ? AND cleaner_user_id = ? AND deleted_at IS NULL`, [input.tenantId, input.taskId, input.userId] ); const current = rows[0]; if (!current || !['STARTED', 'REJECTED'].includes(current.status)) { throw new CleaningTaskError('CLEANING_TASK_STATUS_CONFLICT'); } const [result] = await this.pool.execute( `UPDATE qipai_cleaning_tasks SET status = 'SUBMITTED', submitted_at = UTC_TIMESTAMP(3), photo_urls_json = ?, reject_reason = '' WHERE tenant_id = ? AND id = ? AND cleaner_user_id = ? AND status = ? AND deleted_at IS NULL`, [JSON.stringify(input.photoUrls.slice(0, 9)), input.tenantId, input.taskId, input.userId, current.status] ); if (result.affectedRows !== 1) throw new CleaningTaskError('CLEANING_TASK_STATUS_CONFLICT'); await this.recordEvent(input, input.taskId, current.status, 'SUBMITTED', 'SUBMIT', input.note ?? ''); return this.getMineTask(input, input.taskId); } async assertCanUploadPhoto(input: CleaningActor & { taskId: string }) { this.assertCleaner(input.access, 'write'); const [rows] = await this.pool.execute( `SELECT status, cleaner_user_id AS cleanerUserId FROM qipai_cleaning_tasks WHERE tenant_id = ? AND id = ? AND cleaner_user_id = ? AND status IN ('STARTED', 'REJECTED') AND deleted_at IS NULL`, [input.tenantId, input.taskId, input.userId] ); if (!rows[0]) throw new CleaningTaskError('CLEANING_TASK_STATUS_CONFLICT'); } async createForFinishedOrder( connection: PoolConnection, input: { tenantId: string; orderId: string; actorId: string; traceId: string } ) { const [orders] = await connection.execute( `SELECT id, order_no AS orderNo, store_id AS storeId, room_id AS roomId FROM qipai_orders WHERE tenant_id = ? AND id = ? AND status = 'FINISHED' AND deleted_at IS NULL LIMIT 1`, [input.tenantId, input.orderId] ); const order = orders[0]; if (!order) throw new CleaningTaskError('CLEANING_ORDER_NOT_FINISHED'); const taskNo = `CLN-${order.orderNo}`; const [result] = await connection.execute( `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] ); 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', ?) FROM qipai_cleaning_tasks WHERE tenant_id = ? AND order_id = ?`, [input.actorId, input.traceId, input.orderId, input.tenantId, input.orderId] ); } } async stats(input: CleaningActor) { this.assertCleaner(input.access, 'read'); const [counts] = await this.pool.execute( `SELECT status, COUNT(*) AS total FROM qipai_cleaning_tasks t WHERE tenant_id = ? AND deleted_at IS NULL AND (cleaner_user_id = ? OR EXISTS ( SELECT 1 FROM qipai_cleaning_task_members m WHERE m.tenant_id = t.tenant_id AND m.task_id = t.id AND m.user_id = ? AND m.removed_at IS NULL )) GROUP BY status`, [input.tenantId, input.userId, input.userId] ); const [amountRows] = await this.pool.execute( `SELECT COALESCE(SUM(m.reward_cents), 0) AS amount FROM qipai_cleaning_task_members m INNER JOIN qipai_cleaning_tasks t ON t.tenant_id = m.tenant_id AND t.id = m.task_id WHERE m.tenant_id = ? AND m.user_id = ? AND m.removed_at IS NULL AND m.settled_at IS NULL AND m.reward_cents > 0 AND t.status IN ('SUBMITTED', 'COMPLETED') AND t.deleted_at IS NULL`, [input.tenantId, input.userId] ); return { byStatus: Object.fromEntries(counts.map((row) => [row.status, Number(row.total)])), pendingSettlementCents: Number(amountRows[0]?.amount ?? 0) }; } private async listByWhere( where: string[], params: Array, page: number, pageSize: number, orderBy: string ) { const whereSql = where.join(' AND '); const offset = (page - 1) * pageSize; const [counts] = await this.pool.execute( `SELECT COUNT(*) AS total FROM qipai_cleaning_tasks t WHERE ${whereSql}`, params ); const [rows] = await this.pool.execute( `SELECT t.id, t.task_no AS taskNo, t.store_id AS storeId, s.name AS storeName, 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.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 WHERE m.tenant_id = t.tenant_id AND m.task_id = t.id AND m.removed_at IS NULL) AS memberCount, t.created_at AS createdAt, t.updated_at AS updatedAt FROM qipai_cleaning_tasks t INNER JOIN qipai_stores s ON s.tenant_id = t.tenant_id AND s.id = t.store_id INNER JOIN qipai_rooms r ON r.tenant_id = t.tenant_id AND r.id = t.room_id LEFT JOIN qipai_orders o ON o.tenant_id = t.tenant_id AND o.id = t.order_id WHERE ${whereSql} ORDER BY ${orderBy} LIMIT ? OFFSET ?`, [...params, pageSize, offset] ); return { items: rows.map(publicTask), total: Number(counts[0]?.total ?? 0), page, pageSize }; } private async getMineTask(input: CleaningActor, taskId: string) { const result = await this.listMine({ ...input, page: 1, pageSize: 50 }); const task = result.items.find((item) => item.id === taskId); if (!task) throw new CleaningTaskError('CLEANING_TASK_NOT_FOUND'); return task; } private async getTask(input: CleaningActor, taskId: string) { const result = await this.listManage({ ...input, page: 1, pageSize: 50 }); const task = result.items.find((item) => item.id === taskId); if (!task) throw new CleaningTaskError('CLEANING_TASK_NOT_FOUND'); return task; } private async moveManaged( input: CleaningActor & { taskId: string }, from: CleaningTaskStatus, to: CleaningTaskStatus, action: string, timestampColumn: 'completed_at', note: string ) { this.assertCleaner(input.access, 'write'); await this.assertStoreVisible(input, input.taskId); const [result] = await this.pool.execute( `UPDATE qipai_cleaning_tasks SET status = ?, ${timestampColumn} = UTC_TIMESTAMP(3) WHERE tenant_id = ? AND id = ? AND status = ? AND deleted_at IS NULL`, [to, input.tenantId, input.taskId, from] ); if (result.affectedRows !== 1) throw new CleaningTaskError('CLEANING_TASK_STATUS_CONFLICT'); await this.recordEvent(input, input.taskId, from, to, action, note); return this.getTask(input, input.taskId); } private async moveMine( input: CleaningActor & { taskId: string }, from: CleaningTaskStatus, to: CleaningTaskStatus, action: string, timestampColumn: 'started_at' | 'submitted_at', note: string, extra?: { photo_urls_json?: string; reject_reason?: string } ) { this.assertCleaner(input.access, 'write'); const extraAssignments: string[] = []; const extraParams: Array = []; if (extra?.photo_urls_json !== undefined) { extraAssignments.push('photo_urls_json = ?'); extraParams.push(extra.photo_urls_json); } if (extra?.reject_reason !== undefined) { extraAssignments.push('reject_reason = ?'); extraParams.push(extra.reject_reason); } const setExtra = extraAssignments.length > 0 ? `, ${extraAssignments.join(', ')}` : ''; const [result] = await this.pool.execute( `UPDATE qipai_cleaning_tasks SET status = ?, ${timestampColumn} = UTC_TIMESTAMP(3)${setExtra} WHERE tenant_id = ? AND id = ? AND cleaner_user_id = ? AND status = ? AND deleted_at IS NULL`, [to, ...extraParams, input.tenantId, input.taskId, input.userId, from] ); if (result.affectedRows !== 1) throw new CleaningTaskError('CLEANING_TASK_STATUS_CONFLICT'); await this.recordEvent(input, input.taskId, from, to, action, note); return this.getMineTask(input, input.taskId); } private async getMembers( tenantId: string, taskId: string, connection: Pick | PoolConnection = this.pool ) { const [rows] = await connection.execute( `SELECT m.id, m.task_id AS taskId, m.user_id AS userId, u.nickname, m.member_role AS memberRole, m.reward_cents AS rewardCents, m.joined_at AS joinedAt, m.removed_at AS removedAt, m.settled_at AS settledAt FROM qipai_cleaning_task_members m INNER JOIN qipai_users u ON u.tenant_id = m.tenant_id AND u.id = m.user_id WHERE m.tenant_id = ? AND m.task_id = ? AND m.removed_at IS NULL ORDER BY FIELD(m.member_role, 'LEAD', 'ASSIST'), m.joined_at ASC, m.id ASC`, [tenantId, taskId] ); return rows.map(publicTaskMember); } private async ensureLeadMember( connection: Pick | PoolConnection, tenantId: string, taskId: string ) { await connection.execute( `UPDATE qipai_cleaning_task_members m INNER JOIN qipai_cleaning_tasks t ON t.tenant_id = m.tenant_id AND t.id = m.task_id SET m.removed_at = UTC_TIMESTAMP(3), m.reward_cents = 0, m.updated_at = UTC_TIMESTAMP(3) WHERE m.tenant_id = ? AND m.task_id = ? AND m.member_role = 'LEAD' AND m.user_id <> t.cleaner_user_id AND m.removed_at IS NULL AND m.settled_at IS NULL`, [tenantId, taskId] ); await connection.execute( `INSERT INTO qipai_cleaning_task_members (tenant_id, task_id, user_id, member_role, reward_cents, joined_at) SELECT t.tenant_id, t.id, t.cleaner_user_id, 'LEAD', t.reward_cents, COALESCE(t.claimed_at, UTC_TIMESTAMP(3)) FROM qipai_cleaning_tasks t WHERE t.tenant_id = ? AND t.id = ? AND t.cleaner_user_id IS NOT NULL ON DUPLICATE KEY UPDATE member_role = 'LEAD', removed_at = NULL, updated_at = UTC_TIMESTAMP(3)`, [tenantId, taskId] ); await this.syncLeadReward(connection, tenantId, taskId); } private async syncLeadReward( connection: Pick | PoolConnection, tenantId: string, taskId: string ) { const [rows] = await connection.execute( `SELECT t.reward_cents - COALESCE(SUM( CASE WHEN m.member_role = 'ASSIST' AND m.removed_at IS NULL THEN m.reward_cents ELSE 0 END ), 0) AS amount FROM qipai_cleaning_tasks t LEFT JOIN qipai_cleaning_task_members m ON m.tenant_id = t.tenant_id AND m.task_id = t.id WHERE t.tenant_id = ? AND t.id = ? GROUP BY t.reward_cents`, [tenantId, taskId] ); const leadReward = Number(rows[0]?.amount ?? 0); if (leadReward < 0) throw new CleaningTaskError('CLEANING_MEMBER_REWARD_EXCEEDS_TASK'); await connection.execute( `UPDATE qipai_cleaning_task_members m INNER JOIN qipai_cleaning_tasks t ON t.tenant_id = m.tenant_id AND t.id = m.task_id AND t.cleaner_user_id = m.user_id SET m.reward_cents = ?, m.member_role = 'LEAD', m.removed_at = NULL, m.updated_at = UTC_TIMESTAMP(3) WHERE m.tenant_id = ? AND m.task_id = ?`, [leadReward, tenantId, taskId] ); } 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( `SELECT store_id AS storeId FROM qipai_cleaning_tasks WHERE tenant_id = ? AND id = ? AND deleted_at IS NULL`, [input.tenantId, taskId] ); const storeId = rows[0]?.storeId; if (!storeId || !input.access.storeIds.includes(String(storeId))) { throw new CleaningTaskError('CLEANING_TASK_FORBIDDEN'); } } private async assertCleanerUserForTask(tenantId: string, taskId: string, cleanerUserId: string) { const [rows] = await this.pool.execute( `SELECT 1 FROM qipai_cleaning_tasks t INNER JOIN qipai_users u ON u.tenant_id = t.tenant_id AND u.id = ? AND u.status = 'ACTIVE' AND u.deleted_at IS NULL INNER JOIN qipai_user_roles ur ON ur.tenant_id = u.tenant_id AND ur.user_id = u.id INNER JOIN qipai_roles r ON r.tenant_id = ur.tenant_id AND r.id = ur.role_id AND r.code = 'CLEANER' AND r.status = 'ACTIVE' AND r.deleted_at IS NULL INNER JOIN qipai_user_store_scopes ss ON ss.tenant_id = u.tenant_id AND ss.user_id = u.id AND ss.store_id = t.store_id WHERE t.tenant_id = ? AND t.id = ? AND t.deleted_at IS NULL LIMIT 1`, [cleanerUserId, tenantId, taskId] ); if (!rows[0]) throw new CleaningTaskError('CLEANING_ASSIGNEE_INVALID'); } private async recordEvent( input: CleaningActor, taskId: string, from: CleaningTaskStatus, to: CleaningTaskStatus, action: string, note: string ) { await this.pool.execute( `INSERT IGNORE INTO qipai_cleaning_task_events (tenant_id, task_id, from_status, to_status, action, actor_id, trace_id, note, metadata) VALUES (?, ?, ?, ?, ?, ?, ?, ?, JSON_OBJECT())`, [input.tenantId, taskId, from, to, action, input.userId, input.traceId, note.slice(0, 512)] ); } private async recordEventWithConnection( connection: PoolConnection, input: CleaningActor, taskId: string, from: CleaningTaskStatus, to: CleaningTaskStatus, action: string, note: string ) { 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) VALUES (?, ?, ?, ?, ?, ?, ?, ?, JSON_OBJECT())`, [input.tenantId, taskId, from, to, action, input.userId, `${input.traceId}-${taskId}`.slice(0, 128), note.slice(0, 512)] ); } private assertCleaner(access: AccessProfile, mode: 'read' | 'write') { const permission = mode === 'read' ? 'cleaning.task.read' : 'cleaning.task.write'; if (!access.capabilities.includes(permission) && !access.capabilities.includes('tenant.manage') && !access.roles.includes('PLATFORM_ADMIN')) { throw new CleaningTaskError('CLEANING_TASK_FORBIDDEN'); } } private assertSettlement(access: AccessProfile, mode: 'read' | 'write') { const permission = mode === 'read' ? 'cleaning.settlement.read' : 'cleaning.settlement.write'; if (!access.capabilities.includes(permission) && !access.capabilities.includes('tenant.manage') && !access.roles.includes('PLATFORM_ADMIN')) { throw new CleaningTaskError('CLEANING_SETTLEMENT_FORBIDDEN'); } } private async getSettlement( connection: Pick | PoolConnection, tenantId: string, settlementId: string ) { const [rows] = await connection.execute( `SELECT s.id, s.settlement_no AS settlementNo, s.cleaner_user_id AS cleanerUserId, u.nickname AS cleanerName, s.store_id AS storeId, st.name AS storeName, s.status, s.task_count AS taskCount, s.total_reward_cents AS totalRewardCents, s.period_start AS periodStart, s.period_end AS periodEnd, s.paid_by AS paidBy, s.confirmed_at AS confirmedAt, s.paid_at AS paidAt, s.payout_channel AS payoutChannel, s.payout_reference AS payoutReference, s.payout_state AS payoutState, s.payout_package_info AS payoutPackageInfo, s.payout_error AS payoutError, s.note, s.created_at AS createdAt FROM qipai_cleaning_settlements s INNER JOIN qipai_users u ON u.tenant_id = s.tenant_id AND u.id = s.cleaner_user_id LEFT JOIN qipai_stores st ON st.tenant_id = s.tenant_id AND st.id = s.store_id WHERE s.tenant_id = ? AND s.id = ? AND s.deleted_at IS NULL`, [tenantId, settlementId] ); if (!rows[0]) throw new CleaningTaskError('CLEANING_SETTLEMENT_NOT_FOUND'); return publicSettlement(rows[0]); } private async getSettlementScoped(input: CleaningActor & { settlementId: string }) { const [rows] = await this.pool.execute( `SELECT s.id, s.settlement_no AS settlementNo, s.cleaner_user_id AS cleanerUserId, u.nickname AS cleanerName, s.store_id AS storeId, st.name AS storeName, s.status, s.task_count AS taskCount, s.total_reward_cents AS totalRewardCents, s.period_start AS periodStart, s.period_end AS periodEnd, s.paid_by AS paidBy, s.confirmed_at AS confirmedAt, s.paid_at AS paidAt, s.payout_channel AS payoutChannel, s.payout_reference AS payoutReference, s.payout_state AS payoutState, s.payout_package_info AS payoutPackageInfo, s.payout_error AS payoutError, s.note, s.created_at AS createdAt FROM qipai_cleaning_settlements s INNER JOIN qipai_users u ON u.tenant_id = s.tenant_id AND u.id = s.cleaner_user_id LEFT JOIN qipai_stores st ON st.tenant_id = s.tenant_id AND st.id = s.store_id WHERE s.tenant_id = ? AND s.id = ? AND s.deleted_at IS NULL AND ${storeScopeSql(input.access, 'COALESCE(s.store_id, 0)')}`, [input.tenantId, input.settlementId] ); if (!rows[0]) throw new CleaningTaskError('CLEANING_SETTLEMENT_NOT_FOUND'); return publicSettlement(rows[0]); } private async transaction(work: (connection: PoolConnection) => Promise) { const connection = await this.pool.getConnection(); try { await connection.beginTransaction(); const result = await work(connection); await connection.commit(); return result; } catch (error) { await connection.rollback(); throw error; } finally { connection.release(); } } } function storeScopeSql(access: AccessProfile, storeExpression: string) { if (access.capabilities.includes('tenant.manage') || access.roles.includes('PLATFORM_ADMIN')) { return '1 = 1'; } if (access.storeIds.length === 0) return '1 = 0'; return `${storeExpression} IN (${access.storeIds.map((id) => Number(id)).join(',')})`; } function publicTask(row: CleaningTaskRow) { return { id: String(row.id), taskNo: row.taskNo, storeId: String(row.storeId), storeName: row.storeName, roomId: String(row.roomId), roomName: row.roomName, roomNo: row.roomNo, orderId: row.orderId === null ? null : String(row.orderId), orderNo: row.orderNo, status: row.status, cleanerUserId: row.cleanerUserId === null ? null : String(row.cleanerUserId), priority: Number(row.priority), rewardCents: Number(row.rewardCents), requirement: row.requirement, photoUrls: parseJsonArray(row.photoUrlsJson), rejectReason: row.rejectReason, claimedAt: row.claimedAt, startedAt: row.startedAt, submittedAt: row.submittedAt, completedAt: row.completedAt, memberCount: Number(row.memberCount ?? 0), createdAt: row.createdAt, updatedAt: row.updatedAt }; } function publicTaskMember(row: CleaningTaskMemberRow) { return { id: String(row.id), taskId: String(row.taskId), userId: String(row.userId), nickname: row.nickname, memberRole: row.memberRole, rewardCents: Number(row.rewardCents), joinedAt: row.joinedAt, removedAt: row.removedAt, settledAt: row.settledAt }; } function publicSettlement(row: SettlementRow) { return { id: String(row.id), settlementNo: row.settlementNo, cleanerUserId: String(row.cleanerUserId), cleanerName: row.cleanerName, storeId: row.storeId === null ? null : String(row.storeId), storeName: row.storeName, status: row.status, taskCount: Number(row.taskCount), totalRewardCents: Number(row.totalRewardCents), periodStart: row.periodStart, periodEnd: row.periodEnd, paidBy: row.paidBy === null ? null : String(row.paidBy), confirmedAt: row.confirmedAt, paidAt: row.paidAt, payoutChannel: row.payoutChannel, payoutReference: row.payoutReference, payoutState: row.payoutState, payoutPackageInfo: row.payoutPackageInfo, payoutError: row.payoutError, note: row.note, createdAt: row.createdAt }; } function publicSettlementItem(row: SettlementItemRow) { return { id: String(row.id), settlementId: String(row.settlementId), taskId: String(row.taskId), taskNo: row.taskNo, orderNo: row.orderNo, storeName: row.storeName, roomName: row.roomName, roomNo: row.roomNo, cleanerUserId: String(row.cleanerUserId), cleanerName: row.cleanerName, rewardCents: Number(row.rewardCents), completedAt: row.completedAt, createdAt: row.createdAt }; } function minDate(values: Array) { const dates = values.filter((value): value is Date => value instanceof Date); if (dates.length === 0) return null; return new Date(Math.min(...dates.map((date) => date.getTime()))); } function maxDate(values: Array) { const dates = values.filter((value): value is Date => value instanceof Date); if (dates.length === 0) return null; return new Date(Math.max(...dates.map((date) => date.getTime()))); } function parseJsonArray(value: string | string[] | null): string[] { if (Array.isArray(value)) return value.map(String); if (!value) return []; try { const parsed = JSON.parse(value); return Array.isArray(parsed) ? parsed.map(String) : []; } catch { return []; } }