feat(M08-B): 补后台协作与结算详情
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -114,6 +114,13 @@ const app = await buildApp({
|
||||
calls.push(['listSettlements', input]);
|
||||
return { items: [settlementResponse(input.status || 'DRAFT')], total: 1, page: input.page, pageSize: input.pageSize };
|
||||
},
|
||||
async getSettlementDetail(input) {
|
||||
calls.push(['getSettlementDetail', input]);
|
||||
return {
|
||||
settlement: settlementResponse('DRAFT'),
|
||||
items: [settlementItemResponse()]
|
||||
};
|
||||
},
|
||||
async generateSettlement(input) {
|
||||
calls.push(['generateSettlement', input]);
|
||||
return settlementResponse('DRAFT');
|
||||
@@ -367,6 +374,15 @@ assert.equal(settlementList.statusCode, 200);
|
||||
assert.equal(calls.at(-1)[0], 'listSettlements');
|
||||
assert.equal(calls.at(-1)[1].status, 'DRAFT');
|
||||
|
||||
const settlementDetail = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/admin-api/cleaning/settlements/501',
|
||||
headers: { authorization: `Bearer ${token}` }
|
||||
});
|
||||
assert.equal(settlementDetail.statusCode, 200);
|
||||
assert.equal(calls.at(-1)[0], 'getSettlementDetail');
|
||||
assert.equal(settlementDetail.json().data.items[0].taskNo, 'CLN-20260625-0001');
|
||||
|
||||
const generatedSettlement = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/admin-api/cleaning/settlements',
|
||||
@@ -542,6 +558,24 @@ function settlementResponse(status) {
|
||||
};
|
||||
}
|
||||
|
||||
function settlementItemResponse() {
|
||||
return {
|
||||
id: '601',
|
||||
settlementId: '501',
|
||||
taskId: '101',
|
||||
taskNo: 'CLN-20260625-0001',
|
||||
orderNo: 'O301',
|
||||
storeName: 'Test Store',
|
||||
roomName: 'A Room',
|
||||
roomNo: 'A01',
|
||||
cleanerUserId: '31',
|
||||
cleanerName: 'Cleaner',
|
||||
rewardCents: 600,
|
||||
completedAt: new Date(),
|
||||
createdAt: new Date()
|
||||
};
|
||||
}
|
||||
|
||||
function member(memberRole, userId, rewardCents) {
|
||||
return {
|
||||
id: `${userId}01`,
|
||||
|
||||
Reference in New Issue
Block a user