1161 lines
50 KiB
TypeScript
1161 lines
50 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;
|
|
}
|
|
|
|
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)
|
|
};
|
|
}
|
|
|
|
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 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 [];
|
|
}
|
|
}
|