feat(M08-B): 补后台协作与结算详情

This commit is contained in:
Codex
2026-06-30 10:02:17 +08:00
parent 9506021b29
commit f59beb0e75
15 changed files with 529 additions and 23 deletions
@@ -96,6 +96,21 @@ interface SettlementRow extends RowDataPacket {
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) {}
@@ -370,6 +385,31 @@ export class CleaningTaskRepository {
};
}
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;
}) {
@@ -963,6 +1003,29 @@ export class CleaningTaskRepository {
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 {
@@ -1055,6 +1118,24 @@ function publicSettlement(row: SettlementRow) {
};
}
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;
+16 -1
View File
@@ -89,7 +89,7 @@ export interface CleaningRouteOptions {
repository: Pick<CleaningTaskRepository,
'listHall' | 'listMine' | 'listManage' | 'claim' | 'start' | 'rework' | 'submit'
| 'assign' | 'complete' | 'reject' | 'listMembers' | 'addMember' | 'removeMember' | 'settlementCandidates'
| 'listSettlements' | 'generateSettlement' | 'confirmSettlement' | 'markSettlementPaid'
| 'listSettlements' | 'getSettlementDetail' | 'generateSettlement' | 'confirmSettlement' | 'markSettlementPaid'
| 'recordSettlementPayoutFailure' | 'reclaimTimeouts'
| 'assertCanUploadPhoto' | 'stats'>;
mediaStorage?: MediaStorage;
@@ -183,6 +183,21 @@ export async function registerCleaningRoutes(
}));
});
app.get('/admin-api/cleaning/settlements/:settlementId', async (request, reply) => {
const actor = await requireActor(request, reply, options, 'read');
if (!actor) return;
const params = settlementParamsSchema.safeParse(request.params);
if (!params.success) return invalid(reply, request.traceId);
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.getSettlementDetail({
...actor,
settlementId: params.data.settlementId
}),
traceId: request.traceId
}));
});
app.post('/app-api/cleaning/tasks/:taskId/claim', async (request, reply) => {
const actor = await requireActor(request, reply, options, 'write');
if (!actor) return;