feat(M08-B): 补保洁统计明细

This commit is contained in:
Codex
2026-06-30 10:24:39 +08:00
parent f59beb0e75
commit e48d1f0301
15 changed files with 616 additions and 46 deletions
@@ -111,6 +111,32 @@ interface SettlementItemRow extends RowDataPacket {
completedAt: Date | null;
createdAt: Date;
}
interface ManagerStatusStatsRow extends RowDataPacket {
status: CleaningTaskStatus;
total: number;
rewardCents: number | null;
}
interface ManagerStoreStatsRow extends RowDataPacket {
storeId: string;
storeName: string;
taskTotal: number;
pendingReview: number;
completed: number;
rejected: number;
rewardCents: number | null;
}
interface ManagerSettlementStatsRow extends RowDataPacket {
status: CleaningSettlementStatus;
total: number;
rewardCents: number | null;
}
interface ManagerMemberStatsRow extends RowDataPacket {
cleanerUserId: string;
cleanerName: string;
taskCount: number;
pendingSettlementCents: number | null;
settledRewardCents: number | null;
}
export class CleaningTaskRepository {
constructor(private readonly pool: MySqlPool) {}
@@ -714,6 +740,99 @@ export class CleaningTaskRepository {
};
}
async managerStatistics(input: CleaningActor & { from?: string; to?: string; storeId?: string }) {
this.assertCleaner(input.access, 'read');
this.assertSettlement(input.access, 'read');
const taskWhere = [
't.tenant_id = ?',
't.deleted_at IS NULL',
storeScopeSql(input.access, 't.store_id')
];
const taskParams: Array<string | number> = [input.tenantId];
if (input.storeId) {
taskWhere.push('t.store_id = ?');
taskParams.push(input.storeId);
}
if (input.from) {
taskWhere.push('t.created_at >= ?');
taskParams.push(input.from);
}
if (input.to) {
taskWhere.push('t.created_at < ?');
taskParams.push(input.to);
}
const taskWhereSql = taskWhere.join(' AND ');
const [statusRows] = await this.pool.execute<ManagerStatusStatsRow[]>(
`SELECT t.status, COUNT(*) AS total, COALESCE(SUM(t.reward_cents), 0) AS rewardCents
FROM qipai_cleaning_tasks t
WHERE ${taskWhereSql}
GROUP BY t.status
ORDER BY FIELD(t.status, 'WAITING', 'CLAIMED', 'STARTED', 'SUBMITTED', 'REJECTED',
'COMPLETED', 'SETTLED', 'EXEMPT', 'CANCELLED')`,
taskParams
);
const [storeRows] = await this.pool.execute<ManagerStoreStatsRow[]>(
`SELECT t.store_id AS storeId, s.name AS storeName,
COUNT(*) AS taskTotal,
SUM(CASE WHEN t.status = 'SUBMITTED' THEN 1 ELSE 0 END) AS pendingReview,
SUM(CASE WHEN t.status IN ('COMPLETED', 'SETTLED') THEN 1 ELSE 0 END) AS completed,
SUM(CASE WHEN t.status = 'REJECTED' THEN 1 ELSE 0 END) AS rejected,
COALESCE(SUM(t.reward_cents), 0) AS rewardCents
FROM qipai_cleaning_tasks t
INNER JOIN qipai_stores s ON s.tenant_id = t.tenant_id AND s.id = t.store_id
WHERE ${taskWhereSql}
GROUP BY t.store_id, s.name
ORDER BY pendingReview DESC, taskTotal DESC, t.store_id ASC
LIMIT 20`,
taskParams
);
const settlementWhere = [
's.tenant_id = ?',
's.deleted_at IS NULL',
storeScopeSql(input.access, 'COALESCE(s.store_id, 0)')
];
const settlementParams: Array<string | number> = [input.tenantId];
if (input.storeId) {
settlementWhere.push('s.store_id = ?');
settlementParams.push(input.storeId);
}
if (input.from) {
settlementWhere.push('s.created_at >= ?');
settlementParams.push(input.from);
}
if (input.to) {
settlementWhere.push('s.created_at < ?');
settlementParams.push(input.to);
}
const settlementWhereSql = settlementWhere.join(' AND ');
const [settlementRows] = await this.pool.execute<ManagerSettlementStatsRow[]>(
`SELECT s.status, COUNT(*) AS total, COALESCE(SUM(s.total_reward_cents), 0) AS rewardCents
FROM qipai_cleaning_settlements s
WHERE ${settlementWhereSql}
GROUP BY s.status
ORDER BY FIELD(s.status, 'DRAFT', 'CONFIRMED', 'PAID', 'CANCELLED')`,
settlementParams
);
const [memberRows] = await this.pool.execute<ManagerMemberStatsRow[]>(
`SELECT m.user_id AS cleanerUserId, u.nickname AS cleanerName,
COUNT(DISTINCT m.task_id) AS taskCount,
COALESCE(SUM(CASE
WHEN m.removed_at IS NULL AND m.settled_at IS NULL AND t.status IN ('SUBMITTED', 'COMPLETED')
THEN m.reward_cents ELSE 0 END), 0) AS pendingSettlementCents,
COALESCE(SUM(CASE
WHEN m.settled_at IS NOT NULL THEN m.reward_cents ELSE 0 END), 0) AS settledRewardCents
FROM qipai_cleaning_task_members m
INNER JOIN qipai_cleaning_tasks t ON t.tenant_id = m.tenant_id AND t.id = m.task_id
INNER JOIN qipai_users u ON u.tenant_id = m.tenant_id AND u.id = m.user_id
WHERE ${taskWhereSql}
GROUP BY m.user_id, u.nickname
ORDER BY pendingSettlementCents DESC, settledRewardCents DESC, taskCount DESC
LIMIT 20`,
taskParams
);
return publicManagerStatistics(statusRows, storeRows, settlementRows, memberRows);
}
private async listByWhere(
where: string[],
params: Array<string | number>,
@@ -1136,6 +1255,66 @@ function publicSettlementItem(row: SettlementItemRow) {
};
}
function publicManagerStatistics(
statusRows: ManagerStatusStatsRow[],
storeRows: ManagerStoreStatsRow[],
settlementRows: ManagerSettlementStatsRow[],
memberRows: ManagerMemberStatsRow[]
) {
const byStatus = statusRows.map((row) => ({
status: row.status,
total: Number(row.total),
rewardCents: Number(row.rewardCents ?? 0)
}));
const settlementByStatus = settlementRows.map((row) => ({
status: row.status,
total: Number(row.total),
rewardCents: Number(row.rewardCents ?? 0)
}));
return {
summary: {
taskTotal: byStatus.reduce((sum, row) => sum + row.total, 0),
pendingReview: countStatus(byStatus, 'SUBMITTED'),
active: countStatus(byStatus, 'WAITING') + countStatus(byStatus, 'CLAIMED') + countStatus(byStatus, 'STARTED'),
rejected: countStatus(byStatus, 'REJECTED'),
completed: countStatus(byStatus, 'COMPLETED') + countStatus(byStatus, 'SETTLED'),
rewardCents: byStatus.reduce((sum, row) => sum + row.rewardCents, 0),
pendingSettlementCents: memberRows.reduce((sum, row) => sum + Number(row.pendingSettlementCents ?? 0), 0),
confirmedSettlementCents: settlementByStatus
.filter((row) => row.status === 'CONFIRMED')
.reduce((sum, row) => sum + row.rewardCents, 0),
paidSettlementCents: settlementByStatus
.filter((row) => row.status === 'PAID')
.reduce((sum, row) => sum + row.rewardCents, 0)
},
byStatus,
byStore: storeRows.map((row) => ({
storeId: String(row.storeId),
storeName: row.storeName,
taskTotal: Number(row.taskTotal),
pendingReview: Number(row.pendingReview),
completed: Number(row.completed),
rejected: Number(row.rejected),
rewardCents: Number(row.rewardCents ?? 0)
})),
settlements: settlementByStatus,
members: memberRows.map((row) => ({
cleanerUserId: String(row.cleanerUserId),
cleanerName: row.cleanerName,
taskCount: Number(row.taskCount),
pendingSettlementCents: Number(row.pendingSettlementCents ?? 0),
settledRewardCents: Number(row.settledRewardCents ?? 0)
}))
};
}
function countStatus(
rows: Array<{ status: CleaningTaskStatus; total: number }>,
status: CleaningTaskStatus
) {
return rows.find((row) => row.status === status)?.total ?? 0;
}
function minDate(values: Array<Date | null>) {
const dates = values.filter((value): value is Date => value instanceof Date);
if (dates.length === 0) return null;
+18 -1
View File
@@ -80,6 +80,11 @@ const settlementWechatTransferSchema = z.object({
const settlementWechatSyncSchema = z.object({
note: z.string().trim().max(512).optional()
}).strict();
const statisticsSchema = z.object({
from: z.string().regex(/^\d{4}-\d{2}-\d{2}(?:[ T]\d{2}:\d{2}:\d{2})?$/).optional(),
to: z.string().regex(/^\d{4}-\d{2}-\d{2}(?:[ T]\d{2}:\d{2}:\d{2})?$/).optional(),
storeId: z.string().regex(/^[1-9]\d{0,19}$/).optional()
}).strict();
const reclaimSchema = z.object({
olderThanMinutes: z.coerce.number().int().min(5).max(1440).default(60),
limit: z.coerce.number().int().min(1).max(100).default(20)
@@ -91,7 +96,7 @@ export interface CleaningRouteOptions {
| 'assign' | 'complete' | 'reject' | 'listMembers' | 'addMember' | 'removeMember' | 'settlementCandidates'
| 'listSettlements' | 'getSettlementDetail' | 'generateSettlement' | 'confirmSettlement' | 'markSettlementPaid'
| 'recordSettlementPayoutFailure' | 'reclaimTimeouts'
| 'assertCanUploadPhoto' | 'stats'>;
| 'assertCanUploadPhoto' | 'stats' | 'managerStatistics'>;
mediaStorage?: MediaStorage;
payoutService?: Pick<CleaningPayoutService,
'preflightWechatTransfer' | 'executeWechatTransfer' | 'syncWechatTransfer'
@@ -159,6 +164,18 @@ export async function registerCleaningRoutes(
}));
});
app.get('/admin-api/cleaning/statistics', async (request, reply) => {
const actor = await requireActor(request, reply, options, 'read');
if (!actor) return;
const query = statisticsSchema.safeParse(request.query);
if (!query.success) return invalid(reply, request.traceId);
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.managerStatistics({ ...actor, ...query.data }),
traceId: request.traceId
}));
});
app.get('/admin-api/cleaning/settlement-candidates', async (request, reply) => {
const actor = await requireActor(request, reply, options, 'read');
if (!actor) return;