feat(cleaning): close settlement integrity and reconciliation
This commit is contained in:
@@ -826,6 +826,28 @@ export function listCleaningSettlements(
|
|||||||
return request<PageResult<CleaningSettlement>>(session, `/cleaning/settlements?${params}`);
|
return request<PageResult<CleaningSettlement>>(session, `/cleaning/settlements?${params}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function exportCleaningSettlements(
|
||||||
|
session: ApiSession,
|
||||||
|
input: {
|
||||||
|
status?: SettlementStatus;
|
||||||
|
payoutState?: PayoutStateFilter;
|
||||||
|
storeId?: string;
|
||||||
|
cleanerUserId?: string;
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (input.status) params.set('status', input.status);
|
||||||
|
if (input.payoutState) params.set('payoutState', input.payoutState);
|
||||||
|
if (input.storeId) params.set('storeId', input.storeId);
|
||||||
|
if (input.cleanerUserId) params.set('cleanerUserId', input.cleanerUserId);
|
||||||
|
return request<{
|
||||||
|
items: CleaningSettlement[];
|
||||||
|
total: number;
|
||||||
|
exported: number;
|
||||||
|
truncated: boolean;
|
||||||
|
}>(session, `/cleaning/settlements/export?${params}`);
|
||||||
|
}
|
||||||
|
|
||||||
export function listCleaningSettlementCandidates(
|
export function listCleaningSettlementCandidates(
|
||||||
session: ApiSession,
|
session: ApiSession,
|
||||||
input: { page: number; pageSize: number; storeId?: string; cleanerUserId?: string }
|
input: { page: number; pageSize: number; storeId?: string; cleanerUserId?: string }
|
||||||
@@ -867,6 +889,32 @@ export function confirmCleaningSettlement(session: ApiSession, settlementId: str
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function cancelCleaningSettlement(session: ApiSession, settlementId: string, reason: string) {
|
||||||
|
return request<CleaningSettlement>(
|
||||||
|
session,
|
||||||
|
`/cleaning/settlements/${encodeURIComponent(settlementId)}/cancel`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ reason })
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function markCleaningSettlementPaid(
|
||||||
|
session: ApiSession,
|
||||||
|
settlementId: string,
|
||||||
|
input: { payoutChannel: string; payoutReference: string; note?: string }
|
||||||
|
) {
|
||||||
|
return request<CleaningSettlement>(
|
||||||
|
session,
|
||||||
|
`/cleaning/settlements/${encodeURIComponent(settlementId)}/paid`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(input)
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function executeWechatTransfer(
|
export function executeWechatTransfer(
|
||||||
session: ApiSession,
|
session: ApiSession,
|
||||||
settlementId: string,
|
settlementId: string,
|
||||||
|
|||||||
@@ -56,7 +56,12 @@
|
|||||||
>
|
>
|
||||||
批量同步
|
批量同步
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-button :icon="Download" :disabled="items.length === 0" @click="exportSettlements">
|
<el-button
|
||||||
|
:icon="Download"
|
||||||
|
:disabled="total === 0"
|
||||||
|
:loading="exporting"
|
||||||
|
@click="exportSettlements"
|
||||||
|
>
|
||||||
导出
|
导出
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-button :icon="ListChecks" @click="openCandidates">
|
<el-button :icon="ListChecks" @click="openCandidates">
|
||||||
@@ -108,7 +113,7 @@
|
|||||||
<el-table-column prop="createdAt" label="创建" width="130">
|
<el-table-column prop="createdAt" label="创建" width="130">
|
||||||
<template #default="{ row }">{{ shortDate(row.createdAt) }}</template>
|
<template #default="{ row }">{{ shortDate(row.createdAt) }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="操作" width="320" fixed="right">
|
<el-table-column label="操作" width="470" fixed="right">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<div class="row-actions">
|
<div class="row-actions">
|
||||||
<el-button size="small" :icon="ListChecks" @click="openDetail(row)">详情</el-button>
|
<el-button size="small" :icon="ListChecks" @click="openDetail(row)">详情</el-button>
|
||||||
@@ -137,6 +142,14 @@
|
|||||||
>
|
>
|
||||||
转账
|
转账
|
||||||
</el-button>
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
size="small"
|
||||||
|
:icon="BadgeCheck"
|
||||||
|
:disabled="!canMarkPaid(row)"
|
||||||
|
@click="openManualPaid(row)"
|
||||||
|
>
|
||||||
|
人工已付
|
||||||
|
</el-button>
|
||||||
<el-button
|
<el-button
|
||||||
size="small"
|
size="small"
|
||||||
:icon="RefreshCw"
|
:icon="RefreshCw"
|
||||||
@@ -149,11 +162,21 @@
|
|||||||
size="small"
|
size="small"
|
||||||
type="danger"
|
type="danger"
|
||||||
:icon="CircleAlert"
|
:icon="CircleAlert"
|
||||||
:disabled="row.status !== 'CONFIRMED'"
|
:disabled="!canRecordFailure(row)"
|
||||||
@click="openFailure(row)"
|
@click="openFailure(row)"
|
||||||
>
|
>
|
||||||
失败
|
失败
|
||||||
</el-button>
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
size="small"
|
||||||
|
type="danger"
|
||||||
|
plain
|
||||||
|
:icon="CircleX"
|
||||||
|
:disabled="!canCancelSettlement(row)"
|
||||||
|
@click="openCancel(row)"
|
||||||
|
>
|
||||||
|
冲正
|
||||||
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
@@ -390,7 +413,7 @@
|
|||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
<el-dialog v-model="detailDialog.open" title="结算单详情" width="720px">
|
<el-dialog v-model="detailDialog.open" title="结算单详情" width="880px">
|
||||||
<div v-if="detailDialog.detail" class="dialog-stack">
|
<div v-if="detailDialog.detail" class="dialog-stack">
|
||||||
<div class="detail-grid">
|
<div class="detail-grid">
|
||||||
<span>单号<strong>{{ detailDialog.detail.settlement.settlementNo }}</strong></span>
|
<span>单号<strong>{{ detailDialog.detail.settlement.settlementNo }}</strong></span>
|
||||||
@@ -416,6 +439,9 @@
|
|||||||
<el-table-column prop="rewardCents" label="金额" width="110">
|
<el-table-column prop="rewardCents" label="金额" width="110">
|
||||||
<template #default="{ row }">{{ money(row.rewardCents) }}</template>
|
<template #default="{ row }">{{ money(row.rewardCents) }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
<el-table-column label="份额状态" width="100">
|
||||||
|
<template #default="{ row }">{{ row.reversedAt ? '已冲正' : '有效' }}</template>
|
||||||
|
</el-table-column>
|
||||||
<el-table-column prop="completedAt" label="完成" width="130">
|
<el-table-column prop="completedAt" label="完成" width="130">
|
||||||
<template #default="{ row }">{{ shortDate(row.completedAt) }}</template>
|
<template #default="{ row }">{{ shortDate(row.completedAt) }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
@@ -427,6 +453,9 @@
|
|||||||
<el-descriptions-item label="外部流水">
|
<el-descriptions-item label="外部流水">
|
||||||
{{ compactText(detailDialog.detail.settlement.payoutReference) }}
|
{{ compactText(detailDialog.detail.settlement.payoutReference) }}
|
||||||
</el-descriptions-item>
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="付款请求号">
|
||||||
|
{{ compactText(detailDialog.detail.settlement.payoutRequestNo) }}
|
||||||
|
</el-descriptions-item>
|
||||||
<el-descriptions-item label="微信确认参数" :span="2">
|
<el-descriptions-item label="微信确认参数" :span="2">
|
||||||
{{ compactText(detailDialog.detail.settlement.payoutPackageInfo) }}
|
{{ compactText(detailDialog.detail.settlement.payoutPackageInfo) }}
|
||||||
</el-descriptions-item>
|
</el-descriptions-item>
|
||||||
@@ -436,7 +465,45 @@
|
|||||||
<el-descriptions-item label="备注" :span="2">
|
<el-descriptions-item label="备注" :span="2">
|
||||||
{{ compactText(detailDialog.detail.settlement.note) }}
|
{{ compactText(detailDialog.detail.settlement.note) }}
|
||||||
</el-descriptions-item>
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="冲正操作人">
|
||||||
|
{{ compactText(detailDialog.detail.settlement.cancelledBy) }}
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="冲正时间">
|
||||||
|
{{ shortDate(detailDialog.detail.settlement.cancelledAt) }}
|
||||||
|
</el-descriptions-item>
|
||||||
</el-descriptions>
|
</el-descriptions>
|
||||||
|
<section v-if="detailDialog.detail.reversals.length" class="detail-section">
|
||||||
|
<h4>反向记录</h4>
|
||||||
|
<el-table :data="detailDialog.detail.reversals" size="small">
|
||||||
|
<el-table-column prop="reversalNo" label="反向单号" min-width="190" />
|
||||||
|
<el-table-column label="金额(分)" width="120">
|
||||||
|
<template #default="{ row }">{{ row.amountDeltaCents }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="reason" label="原因" min-width="180" />
|
||||||
|
<el-table-column prop="reversedBy" label="操作人" width="100" />
|
||||||
|
<el-table-column prop="createdAt" label="时间" width="140">
|
||||||
|
<template #default="{ row }">{{ shortDate(row.createdAt) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</section>
|
||||||
|
<section class="detail-section">
|
||||||
|
<h4>状态历史</h4>
|
||||||
|
<el-table :data="detailDialog.detail.events" size="small">
|
||||||
|
<el-table-column prop="action" label="动作" width="150" />
|
||||||
|
<el-table-column label="状态" min-width="150">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ compactText(row.fromStatus) }} → {{ row.toStatus }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作人" width="130">
|
||||||
|
<template #default="{ row }">{{ row.actorType }} · {{ row.actorId }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="note" label="备注" min-width="170" />
|
||||||
|
<el-table-column prop="createdAt" label="时间" width="140">
|
||||||
|
<template #default="{ row }">{{ shortDate(row.createdAt) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<el-button :icon="Download" :disabled="!detailDialog.detail" @click="exportSettlementDetail">
|
<el-button :icon="Download" :disabled="!detailDialog.detail" @click="exportSettlementDetail">
|
||||||
@@ -452,6 +519,7 @@ import { computed, reactive, ref, watch } from 'vue';
|
|||||||
import {
|
import {
|
||||||
BadgeCheck,
|
BadgeCheck,
|
||||||
CircleAlert,
|
CircleAlert,
|
||||||
|
CircleX,
|
||||||
ClipboardCheck,
|
ClipboardCheck,
|
||||||
Download,
|
Download,
|
||||||
FilePlus2,
|
FilePlus2,
|
||||||
@@ -460,8 +528,9 @@ import {
|
|||||||
Search,
|
Search,
|
||||||
Send
|
Send
|
||||||
} from '@lucide/vue';
|
} from '@lucide/vue';
|
||||||
import { ElMessage } from 'element-plus';
|
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||||
import {
|
import {
|
||||||
|
exportCleaningSettlements,
|
||||||
getCleaningSettlementDetail,
|
getCleaningSettlementDetail,
|
||||||
listCleaningSettlementCandidates,
|
listCleaningSettlementCandidates,
|
||||||
preflightWechatTransfer
|
preflightWechatTransfer
|
||||||
@@ -502,6 +571,13 @@ const emit = defineEmits<{
|
|||||||
'sync-transfer': [{ settlementId: string; note?: string }];
|
'sync-transfer': [{ settlementId: string; note?: string }];
|
||||||
'sync-transfer-many': [{ settlementIds: string[]; note?: string }];
|
'sync-transfer-many': [{ settlementIds: string[]; note?: string }];
|
||||||
failure: [{ settlementId: string; error: string; payoutReference?: string; note?: string }];
|
failure: [{ settlementId: string; error: string; payoutReference?: string; note?: string }];
|
||||||
|
cancel: [{ settlementId: string; reason: string }];
|
||||||
|
paid: [{
|
||||||
|
settlementId: string;
|
||||||
|
payoutChannel: string;
|
||||||
|
payoutReference: string;
|
||||||
|
note?: string;
|
||||||
|
}];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const statusOptions = [
|
const statusOptions = [
|
||||||
@@ -524,6 +600,7 @@ const filterDraft = reactive({
|
|||||||
payoutState: props.filters.payoutState
|
payoutState: props.filters.payoutState
|
||||||
});
|
});
|
||||||
const selectedSettlements = ref<CleaningSettlement[]>([]);
|
const selectedSettlements = ref<CleaningSettlement[]>([]);
|
||||||
|
const exporting = ref(false);
|
||||||
const generateDialog = reactive({ open: false, cleanerUserId: '', storeId: '', note: '' });
|
const generateDialog = reactive({ open: false, cleanerUserId: '', storeId: '', note: '' });
|
||||||
const transferDialog = reactive({
|
const transferDialog = reactive({
|
||||||
open: false,
|
open: false,
|
||||||
@@ -616,6 +693,19 @@ function canSelectSettlement(row: CleaningSettlement) {
|
|||||||
return row.status === 'CONFIRMED';
|
return row.status === 'CONFIRMED';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function canCancelSettlement(row: CleaningSettlement) {
|
||||||
|
return (row.status === 'DRAFT' || row.status === 'CONFIRMED')
|
||||||
|
&& !['PROCESSING', 'WAIT_USER_CONFIRM', 'SUCCESS'].includes(row.payoutState);
|
||||||
|
}
|
||||||
|
|
||||||
|
function canMarkPaid(row: CleaningSettlement) {
|
||||||
|
return row.status === 'CONFIRMED' && ['', 'FAIL'].includes(row.payoutState);
|
||||||
|
}
|
||||||
|
|
||||||
|
function canRecordFailure(row: CleaningSettlement) {
|
||||||
|
return row.status === 'CONFIRMED' && ['', 'FAIL'].includes(row.payoutState);
|
||||||
|
}
|
||||||
|
|
||||||
function handleSelectionChange(rows: CleaningSettlement[]) {
|
function handleSelectionChange(rows: CleaningSettlement[]) {
|
||||||
selectedSettlements.value = rows;
|
selectedSettlements.value = rows;
|
||||||
}
|
}
|
||||||
@@ -712,6 +802,51 @@ function submitFailure() {
|
|||||||
failureDialog.open = false;
|
failureDialog.open = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function openCancel(row: CleaningSettlement) {
|
||||||
|
try {
|
||||||
|
const result = await ElMessageBox.prompt(
|
||||||
|
'结算单 ' + row.settlementNo + ' 将生成反向记录并释放任务份额,原单不会删除。',
|
||||||
|
'冲正结算单',
|
||||||
|
{
|
||||||
|
confirmButtonText: '确认冲正',
|
||||||
|
cancelButtonText: '返回',
|
||||||
|
inputPlaceholder: '请输入冲正原因',
|
||||||
|
inputPattern: /\S+/,
|
||||||
|
inputErrorMessage: '冲正原因不能为空',
|
||||||
|
type: 'warning'
|
||||||
|
}
|
||||||
|
);
|
||||||
|
emit('cancel', { settlementId: row.id, reason: result.value.trim() });
|
||||||
|
} catch {
|
||||||
|
// Dialog cancellation is not an operation failure.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openManualPaid(row: CleaningSettlement) {
|
||||||
|
try {
|
||||||
|
const result = await ElMessageBox.prompt(
|
||||||
|
'结算单 ' + row.settlementNo + ' 将由人工渠道完成,请填写可对账的付款流水。',
|
||||||
|
'标记人工已付',
|
||||||
|
{
|
||||||
|
confirmButtonText: '确认已付',
|
||||||
|
cancelButtonText: '返回',
|
||||||
|
inputPlaceholder: '付款流水号',
|
||||||
|
inputPattern: /\S+/,
|
||||||
|
inputErrorMessage: '付款流水不能为空',
|
||||||
|
type: 'warning'
|
||||||
|
}
|
||||||
|
);
|
||||||
|
emit('paid', {
|
||||||
|
settlementId: row.id,
|
||||||
|
payoutChannel: 'MANUAL',
|
||||||
|
payoutReference: result.value.trim(),
|
||||||
|
note: '后台人工确认付款完成'
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Dialog cancellation is not an operation failure.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function openDetail(row: CleaningSettlement) {
|
async function openDetail(row: CleaningSettlement) {
|
||||||
detailDialog.open = true;
|
detailDialog.open = true;
|
||||||
detailDialog.loading = true;
|
detailDialog.loading = true;
|
||||||
@@ -760,35 +895,51 @@ function useCandidateFilters() {
|
|||||||
candidateDialog.open = false;
|
candidateDialog.open = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function exportSettlements() {
|
async function exportSettlements() {
|
||||||
downloadCsv(`cleaning-settlements-${Date.now()}.csv`, [
|
exporting.value = true;
|
||||||
['结算单', '状态', '保洁员', '门店', '任务数', '金额', '转账状态', '外部流水', '微信确认参数', '失败原因', '创建时间'],
|
try {
|
||||||
...props.items.map((item) => [
|
const result = await exportCleaningSettlements(props.session, {
|
||||||
item.settlementNo,
|
status: props.status || undefined,
|
||||||
item.status,
|
payoutState: props.filters.payoutState || undefined,
|
||||||
item.cleanerName,
|
storeId: props.filters.storeId || undefined,
|
||||||
item.storeName || '',
|
cleanerUserId: props.filters.cleanerUserId || undefined
|
||||||
String(item.taskCount),
|
});
|
||||||
money(item.totalRewardCents),
|
downloadCsv(`cleaning-settlements-${Date.now()}.csv`, [
|
||||||
item.payoutState,
|
['结算单', '状态', '保洁员', '门店', '任务数', '金额(分)', '转账状态', '外部流水', '微信确认参数', '失败原因', '创建时间'],
|
||||||
item.payoutReference,
|
...result.items.map((item) => [
|
||||||
item.payoutPackageInfo,
|
item.settlementNo,
|
||||||
item.payoutError,
|
item.status,
|
||||||
shortDate(item.createdAt)
|
item.cleanerName,
|
||||||
])
|
item.storeName || '',
|
||||||
]);
|
String(item.taskCount),
|
||||||
|
String(item.totalRewardCents),
|
||||||
|
item.payoutState,
|
||||||
|
item.payoutReference,
|
||||||
|
item.payoutPackageInfo,
|
||||||
|
item.payoutError,
|
||||||
|
shortDate(item.createdAt)
|
||||||
|
])
|
||||||
|
]);
|
||||||
|
if (result.truncated) {
|
||||||
|
ElMessage.warning(`导出达到上限:${result.exported}/${result.total}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(error instanceof Error ? error.message : '结算导出失败');
|
||||||
|
} finally {
|
||||||
|
exporting.value = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function exportCandidates() {
|
function exportCandidates() {
|
||||||
downloadCsv(`cleaning-settlement-candidates-${Date.now()}.csv`, [
|
downloadCsv(`cleaning-settlement-candidates-${Date.now()}.csv`, [
|
||||||
['任务号', '订单号', '门店', '房间', '保洁员ID', '金额', '完成时间'],
|
['任务号', '订单号', '门店', '房间', '保洁员ID', '金额(分)', '完成时间'],
|
||||||
...candidateDialog.items.map((item) => [
|
...candidateDialog.items.map((item) => [
|
||||||
item.taskNo,
|
item.taskNo,
|
||||||
item.orderNo || '',
|
item.orderNo || '',
|
||||||
item.storeName,
|
item.storeName,
|
||||||
item.roomName || item.roomNo,
|
item.roomName || item.roomNo,
|
||||||
item.cleanerUserId || '',
|
item.cleanerUserId || '',
|
||||||
money(item.rewardCents),
|
String(item.rewardCents),
|
||||||
shortDate(item.completedAt)
|
shortDate(item.completedAt)
|
||||||
])
|
])
|
||||||
]);
|
]);
|
||||||
@@ -805,8 +956,9 @@ function exportSettlementDetail() {
|
|||||||
['settlement', '保洁员', settlement.cleanerName],
|
['settlement', '保洁员', settlement.cleanerName],
|
||||||
['settlement', '门店', settlement.storeName || ''],
|
['settlement', '门店', settlement.storeName || ''],
|
||||||
['settlement', '任务数', String(settlement.taskCount)],
|
['settlement', '任务数', String(settlement.taskCount)],
|
||||||
['settlement', '金额', money(settlement.totalRewardCents)],
|
['settlement', '金额(分)', String(settlement.totalRewardCents)],
|
||||||
['settlement', '转账状态', settlement.payoutState],
|
['settlement', '转账状态', settlement.payoutState],
|
||||||
|
['settlement', '付款请求号', settlement.payoutRequestNo],
|
||||||
['settlement', '外部流水', settlement.payoutReference],
|
['settlement', '外部流水', settlement.payoutReference],
|
||||||
['settlement', '微信确认参数', settlement.payoutPackageInfo],
|
['settlement', '微信确认参数', settlement.payoutPackageInfo],
|
||||||
['settlement', '失败原因', settlement.payoutError],
|
['settlement', '失败原因', settlement.payoutError],
|
||||||
@@ -815,7 +967,17 @@ function exportSettlementDetail() {
|
|||||||
...detail.items.map((item) => [
|
...detail.items.map((item) => [
|
||||||
'item',
|
'item',
|
||||||
item.taskNo,
|
item.taskNo,
|
||||||
`${item.storeName} ${item.roomName || item.roomNo} ${item.cleanerName} ${money(item.rewardCents)} ${shortDate(item.completedAt)}`
|
`${item.storeName} ${item.roomName || item.roomNo} ${item.cleanerName} ${item.rewardCents}分 ${item.reversedAt ? '已冲正' : '有效'} ${shortDate(item.completedAt)}`
|
||||||
|
]),
|
||||||
|
...detail.reversals.map((reversal) => [
|
||||||
|
'reversal',
|
||||||
|
reversal.reversalNo,
|
||||||
|
`${reversal.amountDeltaCents}分 ${reversal.reason} ${reversal.reversedBy} ${shortDate(reversal.createdAt)}`
|
||||||
|
]),
|
||||||
|
...detail.events.map((event) => [
|
||||||
|
'event',
|
||||||
|
event.action,
|
||||||
|
`${event.fromStatus || ''}->${event.toStatus} ${event.actorType}:${event.actorId} ${event.note} ${shortDate(event.createdAt)}`
|
||||||
])
|
])
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -828,7 +990,7 @@ function exportPreflight() {
|
|||||||
['summary', 'ready', result.ready ? 'PASS' : 'BLOCKED'],
|
['summary', 'ready', result.ready ? 'PASS' : 'BLOCKED'],
|
||||||
['settlement', 'settlementNo', result.settlement.settlementNo],
|
['settlement', 'settlementNo', result.settlement.settlementNo],
|
||||||
['settlement', 'status', result.settlement.status],
|
['settlement', 'status', result.settlement.status],
|
||||||
['settlement', 'amount', money(result.settlement.totalRewardCents)],
|
['settlement', 'amountCents', String(result.settlement.totalRewardCents)],
|
||||||
['settlement', 'cleanerUserId', result.settlement.cleanerUserId],
|
['settlement', 'cleanerUserId', result.settlement.cleanerUserId],
|
||||||
['settlement', 'storeId', result.settlement.storeId || ''],
|
['settlement', 'storeId', result.settlement.storeId || ''],
|
||||||
['account', 'merchantId', compactText(result.account.merchantIdMasked)],
|
['account', 'merchantId', compactText(result.account.merchantIdMasked)],
|
||||||
|
|||||||
@@ -288,7 +288,7 @@ function filterByCleaner(cleanerUserId: string) {
|
|||||||
|
|
||||||
function exportCleanerPerformance() {
|
function exportCleanerPerformance() {
|
||||||
downloadCsv(`cleaning-cleaner-performance-${Date.now()}.csv`, [
|
downloadCsv(`cleaning-cleaner-performance-${Date.now()}.csv`, [
|
||||||
['排名', '保洁员', '保洁员ID', '任务', '完成', '驳回', '完成率', '待结算', '已结算'],
|
['排名', '保洁员', '保洁员ID', '任务', '完成', '驳回', '完成率', '待结算(分)', '已结算(分)'],
|
||||||
...cleanerPerformanceRows.value.map((row) => [
|
...cleanerPerformanceRows.value.map((row) => [
|
||||||
String(row.rank),
|
String(row.rank),
|
||||||
row.cleanerName || '',
|
row.cleanerName || '',
|
||||||
@@ -297,8 +297,8 @@ function exportCleanerPerformance() {
|
|||||||
String(row.completedTaskCount),
|
String(row.completedTaskCount),
|
||||||
String(row.rejectedTaskCount),
|
String(row.rejectedTaskCount),
|
||||||
`${row.completionRate}%`,
|
`${row.completionRate}%`,
|
||||||
money(row.pendingSettlementCents),
|
String(row.pendingSettlementCents),
|
||||||
money(row.settledRewardCents)
|
String(row.settledRewardCents)
|
||||||
])
|
])
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -316,14 +316,14 @@ function exportStatistics() {
|
|||||||
['summary', '\u9a73\u56de', String(props.statistics.summary.rejected), '', '', '', ''],
|
['summary', '\u9a73\u56de', String(props.statistics.summary.rejected), '', '', '', ''],
|
||||||
['summary', '\u514d\u6e05\u6d01', String(props.statistics.summary.exempted), '', '', '', ''],
|
['summary', '\u514d\u6e05\u6d01', String(props.statistics.summary.exempted), '', '', '', ''],
|
||||||
['summary', '\u5df2\u5b8c\u6210', String(props.statistics.summary.completed), '', '', '', ''],
|
['summary', '\u5df2\u5b8c\u6210', String(props.statistics.summary.completed), '', '', '', ''],
|
||||||
['summary', '\u5956\u52b1\u5408\u8ba1', money(props.statistics.summary.rewardCents), '', '', '', ''],
|
['summary', '\u5956\u52b1\u5408\u8ba1(\u5206)', String(props.statistics.summary.rewardCents), '', '', '', ''],
|
||||||
['summary', '\u5f85\u7ed3\u7b97', money(props.statistics.summary.pendingSettlementCents), '', '', '', ''],
|
['summary', '\u5f85\u7ed3\u7b97(\u5206)', String(props.statistics.summary.pendingSettlementCents), '', '', '', ''],
|
||||||
['summary', '\u5f85\u53d1\u653e', money(props.statistics.summary.confirmedSettlementCents), '', '', '', ''],
|
['summary', '\u5f85\u53d1\u653e(\u5206)', String(props.statistics.summary.confirmedSettlementCents), '', '', '', ''],
|
||||||
['summary', '\u5df2\u53d1\u653e', money(props.statistics.summary.paidSettlementCents), '', '', '', ''],
|
['summary', '\u5df2\u53d1\u653e(\u5206)', String(props.statistics.summary.paidSettlementCents), '', '', '', ''],
|
||||||
['\u4efb\u52a1\u72b6\u6001', '\u72b6\u6001', '\u6570\u91cf', '\u5956\u52b1', '', '', ''],
|
['\u4efb\u52a1\u72b6\u6001', '\u72b6\u6001', '\u6570\u91cf', '\u5956\u52b1(\u5206)', '', '', ''],
|
||||||
...props.statistics.byStatus.map((row) => ['\u4efb\u52a1\u72b6\u6001', row.status, String(row.total), money(row.rewardCents), '', '', '']),
|
...props.statistics.byStatus.map((row) => ['\u4efb\u52a1\u72b6\u6001', row.status, String(row.total), String(row.rewardCents), '', '', '']),
|
||||||
['\u7ed3\u7b97\u53e3\u5f84', '\u72b6\u6001', '\u5355\u6570', '\u91d1\u989d', '', '', ''],
|
['\u7ed3\u7b97\u53e3\u5f84', '\u72b6\u6001', '\u5355\u6570', '\u91d1\u989d(\u5206)', '', '', ''],
|
||||||
...props.statistics.settlements.map((row) => ['\u7ed3\u7b97\u53e3\u5f84', row.status, String(row.total), money(row.rewardCents), '', '', '']),
|
...props.statistics.settlements.map((row) => ['\u7ed3\u7b97\u53e3\u5f84', row.status, String(row.total), String(row.rewardCents), '', '', '']),
|
||||||
['\u6bcf\u65e5\u8d8b\u52bf', '\u65e5\u671f', '\u4efb\u52a1', '\u5f85\u9a8c', '\u5b8c\u6210', '\u9a73\u56de', '\u514d\u6e05\u6d01'],
|
['\u6bcf\u65e5\u8d8b\u52bf', '\u65e5\u671f', '\u4efb\u52a1', '\u5f85\u9a8c', '\u5b8c\u6210', '\u9a73\u56de', '\u514d\u6e05\u6d01'],
|
||||||
...props.statistics.trend.map((row) => [
|
...props.statistics.trend.map((row) => [
|
||||||
'\u6bcf\u65e5\u8d8b\u52bf',
|
'\u6bcf\u65e5\u8d8b\u52bf',
|
||||||
@@ -334,8 +334,8 @@ function exportStatistics() {
|
|||||||
String(row.rejected),
|
String(row.rejected),
|
||||||
String(row.exempted)
|
String(row.exempted)
|
||||||
]),
|
]),
|
||||||
['\u6bcf\u65e5\u53d1\u653e', '\u65e5\u671f', '\u5df2\u53d1\u653e', '', '', '', ''],
|
['\u6bcf\u65e5\u53d1\u653e', '\u65e5\u671f', '\u5df2\u53d1\u653e(\u5206)', '', '', '', ''],
|
||||||
...props.statistics.trend.map((row) => ['\u6bcf\u65e5\u53d1\u653e', row.date, money(row.paidSettlementCents), '', '', '', '']),
|
...props.statistics.trend.map((row) => ['\u6bcf\u65e5\u53d1\u653e', row.date, String(row.paidSettlementCents), '', '', '', '']),
|
||||||
['\u95e8\u5e97\u660e\u7ec6', '\u95e8\u5e97', '\u4efb\u52a1', '\u5f85\u9a8c', '\u5b8c\u6210', '\u9a73\u56de', '\u514d\u6e05\u6d01'],
|
['\u95e8\u5e97\u660e\u7ec6', '\u95e8\u5e97', '\u4efb\u52a1', '\u5f85\u9a8c', '\u5b8c\u6210', '\u9a73\u56de', '\u514d\u6e05\u6d01'],
|
||||||
...props.statistics.byStore.map((row) => [
|
...props.statistics.byStore.map((row) => [
|
||||||
'\u95e8\u5e97\u660e\u7ec6',
|
'\u95e8\u5e97\u660e\u7ec6',
|
||||||
@@ -346,19 +346,19 @@ function exportStatistics() {
|
|||||||
String(row.rejected),
|
String(row.rejected),
|
||||||
String(row.exempted)
|
String(row.exempted)
|
||||||
]),
|
]),
|
||||||
['\u95e8\u5e97\u5956\u52b1', '\u95e8\u5e97', '\u5956\u52b1', '', '', '', ''],
|
['\u95e8\u5e97\u5956\u52b1', '\u95e8\u5e97', '\u5956\u52b1(\u5206)', '', '', '', ''],
|
||||||
...props.statistics.byStore.map((row) => ['\u95e8\u5e97\u5956\u52b1', `${row.storeName || ''}(${row.storeId})`, money(row.rewardCents), '', '', '', '']),
|
...props.statistics.byStore.map((row) => ['\u95e8\u5e97\u5956\u52b1', `${row.storeName || ''}(${row.storeId})`, String(row.rewardCents), '', '', '', '']),
|
||||||
['\u6210\u5458\u5206\u8d26', '\u4fdd\u6d01\u5458', '\u4efb\u52a1', '\u5b8c\u6210', '\u9a73\u56de', '\u5f85\u7ed3\u7b97', '\u5df2\u7ed3\u7b97'],
|
['\u6210\u5458\u5206\u8d26', '\u4fdd\u6d01\u5458', '\u4efb\u52a1', '\u5b8c\u6210', '\u9a73\u56de', '\u5f85\u7ed3\u7b97(\u5206)', '\u5df2\u7ed3\u7b97(\u5206)'],
|
||||||
...props.statistics.members.map((row) => [
|
...props.statistics.members.map((row) => [
|
||||||
'\u6210\u5458\u5206\u8d26',
|
'\u6210\u5458\u5206\u8d26',
|
||||||
`${row.cleanerName || ''}(${row.cleanerUserId})`,
|
`${row.cleanerName || ''}(${row.cleanerUserId})`,
|
||||||
String(row.taskCount),
|
String(row.taskCount),
|
||||||
String(row.completedTaskCount),
|
String(row.completedTaskCount),
|
||||||
String(row.rejectedTaskCount),
|
String(row.rejectedTaskCount),
|
||||||
money(row.pendingSettlementCents),
|
String(row.pendingSettlementCents),
|
||||||
money(row.settledRewardCents)
|
String(row.settledRewardCents)
|
||||||
]),
|
]),
|
||||||
['\u7ee9\u6548\u6392\u884c', '\u6392\u540d', '\u4fdd\u6d01\u5458', '\u4efb\u52a1', '\u5b8c\u6210\u7387', '\u9a73\u56de', '\u7ed3\u7b97\u5408\u8ba1'],
|
['\u7ee9\u6548\u6392\u884c', '\u6392\u540d', '\u4fdd\u6d01\u5458', '\u4efb\u52a1', '\u5b8c\u6210\u7387', '\u9a73\u56de', '\u7ed3\u7b97\u5408\u8ba1(\u5206)'],
|
||||||
...cleanerPerformanceRows.value.map((row) => [
|
...cleanerPerformanceRows.value.map((row) => [
|
||||||
'\u7ee9\u6548\u6392\u884c',
|
'\u7ee9\u6548\u6392\u884c',
|
||||||
String(row.rank),
|
String(row.rank),
|
||||||
@@ -366,7 +366,7 @@ function exportStatistics() {
|
|||||||
String(row.taskCount),
|
String(row.taskCount),
|
||||||
`${row.completionRate}%`,
|
`${row.completionRate}%`,
|
||||||
String(row.rejectedTaskCount),
|
String(row.rejectedTaskCount),
|
||||||
money(row.settlementTotalCents)
|
String(row.settlementTotalCents)
|
||||||
])
|
])
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -791,7 +791,7 @@ async function removeMember(cleanerUserId: string) {
|
|||||||
|
|
||||||
function exportTasks() {
|
function exportTasks() {
|
||||||
downloadCsv(`cleaning-tasks-${Date.now()}.csv`, [
|
downloadCsv(`cleaning-tasks-${Date.now()}.csv`, [
|
||||||
['任务号', '状态', '门店', '房间', '订单号', '保洁员ID', '协作人数', '奖励金额', '照片数', '提交时间', '完成时间', '驳回原因'],
|
['任务号', '状态', '门店', '房间', '订单号', '保洁员ID', '协作人数', '奖励金额(分)', '照片数', '提交时间', '完成时间', '驳回原因'],
|
||||||
...props.items.map((item) => [
|
...props.items.map((item) => [
|
||||||
item.taskNo,
|
item.taskNo,
|
||||||
item.status,
|
item.status,
|
||||||
@@ -800,7 +800,7 @@ function exportTasks() {
|
|||||||
item.orderNo || '',
|
item.orderNo || '',
|
||||||
item.cleanerUserId || '',
|
item.cleanerUserId || '',
|
||||||
String(item.memberCount || 0),
|
String(item.memberCount || 0),
|
||||||
money(item.rewardCents),
|
String(item.rewardCents),
|
||||||
String(item.photoUrls?.length || 0),
|
String(item.photoUrls?.length || 0),
|
||||||
shortDate(item.submittedAt),
|
shortDate(item.submittedAt),
|
||||||
shortDate(item.completedAt),
|
shortDate(item.completedAt),
|
||||||
@@ -820,7 +820,7 @@ function exportTaskDetail() {
|
|||||||
['task', '房间', task.roomName || task.roomNo],
|
['task', '房间', task.roomName || task.roomNo],
|
||||||
['task', '订单号', task.orderNo || ''],
|
['task', '订单号', task.orderNo || ''],
|
||||||
['task', '保洁员ID', task.cleanerUserId || ''],
|
['task', '保洁员ID', task.cleanerUserId || ''],
|
||||||
['task', '奖励金额', money(task.rewardCents)],
|
['task', '奖励金额(分)', String(task.rewardCents)],
|
||||||
['task', '照片数', String(task.photoUrls?.length || 0)],
|
['task', '照片数', String(task.photoUrls?.length || 0)],
|
||||||
['task', '领取时间', shortDate(task.claimedAt)],
|
['task', '领取时间', shortDate(task.claimedAt)],
|
||||||
['task', '开始时间', shortDate(task.startedAt)],
|
['task', '开始时间', shortDate(task.startedAt)],
|
||||||
@@ -831,7 +831,7 @@ function exportTaskDetail() {
|
|||||||
...detailDialog.members.map((member) => [
|
...detailDialog.members.map((member) => [
|
||||||
'member',
|
'member',
|
||||||
`${member.memberRole}:${member.userId}`,
|
`${member.memberRole}:${member.userId}`,
|
||||||
`${member.nickname || ''} ${money(member.rewardCents)} ${member.settledAt ? shortDate(member.settledAt) : '未结算'}`
|
`${member.nickname || ''} ${member.rewardCents}分 ${member.settledAt ? shortDate(member.settledAt) : '未结算'}`
|
||||||
]),
|
]),
|
||||||
...detailDialog.events.map((event) => [
|
...detailDialog.events.map((event) => [
|
||||||
'event',
|
'event',
|
||||||
@@ -873,13 +873,13 @@ function exportTaskEvents() {
|
|||||||
|
|
||||||
function exportTaskMembers() {
|
function exportTaskMembers() {
|
||||||
downloadCsv(`cleaning-task-members-${memberDialog.taskNo}-${Date.now()}.csv`, [
|
downloadCsv(`cleaning-task-members-${memberDialog.taskNo}-${Date.now()}.csv`, [
|
||||||
['任务号', '成员ID', '昵称', '角色', '分账金额', '结算时间'],
|
['任务号', '成员ID', '昵称', '角色', '分账金额(分)', '结算时间'],
|
||||||
...memberDialog.members.map((member) => [
|
...memberDialog.members.map((member) => [
|
||||||
memberDialog.taskNo,
|
memberDialog.taskNo,
|
||||||
member.userId,
|
member.userId,
|
||||||
member.nickname || '',
|
member.nickname || '',
|
||||||
member.memberRole,
|
member.memberRole,
|
||||||
money(member.rewardCents),
|
String(member.rewardCents),
|
||||||
shortDate(member.settledAt)
|
shortDate(member.settledAt)
|
||||||
])
|
])
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -113,6 +113,8 @@
|
|||||||
@sync-transfer="handleSyncTransfer"
|
@sync-transfer="handleSyncTransfer"
|
||||||
@sync-transfer-many="handleSyncTransferMany"
|
@sync-transfer-many="handleSyncTransferMany"
|
||||||
@failure="handleFailure"
|
@failure="handleFailure"
|
||||||
|
@cancel="handleCancelSettlement"
|
||||||
|
@paid="handleManualPaid"
|
||||||
/>
|
/>
|
||||||
</el-tab-pane>
|
</el-tab-pane>
|
||||||
<el-tab-pane label="统计" name="statistics">
|
<el-tab-pane label="统计" name="statistics">
|
||||||
@@ -167,6 +169,7 @@ import CleaningFieldHandoffPanel from './CleaningFieldHandoffPanel.vue';
|
|||||||
import {
|
import {
|
||||||
ApiError,
|
ApiError,
|
||||||
assignCleaningTask,
|
assignCleaningTask,
|
||||||
|
cancelCleaningSettlement,
|
||||||
completeCleaningTask,
|
completeCleaningTask,
|
||||||
confirmCleaningSettlement,
|
confirmCleaningSettlement,
|
||||||
createStaffUser,
|
createStaffUser,
|
||||||
@@ -177,6 +180,7 @@ import {
|
|||||||
listCleaningSettlements,
|
listCleaningSettlements,
|
||||||
listCleaningTasks,
|
listCleaningTasks,
|
||||||
listStaffUsers,
|
listStaffUsers,
|
||||||
|
markCleaningSettlementPaid,
|
||||||
reclaimCleaningTimeouts,
|
reclaimCleaningTimeouts,
|
||||||
recordPayoutFailure,
|
recordPayoutFailure,
|
||||||
rejectCleaningTask,
|
rejectCleaningTask,
|
||||||
@@ -234,6 +238,7 @@ const statistics = reactive<CleaningStatistics>({
|
|||||||
completed: 0,
|
completed: 0,
|
||||||
rewardCents: 0,
|
rewardCents: 0,
|
||||||
pendingSettlementCents: 0,
|
pendingSettlementCents: 0,
|
||||||
|
draftSettlementCents: 0,
|
||||||
confirmedSettlementCents: 0,
|
confirmedSettlementCents: 0,
|
||||||
paidSettlementCents: 0
|
paidSettlementCents: 0
|
||||||
},
|
},
|
||||||
@@ -372,12 +377,12 @@ function jumpToSettlementStatus(status: SettlementStatus) {
|
|||||||
|
|
||||||
function exportOperationalQueue() {
|
function exportOperationalQueue() {
|
||||||
downloadCsv(`cleaning-operational-queue-${Date.now()}.csv`, [
|
downloadCsv(`cleaning-operational-queue-${Date.now()}.csv`, [
|
||||||
['队列', '数量', '金额', '入口'],
|
['队列', '数量', '金额(分)', '入口'],
|
||||||
['待验收任务', String(statistics.summary.pendingReview), '', '任务/SUBMITTED'],
|
['待验收任务', String(statistics.summary.pendingReview), '', '任务/SUBMITTED'],
|
||||||
['驳回补做', String(statistics.summary.rejected), '', '任务/REJECTED'],
|
['驳回补做', String(statistics.summary.rejected), '', '任务/REJECTED'],
|
||||||
['待确认结算', String(draftSettlementTotal.value), '', '结算/DRAFT'],
|
['待确认结算', String(draftSettlementTotal.value), '', '结算/DRAFT'],
|
||||||
['待发放金额', '', money(statistics.summary.confirmedSettlementCents), '结算/CONFIRMED'],
|
['待发放金额', '', String(statistics.summary.confirmedSettlementCents), '结算/CONFIRMED'],
|
||||||
['待结算金额', '', money(statistics.summary.pendingSettlementCents), '统计/待结算'],
|
['待结算金额', '', String(statistics.summary.pendingSettlementCents), '统计/待结算'],
|
||||||
['已完成任务', String(statistics.summary.completed), '', '统计/已完成']
|
['已完成任务', String(statistics.summary.completed), '', '统计/已完成']
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -519,6 +524,29 @@ async function handleConfirmSettlement(input: { settlementId: string; note?: str
|
|||||||
}, '已确认结算单');
|
}, '已确认结算单');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleCancelSettlement(input: { settlementId: string; reason: string }) {
|
||||||
|
await runAction(async () => {
|
||||||
|
await cancelCleaningSettlement(session.value, input.settlementId, input.reason);
|
||||||
|
await Promise.all([loadTasks(), loadSettlements(), loadStatistics()]);
|
||||||
|
}, '结算单已冲正');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleManualPaid(input: {
|
||||||
|
settlementId: string;
|
||||||
|
payoutChannel: string;
|
||||||
|
payoutReference: string;
|
||||||
|
note?: string;
|
||||||
|
}) {
|
||||||
|
await runAction(async () => {
|
||||||
|
await markCleaningSettlementPaid(session.value, input.settlementId, {
|
||||||
|
payoutChannel: input.payoutChannel,
|
||||||
|
payoutReference: input.payoutReference,
|
||||||
|
note: input.note
|
||||||
|
});
|
||||||
|
await Promise.all([loadTasks(), loadSettlements(), loadStatistics()]);
|
||||||
|
}, '人工付款已完成');
|
||||||
|
}
|
||||||
|
|
||||||
async function handleTransfer(input: { settlementId: string; mode: TransferMode; note?: string }) {
|
async function handleTransfer(input: { settlementId: string; mode: TransferMode; note?: string }) {
|
||||||
await runAction(async () => {
|
await runAction(async () => {
|
||||||
await executeWechatTransfer(session.value, input.settlementId, {
|
await executeWechatTransfer(session.value, input.settlementId, {
|
||||||
|
|||||||
@@ -673,6 +673,7 @@ export interface CleaningTaskEvent {
|
|||||||
toStatus: TaskStatus;
|
toStatus: TaskStatus;
|
||||||
action: string;
|
action: string;
|
||||||
actorId: string;
|
actorId: string;
|
||||||
|
actorType: 'USER' | 'SYSTEM';
|
||||||
traceId: string;
|
traceId: string;
|
||||||
note: string;
|
note: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
@@ -705,9 +706,12 @@ export interface CleaningSettlement {
|
|||||||
periodStart: string | null;
|
periodStart: string | null;
|
||||||
periodEnd: string | null;
|
periodEnd: string | null;
|
||||||
paidBy: string | null;
|
paidBy: string | null;
|
||||||
|
cancelledBy: string | null;
|
||||||
confirmedAt: string | null;
|
confirmedAt: string | null;
|
||||||
paidAt: string | null;
|
paidAt: string | null;
|
||||||
|
cancelledAt: string | null;
|
||||||
payoutChannel: string;
|
payoutChannel: string;
|
||||||
|
payoutRequestNo: string;
|
||||||
payoutReference: string;
|
payoutReference: string;
|
||||||
payoutState: string;
|
payoutState: string;
|
||||||
payoutPackageInfo: string;
|
payoutPackageInfo: string;
|
||||||
@@ -728,13 +732,39 @@ export interface CleaningSettlementItem {
|
|||||||
cleanerUserId: string;
|
cleanerUserId: string;
|
||||||
cleanerName: string;
|
cleanerName: string;
|
||||||
rewardCents: number;
|
rewardCents: number;
|
||||||
|
reversedAt: string | null;
|
||||||
completedAt: string | null;
|
completedAt: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CleaningSettlementEvent {
|
||||||
|
id: string;
|
||||||
|
settlementId: string;
|
||||||
|
fromStatus: SettlementStatus | null;
|
||||||
|
toStatus: SettlementStatus;
|
||||||
|
action: string;
|
||||||
|
actorType: 'USER' | 'SYSTEM';
|
||||||
|
actorId: string;
|
||||||
|
traceId: string;
|
||||||
|
note: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CleaningSettlementReversal {
|
||||||
|
id: string;
|
||||||
|
settlementId: string;
|
||||||
|
reversalNo: string;
|
||||||
|
amountDeltaCents: number;
|
||||||
|
reason: string;
|
||||||
|
reversedBy: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface CleaningSettlementDetail {
|
export interface CleaningSettlementDetail {
|
||||||
settlement: CleaningSettlement;
|
settlement: CleaningSettlement;
|
||||||
items: CleaningSettlementItem[];
|
items: CleaningSettlementItem[];
|
||||||
|
events: CleaningSettlementEvent[];
|
||||||
|
reversals: CleaningSettlementReversal[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CleaningStatistics {
|
export interface CleaningStatistics {
|
||||||
@@ -747,6 +777,7 @@ export interface CleaningStatistics {
|
|||||||
completed: number;
|
completed: number;
|
||||||
rewardCents: number;
|
rewardCents: number;
|
||||||
pendingSettlementCents: number;
|
pendingSettlementCents: number;
|
||||||
|
draftSettlementCents: number;
|
||||||
confirmedSettlementCents: number;
|
confirmedSettlementCents: number;
|
||||||
paidSettlementCents: number;
|
paidSettlementCents: number;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -16,10 +16,12 @@ interface SettlementPayoutRow extends RowDataPacket {
|
|||||||
id: string;
|
id: string;
|
||||||
settlementNo: string;
|
settlementNo: string;
|
||||||
cleanerUserId: string;
|
cleanerUserId: string;
|
||||||
|
generatedBy: string;
|
||||||
storeId: string | null;
|
storeId: string | null;
|
||||||
status: 'DRAFT' | 'CONFIRMED' | 'PAID' | 'CANCELLED';
|
status: 'DRAFT' | 'CONFIRMED' | 'PAID' | 'CANCELLED';
|
||||||
totalRewardCents: number;
|
totalRewardCents: number;
|
||||||
payoutChannel: string;
|
payoutChannel: string;
|
||||||
|
payoutRequestNo: string;
|
||||||
payoutReference: string;
|
payoutReference: string;
|
||||||
payoutState: string;
|
payoutState: string;
|
||||||
payoutPackageInfo: string;
|
payoutPackageInfo: string;
|
||||||
@@ -56,7 +58,8 @@ export class CleaningPayoutService {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly pool: MySqlPool,
|
private readonly pool: MySqlPool,
|
||||||
private readonly repository: Pick<CleaningTaskRepository,
|
private readonly repository: Pick<CleaningTaskRepository,
|
||||||
'markSettlementPaid' | 'recordSettlementPayoutFailure' | 'recordSettlementPayoutPending'>,
|
'beginSettlementPayout' | 'markSettlementPaid'
|
||||||
|
| 'recordSettlementPayoutFailure' | 'recordSettlementPayoutPending'>,
|
||||||
private readonly client: WechatPayClient,
|
private readonly client: WechatPayClient,
|
||||||
private readonly credentials: ReadonlyMap<string, WechatPayCredential>,
|
private readonly credentials: ReadonlyMap<string, WechatPayCredential>,
|
||||||
private readonly mockEnabled: boolean
|
private readonly mockEnabled: boolean
|
||||||
@@ -213,6 +216,9 @@ export class CleaningPayoutService {
|
|||||||
if (settlement.status !== 'CONFIRMED') {
|
if (settlement.status !== 'CONFIRMED') {
|
||||||
throw new CleaningPayoutError('CLEANING_PAYOUT_SETTLEMENT_NOT_CONFIRMED');
|
throw new CleaningPayoutError('CLEANING_PAYOUT_SETTLEMENT_NOT_CONFIRMED');
|
||||||
}
|
}
|
||||||
|
if (['PROCESSING', 'WAIT_USER_CONFIRM', 'SUCCESS'].includes(settlement.payoutState)) {
|
||||||
|
throw new CleaningPayoutError('CLEANING_PAYOUT_ALREADY_STARTED');
|
||||||
|
}
|
||||||
if (Number(settlement.totalRewardCents) <= 0) {
|
if (Number(settlement.totalRewardCents) <= 0) {
|
||||||
throw new CleaningPayoutError('CLEANING_PAYOUT_AMOUNT_INVALID');
|
throw new CleaningPayoutError('CLEANING_PAYOUT_AMOUNT_INVALID');
|
||||||
}
|
}
|
||||||
@@ -234,6 +240,7 @@ export class CleaningPayoutService {
|
|||||||
...input,
|
...input,
|
||||||
payoutChannel: 'WECHAT_TRANSFER_MOCK',
|
payoutChannel: 'WECHAT_TRANSFER_MOCK',
|
||||||
payoutReference: outBillNo,
|
payoutReference: outBillNo,
|
||||||
|
providerConfirmed: true,
|
||||||
note: input.note
|
note: input.note
|
||||||
});
|
});
|
||||||
return { settlement: paid, idempotent: false, transferState: 'SUCCESS' };
|
return { settlement: paid, idempotent: false, transferState: 'SUCCESS' };
|
||||||
@@ -245,6 +252,12 @@ export class CleaningPayoutService {
|
|||||||
);
|
);
|
||||||
const sceneId = credential.transferSceneId;
|
const sceneId = credential.transferSceneId;
|
||||||
if (!sceneId) throw new CleaningPayoutError('WECHAT_TRANSFER_SCENE_NOT_CONFIGURED');
|
if (!sceneId) throw new CleaningPayoutError('WECHAT_TRANSFER_SCENE_NOT_CONFIGURED');
|
||||||
|
await this.repository.beginSettlementPayout({
|
||||||
|
...input,
|
||||||
|
payoutChannel: 'WECHAT_TRANSFER',
|
||||||
|
payoutReference: outBillNo,
|
||||||
|
note: input.note
|
||||||
|
});
|
||||||
try {
|
try {
|
||||||
const result = await this.client.createMerchantTransfer(credential, {
|
const result = await this.client.createMerchantTransfer(credential, {
|
||||||
outBillNo,
|
outBillNo,
|
||||||
@@ -260,6 +273,7 @@ export class CleaningPayoutService {
|
|||||||
...input,
|
...input,
|
||||||
payoutChannel: 'WECHAT_TRANSFER',
|
payoutChannel: 'WECHAT_TRANSFER',
|
||||||
payoutReference: result.transferBillNo || result.outBillNo,
|
payoutReference: result.transferBillNo || result.outBillNo,
|
||||||
|
providerConfirmed: true,
|
||||||
note: input.note
|
note: input.note
|
||||||
});
|
});
|
||||||
return { settlement: paid, idempotent: false, transferState: result.state };
|
return { settlement: paid, idempotent: false, transferState: result.state };
|
||||||
@@ -270,6 +284,7 @@ export class CleaningPayoutService {
|
|||||||
payoutChannel: 'WECHAT_TRANSFER',
|
payoutChannel: 'WECHAT_TRANSFER',
|
||||||
payoutReference: result.transferBillNo || result.outBillNo,
|
payoutReference: result.transferBillNo || result.outBillNo,
|
||||||
error: result.failReason || 'WECHAT_TRANSFER_FAILED',
|
error: result.failReason || 'WECHAT_TRANSFER_FAILED',
|
||||||
|
providerConfirmed: true,
|
||||||
note: input.note
|
note: input.note
|
||||||
});
|
});
|
||||||
return { settlement: failed, idempotent: false, transferState: result.state };
|
return { settlement: failed, idempotent: false, transferState: result.state };
|
||||||
@@ -284,15 +299,8 @@ export class CleaningPayoutService {
|
|||||||
});
|
});
|
||||||
return { settlement: pending, idempotent: false, transferState: result.state };
|
return { settlement: pending, idempotent: false, transferState: result.state };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof WechatPayError) {
|
// A transport/API exception is not proof that WeChat rejected the transfer.
|
||||||
await this.repository.recordSettlementPayoutFailure({
|
// Keep PROCESSING so cancellation and duplicate payout are blocked until sync/reconciliation.
|
||||||
...input,
|
|
||||||
payoutChannel: 'WECHAT_TRANSFER',
|
|
||||||
payoutReference: outBillNo,
|
|
||||||
error: error.code,
|
|
||||||
note: input.note
|
|
||||||
});
|
|
||||||
}
|
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -327,6 +335,7 @@ export class CleaningPayoutService {
|
|||||||
...input,
|
...input,
|
||||||
payoutChannel: 'WECHAT_TRANSFER',
|
payoutChannel: 'WECHAT_TRANSFER',
|
||||||
payoutReference: result.transferBillNo || result.outBillNo,
|
payoutReference: result.transferBillNo || result.outBillNo,
|
||||||
|
providerConfirmed: true,
|
||||||
note: input.note
|
note: input.note
|
||||||
});
|
});
|
||||||
return { settlement: paid, idempotent: false, transferState: result.state };
|
return { settlement: paid, idempotent: false, transferState: result.state };
|
||||||
@@ -337,6 +346,7 @@ export class CleaningPayoutService {
|
|||||||
payoutChannel: 'WECHAT_TRANSFER',
|
payoutChannel: 'WECHAT_TRANSFER',
|
||||||
payoutReference: result.transferBillNo || result.outBillNo,
|
payoutReference: result.transferBillNo || result.outBillNo,
|
||||||
error: result.failReason || 'WECHAT_TRANSFER_FAILED',
|
error: result.failReason || 'WECHAT_TRANSFER_FAILED',
|
||||||
|
providerConfirmed: true,
|
||||||
note: input.note
|
note: input.note
|
||||||
});
|
});
|
||||||
return { settlement: failed, idempotent: false, transferState: result.state };
|
return { settlement: failed, idempotent: false, transferState: result.state };
|
||||||
@@ -364,20 +374,29 @@ export class CleaningPayoutService {
|
|||||||
}
|
}
|
||||||
const outBillNo = stringPayload(payload, 'out_bill_no');
|
const outBillNo = stringPayload(payload, 'out_bill_no');
|
||||||
const transferBillNo = optionalStringPayload(payload, 'transfer_bill_no');
|
const transferBillNo = optionalStringPayload(payload, 'transfer_bill_no');
|
||||||
const settlement = await this.findSettlementByTransferReference(outBillNo, transferBillNo);
|
const settlement = await this.findSettlementByTransferReference(
|
||||||
|
credential.merchantId,
|
||||||
|
outBillNo,
|
||||||
|
transferBillNo
|
||||||
|
);
|
||||||
const actor: CleaningActor = {
|
const actor: CleaningActor = {
|
||||||
tenantId: settlement.tenantId,
|
tenantId: settlement.tenantId,
|
||||||
userId: '0',
|
userId: String(settlement.generatedBy),
|
||||||
access: { roles: ['PLATFORM_ADMIN'], capabilities: ['tenant.manage'], storeIds: [] },
|
access: { roles: ['PLATFORM_ADMIN'], capabilities: ['tenant.manage'], storeIds: [] },
|
||||||
traceId
|
traceId,
|
||||||
|
actorType: 'SYSTEM'
|
||||||
};
|
};
|
||||||
const state = stringPayload(payload, 'state');
|
const state = stringPayload(payload, 'state');
|
||||||
|
if (settlement.status === 'PAID' && state === 'SUCCESS') {
|
||||||
|
return { settlement, transferState: state, idempotent: true };
|
||||||
|
}
|
||||||
if (state === 'SUCCESS') {
|
if (state === 'SUCCESS') {
|
||||||
const paid = await this.repository.markSettlementPaid({
|
const paid = await this.repository.markSettlementPaid({
|
||||||
...actor,
|
...actor,
|
||||||
settlementId: settlement.id,
|
settlementId: settlement.id,
|
||||||
payoutChannel: 'WECHAT_TRANSFER',
|
payoutChannel: 'WECHAT_TRANSFER',
|
||||||
payoutReference: transferBillNo || outBillNo,
|
payoutReference: transferBillNo || outBillNo,
|
||||||
|
providerConfirmed: true,
|
||||||
note: '微信转账回调确认成功'
|
note: '微信转账回调确认成功'
|
||||||
});
|
});
|
||||||
return { settlement: paid, transferState: state, idempotent: false };
|
return { settlement: paid, transferState: state, idempotent: false };
|
||||||
@@ -389,6 +408,7 @@ export class CleaningPayoutService {
|
|||||||
payoutChannel: 'WECHAT_TRANSFER',
|
payoutChannel: 'WECHAT_TRANSFER',
|
||||||
payoutReference: transferBillNo || outBillNo,
|
payoutReference: transferBillNo || outBillNo,
|
||||||
error: optionalStringPayload(payload, 'fail_reason') || 'WECHAT_TRANSFER_FAILED',
|
error: optionalStringPayload(payload, 'fail_reason') || 'WECHAT_TRANSFER_FAILED',
|
||||||
|
providerConfirmed: true,
|
||||||
note: '微信转账回调确认失败'
|
note: '微信转账回调确认失败'
|
||||||
});
|
});
|
||||||
return { settlement: failed, transferState: state, idempotent: false };
|
return { settlement: failed, transferState: state, idempotent: false };
|
||||||
@@ -408,8 +428,10 @@ export class CleaningPayoutService {
|
|||||||
private async loadSettlement(tenantId: string, settlementId: string) {
|
private async loadSettlement(tenantId: string, settlementId: string) {
|
||||||
const [rows] = await this.pool.execute<SettlementPayoutRow[]>(
|
const [rows] = await this.pool.execute<SettlementPayoutRow[]>(
|
||||||
`SELECT id, settlement_no AS settlementNo, cleaner_user_id AS cleanerUserId,
|
`SELECT id, settlement_no AS settlementNo, cleaner_user_id AS cleanerUserId,
|
||||||
|
generated_by AS generatedBy,
|
||||||
store_id AS storeId, status, total_reward_cents AS totalRewardCents,
|
store_id AS storeId, status, total_reward_cents AS totalRewardCents,
|
||||||
payout_channel AS payoutChannel, payout_reference AS payoutReference,
|
payout_channel AS payoutChannel, payout_request_no AS payoutRequestNo,
|
||||||
|
payout_reference AS payoutReference,
|
||||||
payout_state AS payoutState, payout_package_info AS payoutPackageInfo
|
payout_state AS payoutState, payout_package_info AS payoutPackageInfo
|
||||||
FROM qipai_cleaning_settlements
|
FROM qipai_cleaning_settlements
|
||||||
WHERE tenant_id = ? AND id = ? AND deleted_at IS NULL LIMIT 1`,
|
WHERE tenant_id = ? AND id = ? AND deleted_at IS NULL LIMIT 1`,
|
||||||
@@ -468,19 +490,33 @@ export class CleaningPayoutService {
|
|||||||
throw new WechatPayError('WECHAT_CERTIFICATE_NOT_FOUND');
|
throw new WechatPayError('WECHAT_CERTIFICATE_NOT_FOUND');
|
||||||
}
|
}
|
||||||
|
|
||||||
private async findSettlementByTransferReference(outBillNo: string, transferBillNo: string) {
|
private async findSettlementByTransferReference(
|
||||||
|
merchantId: string,
|
||||||
|
outBillNo: string,
|
||||||
|
transferBillNo: string
|
||||||
|
) {
|
||||||
const references = transferBillNo ? [outBillNo, transferBillNo] : [outBillNo];
|
const references = transferBillNo ? [outBillNo, transferBillNo] : [outBillNo];
|
||||||
const [rows] = await this.pool.execute<Array<SettlementPayoutRow & { tenantId: string }>>(
|
const [rows] = await this.pool.execute<Array<SettlementPayoutRow & { tenantId: string }>>(
|
||||||
`SELECT tenant_id AS tenantId, id, settlement_no AS settlementNo,
|
`SELECT s.tenant_id AS tenantId, s.id, s.settlement_no AS settlementNo,
|
||||||
cleaner_user_id AS cleanerUserId, store_id AS storeId, status,
|
s.cleaner_user_id AS cleanerUserId, s.generated_by AS generatedBy,
|
||||||
total_reward_cents AS totalRewardCents, payout_channel AS payoutChannel,
|
s.store_id AS storeId, s.status,
|
||||||
payout_reference AS payoutReference, payout_state AS payoutState,
|
s.total_reward_cents AS totalRewardCents, s.payout_channel AS payoutChannel,
|
||||||
payout_package_info AS payoutPackageInfo
|
s.payout_request_no AS payoutRequestNo,
|
||||||
FROM qipai_cleaning_settlements
|
s.payout_reference AS payoutReference, s.payout_state AS payoutState,
|
||||||
WHERE payout_channel = 'WECHAT_TRANSFER' AND deleted_at IS NULL
|
s.payout_package_info AS payoutPackageInfo
|
||||||
AND payout_reference IN (${references.map(() => '?').join(',')})
|
FROM qipai_cleaning_settlements s
|
||||||
ORDER BY updated_at DESC, id DESC LIMIT 1`,
|
INNER JOIN qipai_collection_accounts account
|
||||||
references
|
ON account.tenant_id = s.tenant_id
|
||||||
|
AND account.provider = 'WECHAT' AND account.merchant_id = ?
|
||||||
|
AND account.enabled = 1
|
||||||
|
AND (account.store_id IS NULL OR account.store_id <=> s.store_id)
|
||||||
|
WHERE s.payout_channel = 'WECHAT_TRANSFER'
|
||||||
|
AND s.deleted_at IS NULL
|
||||||
|
AND (s.payout_request_no IN (${references.map(() => '?').join(',')})
|
||||||
|
OR s.payout_reference IN (${references.map(() => '?').join(',')}))
|
||||||
|
ORDER BY (account.store_id IS NOT NULL) DESC,
|
||||||
|
s.updated_at DESC, s.id DESC LIMIT 1`,
|
||||||
|
[merchantId, ...references, ...references]
|
||||||
);
|
);
|
||||||
if (!rows[0]) throw new CleaningPayoutError('WECHAT_TRANSFER_SETTLEMENT_NOT_FOUND');
|
if (!rows[0]) throw new CleaningPayoutError('WECHAT_TRANSFER_SETTLEMENT_NOT_FOUND');
|
||||||
return rows[0];
|
return rows[0];
|
||||||
@@ -488,8 +524,8 @@ export class CleaningPayoutService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function normalizeOutBillNo(settlementNo: string, settlementId: string) {
|
function normalizeOutBillNo(settlementNo: string, settlementId: string) {
|
||||||
const normalized = settlementNo.replace(/[^A-Za-z0-9_-]/g, '');
|
void settlementNo;
|
||||||
return (normalized || `CLP${settlementId}`).slice(0, 32);
|
return `CLP${settlementId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function stringPayload(payload: Record<string, unknown>, key: string) {
|
function stringPayload(payload: Record<string, unknown>, key: string) {
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export interface CleaningActor {
|
|||||||
userId: string;
|
userId: string;
|
||||||
access: AccessProfile;
|
access: AccessProfile;
|
||||||
traceId: string;
|
traceId: string;
|
||||||
|
actorType?: 'USER' | 'SYSTEM';
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CleaningTaskRow extends RowDataPacket {
|
interface CleaningTaskRow extends RowDataPacket {
|
||||||
@@ -154,9 +155,12 @@ interface SettlementRow extends RowDataPacket {
|
|||||||
periodStart: Date | null;
|
periodStart: Date | null;
|
||||||
periodEnd: Date | null;
|
periodEnd: Date | null;
|
||||||
paidBy: string | null;
|
paidBy: string | null;
|
||||||
|
cancelledBy: string | null;
|
||||||
confirmedAt: Date | null;
|
confirmedAt: Date | null;
|
||||||
paidAt: Date | null;
|
paidAt: Date | null;
|
||||||
|
cancelledAt: Date | null;
|
||||||
payoutChannel: string;
|
payoutChannel: string;
|
||||||
|
payoutRequestNo: string;
|
||||||
payoutReference: string;
|
payoutReference: string;
|
||||||
payoutState: string;
|
payoutState: string;
|
||||||
payoutPackageInfo: string;
|
payoutPackageInfo: string;
|
||||||
@@ -176,9 +180,31 @@ interface SettlementItemRow extends RowDataPacket {
|
|||||||
cleanerUserId: string;
|
cleanerUserId: string;
|
||||||
cleanerName: string;
|
cleanerName: string;
|
||||||
rewardCents: number;
|
rewardCents: number;
|
||||||
|
reversedAt: Date | null;
|
||||||
completedAt: Date | null;
|
completedAt: Date | null;
|
||||||
createdAt: Date;
|
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 {
|
interface ManagerStatusStatsRow extends RowDataPacket {
|
||||||
status: CleaningTaskStatus;
|
status: CleaningTaskStatus;
|
||||||
total: number;
|
total: number;
|
||||||
@@ -596,6 +622,7 @@ export class CleaningTaskRepository {
|
|||||||
if (!['CLAIMED', 'STARTED', 'SUBMITTED', 'COMPLETED'].includes(task.status)) {
|
if (!['CLAIMED', 'STARTED', 'SUBMITTED', 'COMPLETED'].includes(task.status)) {
|
||||||
throw new CleaningTaskError('CLEANING_TASK_STATUS_CONFLICT');
|
throw new CleaningTaskError('CLEANING_TASK_STATUS_CONFLICT');
|
||||||
}
|
}
|
||||||
|
await this.assertTaskSettlementMutable(connection, input.tenantId, input.taskId);
|
||||||
const [existingMembers] = await connection.execute<CleaningTaskMemberRow[]>(
|
const [existingMembers] = await connection.execute<CleaningTaskMemberRow[]>(
|
||||||
`SELECT m.id, m.task_id AS taskId, m.user_id AS userId, '' AS nickname,
|
`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,
|
m.member_role AS memberRole, m.reward_cents AS rewardCents,
|
||||||
@@ -643,6 +670,7 @@ export class CleaningTaskRepository {
|
|||||||
if (String(task.cleanerUserId) === input.cleanerUserId) {
|
if (String(task.cleanerUserId) === input.cleanerUserId) {
|
||||||
throw new CleaningTaskError('CLEANING_MEMBER_LEAD_NOT_REMOVABLE');
|
throw new CleaningTaskError('CLEANING_MEMBER_LEAD_NOT_REMOVABLE');
|
||||||
}
|
}
|
||||||
|
await this.assertTaskSettlementMutable(connection, input.tenantId, input.taskId);
|
||||||
const [result] = await connection.execute<ResultSetHeader>(
|
const [result] = await connection.execute<ResultSetHeader>(
|
||||||
`UPDATE qipai_cleaning_task_members
|
`UPDATE qipai_cleaning_task_members
|
||||||
SET removed_at = UTC_TIMESTAMP(3), reward_cents = 0, updated_at = UTC_TIMESTAMP(3)
|
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.deleted_at IS NULL',
|
||||||
"t.status = 'COMPLETED'",
|
"t.status = 'COMPLETED'",
|
||||||
't.settled_at IS NULL',
|
'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')
|
storeScopeSql(input.access, 't.store_id')
|
||||||
];
|
];
|
||||||
const params: Array<string | number> = [input.tenantId];
|
const params: Array<string | number> = [input.tenantId];
|
||||||
@@ -676,15 +719,23 @@ export class CleaningTaskRepository {
|
|||||||
params.push(input.storeId);
|
params.push(input.storeId);
|
||||||
}
|
}
|
||||||
if (input.cleanerUserId) {
|
if (input.cleanerUserId) {
|
||||||
where.push(`(t.cleaner_user_id = ? OR EXISTS (
|
where.push(`EXISTS (
|
||||||
SELECT 1 FROM qipai_cleaning_task_members candidate_member
|
SELECT 1 FROM qipai_cleaning_task_members candidate_member
|
||||||
WHERE candidate_member.tenant_id = t.tenant_id
|
WHERE candidate_member.tenant_id = t.tenant_id
|
||||||
AND candidate_member.task_id = t.id
|
AND candidate_member.task_id = t.id
|
||||||
AND candidate_member.user_id = ?
|
AND candidate_member.user_id = ?
|
||||||
AND candidate_member.removed_at IS NULL
|
AND candidate_member.removed_at IS NULL
|
||||||
AND candidate_member.settled_at IS NULL
|
AND candidate_member.settled_at IS NULL
|
||||||
))`);
|
AND candidate_member.reward_cents > 0
|
||||||
params.push(input.cleanerUserId, input.cleanerUserId);
|
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');
|
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;
|
payoutState?: CleaningSettlementPayoutState; storeId?: string; cleanerUserId?: string;
|
||||||
}) {
|
}) {
|
||||||
this.assertSettlement(input.access, 'read');
|
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 pageSize = Math.max(1, Math.min(100, Math.trunc(input.pageSize)));
|
||||||
const offset = Math.max(0, (Math.trunc(input.page) - 1) * pageSize);
|
const offset = Math.max(0, (Math.trunc(input.page) - 1) * pageSize);
|
||||||
const [counts] = await this.pool.execute<CountRow[]>(
|
const { rows, total } = await this.querySettlementRows(input, pageSize, offset);
|
||||||
`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
|
|
||||||
);
|
|
||||||
return {
|
return {
|
||||||
items: rows.map(publicSettlement),
|
items: rows.map(publicSettlement),
|
||||||
total: Number(counts[0]?.total ?? 0),
|
total,
|
||||||
page: input.page,
|
page: input.page,
|
||||||
pageSize
|
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 }) {
|
async getSettlementDetail(input: CleaningActor & { settlementId: string }) {
|
||||||
this.assertSettlement(input.access, 'read');
|
this.assertSettlement(input.access, 'read');
|
||||||
const settlement = await this.getSettlementScoped(input);
|
const settlement = await this.getSettlementScoped(input);
|
||||||
@@ -762,7 +779,8 @@ export class CleaningTaskRepository {
|
|||||||
t.task_no AS taskNo, o.order_no AS orderNo,
|
t.task_no AS taskNo, o.order_no AS orderNo,
|
||||||
st.name AS storeName, r.name AS roomName, r.room_no AS roomNo,
|
st.name AS storeName, r.name AS roomName, r.room_no AS roomNo,
|
||||||
i.cleaner_user_id AS cleanerUserId, u.nickname AS cleanerName,
|
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
|
i.created_at AS createdAt
|
||||||
FROM qipai_cleaning_settlement_items i
|
FROM qipai_cleaning_settlement_items i
|
||||||
INNER JOIN qipai_cleaning_settlements s ON s.tenant_id = i.tenant_id
|
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`,
|
ORDER BY t.completed_at ASC, i.id ASC`,
|
||||||
[input.tenantId, input.settlementId]
|
[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 & {
|
async generateSettlement(input: CleaningActor & {
|
||||||
cleanerUserId: string; storeId?: string; note?: string;
|
cleanerUserId: string; storeId?: string; note?: string;
|
||||||
}) {
|
}) {
|
||||||
this.assertSettlement(input.access, 'write');
|
this.assertSettlement(input.access, 'write');
|
||||||
return this.transaction(async (connection) => {
|
try {
|
||||||
|
return await this.transaction(async (connection) => {
|
||||||
await connection.execute(
|
await connection.execute(
|
||||||
`INSERT IGNORE INTO qipai_cleaning_task_members
|
`INSERT IGNORE INTO qipai_cleaning_task_members
|
||||||
(tenant_id, task_id, user_id, member_role, reward_cents, joined_at)
|
(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
|
AND m.settled_at IS NULL AND m.reward_cents > 0
|
||||||
WHERE t.tenant_id = ?
|
WHERE t.tenant_id = ?
|
||||||
AND t.status = 'COMPLETED' AND t.deleted_at IS NULL
|
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}
|
${storeFilter} AND ${scopeSql}
|
||||||
ORDER BY t.completed_at ASC, t.id ASC
|
ORDER BY t.completed_at ASC, t.id ASC
|
||||||
FOR UPDATE`,
|
FOR UPDATE`,
|
||||||
@@ -815,10 +865,15 @@ export class CleaningTaskRepository {
|
|||||||
);
|
);
|
||||||
if (tasks.length === 0) throw new CleaningTaskError('CLEANING_SETTLEMENT_EMPTY');
|
if (tasks.length === 0) throw new CleaningTaskError('CLEANING_SETTLEMENT_EMPTY');
|
||||||
const storeIds = Array.from(new Set(tasks.map((task) => String(task.storeId))));
|
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 totalRewardCents = tasks.reduce((sum, task) => sum + Number(task.rewardCents), 0);
|
||||||
const periodStart = minDate(tasks.map((task) => task.completedAt));
|
const periodStart = minDate(tasks.map((task) => task.completedAt));
|
||||||
const periodEnd = maxDate(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>(
|
const [created] = await connection.execute<ResultSetHeader>(
|
||||||
`INSERT INTO qipai_cleaning_settlements
|
`INSERT INTO qipai_cleaning_settlements
|
||||||
(tenant_id, settlement_no, cleaner_user_id, store_id, status, task_count,
|
(tenant_id, settlement_no, cleaner_user_id, store_id, status, task_count,
|
||||||
@@ -832,39 +887,41 @@ export class CleaningTaskRepository {
|
|||||||
]
|
]
|
||||||
);
|
);
|
||||||
const settlementId = String(created.insertId);
|
const settlementId = String(created.insertId);
|
||||||
for (const task of tasks) {
|
try {
|
||||||
await connection.execute(
|
for (const task of tasks) {
|
||||||
`INSERT INTO qipai_cleaning_settlement_items
|
await connection.execute(
|
||||||
(tenant_id, settlement_id, task_id, cleaner_user_id, reward_cents)
|
`INSERT INTO qipai_cleaning_settlement_items
|
||||||
VALUES (?, ?, ?, ?, ?)`,
|
(tenant_id, settlement_id, task_id, cleaner_user_id, reward_cents)
|
||||||
[input.tenantId, settlementId, task.id, input.cleanerUserId, Number(task.rewardCents)]
|
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(
|
await this.recordSettlementEventWithConnection(
|
||||||
`UPDATE qipai_cleaning_task_members
|
connection, input, settlementId, null, 'DRAFT', 'GENERATE', input.note ?? '', {
|
||||||
SET settled_at = UTC_TIMESTAMP(3)
|
cleanerUserId: input.cleanerUserId,
|
||||||
WHERE tenant_id = ? AND user_id = ? AND task_id IN (${tasks.map(() => '?').join(',')})`,
|
taskCount: tasks.length,
|
||||||
[input.tenantId, input.cleanerUserId, ...tasks.map((task) => task.id)]
|
totalRewardCents
|
||||||
);
|
}
|
||||||
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)]
|
|
||||||
);
|
);
|
||||||
for (const task of tasks) {
|
for (const task of tasks) {
|
||||||
await this.recordEventWithConnection(
|
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 }) {
|
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]
|
[input.userId, input.note ?? '', input.note ?? '', input.tenantId, input.settlementId]
|
||||||
);
|
);
|
||||||
if (result.affectedRows !== 1) throw new CleaningTaskError('CLEANING_SETTLEMENT_STATUS_CONFLICT');
|
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);
|
return this.getSettlement(connection, input.tenantId, input.settlementId);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async markSettlementPaid(input: CleaningActor & {
|
async markSettlementPaid(input: CleaningActor & {
|
||||||
settlementId: string; payoutChannel: string; payoutReference: string; note?: string;
|
settlementId: string; payoutChannel: string; payoutReference: string; note?: string;
|
||||||
|
providerConfirmed?: boolean; manualOverride?: boolean;
|
||||||
}) {
|
}) {
|
||||||
this.assertSettlement(input.access, 'write');
|
this.assertSettlement(input.access, 'write');
|
||||||
return this.transaction(async (connection) => {
|
return this.transaction(async (connection) => {
|
||||||
@@ -895,6 +956,11 @@ export class CleaningTaskRepository {
|
|||||||
s.payout_package_info = '', s.payout_error = '',
|
s.payout_package_info = '', s.payout_error = '',
|
||||||
s.note = CASE WHEN ? = '' THEN s.note ELSE ? END
|
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
|
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)')}`,
|
AND ${storeScopeSql(input.access, 'COALESCE(s.store_id, 0)')}`,
|
||||||
[
|
[
|
||||||
input.userId, input.payoutChannel, input.payoutReference,
|
input.userId, input.payoutChannel, input.payoutReference,
|
||||||
@@ -902,12 +968,58 @@ export class CleaningTaskRepository {
|
|||||||
]
|
]
|
||||||
);
|
);
|
||||||
if (result.affectedRows !== 1) throw new CleaningTaskError('CLEANING_SETTLEMENT_STATUS_CONFLICT');
|
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);
|
return this.getSettlement(connection, input.tenantId, input.settlementId);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async recordSettlementPayoutFailure(input: CleaningActor & {
|
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');
|
this.assertSettlement(input.access, 'write');
|
||||||
return this.transaction(async (connection) => {
|
return this.transaction(async (connection) => {
|
||||||
@@ -918,6 +1030,9 @@ export class CleaningTaskRepository {
|
|||||||
s.payout_state = 'FAIL', s.payout_package_info = '', s.payout_error = ?,
|
s.payout_state = 'FAIL', s.payout_package_info = '', s.payout_error = ?,
|
||||||
s.note = CASE WHEN ? = '' THEN s.note ELSE ? END
|
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
|
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)')}`,
|
AND ${storeScopeSql(input.access, 'COALESCE(s.store_id, 0)')}`,
|
||||||
[
|
[
|
||||||
input.payoutChannel ?? '', input.payoutChannel ?? '',
|
input.payoutChannel ?? '', input.payoutChannel ?? '',
|
||||||
@@ -927,6 +1042,47 @@ export class CleaningTaskRepository {
|
|||||||
]
|
]
|
||||||
);
|
);
|
||||||
if (result.affectedRows !== 1) throw new CleaningTaskError('CLEANING_SETTLEMENT_STATUS_CONFLICT');
|
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);
|
return this.getSettlement(connection, input.tenantId, input.settlementId);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -943,6 +1099,7 @@ export class CleaningTaskRepository {
|
|||||||
s.payout_package_info = ?, s.payout_error = '',
|
s.payout_package_info = ?, s.payout_error = '',
|
||||||
s.note = CASE WHEN ? = '' THEN s.note ELSE ? END
|
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
|
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)')}`,
|
AND ${storeScopeSql(input.access, 'COALESCE(s.store_id, 0)')}`,
|
||||||
[
|
[
|
||||||
input.payoutChannel, input.payoutReference, input.payoutState.slice(0, 32),
|
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');
|
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);
|
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
|
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
|
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 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]
|
[input.tenantId, input.userId]
|
||||||
);
|
);
|
||||||
const [settledRows] = await this.pool.execute<AmountRow[]>(
|
const [settledRows] = await this.pool.execute<AmountRow[]>(
|
||||||
`SELECT COALESCE(SUM(m.reward_cents), 0) AS amount
|
`SELECT COALESCE(SUM(i.reward_cents), 0) AS amount
|
||||||
FROM qipai_cleaning_task_members m
|
FROM qipai_cleaning_settlement_items i
|
||||||
INNER JOIN qipai_cleaning_tasks t ON t.tenant_id = m.tenant_id AND t.id = m.task_id
|
INNER JOIN qipai_cleaning_settlements s
|
||||||
WHERE m.tenant_id = ? AND m.user_id = ? AND m.removed_at IS NULL
|
ON s.tenant_id = i.tenant_id AND s.id = i.settlement_id
|
||||||
AND m.settled_at IS NOT NULL AND m.reward_cents > 0
|
WHERE i.tenant_id = ? AND i.cleaner_user_id = ? AND i.reversed_at IS NULL
|
||||||
AND t.deleted_at IS NULL`,
|
AND s.status = 'PAID' AND s.deleted_at IS NULL`,
|
||||||
[input.tenantId, input.userId]
|
[input.tenantId, input.userId]
|
||||||
);
|
);
|
||||||
const byStatus = Object.fromEntries(counts.map((row) => [row.status, Number(row.total)]));
|
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 IN ('COMPLETED', 'SETTLED') THEN 1 ELSE 0 END) AS completedTaskCount,
|
||||||
SUM(CASE WHEN t.status = 'REJECTED' THEN 1 ELSE 0 END) AS rejectedTaskCount,
|
SUM(CASE WHEN t.status = 'REJECTED' THEN 1 ELSE 0 END) AS rejectedTaskCount,
|
||||||
COALESCE(SUM(CASE
|
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,
|
THEN m.reward_cents ELSE 0 END), 0) AS pendingSettlementCents,
|
||||||
COALESCE(SUM(CASE
|
COALESCE(SUM(COALESCE((
|
||||||
WHEN m.settled_at IS NOT NULL THEN m.reward_cents ELSE 0 END), 0) AS settledRewardCents
|
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
|
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_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
|
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);
|
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(
|
private async listByWhere(
|
||||||
where: string[],
|
where: string[],
|
||||||
params: Array<string | number>,
|
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(
|
private async getTemplate(
|
||||||
connection: Pick<MySqlPool, 'execute'> | PoolConnection,
|
connection: Pick<MySqlPool, 'execute'> | PoolConnection,
|
||||||
tenantId: string,
|
tenantId: string,
|
||||||
@@ -1718,9 +2060,31 @@ export class CleaningTaskRepository {
|
|||||||
await connection.execute(
|
await connection.execute(
|
||||||
`INSERT INTO qipai_cleaning_task_events
|
`INSERT INTO qipai_cleaning_task_events
|
||||||
(tenant_id, task_id, from_status, to_status, action, actor_id, trace_id, note, metadata)
|
(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.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.store_id AS storeId, st.name AS storeName, s.status,
|
||||||
s.task_count AS taskCount, s.total_reward_cents AS totalRewardCents,
|
s.task_count AS taskCount, s.total_reward_cents AS totalRewardCents,
|
||||||
s.period_start AS periodStart, s.period_end AS periodEnd,
|
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.paid_by AS paidBy, s.cancelled_by AS cancelledBy,
|
||||||
s.payout_channel AS payoutChannel, s.payout_reference AS payoutReference,
|
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_state AS payoutState, s.payout_package_info AS payoutPackageInfo,
|
||||||
s.payout_error AS payoutError,
|
s.payout_error AS payoutError,
|
||||||
s.note, s.created_at AS createdAt
|
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.store_id AS storeId, st.name AS storeName, s.status,
|
||||||
s.task_count AS taskCount, s.total_reward_cents AS totalRewardCents,
|
s.task_count AS taskCount, s.total_reward_cents AS totalRewardCents,
|
||||||
s.period_start AS periodStart, s.period_end AS periodEnd,
|
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.paid_by AS paidBy, s.cancelled_by AS cancelledBy,
|
||||||
s.payout_channel AS payoutChannel, s.payout_reference AS payoutReference,
|
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_state AS payoutState, s.payout_package_info AS payoutPackageInfo,
|
||||||
s.payout_error AS payoutError,
|
s.payout_error AS payoutError,
|
||||||
s.note, s.created_at AS createdAt
|
s.note, s.created_at AS createdAt
|
||||||
@@ -1899,6 +2269,7 @@ function publicTaskEvent(row: CleaningTaskEventRow) {
|
|||||||
toStatus: row.toStatus,
|
toStatus: row.toStatus,
|
||||||
action: row.action,
|
action: row.action,
|
||||||
actorId: String(row.actorId),
|
actorId: String(row.actorId),
|
||||||
|
actorType: row.actorType,
|
||||||
traceId: row.traceId,
|
traceId: row.traceId,
|
||||||
note: row.note,
|
note: row.note,
|
||||||
createdAt: row.createdAt
|
createdAt: row.createdAt
|
||||||
@@ -1935,9 +2306,12 @@ function publicSettlement(row: SettlementRow) {
|
|||||||
periodStart: row.periodStart,
|
periodStart: row.periodStart,
|
||||||
periodEnd: row.periodEnd,
|
periodEnd: row.periodEnd,
|
||||||
paidBy: row.paidBy === null ? null : String(row.paidBy),
|
paidBy: row.paidBy === null ? null : String(row.paidBy),
|
||||||
|
cancelledBy: row.cancelledBy === null ? null : String(row.cancelledBy),
|
||||||
confirmedAt: row.confirmedAt,
|
confirmedAt: row.confirmedAt,
|
||||||
paidAt: row.paidAt,
|
paidAt: row.paidAt,
|
||||||
|
cancelledAt: row.cancelledAt,
|
||||||
payoutChannel: row.payoutChannel,
|
payoutChannel: row.payoutChannel,
|
||||||
|
payoutRequestNo: row.payoutRequestNo,
|
||||||
payoutReference: row.payoutReference,
|
payoutReference: row.payoutReference,
|
||||||
payoutState: row.payoutState,
|
payoutState: row.payoutState,
|
||||||
payoutPackageInfo: row.payoutPackageInfo,
|
payoutPackageInfo: row.payoutPackageInfo,
|
||||||
@@ -1960,11 +2334,38 @@ function publicSettlementItem(row: SettlementItemRow) {
|
|||||||
cleanerUserId: String(row.cleanerUserId),
|
cleanerUserId: String(row.cleanerUserId),
|
||||||
cleanerName: row.cleanerName,
|
cleanerName: row.cleanerName,
|
||||||
rewardCents: Number(row.rewardCents),
|
rewardCents: Number(row.rewardCents),
|
||||||
|
reversedAt: row.reversedAt,
|
||||||
completedAt: row.completedAt,
|
completedAt: row.completedAt,
|
||||||
createdAt: row.createdAt
|
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(
|
function publicManagerStatistics(
|
||||||
statusRows: ManagerStatusStatsRow[],
|
statusRows: ManagerStatusStatsRow[],
|
||||||
storeRows: ManagerStoreStatsRow[],
|
storeRows: ManagerStoreStatsRow[],
|
||||||
@@ -1992,6 +2393,9 @@ function publicManagerStatistics(
|
|||||||
completed: countStatus(byStatus, 'COMPLETED') + countStatus(byStatus, 'SETTLED'),
|
completed: countStatus(byStatus, 'COMPLETED') + countStatus(byStatus, 'SETTLED'),
|
||||||
rewardCents: byStatus.reduce((sum, row) => sum + row.rewardCents, 0),
|
rewardCents: byStatus.reduce((sum, row) => sum + row.rewardCents, 0),
|
||||||
pendingSettlementCents: memberRows.reduce((sum, row) => sum + Number(row.pendingSettlementCents ?? 0), 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
|
confirmedSettlementCents: settlementByStatus
|
||||||
.filter((row) => row.status === 'CONFIRMED')
|
.filter((row) => row.status === 'CONFIRMED')
|
||||||
.reduce((sum, row) => sum + row.rewardCents, 0),
|
.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())));
|
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[] {
|
function parseJsonArray(value: string | string[] | null): string[] {
|
||||||
if (Array.isArray(value)) return value.map(String);
|
if (Array.isArray(value)) return value.map(String);
|
||||||
if (!value) return [];
|
if (!value) return [];
|
||||||
|
|||||||
@@ -54,7 +54,8 @@ const migrationFiles: Record<MigrationDirection, readonly string[]> = {
|
|||||||
'database/migrations/2026081002_m08d_content_asset_scope.up.sql',
|
'database/migrations/2026081002_m08d_content_asset_scope.up.sql',
|
||||||
'database/migrations/2026081003_m08d_franchise_leads.up.sql',
|
'database/migrations/2026081003_m08d_franchise_leads.up.sql',
|
||||||
'database/migrations/2026081004_m08d_admin_password_auth.up.sql',
|
'database/migrations/2026081004_m08d_admin_password_auth.up.sql',
|
||||||
'database/migrations/2026081005_m09b_cleaning_rules.up.sql'
|
'database/migrations/2026081005_m09b_cleaning_rules.up.sql',
|
||||||
|
'database/migrations/2026081006_m09c_cleaning_settlement_integrity.up.sql'
|
||||||
],
|
],
|
||||||
verify: [
|
verify: [
|
||||||
'database/migrations/2026061601_m01b_core_schema.verify.sql',
|
'database/migrations/2026061601_m01b_core_schema.verify.sql',
|
||||||
@@ -90,9 +91,11 @@ const migrationFiles: Record<MigrationDirection, readonly string[]> = {
|
|||||||
'database/migrations/2026081002_m08d_content_asset_scope.verify.sql',
|
'database/migrations/2026081002_m08d_content_asset_scope.verify.sql',
|
||||||
'database/migrations/2026081003_m08d_franchise_leads.verify.sql',
|
'database/migrations/2026081003_m08d_franchise_leads.verify.sql',
|
||||||
'database/migrations/2026081004_m08d_admin_password_auth.verify.sql',
|
'database/migrations/2026081004_m08d_admin_password_auth.verify.sql',
|
||||||
'database/migrations/2026081005_m09b_cleaning_rules.verify.sql'
|
'database/migrations/2026081005_m09b_cleaning_rules.verify.sql',
|
||||||
|
'database/migrations/2026081006_m09c_cleaning_settlement_integrity.verify.sql'
|
||||||
],
|
],
|
||||||
down: [
|
down: [
|
||||||
|
'database/migrations/2026081006_m09c_cleaning_settlement_integrity.down.sql',
|
||||||
'database/migrations/2026081005_m09b_cleaning_rules.down.sql',
|
'database/migrations/2026081005_m09b_cleaning_rules.down.sql',
|
||||||
'database/migrations/2026081004_m08d_admin_password_auth.down.sql',
|
'database/migrations/2026081004_m08d_admin_password_auth.down.sql',
|
||||||
'database/migrations/2026081003_m08d_franchise_leads.down.sql',
|
'database/migrations/2026081003_m08d_franchise_leads.down.sql',
|
||||||
@@ -274,7 +277,9 @@ export async function executeMigrationPlan(
|
|||||||
2, 1, 1,
|
2, 1, 1,
|
||||||
1, 1, 1,
|
1, 1, 1,
|
||||||
1, 2, 3, 1,
|
1, 2, 3, 1,
|
||||||
1, 2, 3, 1
|
1, 2, 3, 1,
|
||||||
|
3, 7, 3, 1,
|
||||||
|
2, 8, 4, 1
|
||||||
][index] ?? 1;
|
][index] ?? 1;
|
||||||
if (!Array.isArray(result) || result.length < minimumRows) {
|
if (!Array.isArray(result) || result.length < minimumRows) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ const settlementListSchema = z.object({
|
|||||||
storeId: z.string().regex(/^[1-9]\d{0,19}$/).optional(),
|
storeId: z.string().regex(/^[1-9]\d{0,19}$/).optional(),
|
||||||
cleanerUserId: z.string().regex(/^[1-9]\d{0,19}$/).optional()
|
cleanerUserId: z.string().regex(/^[1-9]\d{0,19}$/).optional()
|
||||||
}).strict();
|
}).strict();
|
||||||
|
const settlementExportSchema = settlementListSchema.omit({ page: true, pageSize: true });
|
||||||
const settlementParamsSchema = z.object({ settlementId: z.string().regex(/^[1-9]\d{0,19}$/) });
|
const settlementParamsSchema = z.object({ settlementId: z.string().regex(/^[1-9]\d{0,19}$/) });
|
||||||
const settlementGenerateSchema = z.object({
|
const settlementGenerateSchema = z.object({
|
||||||
cleanerUserId: z.string().regex(/^[1-9]\d{0,19}$/),
|
cleanerUserId: z.string().regex(/^[1-9]\d{0,19}$/),
|
||||||
@@ -93,6 +94,9 @@ const settlementGenerateSchema = z.object({
|
|||||||
const settlementConfirmSchema = z.object({
|
const settlementConfirmSchema = z.object({
|
||||||
note: z.string().trim().max(512).optional()
|
note: z.string().trim().max(512).optional()
|
||||||
}).strict();
|
}).strict();
|
||||||
|
const settlementCancelSchema = z.object({
|
||||||
|
reason: z.string().trim().min(1).max(512)
|
||||||
|
}).strict();
|
||||||
const settlementPaidSchema = z.object({
|
const settlementPaidSchema = z.object({
|
||||||
payoutChannel: z.string().trim().min(1).max(32),
|
payoutChannel: z.string().trim().min(1).max(32),
|
||||||
payoutReference: z.string().trim().min(1).max(128),
|
payoutReference: z.string().trim().min(1).max(128),
|
||||||
@@ -129,7 +133,7 @@ export interface CleaningRouteOptions {
|
|||||||
repository: Pick<CleaningTaskRepository,
|
repository: Pick<CleaningTaskRepository,
|
||||||
'listHall' | 'listMine' | 'listManage' | 'claim' | 'start' | 'rework' | 'submit'
|
'listHall' | 'listMine' | 'listManage' | 'claim' | 'start' | 'rework' | 'submit'
|
||||||
| 'assign' | 'complete' | 'reject' | 'exempt' | 'listMembers' | 'listEvents' | 'listSubmissions' | 'addMember' | 'removeMember' | 'settlementCandidates'
|
| 'assign' | 'complete' | 'reject' | 'exempt' | 'listMembers' | 'listEvents' | 'listSubmissions' | 'addMember' | 'removeMember' | 'settlementCandidates'
|
||||||
| 'listSettlements' | 'getSettlementDetail' | 'generateSettlement' | 'confirmSettlement' | 'markSettlementPaid'
|
| 'listSettlements' | 'exportSettlements' | 'getSettlementDetail' | 'generateSettlement' | 'confirmSettlement' | 'cancelSettlement' | 'markSettlementPaid'
|
||||||
| 'recordSettlementPayoutFailure' | 'reclaimTimeouts'
|
| 'recordSettlementPayoutFailure' | 'reclaimTimeouts'
|
||||||
| 'assertCanUploadPhoto' | 'recordPhotoUpload' | 'claimExpiredPhotoUploads' | 'markPhotoUploadsDeleted'
|
| 'assertCanUploadPhoto' | 'recordPhotoUpload' | 'claimExpiredPhotoUploads' | 'markPhotoUploadsDeleted'
|
||||||
| 'listTemplates' | 'upsertTemplate'
|
| 'listTemplates' | 'upsertTemplate'
|
||||||
@@ -269,6 +273,18 @@ export async function registerCleaningRoutes(
|
|||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.get('/admin-api/cleaning/settlements/export', async (request, reply) => {
|
||||||
|
const actor = await requireActor(request, reply, options, 'read');
|
||||||
|
if (!actor) return;
|
||||||
|
const query = settlementExportSchema.safeParse(request.query);
|
||||||
|
if (!query.success) return invalid(reply, request.traceId);
|
||||||
|
return handle(reply, request.traceId, async () => ({
|
||||||
|
code: 0,
|
||||||
|
data: await options.repository.exportSettlements({ ...actor, ...query.data }),
|
||||||
|
traceId: request.traceId
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
app.get('/admin-api/cleaning/settlements/:settlementId', async (request, reply) => {
|
app.get('/admin-api/cleaning/settlements/:settlementId', async (request, reply) => {
|
||||||
const actor = await requireActor(request, reply, options, 'read');
|
const actor = await requireActor(request, reply, options, 'read');
|
||||||
if (!actor) return;
|
if (!actor) return;
|
||||||
@@ -607,6 +623,23 @@ export async function registerCleaningRoutes(
|
|||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.post('/admin-api/cleaning/settlements/:settlementId/cancel', async (request, reply) => {
|
||||||
|
const actor = await requireActor(request, reply, options, 'write');
|
||||||
|
if (!actor) return;
|
||||||
|
const params = settlementParamsSchema.safeParse(request.params);
|
||||||
|
const body = settlementCancelSchema.safeParse(request.body ?? {});
|
||||||
|
if (!params.success || !body.success) return invalid(reply, request.traceId);
|
||||||
|
return handle(reply, request.traceId, async () => ({
|
||||||
|
code: 0,
|
||||||
|
data: await options.repository.cancelSettlement({
|
||||||
|
...actor,
|
||||||
|
settlementId: params.data.settlementId,
|
||||||
|
reason: body.data.reason
|
||||||
|
}),
|
||||||
|
traceId: request.traceId
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
app.post('/admin-api/cleaning/settlements/:settlementId/paid', async (request, reply) => {
|
app.post('/admin-api/cleaning/settlements/:settlementId/paid', async (request, reply) => {
|
||||||
const actor = await requireActor(request, reply, options, 'write');
|
const actor = await requireActor(request, reply, options, 'write');
|
||||||
if (!actor) return;
|
if (!actor) return;
|
||||||
@@ -620,7 +653,8 @@ export async function registerCleaningRoutes(
|
|||||||
settlementId: params.data.settlementId,
|
settlementId: params.data.settlementId,
|
||||||
payoutChannel: body.data.payoutChannel,
|
payoutChannel: body.data.payoutChannel,
|
||||||
payoutReference: body.data.payoutReference,
|
payoutReference: body.data.payoutReference,
|
||||||
note: body.data.note
|
note: body.data.note,
|
||||||
|
manualOverride: true
|
||||||
}),
|
}),
|
||||||
traceId: request.traceId
|
traceId: request.traceId
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -60,6 +60,8 @@ const actor = {
|
|||||||
mode: 'API'
|
mode: 'API'
|
||||||
});
|
});
|
||||||
assert.equal(result.transferState, 'SUCCESS');
|
assert.equal(result.transferState, 'SUCCESS');
|
||||||
|
assert.equal(harness.repository.begun.length, 1);
|
||||||
|
assert.equal(harness.repository.begun[0].payoutReference, 'CLP501');
|
||||||
assert.equal(harness.repository.paid[0].payoutChannel, 'WECHAT_TRANSFER');
|
assert.equal(harness.repository.paid[0].payoutChannel, 'WECHAT_TRANSFER');
|
||||||
assert.equal(harness.state.transferInput.amountCents, 1200);
|
assert.equal(harness.state.transferInput.amountCents, 1200);
|
||||||
assert.equal(harness.state.transferInput.openid, 'openid-cleaner');
|
assert.equal(harness.state.transferInput.openid, 'openid-cleaner');
|
||||||
@@ -79,6 +81,20 @@ const actor = {
|
|||||||
assert.equal(harness.repository.paid.length, 0);
|
assert.equal(harness.repository.paid.length, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const harness = createHarness({ payoutState: 'PROCESSING' });
|
||||||
|
await assert.rejects(
|
||||||
|
() => harness.service.executeWechatTransfer({
|
||||||
|
...actor,
|
||||||
|
settlementId: '501',
|
||||||
|
mode: 'API'
|
||||||
|
}),
|
||||||
|
(error) => error instanceof CleaningPayoutError
|
||||||
|
&& error.code === 'CLEANING_PAYOUT_ALREADY_STARTED'
|
||||||
|
);
|
||||||
|
assert.equal(harness.repository.begun.length, 0);
|
||||||
|
}
|
||||||
|
|
||||||
{
|
{
|
||||||
const harness = createHarness({ queryState: 'SUCCESS' });
|
const harness = createHarness({ queryState: 'SUCCESS' });
|
||||||
const result = await harness.service.syncWechatTransfer({
|
const result = await harness.service.syncWechatTransfer({
|
||||||
@@ -87,7 +103,7 @@ const actor = {
|
|||||||
note: 'poll success'
|
note: 'poll success'
|
||||||
});
|
});
|
||||||
assert.equal(result.transferState, 'SUCCESS');
|
assert.equal(result.transferState, 'SUCCESS');
|
||||||
assert.equal(harness.state.queryOutBillNo, 'CLS-20260627-501');
|
assert.equal(harness.state.queryOutBillNo, 'CLP501');
|
||||||
assert.equal(harness.repository.paid[0].payoutReference, 'wx-transfer-query-501');
|
assert.equal(harness.repository.paid[0].payoutReference, 'wx-transfer-query-501');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,7 +131,7 @@ const actor = {
|
|||||||
const harness = createHarness({
|
const harness = createHarness({
|
||||||
notificationPayload: {
|
notificationPayload: {
|
||||||
mch_id: '1900000109',
|
mch_id: '1900000109',
|
||||||
out_bill_no: 'CLS-20260627-501',
|
out_bill_no: 'CLP501',
|
||||||
transfer_bill_no: 'wx-notify-501',
|
transfer_bill_no: 'wx-notify-501',
|
||||||
state: 'SUCCESS'
|
state: 'SUCCESS'
|
||||||
}
|
}
|
||||||
@@ -132,6 +148,27 @@ const actor = {
|
|||||||
assert.equal(harness.repository.paid[0].payoutReference, 'wx-notify-501');
|
assert.equal(harness.repository.paid[0].payoutReference, 'wx-notify-501');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const harness = createHarness({
|
||||||
|
status: 'PAID',
|
||||||
|
payoutState: 'SUCCESS',
|
||||||
|
notificationPayload: {
|
||||||
|
mch_id: '1900000109',
|
||||||
|
out_bill_no: 'CLP501',
|
||||||
|
transfer_bill_no: 'wx-notify-501',
|
||||||
|
state: 'SUCCESS'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const result = await harness.service.processWechatTransferNotification({
|
||||||
|
timestamp: '1782700000',
|
||||||
|
nonce: 'notify-nonce',
|
||||||
|
serial: 'PLATFORM-SERIAL',
|
||||||
|
signature: 'signature'
|
||||||
|
}, '{"resource":"encrypted"}', 'notify-replay-trace');
|
||||||
|
assert.equal(result.idempotent, true);
|
||||||
|
assert.equal(harness.repository.paid.length, 0);
|
||||||
|
}
|
||||||
|
|
||||||
{
|
{
|
||||||
const harness = createHarness({ transferState: 'FAIL', failReason: 'REAL_NAME_CHECK_FAILED' });
|
const harness = createHarness({ transferState: 'FAIL', failReason: 'REAL_NAME_CHECK_FAILED' });
|
||||||
const result = await harness.service.executeWechatTransfer({
|
const result = await harness.service.executeWechatTransfer({
|
||||||
@@ -175,12 +212,14 @@ function createHarness(options = {}) {
|
|||||||
id: '501',
|
id: '501',
|
||||||
settlementNo: 'CLS-20260627-501',
|
settlementNo: 'CLS-20260627-501',
|
||||||
cleanerUserId: '31',
|
cleanerUserId: '31',
|
||||||
|
generatedBy: '21',
|
||||||
storeId: '11',
|
storeId: '11',
|
||||||
status: options.status ?? 'CONFIRMED',
|
status: options.status ?? 'CONFIRMED',
|
||||||
totalRewardCents: 1200,
|
totalRewardCents: 1200,
|
||||||
payoutChannel: options.payoutChannel ?? 'WECHAT_TRANSFER',
|
payoutChannel: options.payoutChannel ?? 'WECHAT_TRANSFER',
|
||||||
payoutReference: options.payoutReference ?? 'CLS-20260627-501',
|
payoutRequestNo: options.payoutRequestNo ?? 'CLP501',
|
||||||
payoutState: options.payoutState ?? 'WAIT_USER_CONFIRM',
|
payoutReference: options.payoutReference ?? 'CLP501',
|
||||||
|
payoutState: options.payoutState ?? '',
|
||||||
payoutPackageInfo: options.payoutPackageInfo ?? 'package-info'
|
payoutPackageInfo: options.payoutPackageInfo ?? 'package-info'
|
||||||
},
|
},
|
||||||
account: {
|
account: {
|
||||||
@@ -210,9 +249,17 @@ function createHarness(options = {}) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
const repository = {
|
const repository = {
|
||||||
|
begun: [],
|
||||||
paid: [],
|
paid: [],
|
||||||
failures: [],
|
failures: [],
|
||||||
pending: [],
|
pending: [],
|
||||||
|
async beginSettlementPayout(input) {
|
||||||
|
this.begun.push(input);
|
||||||
|
state.settlement.payoutChannel = input.payoutChannel;
|
||||||
|
state.settlement.payoutReference = input.payoutReference;
|
||||||
|
state.settlement.payoutState = 'PROCESSING';
|
||||||
|
return { ...state.settlement };
|
||||||
|
},
|
||||||
async markSettlementPaid(input) {
|
async markSettlementPaid(input) {
|
||||||
this.paid.push(input);
|
this.paid.push(input);
|
||||||
return { ...state.settlement, status: 'PAID', payoutReference: input.payoutReference };
|
return { ...state.settlement, status: 'PAID', payoutReference: input.payoutReference };
|
||||||
|
|||||||
@@ -160,11 +160,21 @@ const app = await buildApp({
|
|||||||
calls.push(['listSettlements', input]);
|
calls.push(['listSettlements', input]);
|
||||||
return { items: [settlementResponse(input.status || 'DRAFT')], total: 1, page: input.page, pageSize: input.pageSize };
|
return { items: [settlementResponse(input.status || 'DRAFT')], total: 1, page: input.page, pageSize: input.pageSize };
|
||||||
},
|
},
|
||||||
|
async exportSettlements(input) {
|
||||||
|
calls.push(['exportSettlements', input]);
|
||||||
|
return { items: [settlementResponse(input.status || 'DRAFT')], total: 1, exported: 1, truncated: false };
|
||||||
|
},
|
||||||
async getSettlementDetail(input) {
|
async getSettlementDetail(input) {
|
||||||
calls.push(['getSettlementDetail', input]);
|
calls.push(['getSettlementDetail', input]);
|
||||||
return {
|
return {
|
||||||
settlement: settlementResponse('DRAFT'),
|
settlement: settlementResponse('DRAFT'),
|
||||||
items: [settlementItemResponse()]
|
items: [settlementItemResponse()],
|
||||||
|
events: [{
|
||||||
|
id: '701', settlementId: input.settlementId, fromStatus: null, toStatus: 'DRAFT',
|
||||||
|
action: 'GENERATE', actorId: input.userId, traceId: input.traceId,
|
||||||
|
note: '', createdAt: new Date()
|
||||||
|
}],
|
||||||
|
reversals: []
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
async generateSettlement(input) {
|
async generateSettlement(input) {
|
||||||
@@ -175,6 +185,15 @@ const app = await buildApp({
|
|||||||
calls.push(['confirmSettlement', input]);
|
calls.push(['confirmSettlement', input]);
|
||||||
return settlementResponse('CONFIRMED');
|
return settlementResponse('CONFIRMED');
|
||||||
},
|
},
|
||||||
|
async cancelSettlement(input) {
|
||||||
|
calls.push(['cancelSettlement', input]);
|
||||||
|
return {
|
||||||
|
...settlementResponse('CANCELLED'),
|
||||||
|
cancelledBy: input.userId,
|
||||||
|
cancelledAt: new Date(),
|
||||||
|
note: input.reason
|
||||||
|
};
|
||||||
|
},
|
||||||
async markSettlementPaid(input) {
|
async markSettlementPaid(input) {
|
||||||
calls.push(['markSettlementPaid', input]);
|
calls.push(['markSettlementPaid', input]);
|
||||||
return {
|
return {
|
||||||
@@ -385,6 +404,16 @@ assert.equal(calls.at(-1)[1].status, 'SUBMITTED');
|
|||||||
assert.equal(calls.at(-1)[1].storeId, '11');
|
assert.equal(calls.at(-1)[1].storeId, '11');
|
||||||
assert.equal(calls.at(-1)[1].cleanerUserId, '31');
|
assert.equal(calls.at(-1)[1].cleanerUserId, '31');
|
||||||
|
|
||||||
|
const settlementExport = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/admin-api/cleaning/settlements/export?status=DRAFT&payoutState=FAIL&storeId=11&cleanerUserId=31',
|
||||||
|
headers: { authorization: `Bearer ${token}` }
|
||||||
|
});
|
||||||
|
assert.equal(settlementExport.statusCode, 200);
|
||||||
|
assert.equal(calls.at(-1)[0], 'exportSettlements');
|
||||||
|
assert.equal(calls.at(-1)[1].status, 'DRAFT');
|
||||||
|
assert.equal(settlementExport.json().data.exported, 1);
|
||||||
|
|
||||||
const appManageList = await app.inject({
|
const appManageList = await app.inject({
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
url: '/app-api/management/cleaning/tasks?status=SUBMITTED&storeId=11',
|
url: '/app-api/management/cleaning/tasks?status=SUBMITTED&storeId=11',
|
||||||
@@ -643,6 +672,17 @@ assert.equal(confirmedSettlement.statusCode, 200);
|
|||||||
assert.equal(calls.at(-1)[0], 'confirmSettlement');
|
assert.equal(calls.at(-1)[0], 'confirmSettlement');
|
||||||
assert.equal(calls.at(-1)[1].settlementId, '501');
|
assert.equal(calls.at(-1)[1].settlementId, '501');
|
||||||
|
|
||||||
|
const cancelledSettlement = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/admin-api/cleaning/settlements/501/cancel',
|
||||||
|
headers: { authorization: `Bearer ${token}` },
|
||||||
|
payload: { reason: 'duplicate weekly batch' }
|
||||||
|
});
|
||||||
|
assert.equal(cancelledSettlement.statusCode, 200);
|
||||||
|
assert.equal(cancelledSettlement.json().data.status, 'CANCELLED');
|
||||||
|
assert.equal(calls.at(-1)[0], 'cancelSettlement');
|
||||||
|
assert.equal(calls.at(-1)[1].reason, 'duplicate weekly batch');
|
||||||
|
|
||||||
const failedPayout = await app.inject({
|
const failedPayout = await app.inject({
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
url: '/admin-api/cleaning/settlements/501/payout-failure',
|
url: '/admin-api/cleaning/settlements/501/payout-failure',
|
||||||
|
|||||||
@@ -111,6 +111,15 @@ const adminAuthVerifySql = read('database/migrations/2026081004_m08d_admin_passw
|
|||||||
const cleaningRulesUpSql = read('database/migrations/2026081005_m09b_cleaning_rules.up.sql');
|
const cleaningRulesUpSql = read('database/migrations/2026081005_m09b_cleaning_rules.up.sql');
|
||||||
const cleaningRulesDownSql = read('database/migrations/2026081005_m09b_cleaning_rules.down.sql');
|
const cleaningRulesDownSql = read('database/migrations/2026081005_m09b_cleaning_rules.down.sql');
|
||||||
const cleaningRulesVerifySql = read('database/migrations/2026081005_m09b_cleaning_rules.verify.sql');
|
const cleaningRulesVerifySql = read('database/migrations/2026081005_m09b_cleaning_rules.verify.sql');
|
||||||
|
const cleaningSettlementIntegrityUpSql = read(
|
||||||
|
'database/migrations/2026081006_m09c_cleaning_settlement_integrity.up.sql'
|
||||||
|
);
|
||||||
|
const cleaningSettlementIntegrityDownSql = read(
|
||||||
|
'database/migrations/2026081006_m09c_cleaning_settlement_integrity.down.sql'
|
||||||
|
);
|
||||||
|
const cleaningSettlementIntegrityVerifySql = read(
|
||||||
|
'database/migrations/2026081006_m09c_cleaning_settlement_integrity.verify.sql'
|
||||||
|
);
|
||||||
|
|
||||||
const coreTables = [
|
const coreTables = [
|
||||||
'qipai_schema_migrations',
|
'qipai_schema_migrations',
|
||||||
@@ -507,7 +516,24 @@ assert.match(cleaningRulesUpSql, /uq_qipai_cleaning_submission_revision/);
|
|||||||
assert.match(cleaningRulesUpSql, /retention_until DATETIME\(3\) NOT NULL/);
|
assert.match(cleaningRulesUpSql, /retention_until DATETIME\(3\) NOT NULL/);
|
||||||
assert.match(cleaningRulesDownSql, /DROP FOREIGN KEY fk_qipai_cleaning_tasks_template/);
|
assert.match(cleaningRulesDownSql, /DROP FOREIGN KEY fk_qipai_cleaning_tasks_template/);
|
||||||
assert.match(cleaningRulesVerifySql, /'2026081005'/);
|
assert.match(cleaningRulesVerifySql, /'2026081005'/);
|
||||||
|
for (const table of [
|
||||||
|
'qipai_cleaning_settlement_events', 'qipai_cleaning_settlement_reversals'
|
||||||
|
]) {
|
||||||
|
assert.match(cleaningSettlementIntegrityUpSql, new RegExp('CREATE TABLE IF NOT EXISTS ' + table));
|
||||||
|
assert.match(cleaningSettlementIntegrityDownSql, new RegExp('DROP TABLE IF EXISTS ' + table));
|
||||||
|
assert.match(cleaningSettlementIntegrityVerifySql, new RegExp("'" + table + "'"));
|
||||||
|
}
|
||||||
|
assert.match(cleaningSettlementIntegrityUpSql, /amount_delta_cents BIGINT NOT NULL/);
|
||||||
|
assert.match(cleaningSettlementIntegrityUpSql, /ADD COLUMN cancelled_by/);
|
||||||
|
assert.match(cleaningSettlementIntegrityUpSql, /ADD COLUMN cancelled_at/);
|
||||||
|
assert.match(cleaningSettlementIntegrityUpSql, /ADD COLUMN payout_request_no/);
|
||||||
|
assert.match(cleaningSettlementIntegrityUpSql, /GENERATED ALWAYS AS/);
|
||||||
|
assert.match(cleaningSettlementIntegrityUpSql, /uq_qipai_cleaning_settlement_active_share/);
|
||||||
|
assert.match(cleaningSettlementIntegrityUpSql, /uq_qipai_cleaning_settlement_payout_request/);
|
||||||
|
assert.match(cleaningSettlementIntegrityDownSql, /DROP FOREIGN KEY fk_qipai_cleaning_settlement_items_reversal/);
|
||||||
|
assert.match(cleaningSettlementIntegrityDownSql, /ADD UNIQUE KEY uq_qipai_cleaning_settlement_item_task_user/);
|
||||||
|
assert.match(cleaningSettlementIntegrityVerifySql, /'2026081006'/);
|
||||||
assert.match(franchiseDownSql, /DROP TABLE IF EXISTS qipai_franchise_follow_ups/);
|
assert.match(franchiseDownSql, /DROP TABLE IF EXISTS qipai_franchise_follow_ups/);
|
||||||
assert.match(franchiseVerifySql, /idx_qipai_franchise_follow_up_history/);
|
assert.match(franchiseVerifySql, /idx_qipai_franchise_follow_up_history/);
|
||||||
|
|
||||||
console.log('PASS: M01-B through M09-B migration contracts are present.');
|
console.log('PASS: M01-B through M09-C migration contracts are present.');
|
||||||
|
|||||||
@@ -44,7 +44,8 @@ assert.match(plan.file, /2026081001_m08c_staff_management_access\.up\.sql/);
|
|||||||
assert.match(plan.file, /2026081002_m08d_content_asset_scope\.up\.sql/);
|
assert.match(plan.file, /2026081002_m08d_content_asset_scope\.up\.sql/);
|
||||||
assert.match(plan.file, /2026081003_m08d_franchise_leads\.up\.sql/);
|
assert.match(plan.file, /2026081003_m08d_franchise_leads\.up\.sql/);
|
||||||
assert.match(plan.file, /2026081004_m08d_admin_password_auth\.up\.sql/);
|
assert.match(plan.file, /2026081004_m08d_admin_password_auth\.up\.sql/);
|
||||||
assert.match(plan.file, /2026081005_m09b_cleaning_rules\.up\.sql$/);
|
assert.match(plan.file, /2026081005_m09b_cleaning_rules\.up\.sql/);
|
||||||
|
assert.match(plan.file, /2026081006_m09c_cleaning_settlement_integrity\.up\.sql$/);
|
||||||
assert.match(plan.checksum, /^[a-f0-9]{64}$/);
|
assert.match(plan.checksum, /^[a-f0-9]{64}$/);
|
||||||
assert.ok(plan.statements.length >= 11);
|
assert.ok(plan.statements.length >= 11);
|
||||||
|
|
||||||
@@ -53,7 +54,15 @@ assert.match(verifyPlan.statements[90], /^SELECT column_name/);
|
|||||||
assert.match(verifyPlan.statements[91], /^SELECT index_name/);
|
assert.match(verifyPlan.statements[91], /^SELECT index_name/);
|
||||||
assert.match(verifyPlan.file, /2026081003_m08d_franchise_leads\.verify\.sql/);
|
assert.match(verifyPlan.file, /2026081003_m08d_franchise_leads\.verify\.sql/);
|
||||||
assert.match(verifyPlan.file, /2026081004_m08d_admin_password_auth\.verify\.sql/);
|
assert.match(verifyPlan.file, /2026081004_m08d_admin_password_auth\.verify\.sql/);
|
||||||
assert.match(verifyPlan.file, /2026081005_m09b_cleaning_rules\.verify\.sql$/);
|
assert.match(verifyPlan.file, /2026081005_m09b_cleaning_rules\.verify\.sql/);
|
||||||
|
assert.match(verifyPlan.file, /2026081006_m09c_cleaning_settlement_integrity\.verify\.sql$/);
|
||||||
|
|
||||||
|
const downPlan = await loadMigrationPlan('down');
|
||||||
|
assert.match(downPlan.file, /2026081006_m09c_cleaning_settlement_integrity\.down\.sql/);
|
||||||
|
assert.ok(
|
||||||
|
downPlan.file.indexOf('2026081006_m09c_cleaning_settlement_integrity.down.sql')
|
||||||
|
< downPlan.file.indexOf('2026081005_m09b_cleaning_rules.down.sql')
|
||||||
|
);
|
||||||
|
|
||||||
const calls = [];
|
const calls = [];
|
||||||
const fakePool = {
|
const fakePool = {
|
||||||
|
|||||||
@@ -60,6 +60,8 @@ const expectedTables = [
|
|||||||
'qipai_async_tasks',
|
'qipai_async_tasks',
|
||||||
'qipai_audit_logs',
|
'qipai_audit_logs',
|
||||||
'qipai_auth_sessions',
|
'qipai_auth_sessions',
|
||||||
|
'qipai_cleaning_settlement_events',
|
||||||
|
'qipai_cleaning_settlement_reversals',
|
||||||
'qipai_cleaning_task_photos',
|
'qipai_cleaning_task_photos',
|
||||||
'qipai_cleaning_task_submissions',
|
'qipai_cleaning_task_submissions',
|
||||||
'qipai_cleaning_templates',
|
'qipai_cleaning_templates',
|
||||||
@@ -142,13 +144,14 @@ async function readMigrationVersions(pool) {
|
|||||||
const [rows] = await pool.query(
|
const [rows] = await pool.query(
|
||||||
`SELECT version, name
|
`SELECT version, name
|
||||||
FROM qipai_schema_migrations
|
FROM qipai_schema_migrations
|
||||||
WHERE version IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
WHERE version IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
ORDER BY version`,
|
ORDER BY version`,
|
||||||
['2026061601', '2026061802', '2026061803', '2026061804',
|
['2026061601', '2026061802', '2026061803', '2026061804',
|
||||||
'2026061805', '2026061806', '2026061807', '2026061808', '2026061809',
|
'2026061805', '2026061806', '2026061807', '2026061808', '2026061809',
|
||||||
'2026061810', '2026061811', '2026062012', '2026062013', '2026062014',
|
'2026061810', '2026061811', '2026062012', '2026062013', '2026062014',
|
||||||
'2026062015', '2026062216', '2026062217', '2026062218', '2026062219',
|
'2026062015', '2026062216', '2026062217', '2026062218', '2026062219',
|
||||||
'2026062220', '2026081002', '2026081003', '2026081004', '2026081005']
|
'2026062220', '2026081002', '2026081003', '2026081004', '2026081005',
|
||||||
|
'2026081006']
|
||||||
);
|
);
|
||||||
return rows;
|
return rows;
|
||||||
}
|
}
|
||||||
@@ -1836,7 +1839,7 @@ async function assertSystemOperations(pool, context) {
|
|||||||
assert.equal(logs.items[0].metadata.nested.safe, 'visible');
|
assert.equal(logs.items[0].metadata.nested.safe, 'visible');
|
||||||
const overview = await repository.getSystemOverview(context.tenantId);
|
const overview = await repository.getSystemOverview(context.tenantId);
|
||||||
assert.equal(overview.tenant.id, context.tenantId);
|
assert.equal(overview.tenant.id, context.tenantId);
|
||||||
assert.equal(overview.latestMigration.version, '2026081005');
|
assert.equal(overview.latestMigration.version, '2026081006');
|
||||||
assert.ok(overview.counts.userCount > 0);
|
assert.ok(overview.counts.userCount > 0);
|
||||||
await repository.updateTenant(actor, context.tenantId, {
|
await repository.updateTenant(actor, context.tenantId, {
|
||||||
name: overview.tenant.name, timezone: overview.tenant.timezone
|
name: overview.tenant.name, timezone: overview.tenant.timezone
|
||||||
@@ -2957,8 +2960,163 @@ async function assertCleaningTaskTransactions(pool, context) {
|
|||||||
exemptPolicy: snapshotRows[0].exemptPolicy
|
exemptPolicy: snapshotRows[0].exemptPolicy
|
||||||
}, { version: 1, minPhotoCount: 2, exemptPolicy: 'BEFORE_START' });
|
}, { version: 1, minPhotoCount: 2, exemptPolicy: 'BEFORE_START' });
|
||||||
|
|
||||||
|
const collaborativeTaskId = await insertTask({
|
||||||
|
status: 'COMPLETED', cleanerUserId: firstCleanerId, rewardCents: 100,
|
||||||
|
claimedAt: new Date(), completedAt: new Date()
|
||||||
|
});
|
||||||
|
await insertMember({
|
||||||
|
taskId: collaborativeTaskId, userId: firstCleanerId, memberRole: 'LEAD', rewardCents: 70
|
||||||
|
});
|
||||||
|
await insertMember({
|
||||||
|
taskId: collaborativeTaskId, userId: secondCleanerId, memberRole: 'ASSIST', rewardCents: 30
|
||||||
|
});
|
||||||
|
|
||||||
|
const concurrentSettlementResults = await Promise.allSettled([
|
||||||
|
repository.generateSettlement({
|
||||||
|
...managerActor('m09c-generate-one'), cleanerUserId: firstCleanerId, storeId
|
||||||
|
}),
|
||||||
|
repository.generateSettlement({
|
||||||
|
...managerActor('m09c-generate-two'), cleanerUserId: firstCleanerId, storeId
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
assert.equal(
|
||||||
|
concurrentSettlementResults.filter((result) => result.status === 'fulfilled').length,
|
||||||
|
1
|
||||||
|
);
|
||||||
|
const rejectedSettlement = concurrentSettlementResults.find((result) => result.status === 'rejected');
|
||||||
|
assert.ok(rejectedSettlement?.reason instanceof CleaningTaskError);
|
||||||
|
assert.ok([
|
||||||
|
'CLEANING_SETTLEMENT_EMPTY', 'CLEANING_SETTLEMENT_CONCURRENTLY_CLAIMED'
|
||||||
|
].includes(rejectedSettlement.reason.code));
|
||||||
|
const leadDraft = concurrentSettlementResults.find((result) => result.status === 'fulfilled').value;
|
||||||
|
const assistDraft = await repository.generateSettlement({
|
||||||
|
...managerActor('m09c-generate-assist'), cleanerUserId: secondCleanerId, storeId
|
||||||
|
});
|
||||||
|
|
||||||
|
const [activeShareRows] = await pool.query(
|
||||||
|
`SELECT task_id AS taskId, cleaner_user_id AS cleanerUserId, COUNT(*) AS total
|
||||||
|
FROM qipai_cleaning_settlement_items
|
||||||
|
WHERE tenant_id = ? AND reversed_at IS NULL
|
||||||
|
AND task_id IN (?, ?)
|
||||||
|
GROUP BY task_id, cleaner_user_id`,
|
||||||
|
[context.tenantId, lifecycleTaskId, collaborativeTaskId]
|
||||||
|
);
|
||||||
|
assert.ok(activeShareRows.length >= 3);
|
||||||
|
assert.ok(activeShareRows.every((row) => Number(row.total) === 1));
|
||||||
|
const [draftTaskRows] = await pool.query(
|
||||||
|
`SELECT id, status, settled_at AS settledAt
|
||||||
|
FROM qipai_cleaning_tasks WHERE tenant_id = ? AND id IN (?, ?) ORDER BY id`,
|
||||||
|
[context.tenantId, lifecycleTaskId, collaborativeTaskId]
|
||||||
|
);
|
||||||
|
assert.ok(draftTaskRows.every((row) => row.status === 'COMPLETED' && row.settledAt === null));
|
||||||
|
const firstStatsInDraft = await repository.stats({
|
||||||
|
...cleanerActor(firstCleanerId, 'm09c-stats-draft')
|
||||||
|
});
|
||||||
|
assert.ok(firstStatsInDraft.pendingSettlementCents >= 170);
|
||||||
|
assert.equal(firstStatsInDraft.settledRewardCents, 0);
|
||||||
|
await assert.rejects(
|
||||||
|
() => repository.removeMember({
|
||||||
|
...managerActor('m09c-member-locked'), taskId: collaborativeTaskId,
|
||||||
|
cleanerUserId: secondCleanerId
|
||||||
|
}),
|
||||||
|
(error) => error instanceof CleaningTaskError
|
||||||
|
&& error.code === 'CLEANING_MEMBER_SETTLEMENT_LOCKED'
|
||||||
|
);
|
||||||
|
|
||||||
|
const cancelledLead = await repository.cancelSettlement({
|
||||||
|
...managerActor('m09c-cancel-draft'), settlementId: leadDraft.id,
|
||||||
|
reason: 'M09-C cancellation roundtrip'
|
||||||
|
});
|
||||||
|
assert.equal(cancelledLead.status, 'CANCELLED');
|
||||||
|
const cancelledDetail = await repository.getSettlementDetail({
|
||||||
|
...managerActor('m09c-cancel-detail'), settlementId: leadDraft.id
|
||||||
|
});
|
||||||
|
assert.equal(cancelledDetail.reversals.length, 1);
|
||||||
|
assert.equal(cancelledDetail.reversals[0].amountDeltaCents, -leadDraft.totalRewardCents);
|
||||||
|
assert.ok(cancelledDetail.items.every((item) => item.reversedAt instanceof Date));
|
||||||
|
assert.ok(cancelledDetail.events.some((event) => event.action === 'CANCEL'));
|
||||||
|
|
||||||
|
const retryLead = await repository.generateSettlement({
|
||||||
|
...managerActor('m09c-generate-retry'), cleanerUserId: firstCleanerId, storeId
|
||||||
|
});
|
||||||
|
await repository.confirmSettlement({
|
||||||
|
...managerActor('m09c-confirm-retry'), settlementId: retryLead.id
|
||||||
|
});
|
||||||
|
await repository.beginSettlementPayout({
|
||||||
|
...managerActor('m09c-payout-begin'), settlementId: retryLead.id,
|
||||||
|
payoutChannel: 'WECHAT_TRANSFER', payoutReference: `CLP${retryLead.id}`
|
||||||
|
});
|
||||||
|
await assert.rejects(
|
||||||
|
() => repository.cancelSettlement({
|
||||||
|
...managerActor('m09c-cancel-processing'), settlementId: retryLead.id,
|
||||||
|
reason: 'must not cancel uncertain transfer'
|
||||||
|
}),
|
||||||
|
(error) => error instanceof CleaningTaskError
|
||||||
|
&& error.code === 'CLEANING_SETTLEMENT_PAYOUT_PENDING'
|
||||||
|
);
|
||||||
|
await repository.recordSettlementPayoutFailure({
|
||||||
|
...managerActor('m09c-payout-explicit-fail'), settlementId: retryLead.id,
|
||||||
|
payoutChannel: 'WECHAT_TRANSFER', payoutReference: `CLP${retryLead.id}`,
|
||||||
|
error: 'PROVIDER_CONFIRMED_FAIL', providerConfirmed: true
|
||||||
|
});
|
||||||
|
await repository.cancelSettlement({
|
||||||
|
...managerActor('m09c-cancel-failed'), settlementId: retryLead.id,
|
||||||
|
reason: 'provider confirmed failure'
|
||||||
|
});
|
||||||
|
|
||||||
|
const paidLead = await repository.generateSettlement({
|
||||||
|
...managerActor('m09c-generate-paid'), cleanerUserId: firstCleanerId, storeId
|
||||||
|
});
|
||||||
|
await repository.confirmSettlement({
|
||||||
|
...managerActor('m09c-confirm-paid'), settlementId: paidLead.id
|
||||||
|
});
|
||||||
|
await repository.markSettlementPaid({
|
||||||
|
...managerActor('m09c-manual-paid-lead'), settlementId: paidLead.id,
|
||||||
|
payoutChannel: 'MANUAL', payoutReference: `MANUAL-${paidLead.id}`,
|
||||||
|
manualOverride: true
|
||||||
|
});
|
||||||
|
const [partiallyPaidRows] = await pool.query(
|
||||||
|
`SELECT status FROM qipai_cleaning_tasks WHERE tenant_id = ? AND id = ?`,
|
||||||
|
[context.tenantId, collaborativeTaskId]
|
||||||
|
);
|
||||||
|
assert.equal(partiallyPaidRows[0].status, 'COMPLETED');
|
||||||
|
|
||||||
|
await repository.confirmSettlement({
|
||||||
|
...managerActor('m09c-confirm-assist'), settlementId: assistDraft.id
|
||||||
|
});
|
||||||
|
await repository.markSettlementPaid({
|
||||||
|
...managerActor('m09c-manual-paid-assist'), settlementId: assistDraft.id,
|
||||||
|
payoutChannel: 'MANUAL', payoutReference: `MANUAL-${assistDraft.id}`,
|
||||||
|
manualOverride: true
|
||||||
|
});
|
||||||
|
const [fullyPaidRows] = await pool.query(
|
||||||
|
`SELECT status, settled_at IS NOT NULL AS settled
|
||||||
|
FROM qipai_cleaning_tasks WHERE tenant_id = ? AND id = ?`,
|
||||||
|
[context.tenantId, collaborativeTaskId]
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
{ status: fullyPaidRows[0].status, settled: Number(fullyPaidRows[0].settled) },
|
||||||
|
{ status: 'SETTLED', settled: 1 }
|
||||||
|
);
|
||||||
|
const firstStatsPaid = await repository.stats({
|
||||||
|
...cleanerActor(firstCleanerId, 'm09c-stats-paid')
|
||||||
|
});
|
||||||
|
assert.ok(firstStatsPaid.settledRewardCents >= 170);
|
||||||
|
|
||||||
|
const paidPage = await repository.listSettlements({
|
||||||
|
...managerActor('m09c-list-paid'), page: 1, pageSize: 50, status: 'PAID', storeId
|
||||||
|
});
|
||||||
|
const paidExport = await repository.exportSettlements({
|
||||||
|
...managerActor('m09c-export-paid'), status: 'PAID', storeId
|
||||||
|
});
|
||||||
|
assert.deepEqual(
|
||||||
|
paidExport.items.slice(0, paidPage.items.length).map((item) => item.id),
|
||||||
|
paidPage.items.map((item) => item.id)
|
||||||
|
);
|
||||||
|
assert.equal(paidExport.total, paidPage.total);
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
'PASS: M09-A/M09-B cleaning transactions, template snapshots, photo revisions and exemption rules are consistent.'
|
'PASS: M09-A/M09-B/M09-C cleaning transactions, rules and settlement integrity are consistent.'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3009,7 +3167,8 @@ try {
|
|||||||
{ version: '2026081002', name: 'm08d_content_asset_scope' },
|
{ version: '2026081002', name: 'm08d_content_asset_scope' },
|
||||||
{ version: '2026081003', name: 'm08d_franchise_leads' },
|
{ version: '2026081003', name: 'm08d_franchise_leads' },
|
||||||
{ version: '2026081004', name: 'm08d_admin_password_auth' },
|
{ version: '2026081004', name: 'm08d_admin_password_auth' },
|
||||||
{ version: '2026081005', name: 'm09b_cleaning_rules' }
|
{ version: '2026081005', name: 'm09b_cleaning_rules' },
|
||||||
|
{ version: '2026081006', name: 'm09c_cleaning_settlement_integrity' }
|
||||||
]);
|
]);
|
||||||
await assertTaskDurability(pool);
|
await assertTaskDurability(pool);
|
||||||
const loginContext = await assertPlatformTenantIsolation(pool);
|
const loginContext = await assertPlatformTenantIsolation(pool);
|
||||||
@@ -3038,7 +3197,7 @@ try {
|
|||||||
await executeMigrationPlan(pool, plans.down);
|
await executeMigrationPlan(pool, plans.down);
|
||||||
assert.deepEqual(await readCoreTables(pool), []);
|
assert.deepEqual(await readCoreTables(pool), []);
|
||||||
await assertLegacyCompatibility(pool);
|
await assertLegacyCompatibility(pool);
|
||||||
console.log('PASS: down removed all M01-B through M09-B migration tables.');
|
console.log('PASS: down removed all M01-B through M09-C migration tables.');
|
||||||
|
|
||||||
await executeMigrationPlan(pool, plans.up);
|
await executeMigrationPlan(pool, plans.up);
|
||||||
await executeMigrationPlan(pool, plans.verify);
|
await executeMigrationPlan(pool, plans.verify);
|
||||||
@@ -3067,7 +3226,8 @@ try {
|
|||||||
{ version: '2026081002', name: 'm08d_content_asset_scope' },
|
{ version: '2026081002', name: 'm08d_content_asset_scope' },
|
||||||
{ version: '2026081003', name: 'm08d_franchise_leads' },
|
{ version: '2026081003', name: 'm08d_franchise_leads' },
|
||||||
{ version: '2026081004', name: 'm08d_admin_password_auth' },
|
{ version: '2026081004', name: 'm08d_admin_password_auth' },
|
||||||
{ version: '2026081005', name: 'm09b_cleaning_rules' }
|
{ version: '2026081005', name: 'm09b_cleaning_rules' },
|
||||||
|
{ version: '2026081006', name: 'm09c_cleaning_settlement_integrity' }
|
||||||
]);
|
]);
|
||||||
await assertLegacyCompatibility(pool);
|
await assertLegacyCompatibility(pool);
|
||||||
console.log('PASS: second up and verify restored the schema.');
|
console.log('PASS: second up and verify restored the schema.');
|
||||||
@@ -3208,7 +3368,14 @@ try {
|
|||||||
'cleaning template snapshot remains stable after config version change',
|
'cleaning template snapshot remains stable after config version change',
|
||||||
'rejected and accepted cleaning photo revisions remain distinguishable',
|
'rejected and accepted cleaning photo revisions remain distinguishable',
|
||||||
'expired unattached cleaning photo retention cleanup',
|
'expired unattached cleaning photo retention cleanup',
|
||||||
'task-level cleaning exemption policy denial'
|
'task-level cleaning exemption policy denial',
|
||||||
|
'concurrent cleaning settlement generation has one winner per member share',
|
||||||
|
'draft and confirmed rewards remain pending until paid',
|
||||||
|
'active settlement freezes member reward allocation',
|
||||||
|
'settlement reversal preserves history and releases the share',
|
||||||
|
'processing payout blocks cancellation until provider-confirmed failure',
|
||||||
|
'collaborative task settles only after every member share is paid',
|
||||||
|
'settlement page and CSV export reuse identical query ordering'
|
||||||
]
|
]
|
||||||
}, null, 2));
|
}, null, 2));
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
DELETE FROM qipai_schema_migrations
|
DELETE FROM qipai_schema_migrations
|
||||||
WHERE version = '2026062627';
|
WHERE version = '2026062627';
|
||||||
|
|
||||||
|
DELETE duplicate_item
|
||||||
|
FROM qipai_cleaning_settlement_items duplicate_item
|
||||||
|
INNER JOIN qipai_cleaning_settlement_items retained_item
|
||||||
|
ON retained_item.tenant_id = duplicate_item.tenant_id
|
||||||
|
AND retained_item.task_id = duplicate_item.task_id
|
||||||
|
AND retained_item.id < duplicate_item.id;
|
||||||
|
|
||||||
ALTER TABLE qipai_cleaning_settlement_items
|
ALTER TABLE qipai_cleaning_settlement_items
|
||||||
DROP FOREIGN KEY fk_qipai_cleaning_settlement_items_cleaner,
|
DROP FOREIGN KEY fk_qipai_cleaning_settlement_items_cleaner,
|
||||||
DROP INDEX uq_qipai_cleaning_settlement_item_task_user,
|
DROP INDEX uq_qipai_cleaning_settlement_item_task_user,
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ WHERE table_schema = DATABASE()
|
|||||||
'idx_qipai_cleaning_task_member_user',
|
'idx_qipai_cleaning_task_member_user',
|
||||||
'idx_qipai_cleaning_task_member_task',
|
'idx_qipai_cleaning_task_member_task',
|
||||||
'uq_qipai_cleaning_settlement_item_task_user',
|
'uq_qipai_cleaning_settlement_item_task_user',
|
||||||
|
'uq_qipai_cleaning_settlement_active_share',
|
||||||
'idx_qipai_cleaning_settlement_items_cleaner'
|
'idx_qipai_cleaning_settlement_items_cleaner'
|
||||||
)
|
)
|
||||||
GROUP BY table_name, index_name;
|
GROUP BY table_name, index_name;
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
DELETE FROM qipai_schema_migrations WHERE version = '2026081006';
|
||||||
|
|
||||||
|
ALTER TABLE qipai_cleaning_settlement_items
|
||||||
|
DROP FOREIGN KEY fk_qipai_cleaning_settlement_items_reversal;
|
||||||
|
|
||||||
|
DELETE reversed
|
||||||
|
FROM qipai_cleaning_settlement_items reversed
|
||||||
|
INNER JOIN qipai_cleaning_settlement_items active
|
||||||
|
ON active.tenant_id = reversed.tenant_id
|
||||||
|
AND active.task_id = reversed.task_id
|
||||||
|
AND active.cleaner_user_id = reversed.cleaner_user_id
|
||||||
|
AND active.reversed_at IS NULL
|
||||||
|
WHERE reversed.reversed_at IS NOT NULL;
|
||||||
|
|
||||||
|
DELETE older
|
||||||
|
FROM qipai_cleaning_settlement_items older
|
||||||
|
INNER JOIN qipai_cleaning_settlement_items newer
|
||||||
|
ON newer.tenant_id = older.tenant_id
|
||||||
|
AND newer.task_id = older.task_id
|
||||||
|
AND newer.cleaner_user_id = older.cleaner_user_id
|
||||||
|
AND newer.id > older.id
|
||||||
|
WHERE older.reversed_at IS NOT NULL AND newer.reversed_at IS NOT NULL;
|
||||||
|
|
||||||
|
ALTER TABLE qipai_cleaning_settlement_items
|
||||||
|
DROP INDEX uq_qipai_cleaning_settlement_active_share,
|
||||||
|
DROP INDEX idx_qipai_cleaning_settlement_item_reversal,
|
||||||
|
DROP COLUMN active_share_key,
|
||||||
|
DROP COLUMN reversed_at,
|
||||||
|
DROP COLUMN reversal_id,
|
||||||
|
ADD UNIQUE KEY uq_qipai_cleaning_settlement_item_task_user
|
||||||
|
(tenant_id, task_id, cleaner_user_id);
|
||||||
|
|
||||||
|
ALTER TABLE qipai_cleaning_settlements
|
||||||
|
DROP FOREIGN KEY fk_qipai_cleaning_settlements_cancelled_by,
|
||||||
|
DROP INDEX idx_qipai_cleaning_settlement_cancelled,
|
||||||
|
DROP INDEX uq_qipai_cleaning_settlement_payout_request,
|
||||||
|
DROP COLUMN payout_request_key,
|
||||||
|
DROP COLUMN payout_request_no,
|
||||||
|
DROP COLUMN cancelled_at,
|
||||||
|
DROP COLUMN cancelled_by;
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS qipai_cleaning_settlement_events;
|
||||||
|
DROP TABLE IF EXISTS qipai_cleaning_settlement_reversals;
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS qipai_cleaning_settlement_reversals (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
tenant_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
reversal_no VARCHAR(64) NOT NULL,
|
||||||
|
settlement_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
amount_delta_cents BIGINT NOT NULL,
|
||||||
|
reason VARCHAR(512) NOT NULL,
|
||||||
|
reversed_by BIGINT UNSIGNED NOT NULL,
|
||||||
|
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
CONSTRAINT fk_qipai_cleaning_settlement_reversal_tenant FOREIGN KEY (tenant_id)
|
||||||
|
REFERENCES qipai_tenants(id),
|
||||||
|
CONSTRAINT fk_qipai_cleaning_settlement_reversal_settlement FOREIGN KEY (settlement_id)
|
||||||
|
REFERENCES qipai_cleaning_settlements(id),
|
||||||
|
CONSTRAINT fk_qipai_cleaning_settlement_reversal_actor FOREIGN KEY (reversed_by)
|
||||||
|
REFERENCES qipai_users(id),
|
||||||
|
CONSTRAINT chk_qipai_cleaning_settlement_reversal_amount CHECK (amount_delta_cents <= 0),
|
||||||
|
UNIQUE KEY uq_qipai_cleaning_settlement_reversal_no (tenant_id, reversal_no),
|
||||||
|
UNIQUE KEY uq_qipai_cleaning_settlement_reversal_parent (tenant_id, settlement_id),
|
||||||
|
KEY idx_qipai_cleaning_settlement_reversal_created (tenant_id, created_at)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS qipai_cleaning_settlement_events (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
tenant_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
settlement_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
from_status VARCHAR(32) NULL,
|
||||||
|
to_status VARCHAR(32) NOT NULL,
|
||||||
|
action VARCHAR(32) NOT NULL,
|
||||||
|
actor_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
actor_type VARCHAR(16) NOT NULL DEFAULT 'USER',
|
||||||
|
trace_id VARCHAR(64) NOT NULL,
|
||||||
|
note VARCHAR(512) NOT NULL DEFAULT '',
|
||||||
|
metadata JSON NOT NULL,
|
||||||
|
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
CONSTRAINT fk_qipai_cleaning_settlement_event_tenant FOREIGN KEY (tenant_id)
|
||||||
|
REFERENCES qipai_tenants(id),
|
||||||
|
CONSTRAINT fk_qipai_cleaning_settlement_event_parent FOREIGN KEY (settlement_id)
|
||||||
|
REFERENCES qipai_cleaning_settlements(id),
|
||||||
|
CONSTRAINT fk_qipai_cleaning_settlement_event_actor FOREIGN KEY (actor_id)
|
||||||
|
REFERENCES qipai_users(id),
|
||||||
|
CONSTRAINT chk_qipai_cleaning_settlement_event_actor_type CHECK (
|
||||||
|
actor_type IN ('USER', 'SYSTEM')
|
||||||
|
),
|
||||||
|
UNIQUE KEY uq_qipai_cleaning_settlement_event_trace
|
||||||
|
(tenant_id, settlement_id, action, trace_id),
|
||||||
|
KEY idx_qipai_cleaning_settlement_event_history
|
||||||
|
(tenant_id, settlement_id, created_at, id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
ALTER TABLE qipai_cleaning_settlements
|
||||||
|
ADD COLUMN cancelled_by BIGINT UNSIGNED NULL AFTER paid_by,
|
||||||
|
ADD COLUMN cancelled_at DATETIME(3) NULL AFTER paid_at,
|
||||||
|
ADD COLUMN payout_request_no VARCHAR(64) NOT NULL DEFAULT '' AFTER payout_channel,
|
||||||
|
ADD COLUMN payout_request_key VARCHAR(128)
|
||||||
|
GENERATED ALWAYS AS (
|
||||||
|
CASE WHEN payout_request_no <> ''
|
||||||
|
THEN CONCAT(payout_channel, ':', payout_request_no)
|
||||||
|
ELSE NULL
|
||||||
|
END
|
||||||
|
) STORED AFTER payout_request_no,
|
||||||
|
ADD CONSTRAINT fk_qipai_cleaning_settlements_cancelled_by FOREIGN KEY (cancelled_by)
|
||||||
|
REFERENCES qipai_users(id),
|
||||||
|
ADD KEY idx_qipai_cleaning_settlement_cancelled (tenant_id, status, cancelled_at),
|
||||||
|
ADD UNIQUE KEY uq_qipai_cleaning_settlement_payout_request
|
||||||
|
(tenant_id, payout_request_key);
|
||||||
|
|
||||||
|
ALTER TABLE qipai_cleaning_settlement_items
|
||||||
|
DROP INDEX uq_qipai_cleaning_settlement_item_task_user,
|
||||||
|
ADD COLUMN reversal_id BIGINT UNSIGNED NULL AFTER reward_cents,
|
||||||
|
ADD COLUMN reversed_at DATETIME(3) NULL AFTER reversal_id,
|
||||||
|
ADD COLUMN active_share_key VARCHAR(128)
|
||||||
|
GENERATED ALWAYS AS (
|
||||||
|
CASE WHEN reversed_at IS NULL
|
||||||
|
THEN CONCAT(CAST(task_id AS CHAR), ':', CAST(cleaner_user_id AS CHAR))
|
||||||
|
ELSE NULL
|
||||||
|
END
|
||||||
|
) STORED AFTER reversed_at,
|
||||||
|
ADD CONSTRAINT fk_qipai_cleaning_settlement_items_reversal FOREIGN KEY (reversal_id)
|
||||||
|
REFERENCES qipai_cleaning_settlement_reversals(id),
|
||||||
|
ADD UNIQUE KEY uq_qipai_cleaning_settlement_active_share (tenant_id, active_share_key),
|
||||||
|
ADD KEY idx_qipai_cleaning_settlement_item_reversal (tenant_id, reversal_id);
|
||||||
|
|
||||||
|
UPDATE qipai_cleaning_settlements
|
||||||
|
SET cancelled_by = generated_by,
|
||||||
|
cancelled_at = COALESCE(cancelled_at, updated_at)
|
||||||
|
WHERE status = 'CANCELLED' AND cancelled_by IS NULL;
|
||||||
|
|
||||||
|
INSERT IGNORE INTO qipai_cleaning_settlement_reversals
|
||||||
|
(tenant_id, reversal_no, settlement_id, amount_delta_cents, reason, reversed_by, created_at)
|
||||||
|
SELECT s.tenant_id, CONCAT('CLR-M09C-', s.id), s.id, -CAST(s.total_reward_cents AS SIGNED),
|
||||||
|
'M09-C historical cancellation baseline', s.cancelled_by,
|
||||||
|
COALESCE(s.cancelled_at, s.updated_at)
|
||||||
|
FROM qipai_cleaning_settlements s
|
||||||
|
WHERE s.status = 'CANCELLED' AND s.deleted_at IS NULL;
|
||||||
|
|
||||||
|
UPDATE qipai_cleaning_settlement_items i
|
||||||
|
INNER JOIN qipai_cleaning_settlement_reversals r
|
||||||
|
ON r.tenant_id = i.tenant_id AND r.settlement_id = i.settlement_id
|
||||||
|
SET i.reversal_id = r.id,
|
||||||
|
i.reversed_at = r.created_at
|
||||||
|
WHERE i.reversed_at IS NULL;
|
||||||
|
|
||||||
|
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
|
||||||
|
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
|
||||||
|
SET m.settled_at = CASE
|
||||||
|
WHEN s.status = 'PAID' AND i.reversed_at IS NULL
|
||||||
|
THEN COALESCE(s.paid_at, m.settled_at, UTC_TIMESTAMP(3))
|
||||||
|
ELSE NULL
|
||||||
|
END
|
||||||
|
WHERE i.id IS NOT NULL;
|
||||||
|
|
||||||
|
UPDATE qipai_cleaning_tasks t
|
||||||
|
SET t.status = 'COMPLETED', t.settled_at = NULL
|
||||||
|
WHERE t.status = 'SETTLED'
|
||||||
|
AND 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
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT IGNORE INTO qipai_cleaning_settlement_events
|
||||||
|
(tenant_id, settlement_id, from_status, to_status, action, actor_id,
|
||||||
|
actor_type, trace_id, note, metadata, created_at)
|
||||||
|
SELECT s.tenant_id, s.id, NULL, s.status, 'MIGRATE', s.generated_by, 'SYSTEM',
|
||||||
|
CONCAT('m09c-', s.id), 'M09-C settlement history baseline',
|
||||||
|
JSON_OBJECT('source', '2026081006'), s.created_at
|
||||||
|
FROM qipai_cleaning_settlements s
|
||||||
|
WHERE s.deleted_at IS NULL;
|
||||||
|
|
||||||
|
INSERT IGNORE INTO qipai_schema_migrations (version, name)
|
||||||
|
VALUES ('2026081006', 'm09c_cleaning_settlement_integrity');
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
SELECT table_name
|
||||||
|
FROM information_schema.tables
|
||||||
|
WHERE table_schema = DATABASE()
|
||||||
|
AND table_name IN (
|
||||||
|
'qipai_cleaning_settlement_events',
|
||||||
|
'qipai_cleaning_settlement_reversals'
|
||||||
|
)
|
||||||
|
ORDER BY table_name;
|
||||||
|
|
||||||
|
SELECT table_name, column_name, generation_expression
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_schema = DATABASE()
|
||||||
|
AND ((table_name = 'qipai_cleaning_settlement_items'
|
||||||
|
AND column_name IN ('reversal_id', 'reversed_at', 'active_share_key'))
|
||||||
|
OR (table_name = 'qipai_cleaning_settlements'
|
||||||
|
AND column_name IN (
|
||||||
|
'cancelled_by', 'cancelled_at', 'payout_request_no', 'payout_request_key'
|
||||||
|
))
|
||||||
|
OR (table_name = 'qipai_cleaning_settlement_events'
|
||||||
|
AND column_name = 'actor_type'))
|
||||||
|
ORDER BY table_name, column_name;
|
||||||
|
|
||||||
|
SELECT table_name, index_name
|
||||||
|
FROM information_schema.statistics
|
||||||
|
WHERE table_schema = DATABASE()
|
||||||
|
AND ((table_name = 'qipai_cleaning_settlement_items'
|
||||||
|
AND index_name = 'uq_qipai_cleaning_settlement_active_share')
|
||||||
|
OR (table_name = 'qipai_cleaning_settlement_events'
|
||||||
|
AND index_name = 'uq_qipai_cleaning_settlement_event_trace')
|
||||||
|
OR (table_name = 'qipai_cleaning_settlement_reversals'
|
||||||
|
AND index_name = 'uq_qipai_cleaning_settlement_reversal_parent')
|
||||||
|
OR (table_name = 'qipai_cleaning_settlements'
|
||||||
|
AND index_name = 'uq_qipai_cleaning_settlement_payout_request'))
|
||||||
|
GROUP BY table_name, index_name
|
||||||
|
ORDER BY table_name, index_name;
|
||||||
|
|
||||||
|
SELECT version, name
|
||||||
|
FROM qipai_schema_migrations
|
||||||
|
WHERE version = '2026081006';
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { existsSync, readFileSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
|
||||||
|
const root = process.cwd();
|
||||||
|
const read = (path) => readFileSync(join(root, path), 'utf8');
|
||||||
|
|
||||||
|
for (const suffix of ['up', 'down', 'verify']) {
|
||||||
|
assert.ok(
|
||||||
|
existsSync(join(root, 'database/migrations/2026081006_m09c_cleaning_settlement_integrity.' + suffix + '.sql')),
|
||||||
|
'M09-C ' + suffix + ' migration is missing'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const migration = read('database/migrations/2026081006_m09c_cleaning_settlement_integrity.up.sql');
|
||||||
|
for (const pattern of [
|
||||||
|
'qipai_cleaning_settlement_reversals',
|
||||||
|
'qipai_cleaning_settlement_events',
|
||||||
|
'amount_delta_cents BIGINT',
|
||||||
|
'active_share_key',
|
||||||
|
'uq_qipai_cleaning_settlement_active_share',
|
||||||
|
'payout_request_no',
|
||||||
|
'uq_qipai_cleaning_settlement_payout_request',
|
||||||
|
"actor_type IN ('USER', 'SYSTEM')"
|
||||||
|
]) assert.ok(migration.includes(pattern), 'migration is missing ' + pattern);
|
||||||
|
|
||||||
|
const repository = read('backend/src/cleaning/cleaning-task-repository.ts');
|
||||||
|
for (const pattern of [
|
||||||
|
'CLEANING_SETTLEMENT_CONCURRENTLY_CLAIMED',
|
||||||
|
'assertTaskSettlementMutable',
|
||||||
|
'beginSettlementPayout',
|
||||||
|
'cancelSettlement',
|
||||||
|
'recordSettlementEventWithConnection',
|
||||||
|
"status = 'SETTLED'",
|
||||||
|
'exportSettlements',
|
||||||
|
'querySettlementRows'
|
||||||
|
]) assert.ok(repository.includes(pattern), 'repository is missing ' + pattern);
|
||||||
|
assert.match(repository, /payout_state IN \('PROCESSING', 'WAIT_USER_CONFIRM'\)/);
|
||||||
|
assert.match(repository, /reversed_at IS NULL/);
|
||||||
|
|
||||||
|
const payout = read('backend/src/cleaning/cleaning-payout-service.ts');
|
||||||
|
assert.match(payout, /await this\.repository\.beginSettlementPayout/);
|
||||||
|
assert.match(payout, /actorType: 'SYSTEM'/);
|
||||||
|
assert.match(payout, /payout_request_no AS payoutRequestNo/);
|
||||||
|
assert.ok(payout.includes('CLP$' + '{settlementId}'), 'stable payout request id is missing');
|
||||||
|
|
||||||
|
const routes = read('backend/src/routes/cleaning.ts');
|
||||||
|
assert.match(routes, /settlements\/export/);
|
||||||
|
assert.match(routes, /settlements\/:settlementId\/cancel/);
|
||||||
|
assert.match(routes, /manualOverride: true/);
|
||||||
|
|
||||||
|
const admin = read('admin/src/components/CleaningSettlementsPanel.vue');
|
||||||
|
for (const pattern of [
|
||||||
|
'exportCleaningSettlements',
|
||||||
|
'canCancelSettlement',
|
||||||
|
'openManualPaid',
|
||||||
|
'detailDialog.detail.reversals',
|
||||||
|
'detailDialog.detail.events',
|
||||||
|
'payoutState: props.filters.payoutState',
|
||||||
|
"['', 'FAIL'].includes(row.payoutState)",
|
||||||
|
'金额(分)'
|
||||||
|
]) assert.ok(admin.includes(pattern), 'admin settlement UI is missing ' + pattern);
|
||||||
|
|
||||||
|
for (const [file, pattern] of [
|
||||||
|
['admin/src/components/CleaningStatisticsPanel.vue', '待结算(分)'],
|
||||||
|
['admin/src/components/CleaningTasksPanel.vue', '金额(分)'],
|
||||||
|
['admin/src/components/CleaningWorkspace.vue', '金额(分)']
|
||||||
|
]) assert.ok(read(file).includes(pattern), file + ' must export monetary values as integer cents');
|
||||||
|
|
||||||
|
const mysqlTest = read('backend/tests/mysql-migration-roundtrip.test.mjs');
|
||||||
|
for (const pattern of [
|
||||||
|
'm09c-generate-one',
|
||||||
|
'CLEANING_MEMBER_SETTLEMENT_LOCKED',
|
||||||
|
'm09c-cancel-processing',
|
||||||
|
'collaborative task settles only after every member share is paid'
|
||||||
|
]) assert.ok(mysqlTest.includes(pattern), 'live MySQL test is missing ' + pattern);
|
||||||
|
|
||||||
|
console.log('PASS: M09-C settlement integrity, reversal, payout and shared-query gates are present.');
|
||||||
@@ -34,6 +34,8 @@ node scripts/check-miniapp-m08-c.mjs
|
|||||||
Assert-NativeSuccess "check-miniapp-m08-c"
|
Assert-NativeSuccess "check-miniapp-m08-c"
|
||||||
node scripts/check-m09-b-cleaning-rules.mjs
|
node scripts/check-m09-b-cleaning-rules.mjs
|
||||||
Assert-NativeSuccess "check-m09-b-cleaning-rules"
|
Assert-NativeSuccess "check-m09-b-cleaning-rules"
|
||||||
|
node scripts/check-m09-c-settlement-integrity.mjs
|
||||||
|
Assert-NativeSuccess "check-m09-c-settlement-integrity"
|
||||||
|
|
||||||
if (Test-Path "admin/package.json") {
|
if (Test-Path "admin/package.json") {
|
||||||
Push-Location admin
|
Push-Location admin
|
||||||
|
|||||||
@@ -93,6 +93,6 @@ export QIPAI_MYSQL_USER="${username}"
|
|||||||
export QIPAI_MYSQL_PASSWORD="${password}"
|
export QIPAI_MYSQL_PASSWORD="${password}"
|
||||||
export QIPAI_MYSQL_CONNECTION_LIMIT=2
|
export QIPAI_MYSQL_CONNECTION_LIMIT=2
|
||||||
|
|
||||||
echo "INFO: MySQL ${mysql_version}; running M01-B through M06-D integration roundtrip in a temporary database."
|
echo "INFO: MySQL ${mysql_version}; running M01-B through M09-C integration roundtrip in a temporary database."
|
||||||
npm --prefix backend run test:mysql:migration
|
npm --prefix backend run test:mysql:migration
|
||||||
echo "PASS: M01-B through M06-D live MySQL integration roundtrip completed."
|
echo "PASS: M01-B through M09-C live MySQL integration roundtrip completed."
|
||||||
|
|||||||
Reference in New Issue
Block a user