Files
qipai/backend/src/cleaning/cleaning-task-repository.ts
T
2026-06-30 10:24:39 +08:00

1340 lines
57 KiB
TypeScript

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;
}
interface ManagerStatusStatsRow extends RowDataPacket {
status: CleaningTaskStatus;
total: number;
rewardCents: number | null;
}
interface ManagerStoreStatsRow extends RowDataPacket {
storeId: string;
storeName: string;
taskTotal: number;
pendingReview: number;
completed: number;
rejected: number;
rewardCents: number | null;
}
interface ManagerSettlementStatsRow extends RowDataPacket {
status: CleaningSettlementStatus;
total: number;
rewardCents: number | null;
}
interface ManagerMemberStatsRow extends RowDataPacket {
cleanerUserId: string;
cleanerName: string;
taskCount: number;
pendingSettlementCents: number | null;
settledRewardCents: number | null;
}
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<string | number> = [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<string | number> = [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<string | number> = [input.tenantId];
if (input.status) {
where.push('t.status = ?');
params.push(input.status);
}
return this.listByWhere(where, params, input.page, input.pageSize, 't.updated_at DESC, t.id DESC');
}
async claim(input: CleaningActor & { taskId: string }) {
this.assertCleaner(input.access, 'write');
await this.assertStoreVisible(input, input.taskId);
const [result] = await this.pool.execute<ResultSetHeader>(
`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<ResultSetHeader>(
`UPDATE qipai_cleaning_tasks
SET status = 'CLAIMED', cleaner_user_id = ?, claimed_at = COALESCE(claimed_at, UTC_TIMESTAMP(3)),
reject_reason = ''
WHERE tenant_id = ? AND id = ? AND status IN ('WAITING', 'CLAIMED', 'REJECTED')
AND deleted_at IS NULL`,
[input.cleanerUserId, input.tenantId, input.taskId]
);
if (result.affectedRows !== 1) throw new CleaningTaskError('CLEANING_TASK_ASSIGN_CONFLICT');
await this.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<ResultSetHeader>(
`UPDATE qipai_cleaning_tasks
SET status = 'REJECTED', reject_reason = ?
WHERE tenant_id = ? AND id = ? AND status = 'SUBMITTED' AND deleted_at IS NULL`,
[input.reason.slice(0, 512), input.tenantId, input.taskId]
);
if (result.affectedRows !== 1) throw new CleaningTaskError('CLEANING_TASK_STATUS_CONFLICT');
await this.recordEvent(input, input.taskId, 'SUBMITTED', 'REJECTED', 'REJECT', input.reason);
return this.getTask(input, input.taskId);
}
async 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<CleaningTaskRow[]>(
`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<CleaningTaskMemberRow[]>(
`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<CurrentStatusRow[]>(
`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<ResultSetHeader>(
`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<string | number> = [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<CountRow[]>(
`SELECT COUNT(*) AS total FROM qipai_cleaning_settlements s WHERE ${whereSql}`,
params
);
const [rows] = await this.pool.execute<SettlementRow[]>(
`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<SettlementItemRow[]>(
`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<string | number> = [input.tenantId, input.cleanerUserId];
const storeFilter = input.storeId ? 'AND t.store_id = ?' : '';
if (input.storeId) params.push(input.storeId);
const [tasks] = await connection.execute<SettlementCandidateRow[]>(
`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<ResultSetHeader>(
`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<ResultSetHeader>(
`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<ResultSetHeader>(
`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<ResultSetHeader>(
`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<ResultSetHeader>(
`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<RowDataPacket[]>(
`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<CurrentStatusRow[]>(
`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<ResultSetHeader>(
`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<CurrentStatusRow[]>(
`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<FinishedOrderRow[]>(
`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<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]
);
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<StatusCountRow[]>(
`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<AmountRow[]>(
`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)
};
}
async managerStatistics(input: CleaningActor & { from?: string; to?: string; storeId?: string }) {
this.assertCleaner(input.access, 'read');
this.assertSettlement(input.access, 'read');
const taskWhere = [
't.tenant_id = ?',
't.deleted_at IS NULL',
storeScopeSql(input.access, 't.store_id')
];
const taskParams: Array<string | number> = [input.tenantId];
if (input.storeId) {
taskWhere.push('t.store_id = ?');
taskParams.push(input.storeId);
}
if (input.from) {
taskWhere.push('t.created_at >= ?');
taskParams.push(input.from);
}
if (input.to) {
taskWhere.push('t.created_at < ?');
taskParams.push(input.to);
}
const taskWhereSql = taskWhere.join(' AND ');
const [statusRows] = await this.pool.execute<ManagerStatusStatsRow[]>(
`SELECT t.status, COUNT(*) AS total, COALESCE(SUM(t.reward_cents), 0) AS rewardCents
FROM qipai_cleaning_tasks t
WHERE ${taskWhereSql}
GROUP BY t.status
ORDER BY FIELD(t.status, 'WAITING', 'CLAIMED', 'STARTED', 'SUBMITTED', 'REJECTED',
'COMPLETED', 'SETTLED', 'EXEMPT', 'CANCELLED')`,
taskParams
);
const [storeRows] = await this.pool.execute<ManagerStoreStatsRow[]>(
`SELECT t.store_id AS storeId, s.name AS storeName,
COUNT(*) AS taskTotal,
SUM(CASE WHEN t.status = 'SUBMITTED' THEN 1 ELSE 0 END) AS pendingReview,
SUM(CASE WHEN t.status IN ('COMPLETED', 'SETTLED') THEN 1 ELSE 0 END) AS completed,
SUM(CASE WHEN t.status = 'REJECTED' THEN 1 ELSE 0 END) AS rejected,
COALESCE(SUM(t.reward_cents), 0) AS rewardCents
FROM qipai_cleaning_tasks t
INNER JOIN qipai_stores s ON s.tenant_id = t.tenant_id AND s.id = t.store_id
WHERE ${taskWhereSql}
GROUP BY t.store_id, s.name
ORDER BY pendingReview DESC, taskTotal DESC, t.store_id ASC
LIMIT 20`,
taskParams
);
const settlementWhere = [
's.tenant_id = ?',
's.deleted_at IS NULL',
storeScopeSql(input.access, 'COALESCE(s.store_id, 0)')
];
const settlementParams: Array<string | number> = [input.tenantId];
if (input.storeId) {
settlementWhere.push('s.store_id = ?');
settlementParams.push(input.storeId);
}
if (input.from) {
settlementWhere.push('s.created_at >= ?');
settlementParams.push(input.from);
}
if (input.to) {
settlementWhere.push('s.created_at < ?');
settlementParams.push(input.to);
}
const settlementWhereSql = settlementWhere.join(' AND ');
const [settlementRows] = await this.pool.execute<ManagerSettlementStatsRow[]>(
`SELECT s.status, COUNT(*) AS total, COALESCE(SUM(s.total_reward_cents), 0) AS rewardCents
FROM qipai_cleaning_settlements s
WHERE ${settlementWhereSql}
GROUP BY s.status
ORDER BY FIELD(s.status, 'DRAFT', 'CONFIRMED', 'PAID', 'CANCELLED')`,
settlementParams
);
const [memberRows] = await this.pool.execute<ManagerMemberStatsRow[]>(
`SELECT m.user_id AS cleanerUserId, u.nickname AS cleanerName,
COUNT(DISTINCT m.task_id) AS taskCount,
COALESCE(SUM(CASE
WHEN m.removed_at IS NULL AND m.settled_at IS NULL AND t.status IN ('SUBMITTED', 'COMPLETED')
THEN m.reward_cents ELSE 0 END), 0) AS pendingSettlementCents,
COALESCE(SUM(CASE
WHEN m.settled_at IS NOT NULL THEN m.reward_cents ELSE 0 END), 0) AS settledRewardCents
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
INNER JOIN qipai_users u ON u.tenant_id = m.tenant_id AND u.id = m.user_id
WHERE ${taskWhereSql}
GROUP BY m.user_id, u.nickname
ORDER BY pendingSettlementCents DESC, settledRewardCents DESC, taskCount DESC
LIMIT 20`,
taskParams
);
return publicManagerStatistics(statusRows, storeRows, settlementRows, memberRows);
}
private async listByWhere(
where: string[],
params: Array<string | number>,
page: number,
pageSize: number,
orderBy: string
) {
const whereSql = where.join(' AND ');
const offset = (page - 1) * pageSize;
const [counts] = await this.pool.execute<CountRow[]>(
`SELECT COUNT(*) AS total FROM qipai_cleaning_tasks t WHERE ${whereSql}`,
params
);
const [rows] = await this.pool.execute<CleaningTaskRow[]>(
`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<ResultSetHeader>(
`UPDATE qipai_cleaning_tasks
SET status = ?, ${timestampColumn} = UTC_TIMESTAMP(3)
WHERE tenant_id = ? AND id = ? AND status = ? AND deleted_at IS NULL`,
[to, input.tenantId, input.taskId, from]
);
if (result.affectedRows !== 1) throw new CleaningTaskError('CLEANING_TASK_STATUS_CONFLICT');
await this.recordEvent(input, input.taskId, from, to, action, note);
return this.getTask(input, input.taskId);
}
private async moveMine(
input: CleaningActor & { taskId: string },
from: CleaningTaskStatus,
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<string | number> = [];
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<ResultSetHeader>(
`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<MySqlPool, 'execute'> | PoolConnection = this.pool
) {
const [rows] = await connection.execute<CleaningTaskMemberRow[]>(
`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<MySqlPool, 'execute'> | 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<MySqlPool, 'execute'> | PoolConnection,
tenantId: string,
taskId: string
) {
const [rows] = await connection.execute<AmountRow[]>(
`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<RowDataPacket[]>(
`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<RowDataPacket[]>(
`SELECT 1
FROM qipai_cleaning_tasks t
INNER JOIN qipai_users u ON u.tenant_id = t.tenant_id AND u.id = ?
AND u.status = 'ACTIVE' AND u.deleted_at IS NULL
INNER JOIN qipai_user_roles ur ON ur.tenant_id = u.tenant_id AND ur.user_id = u.id
INNER JOIN qipai_roles r ON r.tenant_id = ur.tenant_id AND r.id = ur.role_id
AND r.code = 'CLEANER' AND r.status = 'ACTIVE' AND r.deleted_at IS NULL
INNER JOIN qipai_user_store_scopes ss ON ss.tenant_id = u.tenant_id
AND ss.user_id = u.id AND ss.store_id = t.store_id
WHERE t.tenant_id = ? AND t.id = ? AND t.deleted_at IS NULL
LIMIT 1`,
[cleanerUserId, tenantId, taskId]
);
if (!rows[0]) throw new CleaningTaskError('CLEANING_ASSIGNEE_INVALID');
}
private async recordEvent(
input: CleaningActor,
taskId: string,
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<MySqlPool, 'execute'> | PoolConnection,
tenantId: string,
settlementId: string
) {
const [rows] = await connection.execute<SettlementRow[]>(
`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<SettlementRow[]>(
`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<T>(work: (connection: PoolConnection) => Promise<T>) {
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 publicManagerStatistics(
statusRows: ManagerStatusStatsRow[],
storeRows: ManagerStoreStatsRow[],
settlementRows: ManagerSettlementStatsRow[],
memberRows: ManagerMemberStatsRow[]
) {
const byStatus = statusRows.map((row) => ({
status: row.status,
total: Number(row.total),
rewardCents: Number(row.rewardCents ?? 0)
}));
const settlementByStatus = settlementRows.map((row) => ({
status: row.status,
total: Number(row.total),
rewardCents: Number(row.rewardCents ?? 0)
}));
return {
summary: {
taskTotal: byStatus.reduce((sum, row) => sum + row.total, 0),
pendingReview: countStatus(byStatus, 'SUBMITTED'),
active: countStatus(byStatus, 'WAITING') + countStatus(byStatus, 'CLAIMED') + countStatus(byStatus, 'STARTED'),
rejected: countStatus(byStatus, 'REJECTED'),
completed: countStatus(byStatus, 'COMPLETED') + countStatus(byStatus, 'SETTLED'),
rewardCents: byStatus.reduce((sum, row) => sum + row.rewardCents, 0),
pendingSettlementCents: memberRows.reduce((sum, row) => sum + Number(row.pendingSettlementCents ?? 0), 0),
confirmedSettlementCents: settlementByStatus
.filter((row) => row.status === 'CONFIRMED')
.reduce((sum, row) => sum + row.rewardCents, 0),
paidSettlementCents: settlementByStatus
.filter((row) => row.status === 'PAID')
.reduce((sum, row) => sum + row.rewardCents, 0)
},
byStatus,
byStore: storeRows.map((row) => ({
storeId: String(row.storeId),
storeName: row.storeName,
taskTotal: Number(row.taskTotal),
pendingReview: Number(row.pendingReview),
completed: Number(row.completed),
rejected: Number(row.rejected),
rewardCents: Number(row.rewardCents ?? 0)
})),
settlements: settlementByStatus,
members: memberRows.map((row) => ({
cleanerUserId: String(row.cleanerUserId),
cleanerName: row.cleanerName,
taskCount: Number(row.taskCount),
pendingSettlementCents: Number(row.pendingSettlementCents ?? 0),
settledRewardCents: Number(row.settledRewardCents ?? 0)
}))
};
}
function countStatus(
rows: Array<{ status: CleaningTaskStatus; total: number }>,
status: CleaningTaskStatus
) {
return rows.find((row) => row.status === status)?.total ?? 0;
}
function minDate(values: Array<Date | null>) {
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<Date | null>) {
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 [];
}
}