feat(M08-B): 补保洁统计明细
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -156,6 +156,40 @@ const app = await buildApp({
|
||||
async stats(input) {
|
||||
calls.push(['stats', input]);
|
||||
return { byStatus: { SUBMITTED: 2 }, pendingSettlementCents: 1200 };
|
||||
},
|
||||
async managerStatistics(input) {
|
||||
calls.push(['managerStatistics', input]);
|
||||
return {
|
||||
summary: {
|
||||
taskTotal: 5,
|
||||
pendingReview: 2,
|
||||
active: 1,
|
||||
rejected: 1,
|
||||
completed: 1,
|
||||
rewardCents: 3000,
|
||||
pendingSettlementCents: 1200,
|
||||
confirmedSettlementCents: 800,
|
||||
paidSettlementCents: 600
|
||||
},
|
||||
byStatus: [{ status: 'SUBMITTED', total: 2, rewardCents: 1200 }],
|
||||
byStore: [{
|
||||
storeId: '11',
|
||||
storeName: 'Test Store',
|
||||
taskTotal: 5,
|
||||
pendingReview: 2,
|
||||
completed: 1,
|
||||
rejected: 1,
|
||||
rewardCents: 3000
|
||||
}],
|
||||
settlements: [{ status: 'CONFIRMED', total: 1, rewardCents: 800 }],
|
||||
members: [{
|
||||
cleanerUserId: '31',
|
||||
cleanerName: 'Cleaner',
|
||||
taskCount: 3,
|
||||
pendingSettlementCents: 1200,
|
||||
settledRewardCents: 600
|
||||
}]
|
||||
};
|
||||
}
|
||||
},
|
||||
payoutService: {
|
||||
@@ -248,6 +282,16 @@ assert.equal(manageList.statusCode, 200);
|
||||
assert.equal(calls.at(-1)[0], 'listManage');
|
||||
assert.equal(calls.at(-1)[1].status, 'SUBMITTED');
|
||||
|
||||
const managerStats = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/admin-api/cleaning/statistics?from=2026-06-01&to=2026-07-01&storeId=11',
|
||||
headers: { authorization: `Bearer ${token}` }
|
||||
});
|
||||
assert.equal(managerStats.statusCode, 200);
|
||||
assert.equal(calls.at(-1)[0], 'managerStatistics');
|
||||
assert.equal(calls.at(-1)[1].storeId, '11');
|
||||
assert.equal(managerStats.json().data.summary.pendingSettlementCents, 1200);
|
||||
|
||||
const claim = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/app-api/cleaning/tasks/101/claim',
|
||||
|
||||
Reference in New Issue
Block a user