|
|
|
@@ -21,6 +21,7 @@ export interface CleaningActor {
|
|
|
|
|
userId: string;
|
|
|
|
|
access: AccessProfile;
|
|
|
|
|
traceId: string;
|
|
|
|
|
actorType?: 'USER' | 'SYSTEM';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface CleaningTaskRow extends RowDataPacket {
|
|
|
|
@@ -154,9 +155,12 @@ interface SettlementRow extends RowDataPacket {
|
|
|
|
|
periodStart: Date | null;
|
|
|
|
|
periodEnd: Date | null;
|
|
|
|
|
paidBy: string | null;
|
|
|
|
|
cancelledBy: string | null;
|
|
|
|
|
confirmedAt: Date | null;
|
|
|
|
|
paidAt: Date | null;
|
|
|
|
|
cancelledAt: Date | null;
|
|
|
|
|
payoutChannel: string;
|
|
|
|
|
payoutRequestNo: string;
|
|
|
|
|
payoutReference: string;
|
|
|
|
|
payoutState: string;
|
|
|
|
|
payoutPackageInfo: string;
|
|
|
|
@@ -176,9 +180,31 @@ interface SettlementItemRow extends RowDataPacket {
|
|
|
|
|
cleanerUserId: string;
|
|
|
|
|
cleanerName: string;
|
|
|
|
|
rewardCents: number;
|
|
|
|
|
reversedAt: Date | null;
|
|
|
|
|
completedAt: Date | null;
|
|
|
|
|
createdAt: Date;
|
|
|
|
|
}
|
|
|
|
|
interface SettlementEventRow extends RowDataPacket {
|
|
|
|
|
id: string;
|
|
|
|
|
settlementId: string;
|
|
|
|
|
fromStatus: CleaningSettlementStatus | null;
|
|
|
|
|
toStatus: CleaningSettlementStatus;
|
|
|
|
|
action: string;
|
|
|
|
|
actorId: string;
|
|
|
|
|
actorType: 'USER' | 'SYSTEM';
|
|
|
|
|
traceId: string;
|
|
|
|
|
note: string;
|
|
|
|
|
createdAt: Date;
|
|
|
|
|
}
|
|
|
|
|
interface SettlementReversalRow extends RowDataPacket {
|
|
|
|
|
id: string;
|
|
|
|
|
settlementId: string;
|
|
|
|
|
reversalNo: string;
|
|
|
|
|
amountDeltaCents: number;
|
|
|
|
|
reason: string;
|
|
|
|
|
reversedBy: string;
|
|
|
|
|
createdAt: Date;
|
|
|
|
|
}
|
|
|
|
|
interface ManagerStatusStatsRow extends RowDataPacket {
|
|
|
|
|
status: CleaningTaskStatus;
|
|
|
|
|
total: number;
|
|
|
|
@@ -596,6 +622,7 @@ export class CleaningTaskRepository {
|
|
|
|
|
if (!['CLAIMED', 'STARTED', 'SUBMITTED', 'COMPLETED'].includes(task.status)) {
|
|
|
|
|
throw new CleaningTaskError('CLEANING_TASK_STATUS_CONFLICT');
|
|
|
|
|
}
|
|
|
|
|
await this.assertTaskSettlementMutable(connection, input.tenantId, input.taskId);
|
|
|
|
|
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,
|
|
|
|
@@ -643,6 +670,7 @@ export class CleaningTaskRepository {
|
|
|
|
|
if (String(task.cleanerUserId) === input.cleanerUserId) {
|
|
|
|
|
throw new CleaningTaskError('CLEANING_MEMBER_LEAD_NOT_REMOVABLE');
|
|
|
|
|
}
|
|
|
|
|
await this.assertTaskSettlementMutable(connection, input.tenantId, input.taskId);
|
|
|
|
|
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)
|
|
|
|
@@ -668,6 +696,21 @@ export class CleaningTaskRepository {
|
|
|
|
|
't.deleted_at IS NULL',
|
|
|
|
|
"t.status = 'COMPLETED'",
|
|
|
|
|
't.settled_at IS NULL',
|
|
|
|
|
`EXISTS (
|
|
|
|
|
SELECT 1 FROM qipai_cleaning_task_members pending_member
|
|
|
|
|
WHERE pending_member.tenant_id = t.tenant_id
|
|
|
|
|
AND pending_member.task_id = t.id
|
|
|
|
|
AND pending_member.removed_at IS NULL
|
|
|
|
|
AND pending_member.settled_at IS NULL
|
|
|
|
|
AND pending_member.reward_cents > 0
|
|
|
|
|
AND NOT EXISTS (
|
|
|
|
|
SELECT 1 FROM qipai_cleaning_settlement_items active_item
|
|
|
|
|
WHERE active_item.tenant_id = pending_member.tenant_id
|
|
|
|
|
AND active_item.task_id = pending_member.task_id
|
|
|
|
|
AND active_item.cleaner_user_id = pending_member.user_id
|
|
|
|
|
AND active_item.reversed_at IS NULL
|
|
|
|
|
)
|
|
|
|
|
)`,
|
|
|
|
|
storeScopeSql(input.access, 't.store_id')
|
|
|
|
|
];
|
|
|
|
|
const params: Array<string | number> = [input.tenantId];
|
|
|
|
@@ -676,15 +719,23 @@ export class CleaningTaskRepository {
|
|
|
|
|
params.push(input.storeId);
|
|
|
|
|
}
|
|
|
|
|
if (input.cleanerUserId) {
|
|
|
|
|
where.push(`(t.cleaner_user_id = ? OR EXISTS (
|
|
|
|
|
where.push(`EXISTS (
|
|
|
|
|
SELECT 1 FROM qipai_cleaning_task_members candidate_member
|
|
|
|
|
WHERE candidate_member.tenant_id = t.tenant_id
|
|
|
|
|
AND candidate_member.task_id = t.id
|
|
|
|
|
AND candidate_member.user_id = ?
|
|
|
|
|
AND candidate_member.removed_at IS NULL
|
|
|
|
|
AND candidate_member.settled_at IS NULL
|
|
|
|
|
))`);
|
|
|
|
|
params.push(input.cleanerUserId, input.cleanerUserId);
|
|
|
|
|
AND candidate_member.reward_cents > 0
|
|
|
|
|
AND NOT EXISTS (
|
|
|
|
|
SELECT 1 FROM qipai_cleaning_settlement_items active_item
|
|
|
|
|
WHERE active_item.tenant_id = candidate_member.tenant_id
|
|
|
|
|
AND active_item.task_id = candidate_member.task_id
|
|
|
|
|
AND active_item.cleaner_user_id = candidate_member.user_id
|
|
|
|
|
AND active_item.reversed_at IS NULL
|
|
|
|
|
)
|
|
|
|
|
)`);
|
|
|
|
|
params.push(input.cleanerUserId);
|
|
|
|
|
}
|
|
|
|
|
return this.listByWhere(where, params, input.page, input.pageSize, 't.completed_at ASC, t.id ASC');
|
|
|
|
|
}
|
|
|
|
@@ -694,66 +745,32 @@ export class CleaningTaskRepository {
|
|
|
|
|
payoutState?: CleaningSettlementPayoutState; storeId?: string; cleanerUserId?: string;
|
|
|
|
|
}) {
|
|
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
if (input.payoutState) {
|
|
|
|
|
if (input.payoutState === 'NONE') {
|
|
|
|
|
where.push("s.payout_state = ''");
|
|
|
|
|
} else {
|
|
|
|
|
where.push('s.payout_state = ?');
|
|
|
|
|
params.push(input.payoutState);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (input.storeId) {
|
|
|
|
|
where.push('s.store_id = ?');
|
|
|
|
|
params.push(input.storeId);
|
|
|
|
|
}
|
|
|
|
|
if (input.cleanerUserId) {
|
|
|
|
|
where.push('s.cleaner_user_id = ?');
|
|
|
|
|
params.push(input.cleanerUserId);
|
|
|
|
|
}
|
|
|
|
|
const whereSql = where.join(' AND ');
|
|
|
|
|
const pageSize = Math.max(1, Math.min(100, Math.trunc(input.pageSize)));
|
|
|
|
|
const offset = Math.max(0, (Math.trunc(input.page) - 1) * 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 ${pageSize} OFFSET ${offset}`,
|
|
|
|
|
params
|
|
|
|
|
);
|
|
|
|
|
const { rows, total } = await this.querySettlementRows(input, pageSize, offset);
|
|
|
|
|
return {
|
|
|
|
|
items: rows.map(publicSettlement),
|
|
|
|
|
total: Number(counts[0]?.total ?? 0),
|
|
|
|
|
total,
|
|
|
|
|
page: input.page,
|
|
|
|
|
pageSize
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async exportSettlements(input: CleaningActor & {
|
|
|
|
|
status?: CleaningSettlementStatus; payoutState?: CleaningSettlementPayoutState;
|
|
|
|
|
storeId?: string; cleanerUserId?: string;
|
|
|
|
|
}) {
|
|
|
|
|
this.assertSettlement(input.access, 'read');
|
|
|
|
|
const limit = 5000;
|
|
|
|
|
const { rows, total } = await this.querySettlementRows(input, limit, 0);
|
|
|
|
|
return {
|
|
|
|
|
items: rows.map(publicSettlement),
|
|
|
|
|
total,
|
|
|
|
|
exported: rows.length,
|
|
|
|
|
truncated: total > rows.length
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async getSettlementDetail(input: CleaningActor & { settlementId: string }) {
|
|
|
|
|
this.assertSettlement(input.access, 'read');
|
|
|
|
|
const settlement = await this.getSettlementScoped(input);
|
|
|
|
@@ -762,7 +779,8 @@ export class CleaningTaskRepository {
|
|
|
|
|
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.reward_cents AS rewardCents, i.reversed_at AS reversedAt,
|
|
|
|
|
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
|
|
|
|
@@ -776,14 +794,39 @@ export class CleaningTaskRepository {
|
|
|
|
|
ORDER BY t.completed_at ASC, i.id ASC`,
|
|
|
|
|
[input.tenantId, input.settlementId]
|
|
|
|
|
);
|
|
|
|
|
return { settlement, items: items.map(publicSettlementItem) };
|
|
|
|
|
const [events] = await this.pool.execute<SettlementEventRow[]>(
|
|
|
|
|
`SELECT e.id, e.settlement_id AS settlementId, e.from_status AS fromStatus,
|
|
|
|
|
e.to_status AS toStatus, e.action, e.actor_id AS actorId,
|
|
|
|
|
e.actor_type AS actorType, e.trace_id AS traceId,
|
|
|
|
|
e.note, e.created_at AS createdAt
|
|
|
|
|
FROM qipai_cleaning_settlement_events e
|
|
|
|
|
WHERE e.tenant_id = ? AND e.settlement_id = ?
|
|
|
|
|
ORDER BY e.created_at ASC, e.id ASC`,
|
|
|
|
|
[input.tenantId, input.settlementId]
|
|
|
|
|
);
|
|
|
|
|
const [reversals] = await this.pool.execute<SettlementReversalRow[]>(
|
|
|
|
|
`SELECT r.id, r.settlement_id AS settlementId, r.reversal_no AS reversalNo,
|
|
|
|
|
r.amount_delta_cents AS amountDeltaCents, r.reason,
|
|
|
|
|
r.reversed_by AS reversedBy, r.created_at AS createdAt
|
|
|
|
|
FROM qipai_cleaning_settlement_reversals r
|
|
|
|
|
WHERE r.tenant_id = ? AND r.settlement_id = ?
|
|
|
|
|
ORDER BY r.created_at ASC, r.id ASC`,
|
|
|
|
|
[input.tenantId, input.settlementId]
|
|
|
|
|
);
|
|
|
|
|
return {
|
|
|
|
|
settlement,
|
|
|
|
|
items: items.map(publicSettlementItem),
|
|
|
|
|
events: events.map(publicSettlementEvent),
|
|
|
|
|
reversals: reversals.map(publicSettlementReversal)
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async generateSettlement(input: CleaningActor & {
|
|
|
|
|
cleanerUserId: string; storeId?: string; note?: string;
|
|
|
|
|
}) {
|
|
|
|
|
this.assertSettlement(input.access, 'write');
|
|
|
|
|
return this.transaction(async (connection) => {
|
|
|
|
|
try {
|
|
|
|
|
return await 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)
|
|
|
|
@@ -808,6 +851,13 @@ export class CleaningTaskRepository {
|
|
|
|
|
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
|
|
|
|
|
AND NOT EXISTS (
|
|
|
|
|
SELECT 1 FROM qipai_cleaning_settlement_items active_item
|
|
|
|
|
WHERE active_item.tenant_id = m.tenant_id
|
|
|
|
|
AND active_item.task_id = m.task_id
|
|
|
|
|
AND active_item.cleaner_user_id = m.user_id
|
|
|
|
|
AND active_item.reversed_at IS NULL
|
|
|
|
|
)
|
|
|
|
|
${storeFilter} AND ${scopeSql}
|
|
|
|
|
ORDER BY t.completed_at ASC, t.id ASC
|
|
|
|
|
FOR UPDATE`,
|
|
|
|
@@ -815,10 +865,15 @@ export class CleaningTaskRepository {
|
|
|
|
|
);
|
|
|
|
|
if (tasks.length === 0) throw new CleaningTaskError('CLEANING_SETTLEMENT_EMPTY');
|
|
|
|
|
const storeIds = Array.from(new Set(tasks.map((task) => String(task.storeId))));
|
|
|
|
|
if (storeIds.length > 1
|
|
|
|
|
&& !input.access.capabilities.includes('tenant.manage')
|
|
|
|
|
&& !input.access.roles.includes('PLATFORM_ADMIN')) {
|
|
|
|
|
throw new CleaningTaskError('CLEANING_SETTLEMENT_STORE_REQUIRED');
|
|
|
|
|
}
|
|
|
|
|
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 settlementNo = `CLS-${Date.now()}-${input.cleanerUserId}-${input.traceId.slice(-8)}`;
|
|
|
|
|
const [created] = await connection.execute<ResultSetHeader>(
|
|
|
|
|
`INSERT INTO qipai_cleaning_settlements
|
|
|
|
|
(tenant_id, settlement_no, cleaner_user_id, store_id, status, task_count,
|
|
|
|
@@ -832,39 +887,41 @@ export class CleaningTaskRepository {
|
|
|
|
|
]
|
|
|
|
|
);
|
|
|
|
|
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)]
|
|
|
|
|
);
|
|
|
|
|
try {
|
|
|
|
|
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)]
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
if (isMySqlDuplicate(error)) {
|
|
|
|
|
throw new CleaningTaskError('CLEANING_SETTLEMENT_CONCURRENTLY_CLAIMED');
|
|
|
|
|
}
|
|
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
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)]
|
|
|
|
|
await this.recordSettlementEventWithConnection(
|
|
|
|
|
connection, input, settlementId, null, 'DRAFT', 'GENERATE', input.note ?? '', {
|
|
|
|
|
cleanerUserId: input.cleanerUserId,
|
|
|
|
|
taskCount: tasks.length,
|
|
|
|
|
totalRewardCents
|
|
|
|
|
}
|
|
|
|
|
);
|
|
|
|
|
for (const task of tasks) {
|
|
|
|
|
await this.recordEventWithConnection(
|
|
|
|
|
connection, input, String(task.id), 'COMPLETED', 'COMPLETED', 'SETTLE_MEMBER', settlementNo
|
|
|
|
|
connection, input, String(task.id), 'COMPLETED', 'COMPLETED', 'ALLOCATE_SETTLEMENT', settlementNo
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
return this.getSettlement(connection, input.tenantId, settlementId);
|
|
|
|
|
});
|
|
|
|
|
return this.getSettlement(connection, input.tenantId, settlementId);
|
|
|
|
|
});
|
|
|
|
|
} catch (error) {
|
|
|
|
|
if (isMySqlSettlementContention(error)) {
|
|
|
|
|
throw new CleaningTaskError('CLEANING_SETTLEMENT_CONCURRENTLY_CLAIMED');
|
|
|
|
|
}
|
|
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async confirmSettlement(input: CleaningActor & { settlementId: string; note?: string }) {
|
|
|
|
@@ -879,12 +936,16 @@ export class CleaningTaskRepository {
|
|
|
|
|
[input.userId, input.note ?? '', input.note ?? '', input.tenantId, input.settlementId]
|
|
|
|
|
);
|
|
|
|
|
if (result.affectedRows !== 1) throw new CleaningTaskError('CLEANING_SETTLEMENT_STATUS_CONFLICT');
|
|
|
|
|
await this.recordSettlementEventWithConnection(
|
|
|
|
|
connection, input, input.settlementId, 'DRAFT', 'CONFIRMED', 'CONFIRM', input.note ?? ''
|
|
|
|
|
);
|
|
|
|
|
return this.getSettlement(connection, input.tenantId, input.settlementId);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async markSettlementPaid(input: CleaningActor & {
|
|
|
|
|
settlementId: string; payoutChannel: string; payoutReference: string; note?: string;
|
|
|
|
|
providerConfirmed?: boolean; manualOverride?: boolean;
|
|
|
|
|
}) {
|
|
|
|
|
this.assertSettlement(input.access, 'write');
|
|
|
|
|
return this.transaction(async (connection) => {
|
|
|
|
@@ -895,6 +956,11 @@ export class CleaningTaskRepository {
|
|
|
|
|
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 ${input.providerConfirmed
|
|
|
|
|
? "s.payout_state <> 'SUCCESS'"
|
|
|
|
|
: input.manualOverride
|
|
|
|
|
? "s.payout_state IN ('', 'FAIL')"
|
|
|
|
|
: '1 = 0'}
|
|
|
|
|
AND ${storeScopeSql(input.access, 'COALESCE(s.store_id, 0)')}`,
|
|
|
|
|
[
|
|
|
|
|
input.userId, input.payoutChannel, input.payoutReference,
|
|
|
|
@@ -902,12 +968,58 @@ export class CleaningTaskRepository {
|
|
|
|
|
]
|
|
|
|
|
);
|
|
|
|
|
if (result.affectedRows !== 1) throw new CleaningTaskError('CLEANING_SETTLEMENT_STATUS_CONFLICT');
|
|
|
|
|
await connection.execute(
|
|
|
|
|
`UPDATE qipai_cleaning_task_members m
|
|
|
|
|
INNER JOIN qipai_cleaning_settlement_items i
|
|
|
|
|
ON i.tenant_id = m.tenant_id AND i.task_id = m.task_id
|
|
|
|
|
AND i.cleaner_user_id = m.user_id AND i.reversed_at IS NULL
|
|
|
|
|
SET m.settled_at = UTC_TIMESTAMP(3), m.updated_at = UTC_TIMESTAMP(3)
|
|
|
|
|
WHERE i.tenant_id = ? AND i.settlement_id = ? AND m.removed_at IS NULL`,
|
|
|
|
|
[input.tenantId, input.settlementId]
|
|
|
|
|
);
|
|
|
|
|
const [paidTasks] = await connection.execute<Array<RowDataPacket & { id: string }>>(
|
|
|
|
|
`SELECT DISTINCT t.id
|
|
|
|
|
FROM qipai_cleaning_tasks t
|
|
|
|
|
INNER JOIN qipai_cleaning_settlement_items i
|
|
|
|
|
ON i.tenant_id = t.tenant_id AND i.task_id = t.id AND i.reversed_at IS NULL
|
|
|
|
|
WHERE i.tenant_id = ? AND i.settlement_id = ?
|
|
|
|
|
AND t.status = 'COMPLETED'
|
|
|
|
|
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
|
|
|
|
|
)
|
|
|
|
|
FOR UPDATE`,
|
|
|
|
|
[input.tenantId, input.settlementId]
|
|
|
|
|
);
|
|
|
|
|
if (paidTasks.length > 0) {
|
|
|
|
|
await connection.execute(
|
|
|
|
|
`UPDATE qipai_cleaning_tasks
|
|
|
|
|
SET status = 'SETTLED', settled_at = UTC_TIMESTAMP(3)
|
|
|
|
|
WHERE tenant_id = ? AND status = 'COMPLETED'
|
|
|
|
|
AND id IN (${paidTasks.map(() => '?').join(',')})`,
|
|
|
|
|
[input.tenantId, ...paidTasks.map((task) => task.id)]
|
|
|
|
|
);
|
|
|
|
|
for (const task of paidTasks) {
|
|
|
|
|
await this.recordEventWithConnection(
|
|
|
|
|
connection, input, String(task.id), 'COMPLETED', 'SETTLED', 'SETTLEMENT_PAID', input.payoutReference
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
await this.recordSettlementEventWithConnection(
|
|
|
|
|
connection, input, input.settlementId, 'CONFIRMED', 'PAID', 'PAID', input.note ?? '', {
|
|
|
|
|
payoutChannel: input.payoutChannel,
|
|
|
|
|
payoutReference: input.payoutReference
|
|
|
|
|
}
|
|
|
|
|
);
|
|
|
|
|
return this.getSettlement(connection, input.tenantId, input.settlementId);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async recordSettlementPayoutFailure(input: CleaningActor & {
|
|
|
|
|
settlementId: string; payoutChannel?: string; payoutReference?: string; error: string; note?: string;
|
|
|
|
|
settlementId: string; payoutChannel?: string; payoutReference?: string;
|
|
|
|
|
error: string; note?: string; providerConfirmed?: boolean;
|
|
|
|
|
}) {
|
|
|
|
|
this.assertSettlement(input.access, 'write');
|
|
|
|
|
return this.transaction(async (connection) => {
|
|
|
|
@@ -918,6 +1030,9 @@ export class CleaningTaskRepository {
|
|
|
|
|
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 ${input.providerConfirmed
|
|
|
|
|
? "s.payout_state <> 'SUCCESS'"
|
|
|
|
|
: "s.payout_state IN ('', 'FAIL')"}
|
|
|
|
|
AND ${storeScopeSql(input.access, 'COALESCE(s.store_id, 0)')}`,
|
|
|
|
|
[
|
|
|
|
|
input.payoutChannel ?? '', input.payoutChannel ?? '',
|
|
|
|
@@ -927,6 +1042,47 @@ export class CleaningTaskRepository {
|
|
|
|
|
]
|
|
|
|
|
);
|
|
|
|
|
if (result.affectedRows !== 1) throw new CleaningTaskError('CLEANING_SETTLEMENT_STATUS_CONFLICT');
|
|
|
|
|
await this.recordSettlementEventWithConnection(
|
|
|
|
|
connection, input, input.settlementId, 'CONFIRMED', 'CONFIRMED', 'PAYOUT_FAILURE',
|
|
|
|
|
input.note ?? input.error, {
|
|
|
|
|
payoutChannel: input.payoutChannel ?? '',
|
|
|
|
|
payoutReference: input.payoutReference ?? '',
|
|
|
|
|
error: input.error.slice(0, 512)
|
|
|
|
|
}
|
|
|
|
|
);
|
|
|
|
|
return this.getSettlement(connection, input.tenantId, input.settlementId);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async beginSettlementPayout(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.payout_channel = ?, s.payout_request_no = ?, s.payout_reference = ?,
|
|
|
|
|
s.payout_state = 'PROCESSING',
|
|
|
|
|
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.payout_state IN ('', 'FAIL') AND s.deleted_at IS NULL
|
|
|
|
|
AND ${storeScopeSql(input.access, 'COALESCE(s.store_id, 0)')}`,
|
|
|
|
|
[input.payoutChannel, input.payoutReference, input.payoutReference,
|
|
|
|
|
input.note ?? '', input.note ?? '',
|
|
|
|
|
input.tenantId, input.settlementId]
|
|
|
|
|
);
|
|
|
|
|
if (result.affectedRows !== 1) {
|
|
|
|
|
throw new CleaningTaskError('CLEANING_SETTLEMENT_PAYOUT_ALREADY_STARTED');
|
|
|
|
|
}
|
|
|
|
|
await this.recordSettlementEventWithConnection(
|
|
|
|
|
connection, input, input.settlementId, 'CONFIRMED', 'CONFIRMED', 'PAYOUT_BEGIN',
|
|
|
|
|
input.note ?? '', {
|
|
|
|
|
payoutChannel: input.payoutChannel,
|
|
|
|
|
payoutReference: input.payoutReference,
|
|
|
|
|
payoutState: 'PROCESSING'
|
|
|
|
|
}
|
|
|
|
|
);
|
|
|
|
|
return this.getSettlement(connection, input.tenantId, input.settlementId);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
@@ -943,6 +1099,7 @@ export class CleaningTaskRepository {
|
|
|
|
|
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 s.payout_state IN ('PROCESSING', 'WAIT_USER_CONFIRM')
|
|
|
|
|
AND ${storeScopeSql(input.access, 'COALESCE(s.store_id, 0)')}`,
|
|
|
|
|
[
|
|
|
|
|
input.payoutChannel, input.payoutReference, input.payoutState.slice(0, 32),
|
|
|
|
@@ -951,6 +1108,105 @@ export class CleaningTaskRepository {
|
|
|
|
|
]
|
|
|
|
|
);
|
|
|
|
|
if (result.affectedRows !== 1) throw new CleaningTaskError('CLEANING_SETTLEMENT_STATUS_CONFLICT');
|
|
|
|
|
await this.recordSettlementEventWithConnection(
|
|
|
|
|
connection, input, input.settlementId, 'CONFIRMED', 'CONFIRMED', 'PAYOUT_PENDING',
|
|
|
|
|
input.note ?? '', {
|
|
|
|
|
payoutChannel: input.payoutChannel,
|
|
|
|
|
payoutReference: input.payoutReference,
|
|
|
|
|
payoutState: input.payoutState.slice(0, 32)
|
|
|
|
|
}
|
|
|
|
|
);
|
|
|
|
|
return this.getSettlement(connection, input.tenantId, input.settlementId);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async cancelSettlement(input: CleaningActor & { settlementId: string; reason: string }) {
|
|
|
|
|
this.assertSettlement(input.access, 'write');
|
|
|
|
|
return this.transaction(async (connection) => {
|
|
|
|
|
const [rows] = await connection.execute<Array<RowDataPacket & {
|
|
|
|
|
status: CleaningSettlementStatus;
|
|
|
|
|
payoutState: string;
|
|
|
|
|
totalRewardCents: number;
|
|
|
|
|
settlementNo: string;
|
|
|
|
|
}>>(
|
|
|
|
|
`SELECT s.status, s.payout_state AS payoutState,
|
|
|
|
|
s.total_reward_cents AS totalRewardCents, s.settlement_no AS settlementNo
|
|
|
|
|
FROM qipai_cleaning_settlements s
|
|
|
|
|
WHERE s.tenant_id = ? AND s.id = ? AND s.deleted_at IS NULL
|
|
|
|
|
AND ${storeScopeSql(input.access, 'COALESCE(s.store_id, 0)')}
|
|
|
|
|
FOR UPDATE`,
|
|
|
|
|
[input.tenantId, input.settlementId]
|
|
|
|
|
);
|
|
|
|
|
const settlement = rows[0];
|
|
|
|
|
if (!settlement) throw new CleaningTaskError('CLEANING_SETTLEMENT_NOT_FOUND');
|
|
|
|
|
if (!['DRAFT', 'CONFIRMED'].includes(settlement.status)) {
|
|
|
|
|
throw new CleaningTaskError('CLEANING_SETTLEMENT_STATUS_CONFLICT');
|
|
|
|
|
}
|
|
|
|
|
if (['PROCESSING', 'WAIT_USER_CONFIRM', 'SUCCESS'].includes(settlement.payoutState)) {
|
|
|
|
|
throw new CleaningTaskError('CLEANING_SETTLEMENT_PAYOUT_PENDING');
|
|
|
|
|
}
|
|
|
|
|
const reversalNo = `CLR-${Date.now()}-${input.settlementId}-${input.traceId.slice(-8)}`;
|
|
|
|
|
const [created] = await connection.execute<ResultSetHeader>(
|
|
|
|
|
`INSERT INTO qipai_cleaning_settlement_reversals
|
|
|
|
|
(tenant_id, reversal_no, settlement_id, amount_delta_cents, reason, reversed_by)
|
|
|
|
|
VALUES (?, ?, ?, ?, ?, ?)`,
|
|
|
|
|
[input.tenantId, reversalNo, input.settlementId,
|
|
|
|
|
-Number(settlement.totalRewardCents), input.reason, input.userId]
|
|
|
|
|
);
|
|
|
|
|
const reversalId = String(created.insertId);
|
|
|
|
|
const [items] = await connection.execute<Array<RowDataPacket & { taskId: string }>>(
|
|
|
|
|
`SELECT i.task_id AS taskId
|
|
|
|
|
FROM qipai_cleaning_settlement_items i
|
|
|
|
|
WHERE i.tenant_id = ? AND i.settlement_id = ? AND i.reversed_at IS NULL
|
|
|
|
|
FOR UPDATE`,
|
|
|
|
|
[input.tenantId, input.settlementId]
|
|
|
|
|
);
|
|
|
|
|
if (items.length === 0) throw new CleaningTaskError('CLEANING_SETTLEMENT_STATUS_CONFLICT');
|
|
|
|
|
await connection.execute(
|
|
|
|
|
`UPDATE qipai_cleaning_settlement_items
|
|
|
|
|
SET reversal_id = ?, reversed_at = UTC_TIMESTAMP(3)
|
|
|
|
|
WHERE tenant_id = ? AND settlement_id = ? AND reversed_at IS NULL`,
|
|
|
|
|
[reversalId, input.tenantId, input.settlementId]
|
|
|
|
|
);
|
|
|
|
|
await connection.execute(
|
|
|
|
|
`UPDATE qipai_cleaning_task_members m
|
|
|
|
|
INNER JOIN qipai_cleaning_settlement_items i
|
|
|
|
|
ON i.tenant_id = m.tenant_id AND i.task_id = m.task_id
|
|
|
|
|
AND i.cleaner_user_id = m.user_id
|
|
|
|
|
SET m.settled_at = NULL, m.updated_at = UTC_TIMESTAMP(3)
|
|
|
|
|
WHERE i.tenant_id = ? AND i.settlement_id = ? AND i.reversal_id = ?`,
|
|
|
|
|
[input.tenantId, input.settlementId, reversalId]
|
|
|
|
|
);
|
|
|
|
|
await connection.execute(
|
|
|
|
|
`UPDATE qipai_cleaning_tasks t
|
|
|
|
|
INNER JOIN qipai_cleaning_settlement_items i
|
|
|
|
|
ON i.tenant_id = t.tenant_id AND i.task_id = t.id
|
|
|
|
|
SET t.status = 'COMPLETED', t.settled_at = NULL
|
|
|
|
|
WHERE i.tenant_id = ? AND i.settlement_id = ? AND i.reversal_id = ?
|
|
|
|
|
AND t.status IN ('COMPLETED', 'SETTLED')`,
|
|
|
|
|
[input.tenantId, input.settlementId, reversalId]
|
|
|
|
|
);
|
|
|
|
|
const [updated] = await connection.execute<ResultSetHeader>(
|
|
|
|
|
`UPDATE qipai_cleaning_settlements
|
|
|
|
|
SET status = 'CANCELLED', cancelled_by = ?, cancelled_at = UTC_TIMESTAMP(3),
|
|
|
|
|
note = CASE WHEN ? = '' THEN note ELSE ? END
|
|
|
|
|
WHERE tenant_id = ? AND id = ? AND status = ? AND deleted_at IS NULL`,
|
|
|
|
|
[input.userId, input.reason, input.reason, input.tenantId,
|
|
|
|
|
input.settlementId, settlement.status]
|
|
|
|
|
);
|
|
|
|
|
if (updated.affectedRows !== 1) throw new CleaningTaskError('CLEANING_SETTLEMENT_STATUS_CONFLICT');
|
|
|
|
|
await this.recordSettlementEventWithConnection(
|
|
|
|
|
connection, input, input.settlementId, settlement.status, 'CANCELLED', 'CANCEL', input.reason, {
|
|
|
|
|
reversalId,
|
|
|
|
|
reversalNo,
|
|
|
|
|
amountDeltaCents: -Number(settlement.totalRewardCents)
|
|
|
|
|
}
|
|
|
|
|
);
|
|
|
|
|
for (const item of items) {
|
|
|
|
|
await this.recordEventWithConnection(
|
|
|
|
|
connection, input, String(item.taskId), 'COMPLETED', 'COMPLETED', 'REVERSE_SETTLEMENT', reversalNo
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
return this.getSettlement(connection, input.tenantId, input.settlementId);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
@@ -1245,16 +1501,16 @@ export class CleaningTaskRepository {
|
|
|
|
|
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`,
|
|
|
|
|
AND t.status = 'COMPLETED' AND t.deleted_at IS NULL`,
|
|
|
|
|
[input.tenantId, input.userId]
|
|
|
|
|
);
|
|
|
|
|
const [settledRows] = 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 NOT NULL AND m.reward_cents > 0
|
|
|
|
|
AND t.deleted_at IS NULL`,
|
|
|
|
|
`SELECT COALESCE(SUM(i.reward_cents), 0) AS amount
|
|
|
|
|
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
|
|
|
|
|
WHERE i.tenant_id = ? AND i.cleaner_user_id = ? AND i.reversed_at IS NULL
|
|
|
|
|
AND s.status = 'PAID' AND s.deleted_at IS NULL`,
|
|
|
|
|
[input.tenantId, input.userId]
|
|
|
|
|
);
|
|
|
|
|
const byStatus = Object.fromEntries(counts.map((row) => [row.status, Number(row.total)]));
|
|
|
|
@@ -1368,10 +1624,21 @@ export class CleaningTaskRepository {
|
|
|
|
|
SUM(CASE WHEN t.status IN ('COMPLETED', 'SETTLED') THEN 1 ELSE 0 END) AS completedTaskCount,
|
|
|
|
|
SUM(CASE WHEN t.status = 'REJECTED' THEN 1 ELSE 0 END) AS rejectedTaskCount,
|
|
|
|
|
COALESCE(SUM(CASE
|
|
|
|
|
WHEN m.removed_at IS NULL AND m.settled_at IS NULL AND t.status IN ('SUBMITTED', 'COMPLETED')
|
|
|
|
|
WHEN m.removed_at IS NULL AND m.settled_at IS NULL AND t.status = '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
|
|
|
|
|
COALESCE(SUM(COALESCE((
|
|
|
|
|
SELECT MAX(paid_item.reward_cents)
|
|
|
|
|
FROM qipai_cleaning_settlement_items paid_item
|
|
|
|
|
INNER JOIN qipai_cleaning_settlements paid_settlement
|
|
|
|
|
ON paid_settlement.tenant_id = paid_item.tenant_id
|
|
|
|
|
AND paid_settlement.id = paid_item.settlement_id
|
|
|
|
|
WHERE paid_item.tenant_id = m.tenant_id
|
|
|
|
|
AND paid_item.task_id = m.task_id
|
|
|
|
|
AND paid_item.cleaner_user_id = m.user_id
|
|
|
|
|
AND paid_item.reversed_at IS NULL
|
|
|
|
|
AND paid_settlement.status = 'PAID'
|
|
|
|
|
AND paid_settlement.deleted_at IS NULL
|
|
|
|
|
), 0)), 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
|
|
|
|
@@ -1414,6 +1681,66 @@ export class CleaningTaskRepository {
|
|
|
|
|
return publicManagerStatistics(statusRows, storeRows, settlementRows, memberRows, trendRows);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async querySettlementRows(input: CleaningActor & {
|
|
|
|
|
status?: CleaningSettlementStatus; payoutState?: CleaningSettlementPayoutState;
|
|
|
|
|
storeId?: string; cleanerUserId?: string;
|
|
|
|
|
}, limit: number, offset: number) {
|
|
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
if (input.payoutState) {
|
|
|
|
|
if (input.payoutState === 'NONE') where.push("s.payout_state = ''");
|
|
|
|
|
else {
|
|
|
|
|
where.push('s.payout_state = ?');
|
|
|
|
|
params.push(input.payoutState);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (input.storeId) {
|
|
|
|
|
where.push('s.store_id = ?');
|
|
|
|
|
params.push(input.storeId);
|
|
|
|
|
}
|
|
|
|
|
if (input.cleanerUserId) {
|
|
|
|
|
where.push('s.cleaner_user_id = ?');
|
|
|
|
|
params.push(input.cleanerUserId);
|
|
|
|
|
}
|
|
|
|
|
const whereSql = where.join(' AND ');
|
|
|
|
|
const safeLimit = Math.max(1, Math.min(5000, Math.trunc(limit)));
|
|
|
|
|
const safeOffset = Math.max(0, Math.trunc(offset));
|
|
|
|
|
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.cancelled_by AS cancelledBy,
|
|
|
|
|
s.confirmed_at AS confirmedAt, s.paid_at AS paidAt,
|
|
|
|
|
s.cancelled_at AS cancelledAt,
|
|
|
|
|
s.payout_channel AS payoutChannel, s.payout_request_no AS payoutRequestNo,
|
|
|
|
|
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 ${safeLimit} OFFSET ${safeOffset}`,
|
|
|
|
|
params
|
|
|
|
|
);
|
|
|
|
|
return { rows, total: Number(counts[0]?.total ?? 0) };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async listByWhere(
|
|
|
|
|
where: string[],
|
|
|
|
|
params: Array<string | number>,
|
|
|
|
@@ -1615,6 +1942,21 @@ export class CleaningTaskRepository {
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async assertTaskSettlementMutable(
|
|
|
|
|
connection: PoolConnection,
|
|
|
|
|
tenantId: string,
|
|
|
|
|
taskId: string
|
|
|
|
|
) {
|
|
|
|
|
const [rows] = await connection.execute<Array<RowDataPacket & { id: string }>>(
|
|
|
|
|
`SELECT id
|
|
|
|
|
FROM qipai_cleaning_settlement_items
|
|
|
|
|
WHERE tenant_id = ? AND task_id = ? AND reversed_at IS NULL
|
|
|
|
|
LIMIT 1 FOR UPDATE`,
|
|
|
|
|
[tenantId, taskId]
|
|
|
|
|
);
|
|
|
|
|
if (rows[0]) throw new CleaningTaskError('CLEANING_MEMBER_SETTLEMENT_LOCKED');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async getTemplate(
|
|
|
|
|
connection: Pick<MySqlPool, 'execute'> | PoolConnection,
|
|
|
|
|
tenantId: string,
|
|
|
|
@@ -1718,9 +2060,31 @@ export class CleaningTaskRepository {
|
|
|
|
|
await connection.execute(
|
|
|
|
|
`INSERT INTO qipai_cleaning_task_events
|
|
|
|
|
(tenant_id, task_id, from_status, to_status, action, actor_id, trace_id, note, metadata)
|
|
|
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, JSON_OBJECT())`,
|
|
|
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, JSON_OBJECT('actorType', ?))`,
|
|
|
|
|
[input.tenantId, taskId, from, to, action, input.userId,
|
|
|
|
|
`${input.traceId}-${taskId}`.slice(0, 128), note.slice(0, 512)]
|
|
|
|
|
`${input.traceId}-${taskId}`.slice(0, 128), note.slice(0, 512),
|
|
|
|
|
input.actorType ?? 'USER']
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async recordSettlementEventWithConnection(
|
|
|
|
|
connection: PoolConnection,
|
|
|
|
|
input: CleaningActor,
|
|
|
|
|
settlementId: string,
|
|
|
|
|
from: CleaningSettlementStatus | null,
|
|
|
|
|
to: CleaningSettlementStatus,
|
|
|
|
|
action: string,
|
|
|
|
|
note: string,
|
|
|
|
|
metadata: Record<string, unknown> = {}
|
|
|
|
|
) {
|
|
|
|
|
await connection.execute(
|
|
|
|
|
`INSERT INTO qipai_cleaning_settlement_events
|
|
|
|
|
(tenant_id, settlement_id, from_status, to_status, action,
|
|
|
|
|
actor_id, actor_type, trace_id, note, metadata)
|
|
|
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, CAST(? AS JSON))`,
|
|
|
|
|
[input.tenantId, settlementId, from, to, action.slice(0, 32), input.userId,
|
|
|
|
|
input.actorType ?? 'USER', input.traceId.slice(0, 64), note.slice(0, 512),
|
|
|
|
|
JSON.stringify(metadata)]
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
@@ -1753,8 +2117,11 @@ export class CleaningTaskRepository {
|
|
|
|
|
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.paid_by AS paidBy, s.cancelled_by AS cancelledBy,
|
|
|
|
|
s.confirmed_at AS confirmedAt, s.paid_at AS paidAt,
|
|
|
|
|
s.cancelled_at AS cancelledAt,
|
|
|
|
|
s.payout_channel AS payoutChannel, s.payout_request_no AS payoutRequestNo,
|
|
|
|
|
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
|
|
|
|
@@ -1775,8 +2142,11 @@ export class CleaningTaskRepository {
|
|
|
|
|
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.paid_by AS paidBy, s.cancelled_by AS cancelledBy,
|
|
|
|
|
s.confirmed_at AS confirmedAt, s.paid_at AS paidAt,
|
|
|
|
|
s.cancelled_at AS cancelledAt,
|
|
|
|
|
s.payout_channel AS payoutChannel, s.payout_request_no AS payoutRequestNo,
|
|
|
|
|
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
|
|
|
|
@@ -1899,6 +2269,7 @@ function publicTaskEvent(row: CleaningTaskEventRow) {
|
|
|
|
|
toStatus: row.toStatus,
|
|
|
|
|
action: row.action,
|
|
|
|
|
actorId: String(row.actorId),
|
|
|
|
|
actorType: row.actorType,
|
|
|
|
|
traceId: row.traceId,
|
|
|
|
|
note: row.note,
|
|
|
|
|
createdAt: row.createdAt
|
|
|
|
@@ -1935,9 +2306,12 @@ function publicSettlement(row: SettlementRow) {
|
|
|
|
|
periodStart: row.periodStart,
|
|
|
|
|
periodEnd: row.periodEnd,
|
|
|
|
|
paidBy: row.paidBy === null ? null : String(row.paidBy),
|
|
|
|
|
cancelledBy: row.cancelledBy === null ? null : String(row.cancelledBy),
|
|
|
|
|
confirmedAt: row.confirmedAt,
|
|
|
|
|
paidAt: row.paidAt,
|
|
|
|
|
cancelledAt: row.cancelledAt,
|
|
|
|
|
payoutChannel: row.payoutChannel,
|
|
|
|
|
payoutRequestNo: row.payoutRequestNo,
|
|
|
|
|
payoutReference: row.payoutReference,
|
|
|
|
|
payoutState: row.payoutState,
|
|
|
|
|
payoutPackageInfo: row.payoutPackageInfo,
|
|
|
|
@@ -1960,11 +2334,38 @@ function publicSettlementItem(row: SettlementItemRow) {
|
|
|
|
|
cleanerUserId: String(row.cleanerUserId),
|
|
|
|
|
cleanerName: row.cleanerName,
|
|
|
|
|
rewardCents: Number(row.rewardCents),
|
|
|
|
|
reversedAt: row.reversedAt,
|
|
|
|
|
completedAt: row.completedAt,
|
|
|
|
|
createdAt: row.createdAt
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function publicSettlementEvent(row: SettlementEventRow) {
|
|
|
|
|
return {
|
|
|
|
|
id: String(row.id),
|
|
|
|
|
settlementId: String(row.settlementId),
|
|
|
|
|
fromStatus: row.fromStatus,
|
|
|
|
|
toStatus: row.toStatus,
|
|
|
|
|
action: row.action,
|
|
|
|
|
actorId: String(row.actorId),
|
|
|
|
|
traceId: row.traceId,
|
|
|
|
|
note: row.note,
|
|
|
|
|
createdAt: row.createdAt
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function publicSettlementReversal(row: SettlementReversalRow) {
|
|
|
|
|
return {
|
|
|
|
|
id: String(row.id),
|
|
|
|
|
settlementId: String(row.settlementId),
|
|
|
|
|
reversalNo: row.reversalNo,
|
|
|
|
|
amountDeltaCents: Number(row.amountDeltaCents),
|
|
|
|
|
reason: row.reason,
|
|
|
|
|
reversedBy: String(row.reversedBy),
|
|
|
|
|
createdAt: row.createdAt
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function publicManagerStatistics(
|
|
|
|
|
statusRows: ManagerStatusStatsRow[],
|
|
|
|
|
storeRows: ManagerStoreStatsRow[],
|
|
|
|
@@ -1992,6 +2393,9 @@ function publicManagerStatistics(
|
|
|
|
|
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),
|
|
|
|
|
draftSettlementCents: settlementByStatus
|
|
|
|
|
.filter((row) => row.status === 'DRAFT')
|
|
|
|
|
.reduce((sum, row) => sum + row.rewardCents, 0),
|
|
|
|
|
confirmedSettlementCents: settlementByStatus
|
|
|
|
|
.filter((row) => row.status === 'CONFIRMED')
|
|
|
|
|
.reduce((sum, row) => sum + row.rewardCents, 0),
|
|
|
|
@@ -2057,6 +2461,17 @@ function maxDate(values: Array<Date | null>) {
|
|
|
|
|
return new Date(Math.max(...dates.map((date) => date.getTime())));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function isMySqlDuplicate(error: unknown) {
|
|
|
|
|
return typeof error === 'object' && error !== null
|
|
|
|
|
&& Reflect.get(error, 'code') === 'ER_DUP_ENTRY';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function isMySqlSettlementContention(error: unknown) {
|
|
|
|
|
if (isMySqlDuplicate(error)) return true;
|
|
|
|
|
if (typeof error !== 'object' || error === null) return false;
|
|
|
|
|
return ['ER_LOCK_DEADLOCK', 'ER_LOCK_WAIT_TIMEOUT'].includes(String(Reflect.get(error, 'code')));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function parseJsonArray(value: string | string[] | null): string[] {
|
|
|
|
|
if (Array.isArray(value)) return value.map(String);
|
|
|
|
|
if (!value) return [];
|
|
|
|
|