fix(M09-REGRESSION): 完成商品逐单对账与权限收口
This commit is contained in:
@@ -136,9 +136,9 @@ sh scripts/setup-git-hooks.sh
|
||||
|
||||
项目已开发部分模块。具体完成度不得从 README 猜测,必须以现有代码、测试、数据库迁移、Git 历史以及 `docs/current-baseline.md`、`docs/module-status.md`、`docs/feature-status.md` 为准。
|
||||
|
||||
- 当前执行游标:`M09-REGRESSION`(TODO);M09-D4 顾客小程序与 Vue 商品运营工作台已完成交付级回归,随本次中文工程提交固化;真实团购商家授权、微信和硬件现场验收保持 `BLOCKED_EXTERNAL`。
|
||||
- 最近工程结果:已推送工程提交 `60264cb` 完成 M09-D3 商品寄存与安全取出闭环;当前 M09-D4 随本次中文工程提交固化登录态顾客目录、选购/订单/寄存小程序页,以及按目录、库存、订单、寄存能力分域的响应式运营工作台。领取码只在签发当次显示,不写入小程序本地缓存。
|
||||
- 下一工程目标:执行 `M09-REGRESSION`,完成商品目录、库存、订单支付/退款、寄存数量、权限与界面的全链路对账和模块级验收。
|
||||
- 当前执行游标:`M10-A`(TODO);M09 商品、库存、订单、支付退款与寄存已完成模块级回归,真实团购商家授权、微信和硬件现场验收保持 `BLOCKED_EXTERNAL`。
|
||||
- 最近工程结果:已推送 `e91b979` 完成 M09-D4 商品选购与运营工作台;当前 M09-REGRESSION 随本次中文工程提交固化逐单金额/支付退款/库存/寄存对账、运营明细、`platform.manage` 权限一致性及 MySQL 8.4 库存分页兼容修复。
|
||||
- 下一工程目标:执行 `M10-A`,建立复用 outbox 的通知中心、模板、幂等投递、失败重试与人工补发闭环。
|
||||
|
||||
## 版本递进
|
||||
|
||||
|
||||
@@ -7072,14 +7072,14 @@ TARGET=CODE_COMPLETE_THEN_RELEASE_CANDIDATE_THEN_PRODUCTION_ACCEPTED
|
||||
execution_cursor_v57:
|
||||
document_version: V5.7
|
||||
audited_snapshot_sha256: cc802fecbea399698e0a9c1b38127bc12ff260d8f4d6a084828108307f557d6f
|
||||
evidence_head: 60264cbd11e3cbb759d40405ba364238a8a2c559
|
||||
evidence_origin_main: 60264cbd11e3cbb759d40405ba364238a8a2c559
|
||||
evidence_head: e91b97912840a358900a51814660c96fb4f168ea
|
||||
evidence_origin_main: e91b97912840a358900a51814660c96fb4f168ea
|
||||
base_branch: main
|
||||
remote: ssh://git@git.txyundm.cn:2222/panda/qipai.git
|
||||
current_stage: M09-REGRESSION
|
||||
current_stage_status: TODO_AFTER_M09_D4_CURRENT_COMMIT
|
||||
last_completed_functional_stage: M09-D4
|
||||
last_engineering_commit: 60264cbd11e3cbb759d40405ba364238a8a2c559
|
||||
current_stage: M10-A
|
||||
current_stage_status: TODO_AFTER_M09_REGRESSION_CURRENT_COMMIT
|
||||
last_completed_functional_stage: M09-REGRESSION
|
||||
last_engineering_commit: e91b97912840a358900a51814660c96fb4f168ea
|
||||
last_tooling_commit: 2de796143ff418d681fdb972040b8555a8e058d1
|
||||
m09_d1_business_commit: 93af128002c27ea548e9ed7d0c9af7b8f264002a
|
||||
m09_d1_static_gate: PASS
|
||||
@@ -7100,12 +7100,19 @@ execution_cursor_v57:
|
||||
m09_d3_mysql_84_roundtrip: PASS_217_UP_159_VERIFY_199_DOWN
|
||||
m09_d3_one_time_credential: PASS
|
||||
m09_d3_concurrent_retrieval: PASS
|
||||
m09_d4_business_commit: PENDING_CURRENT_CHINESE_ENGINEERING_COMMIT
|
||||
m09_d4_business_commit: e91b97912840a358900a51814660c96fb4f168ea
|
||||
m09_d4_static_gate: PASS
|
||||
m09_d4_windows_full_test: PASS_71_6_SECONDS
|
||||
m09_d4_admin_build_and_test: PASS
|
||||
m09_d4_mysql_84_roundtrip: PASS_217_UP_159_VERIFY_199_DOWN
|
||||
m09_d4_one_time_credential_not_persisted: PASS
|
||||
m09_regression_business_commit: PENDING_CURRENT_CHINESE_ENGINEERING_COMMIT
|
||||
m09_regression_static_gate: PASS
|
||||
m09_regression_backend_build_and_full_test: PASS
|
||||
m09_regression_admin_build_and_test: PASS
|
||||
m09_regression_mysql_84_roundtrip: PASS_217_UP_159_VERIFY_199_DOWN
|
||||
m09_regression_platform_manage_scope: PASS
|
||||
m09_regression_order_payment_refund_inventory_storage_reconciliation: PASS
|
||||
default_next_stage: M10-A
|
||||
actual_head_policy: REAL_REPOSITORY_EVIDENCE_WINS
|
||||
worktree_protected: true
|
||||
@@ -7120,7 +7127,7 @@ execution_cursor_v57:
|
||||
auto_continue_until_code_complete: true
|
||||
```
|
||||
|
||||
真实 `D:\qipai` 若有 `60264cb` 之后的提交,Codex 必须以 `git merge-base`、diff、迁移、代码、测试、提交和远端 SHA 判断进度,自动跳到第一个未完成阶段。当前 M09-D4 的推进依据是顾客/运营工程、静态门禁、Windows 全量与真实 MySQL 共同证据,不是 docs-only 状态变更;伴随业务提交完成后进入 M09-REGRESSION。
|
||||
真实 `D:\qipai` 若有 `e91b979` 之后的提交,Codex 必须以 `git merge-base`、diff、迁移、代码、测试、提交和远端 SHA 判断进度,自动跳到第一个未完成阶段。当前 M09-REGRESSION 的推进依据是逐单对账、权限修复、静态门禁、Windows 全量与真实 MySQL 共同证据,不是 docs-only 状态变更;伴随业务提交完成后进入 M10-A。
|
||||
|
||||
## 30.2 最终唯一队列
|
||||
|
||||
@@ -7136,7 +7143,7 @@ M09-REGRESSION
|
||||
→ PRODUCTION_ACCEPTED
|
||||
```
|
||||
|
||||
Git 中文门禁和 M09-D1/D2/D3 已由真实工程提交闭环;M09-D4 当前伴随业务提交必须完成中文 commit、push 和远端校验后继续 M09-REGRESSION。不能依据文档中的 `DONE` 单独跳过其他阶段;已完成阶段若出现回归,只做针对性修复,不得重启整个模块。
|
||||
Git 中文门禁和 M09-D1/D2/D3/D4 已由真实工程提交闭环;M09-REGRESSION 当前伴随业务提交必须完成中文 commit、push 和远端校验后继续 M10-A。不能依据文档中的 `DONE` 单独跳过其他阶段;已完成阶段若出现回归,只做针对性修复,不得重启整个模块。
|
||||
|
||||
M09-D4 的唯一允许推进条件:
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@ import type {
|
||||
PaymentAuthorizationStatus,
|
||||
ProductListing,
|
||||
ProductOrder,
|
||||
ProductOrderReconciliation,
|
||||
ProductStorage,
|
||||
InventoryStock,
|
||||
PlatformApplication,
|
||||
@@ -387,6 +388,12 @@ export function getProductOrder(session: ApiSession, orderId: string) {
|
||||
return request<ProductOrder>(session, `/product-orders/${encodeURIComponent(orderId)}`);
|
||||
}
|
||||
|
||||
export function getProductOrderReconciliation(session: ApiSession, orderId: string) {
|
||||
return request<ProductOrderReconciliation>(
|
||||
session, `/product-orders/${encodeURIComponent(orderId)}/reconciliation`
|
||||
);
|
||||
}
|
||||
|
||||
export function runProductOrderAction(
|
||||
session: ApiSession, orderId: string,
|
||||
input: { requestId: string; action: string; reason: string }
|
||||
|
||||
@@ -52,7 +52,7 @@
|
||||
<el-table-column label="状态" width="150"><template #default="{ row }"><el-tag :type="orderStatusType(row.status)">{{ statusLabel(row.status) }}</el-tag></template></el-table-column>
|
||||
<el-table-column label="履约" width="130"><template #default="{ row }">{{ fulfillmentLabel(row.fulfillmentMode) }}</template></el-table-column>
|
||||
<el-table-column label="数量 / 金额" min-width="150"><template #default="{ row }">{{ row.totalQuantity }} 件 · {{ money(row.totalAmountCents) }}</template></el-table-column>
|
||||
<el-table-column label="操作" min-width="300" fixed="right"><template #default="{ row }"><div class="row-actions"><el-button size="small" @click="showOrder(row)">详情</el-button><template v-if="canOrderWrite"><el-button v-for="action in orderActions(row)" :key="action" size="small" type="primary" plain @click="actOnOrder(row, action)">{{ actionLabel(action) }}</el-button></template></div></template></el-table-column>
|
||||
<el-table-column label="操作" min-width="340" fixed="right"><template #default="{ row }"><div class="row-actions"><el-button size="small" @click="showOrder(row)">详情</el-button><el-button size="small" type="success" plain @click="showReconciliation(row)">逐单对账</el-button><template v-if="canOrderWrite"><el-button v-for="action in orderActions(row)" :key="action" size="small" type="primary" plain @click="actOnOrder(row, action)">{{ actionLabel(action) }}</el-button></template></div></template></el-table-column>
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
|
||||
@@ -71,7 +71,69 @@
|
||||
</section>
|
||||
|
||||
<el-drawer v-model="detailDrawer" :title="detailTitle" size="min(720px, 96vw)">
|
||||
<template v-if="selectedOrder"><el-descriptions :column="2" border><el-descriptions-item label="订单号">{{ selectedOrder.orderNo }}</el-descriptions-item><el-descriptions-item label="状态">{{ statusLabel(selectedOrder.status) }}</el-descriptions-item><el-descriptions-item label="会员">#{{ selectedOrder.memberId }}</el-descriptions-item><el-descriptions-item label="金额">{{ money(selectedOrder.totalAmountCents) }}</el-descriptions-item></el-descriptions><el-table :data="selectedOrder.items || []"><el-table-column prop="productName" label="商品" /><el-table-column prop="skuName" label="规格" /><el-table-column prop="quantity" label="数量" width="80" /><el-table-column label="小计" width="110"><template #default="{ row }">{{ money(row.subtotalCents) }}</template></el-table-column></el-table></template>
|
||||
<template v-if="selectedReconciliation">
|
||||
<el-alert :title="selectedReconciliation.consistent ? '金额、支付退款、库存和寄存账实一致' : '发现账实差异,请按失败检查项排查'" :type="selectedReconciliation.consistent ? 'success' : 'error'" show-icon :closable="false" />
|
||||
<el-descriptions :column="2" border class="reconciliation-summary">
|
||||
<el-descriptions-item label="订单金额">{{ money(selectedReconciliation.amounts.orderAmountCents) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="明细合计">{{ money(selectedReconciliation.amounts.itemAmountCents) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="成功实收">{{ money(selectedReconciliation.amounts.capturedAmountCents) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="成功退款">{{ money(selectedReconciliation.amounts.succeededRefundCents) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="净收入">{{ money(selectedReconciliation.amounts.netRevenueCents) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="库存状态">{{ selectedReconciliation.inventory.status }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<h4>一致性检查</h4>
|
||||
<el-table :data="selectedReconciliation.checks" row-key="key">
|
||||
<el-table-column label="检查项" min-width="210"><template #default="{ row }">{{ reconciliationLabel(row.key) }}</template></el-table-column>
|
||||
<el-table-column label="结果" width="90"><template #default="{ row }"><el-tag :type="row.passed ? 'success' : 'danger'">{{ row.passed ? '一致' : '差异' }}</el-tag></template></el-table-column>
|
||||
<el-table-column prop="expected" label="应有" width="90" />
|
||||
<el-table-column prop="actual" label="实际" width="90" />
|
||||
</el-table>
|
||||
<h4>库存不可变流水</h4>
|
||||
<el-table :data="selectedReconciliation.inventory.ledger" row-key="id">
|
||||
<el-table-column label="商品 / SKU" min-width="180"><template #default="{ row }">{{ row.productName }} · {{ row.skuName }}</template></el-table-column>
|
||||
<el-table-column prop="operation" label="动作" width="100" />
|
||||
<el-table-column prop="reservationDelta" label="占用变化" width="100" />
|
||||
<el-table-column prop="saleDelta" label="销售变化" width="100" />
|
||||
<el-table-column label="时间" min-width="160"><template #default="{ row }">{{ dateText(row.createdAt) }}</template></el-table-column>
|
||||
</el-table>
|
||||
<h4>关联寄存</h4>
|
||||
<el-table :data="selectedReconciliation.storages" row-key="id">
|
||||
<el-table-column prop="storageNo" label="寄存单" min-width="180" />
|
||||
<el-table-column prop="status" label="状态" width="140" />
|
||||
<el-table-column label="记录 / 明细剩余" min-width="150"><template #default="{ row }">{{ row.recordRemainingQuantity }} / {{ row.itemRemainingQuantity }}</template></el-table-column>
|
||||
<el-table-column label="结果" width="90"><template #default="{ row }"><el-tag :type="row.consistent ? 'success' : 'danger'">{{ row.consistent ? '一致' : '差异' }}</el-tag></template></el-table-column>
|
||||
</el-table>
|
||||
</template>
|
||||
<template v-if="selectedOrder">
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item label="订单号">{{ selectedOrder.orderNo }}</el-descriptions-item>
|
||||
<el-descriptions-item label="状态">{{ statusLabel(selectedOrder.status) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="会员">#{{ selectedOrder.memberId }}</el-descriptions-item>
|
||||
<el-descriptions-item label="金额">{{ money(selectedOrder.totalAmountCents) }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<h4>订单明细</h4>
|
||||
<el-table :data="selectedOrder.items || []">
|
||||
<el-table-column prop="productName" label="商品" /><el-table-column prop="skuName" label="规格" />
|
||||
<el-table-column prop="quantity" label="数量" width="80" /><el-table-column label="小计" width="110"><template #default="{ row }">{{ money(row.subtotalCents) }}</template></el-table-column>
|
||||
</el-table>
|
||||
<h4>支付流水</h4>
|
||||
<el-table :data="selectedOrder.payments || []">
|
||||
<el-table-column prop="paymentNo" label="支付单号" min-width="180" /><el-table-column prop="provider" label="通道" width="90" />
|
||||
<el-table-column prop="status" label="状态" width="120" /><el-table-column label="金额" width="110"><template #default="{ row }">{{ money(row.amountCents) }}</template></el-table-column>
|
||||
<el-table-column label="已退" width="110"><template #default="{ row }">{{ money(row.refundedAmountCents) }}</template></el-table-column>
|
||||
</el-table>
|
||||
<h4>退款流水</h4>
|
||||
<el-table :data="selectedOrder.refunds || []">
|
||||
<el-table-column prop="refundNo" label="退款单号" min-width="180" /><el-table-column prop="status" label="状态" width="110" />
|
||||
<el-table-column label="金额" width="110"><template #default="{ row }">{{ money(row.amountCents) }}</template></el-table-column>
|
||||
<el-table-column label="库存处理" min-width="150"><template #default="{ row }">{{ row.inventoryDisposition }} / {{ row.inventoryStatus }}</template></el-table-column>
|
||||
</el-table>
|
||||
<h4>不可变事件</h4>
|
||||
<el-table :data="selectedOrder.events || []">
|
||||
<el-table-column prop="action" label="动作" width="120" /><el-table-column label="状态迁移" min-width="180"><template #default="{ row }">{{ row.fromStatus || '创建' }} → {{ row.toStatus }}</template></el-table-column>
|
||||
<el-table-column prop="reason" label="原因" min-width="150" /><el-table-column label="时间" min-width="165"><template #default="{ row }">{{ dateText(row.createdAt) }}</template></el-table-column>
|
||||
</el-table>
|
||||
</template>
|
||||
<template v-if="selectedStorage"><el-descriptions :column="2" border><el-descriptions-item label="寄存单">{{ selectedStorage.storageNo }}</el-descriptions-item><el-descriptions-item label="状态">{{ storageStatusLabel(selectedStorage.status) }}</el-descriptions-item><el-descriptions-item label="寄存人">#{{ selectedStorage.memberId }}</el-descriptions-item><el-descriptions-item label="到期">{{ dateText(selectedStorage.expiresAt) }}</el-descriptions-item></el-descriptions><el-table :data="selectedStorage.items || []"><el-table-column prop="productName" label="商品" /><el-table-column prop="skuName" label="规格" /><el-table-column prop="totalQuantity" label="寄存" width="80" /><el-table-column prop="remainingQuantity" label="剩余" width="80" /></el-table><h4>取出流水</h4><el-table :data="selectedStorage.movements || []"><el-table-column prop="storageItemId" label="明细" /><el-table-column prop="quantity" label="取出" width="80" /><el-table-column prop="remainingAfter" label="取出后" width="90" /></el-table></template>
|
||||
</el-drawer>
|
||||
|
||||
@@ -86,13 +148,13 @@ import { computed, reactive, ref, watch } from 'vue';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import { RefreshCw } from '@lucide/vue';
|
||||
import {
|
||||
ApiError, createOrderProductStorage, getProductOrder, getProductStorage,
|
||||
ApiError, createOrderProductStorage, getProductOrder, getProductOrderReconciliation, getProductStorage,
|
||||
listInventoryStocks, listManagedStores, listProductListings, listProductOrders,
|
||||
listProductStorages, mutateInventory, runProductOrderAction, runProductStorageAction,
|
||||
type ApiSession
|
||||
} from '../api';
|
||||
import { adminSessionState } from '../session';
|
||||
import type { InventoryStock, ManagedStore, PageResult, ProductListing, ProductOrder, ProductStorage } from '../types';
|
||||
import type { InventoryStock, ManagedStore, PageResult, ProductListing, ProductOrder, ProductOrderReconciliation, ProductStorage } from '../types';
|
||||
|
||||
const props = defineProps<{ session: ApiSession }>();
|
||||
const stores = ref<ManagedStore[]>([]); const storeId = ref(''); const activeTab = ref('catalog');
|
||||
@@ -102,6 +164,7 @@ const storages = reactive<PageResult<ProductStorage>>({ items: [], total: 0, pag
|
||||
const loading = ref(false); const saving = ref(false); const lastError = ref(''); const stockSearch = ref('');
|
||||
const orderStatus = ref(''); const storageStatus = ref(''); const detailDrawer = ref(false);
|
||||
const selectedOrder = ref<ProductOrder | null>(null); const selectedStorage = ref<ProductStorage | null>(null);
|
||||
const selectedReconciliation = ref<ProductOrderReconciliation | null>(null);
|
||||
const retrieveDialog = ref(false); const retrieveStorage = ref<ProductStorage | null>(null);
|
||||
const retrieveForm = reactive({ storageItemId: '', quantity: 1, credential: '' });
|
||||
const createStorageDialog = ref(false); const createStorageForm = reactive({ orderId: '', expiresAt: '' });
|
||||
@@ -120,7 +183,7 @@ const canOrderWrite = computed(() => hasAccess('goods.order.manage'));
|
||||
const canStorageRead = computed(() => hasAccess('goods.storage.read', 'goods.storage.manage'));
|
||||
const canStorageWrite = computed(() => hasAccess('goods.storage.manage'));
|
||||
const selectedRetrieveItem = computed(() => retrieveStorage.value?.items?.find((item) => item.id === retrieveForm.storageItemId));
|
||||
const detailTitle = computed(() => selectedOrder.value ? `商品订单 ${selectedOrder.value.orderNo}` : selectedStorage.value ? `寄存单 ${selectedStorage.value.storageNo}` : '业务详情');
|
||||
const detailTitle = computed(() => selectedReconciliation.value ? `逐单对账 ${selectedReconciliation.value.order.orderNo}` : selectedOrder.value ? `商品订单 ${selectedOrder.value.orderNo}` : selectedStorage.value ? `寄存单 ${selectedStorage.value.storageNo}` : '业务详情');
|
||||
|
||||
async function capture<T>(work: () => Promise<T>) { lastError.value = ''; try { return await work(); } catch (error) { lastError.value = error instanceof ApiError ? `${error.code}${error.traceId ? ` · ${error.traceId}` : ''}` : error instanceof Error ? error.message : '操作失败'; throw error; } }
|
||||
async function loadWorkspace() { if (!props.session.token) return; loading.value = true; try { stores.value = await capture(() => listManagedStores(props.session)); storeId.value ||= stores.value[0]?.id || ''; activeTab.value = canCatalogRead.value ? 'catalog' : canInventoryRead.value ? 'inventory' : canOrderRead.value ? 'orders' : 'storages'; await loadAll(); } catch { /* displayed */ } finally { loading.value = false; } }
|
||||
@@ -131,10 +194,11 @@ async function loadOrders() { if (!storeId.value || !canOrderRead.value) return;
|
||||
async function loadStorages() { if (!storeId.value || !canStorageRead.value) return; loading.value = true; try { Object.assign(storages, await capture(() => listProductStorages(props.session, { storeId: storeId.value, pageSize: 50, status: storageStatus.value || undefined }))); } catch { /* displayed */ } finally { loading.value = false; } }
|
||||
async function inventoryQuantity(row: InventoryStock, action: 'inbound' | 'loss') { try { const { value } = await ElMessageBox.prompt(`当前可用 ${row.availableQuantity},请输入${action === 'inbound' ? '入库' : '损耗'}数量`, `${row.productName} · ${row.skuName}`, { inputPattern: /^[1-9]\d{0,8}$/, inputErrorMessage: '请输入正整数' }); await capture(() => mutateInventory(props.session, row.skuId, action, { storeId: storeId.value, requestId: requestId(`admin-${action}`), reason: action === 'inbound' ? '运营后台商品入库' : '运营后台登记损耗', expectedVersion: row.version, quantity: Number(value) })); ElMessage.success('库存已更新'); await loadStocks(); } catch (error) { if (error !== 'cancel' && error !== 'close') { /* displayed */ } } }
|
||||
async function stocktake(row: InventoryStock) { try { const { value } = await ElMessageBox.prompt(`当前可用 ${row.availableQuantity},请输入盘点后的可用数量`, `${row.productName} · ${row.skuName}`, { inputPattern: /^\d{1,9}$/, inputErrorMessage: '请输入非负整数' }); await capture(() => mutateInventory(props.session, row.skuId, 'stocktake', { storeId: storeId.value, requestId: requestId('admin-stocktake'), reason: '运营后台库存盘点', expectedVersion: row.version, availableQuantity: Number(value), lossQuantity: row.lossQuantity })); ElMessage.success('盘点已完成'); await loadStocks(); } catch (error) { if (error !== 'cancel' && error !== 'close') { /* displayed */ } } }
|
||||
async function showOrder(row: ProductOrder) { try { selectedStorage.value = null; selectedOrder.value = await capture(() => getProductOrder(props.session, row.id)); detailDrawer.value = true; } catch { /* displayed */ } }
|
||||
async function showOrder(row: ProductOrder) { try { selectedStorage.value = null; selectedReconciliation.value = null; selectedOrder.value = await capture(() => getProductOrder(props.session, row.id)); detailDrawer.value = true; } catch { /* displayed */ } }
|
||||
async function showReconciliation(row: ProductOrder) { try { selectedOrder.value = null; selectedStorage.value = null; selectedReconciliation.value = await capture(() => getProductOrderReconciliation(props.session, row.id)); detailDrawer.value = true; } catch { /* displayed */ } }
|
||||
function orderActions(row: ProductOrder) { if (row.status === 'PAID') return ['ACCEPT', 'REFUND']; if (row.status === 'ACCEPTED') return row.fulfillmentMode === 'DELIVERY' ? ['START_DELIVERY', 'CANCEL'] : ['MARK_READY', 'CANCEL']; if (['DELIVERING', 'READY_FOR_SELF_SERVICE'].includes(row.status)) return ['COMPLETE']; if (row.status === 'COMPLETED') return ['REFUND']; if (row.status === 'PENDING_PAYMENT') return ['CANCEL']; return []; }
|
||||
async function actOnOrder(row: ProductOrder, action: string) { try { const destructive = ['CANCEL', 'REFUND'].includes(action); let reason = ''; if (destructive) { const result = await ElMessageBox.prompt(`将对订单 ${row.orderNo} 执行“${actionLabel(action)}”,库存与退款状态会按当前履约阶段处理。请输入原因。`, '确认商品订单操作', { inputPattern: /\S{2,512}/, inputErrorMessage: '请填写至少 2 个字符的原因', type: 'warning' }); reason = result.value.trim(); } await capture(() => runProductOrderAction(props.session, row.id, { requestId: requestId(`order-${action.toLowerCase()}`), action, reason })); ElMessage.success('订单状态已更新'); await loadOrders(); } catch (error) { if (error !== 'cancel' && error !== 'close') { /* displayed */ } } }
|
||||
async function showStorage(row: ProductStorage) { try { selectedOrder.value = null; selectedStorage.value = await capture(() => getProductStorage(props.session, row.id)); detailDrawer.value = true; } catch { /* displayed */ } }
|
||||
async function showStorage(row: ProductStorage) { try { selectedOrder.value = null; selectedReconciliation.value = null; selectedStorage.value = await capture(() => getProductStorage(props.session, row.id)); detailDrawer.value = true; } catch { /* displayed */ } }
|
||||
async function openRetrieve(row: ProductStorage) { try { retrieveStorage.value = await capture(() => getProductStorage(props.session, row.id)); const first = retrieveStorage.value.items?.find((item) => item.remainingQuantity > 0); Object.assign(retrieveForm, { storageItemId: first?.id || '', quantity: 1, credential: '' }); retrieveDialog.value = true; } catch { /* displayed */ } }
|
||||
async function submitRetrieve() { if (!retrieveStorage.value || !retrieveForm.storageItemId || !retrieveForm.credential.trim()) return ElMessage.warning('请填写寄存明细、数量和领取码'); saving.value = true; try { const result = await capture(() => runProductStorageAction(props.session, retrieveStorage.value!.id, 'retrieve', { requestId: requestId('storage-retrieve'), claimCredential: retrieveForm.credential.trim(), items: [{ storageItemId: retrieveForm.storageItemId, quantity: retrieveForm.quantity }] })); retrieveDialog.value = false; retrieveForm.credential = ''; if (result.nextClaimCredential) await showOneTimeCredential(result.nextClaimCredential, '部分取出已完成,旧领取码已失效'); else ElMessage.success('商品已取出'); await loadStorages(); } catch { /* displayed */ } finally { saving.value = false; } }
|
||||
async function rotateStorage(row: ProductStorage) { try { await ElMessageBox.confirm(`寄存单 ${row.storageNo} 的当前领取码会立即失效,新码只显示一次。`, '确认轮换领取码', { type: 'warning' }); const result = await capture(() => runProductStorageAction(props.session, row.id, 'credential/rotate', { requestId: requestId('storage-rotate') })); if (result.claimCredential) await showOneTimeCredential(result.claimCredential, '领取码已轮换'); await loadStorages(); } catch (error) { if (error !== 'cancel' && error !== 'close') { /* displayed */ } } }
|
||||
@@ -152,6 +216,7 @@ function actionLabel(value: string) { return ({ ACCEPT: '接单', START_DELIVERY
|
||||
function orderStatusType(value: string) { return ['COMPLETED', 'REFUNDED'].includes(value) ? 'success' : ['CANCELLED', 'REFUND_FAILED'].includes(value) ? 'danger' : ['PAID', 'ACCEPTED', 'DELIVERING', 'READY_FOR_SELF_SERVICE', 'REFUNDING'].includes(value) ? 'warning' : 'info'; }
|
||||
function storageStatusLabel(value: string) { return ({ STORED: '已寄存', PARTIALLY_RETRIEVED: '部分取出', RETRIEVED: '已取完', EXPIRED: '已过期', CANCELLED: '已取消' } as Record<string, string>)[value] || value; }
|
||||
function storageStatusType(value: string) { return value === 'RETRIEVED' ? 'success' : value === 'PARTIALLY_RETRIEVED' ? 'warning' : ['EXPIRED', 'CANCELLED'].includes(value) ? 'danger' : 'primary'; }
|
||||
function reconciliationLabel(value: string) { return ({ ORDER_ITEM_AMOUNT: '订单金额 = 明细小计', ORDER_ITEM_QUANTITY: '订单数量 = 明细数量', ORDER_ITEM_COUNT: '订单行数 = 明细行数', PAYMENT_CAPTURED_AMOUNT: '订单实收 = 成功支付', PAYMENT_REFUNDED_AMOUNT: '订单退款 = 支付退款累计', REFUND_SUCCEEDED_AMOUNT: '订单退款 = 成功退款单', INVENTORY_RESERVATION_BALANCE: '库存占用余额', INVENTORY_SALE_BALANCE: '库存销售余额', STORAGE_ITEM_BALANCE: '寄存记录 = 寄存明细' } as Record<string, string>)[value] || value; }
|
||||
function isStorageActive(value: string) { return ['STORED', 'PARTIALLY_RETRIEVED'].includes(value); }
|
||||
watch(() => props.session.token, (token) => { if (token) void loadWorkspace(); }, { immediate: true });
|
||||
</script>
|
||||
|
||||
+47
-1
@@ -43,7 +43,8 @@ export interface ProductOrder {
|
||||
itemCount: number; totalQuantity: number; totalAmountCents: number;
|
||||
paidAmountCents: number; refundedAmountCents: number; note: string;
|
||||
inventoryStatus: string; createdAt: string; updatedAt: string;
|
||||
items?: ProductOrderItem[];
|
||||
items?: ProductOrderItem[]; payments?: ProductPayment[];
|
||||
refunds?: ProductRefund[]; events?: ProductOrderEvent[];
|
||||
}
|
||||
|
||||
export interface ProductOrderItem {
|
||||
@@ -51,6 +52,51 @@ export interface ProductOrderItem {
|
||||
unitPriceCents: number; subtotalCents: number; note?: string;
|
||||
}
|
||||
|
||||
export interface ProductPayment {
|
||||
id: string; paymentNo: string; provider: string; channel: string; status: string;
|
||||
amountCents: number; refundedAmountCents: number;
|
||||
expiresAt: string; paidAt: string | null; createdAt: string;
|
||||
}
|
||||
|
||||
export interface ProductRefund {
|
||||
id: string; paymentId: string; refundNo: string; provider: string; status: string;
|
||||
amountCents: number; reason: string; inventoryDisposition: string;
|
||||
inventoryStatus: string; completedAt: string | null; createdAt: string;
|
||||
}
|
||||
|
||||
export interface ProductOrderEvent {
|
||||
id: string; fromStatus: string | null; toStatus: string; action: string;
|
||||
actorType: string; actorId: string | null; versionAfter: number;
|
||||
reason: string; createdAt: string;
|
||||
}
|
||||
|
||||
export interface ProductOrderReconciliation {
|
||||
order: { id: string; orderNo: string; status: string; inventoryStatus: string };
|
||||
amounts: {
|
||||
orderAmountCents: number; itemAmountCents: number;
|
||||
paidAmountCents: number; capturedAmountCents: number;
|
||||
refundedAmountCents: number; paymentRefundedCents: number;
|
||||
succeededRefundCents: number; netRevenueCents: number;
|
||||
};
|
||||
inventory: {
|
||||
status: string; reservationBalance: number; saleBalance: number;
|
||||
ledger: Array<{
|
||||
id: string; inventoryId: string; skuId: string; productName: string; skuName: string;
|
||||
requestId: string; operation: string; availableDelta: number; lockedDelta: number;
|
||||
lossDelta: number; reservationDelta: number; saleDelta: number;
|
||||
availableAfter: number; lockedAfter: number; lossAfter: number;
|
||||
versionAfter: number; createdAt: string;
|
||||
}>;
|
||||
};
|
||||
storages: Array<{
|
||||
id: string; storageNo: string; status: string; recordTotalQuantity: number;
|
||||
recordRemainingQuantity: number; itemTotalQuantity: number;
|
||||
itemRemainingQuantity: number; itemCount: number; consistent: boolean;
|
||||
}>;
|
||||
checks: Array<{ key: string; passed: boolean; expected: number; actual: number }>;
|
||||
consistent: boolean;
|
||||
}
|
||||
|
||||
export interface ProductStorage {
|
||||
id: string; storeId: string; memberId: string; sourceOrderId: string | null;
|
||||
storageNo: string; sourceType: 'ORDER' | 'MANUAL'; status: string;
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
"db:migrate:down": "npm run build && node dist/db/migrate-cli.js down",
|
||||
"test:mysql:migration": "npm run build && node tests/mysql-migration-roundtrip.test.mjs",
|
||||
"pretest": "npm run build && node tests/product-storage-service.test.mjs && node tests/product-storage-route.test.mjs",
|
||||
"test": "npm run build && node tests/backend-contract.test.mjs && node tests/mqtt-service.test.mjs && node tests/migration-contract.test.mjs && node tests/mysql-pool-contract.test.mjs && node tests/migration-runner.test.mjs && node tests/legacy-money.test.mjs && node tests/legacy-read-repository.test.mjs && node tests/task-repository.test.mjs && node tests/platform-config-repository.test.mjs && node tests/auth.test.mjs && node tests/admin-auth.test.mjs && node tests/rbac.test.mjs && node tests/user-management.test.mjs && node tests/store-room.test.mjs && node tests/content-management.test.mjs && node tests/franchise.test.mjs && node tests/system-operations.test.mjs && node tests/store-discovery.test.mjs && node tests/store-access.test.mjs && node tests/pricing.test.mjs && node tests/order-state.test.mjs && node tests/order-query.test.mjs && node tests/order-management.test.mjs && node tests/order-share.test.mjs && node tests/payment.test.mjs && node tests/wechat-pay.test.mjs && node tests/third-party.test.mjs && node tests/profit-sharing.test.mjs && node tests/device.test.mjs && node tests/iot-protocol.test.mjs && node tests/device-control.test.mjs && node tests/order-device-automation.test.mjs && node tests/hardware-smoke-runner.test.mjs && node tests/wallet-ledger.test.mjs && node tests/recharge-service.test.mjs && node tests/recharge-route.test.mjs && node tests/marketing-benefit-service.test.mjs && node tests/member-profile-service.test.mjs && node tests/member-profile-route.test.mjs && node tests/cleaning-payout-service.test.mjs && node tests/cleaning-route.test.mjs && node tests/business-statistics.test.mjs && node tests/product-catalog.test.mjs && node tests/product-route.test.mjs && node tests/inventory-service.test.mjs && node tests/inventory-route.test.mjs && node tests/product-order-service.test.mjs && node tests/product-order-route.test.mjs"
|
||||
"test": "npm run build && node tests/backend-contract.test.mjs && node tests/mqtt-service.test.mjs && node tests/migration-contract.test.mjs && node tests/mysql-pool-contract.test.mjs && node tests/migration-runner.test.mjs && node tests/legacy-money.test.mjs && node tests/legacy-read-repository.test.mjs && node tests/task-repository.test.mjs && node tests/platform-config-repository.test.mjs && node tests/auth.test.mjs && node tests/admin-auth.test.mjs && node tests/rbac.test.mjs && node tests/user-management.test.mjs && node tests/store-room.test.mjs && node tests/content-management.test.mjs && node tests/franchise.test.mjs && node tests/system-operations.test.mjs && node tests/store-discovery.test.mjs && node tests/store-access.test.mjs && node tests/pricing.test.mjs && node tests/order-state.test.mjs && node tests/order-query.test.mjs && node tests/order-management.test.mjs && node tests/order-share.test.mjs && node tests/payment.test.mjs && node tests/wechat-pay.test.mjs && node tests/third-party.test.mjs && node tests/profit-sharing.test.mjs && node tests/device.test.mjs && node tests/iot-protocol.test.mjs && node tests/device-control.test.mjs && node tests/order-device-automation.test.mjs && node tests/hardware-smoke-runner.test.mjs && node tests/wallet-ledger.test.mjs && node tests/recharge-service.test.mjs && node tests/recharge-route.test.mjs && node tests/marketing-benefit-service.test.mjs && node tests/member-profile-service.test.mjs && node tests/member-profile-route.test.mjs && node tests/cleaning-payout-service.test.mjs && node tests/cleaning-route.test.mjs && node tests/business-statistics.test.mjs && node tests/product-catalog.test.mjs && node tests/product-route.test.mjs && node tests/inventory-service.test.mjs && node tests/inventory-route.test.mjs && node tests/product-order-service.test.mjs && node tests/product-order-route.test.mjs && node tests/product-reconciliation.test.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^11.2.0",
|
||||
|
||||
@@ -310,8 +310,8 @@ export class InventoryService {
|
||||
ON p.tenant_id = s.tenant_id AND p.id = s.product_id AND p.deleted_at IS NULL
|
||||
WHERE ${where}
|
||||
ORDER BY p.sort_order, p.id, s.id
|
||||
LIMIT ? OFFSET ?`,
|
||||
[...params, pageSize, (page - 1) * pageSize]
|
||||
LIMIT ${pageSize} OFFSET ${(page - 1) * pageSize}`,
|
||||
params
|
||||
);
|
||||
return {
|
||||
items: rows.map(mapStock),
|
||||
@@ -346,8 +346,8 @@ export class InventoryService {
|
||||
trace_id AS traceId, reason, metadata, created_at AS createdAt
|
||||
FROM qipai_product_inventory_ledger
|
||||
WHERE tenant_id = ? AND store_id = ? AND inventory_id = ?
|
||||
ORDER BY id DESC LIMIT ? OFFSET ?`,
|
||||
[...params, pageSize, (page - 1) * pageSize]
|
||||
ORDER BY id DESC LIMIT ${pageSize} OFFSET ${(page - 1) * pageSize}`,
|
||||
params
|
||||
);
|
||||
return {
|
||||
items: rows.map(mapLedger),
|
||||
@@ -1013,6 +1013,7 @@ export class InventoryService {
|
||||
private assertStoreScope(actor: ManagementActor, storeId: string, write: boolean) {
|
||||
assertId(storeId, 'INVENTORY_STORE_ID_INVALID');
|
||||
if (actor.access.capabilities.includes('tenant.manage')
|
||||
|| actor.access.capabilities.includes('platform.manage')
|
||||
|| actor.access.roles.includes('PLATFORM_ADMIN')) return;
|
||||
const capability = write ? 'inventory.adjust' : 'inventory.read';
|
||||
const hasCapability = actor.access.capabilities.includes(capability)
|
||||
|
||||
@@ -378,6 +378,54 @@ export class ProductOrderService {
|
||||
return this.transaction((connection) => this.loadOrderView(connection, actor, orderId, true));
|
||||
}
|
||||
|
||||
async reconcileForManagement(actor: ProductOrderActor, orderId: string) {
|
||||
assertId(orderId);
|
||||
return this.transaction(async (connection) => {
|
||||
const order = await this.loadOrder(connection, actor.tenantId, orderId, false);
|
||||
this.assertManagementStore(actor, order.storeId);
|
||||
const detail = await this.loadOrderView(connection, actor, orderId, true);
|
||||
const [inventoryLedger] = await connection.execute<RowDataPacket[]>(
|
||||
`SELECT l.id, l.inventory_id AS inventoryId, l.sku_id AS skuId,
|
||||
p.name AS productName, s.name AS skuName, l.request_id AS requestId,
|
||||
l.operation, l.available_delta AS availableDelta,
|
||||
l.locked_delta AS lockedDelta, l.loss_delta AS lossDelta,
|
||||
CAST(JSON_UNQUOTE(JSON_EXTRACT(l.metadata,
|
||||
'$.inventoryReservationDelta')) AS SIGNED) AS reservationDelta,
|
||||
CAST(JSON_UNQUOTE(JSON_EXTRACT(l.metadata,
|
||||
'$.inventorySaleDelta')) AS SIGNED) AS saleDelta,
|
||||
l.available_after AS availableAfter, l.locked_after AS lockedAfter,
|
||||
l.loss_after AS lossAfter, l.version_after AS versionAfter,
|
||||
l.created_at AS createdAt
|
||||
FROM qipai_product_inventory_ledger l
|
||||
INNER JOIN qipai_product_skus s
|
||||
ON s.tenant_id = l.tenant_id AND s.id = l.sku_id
|
||||
INNER JOIN qipai_products p
|
||||
ON p.tenant_id = s.tenant_id AND p.id = s.product_id
|
||||
WHERE l.tenant_id = ? AND l.store_id = ?
|
||||
AND l.business_type = 'PRODUCT_ORDER' AND l.business_id = ?
|
||||
ORDER BY l.created_at, l.id`,
|
||||
[actor.tenantId, order.storeId, order.inventoryBusinessId]
|
||||
);
|
||||
const [storageSummaries] = await connection.execute<RowDataPacket[]>(
|
||||
`SELECT r.id, r.storage_no AS storageNo, r.status,
|
||||
r.total_quantity AS recordTotalQuantity,
|
||||
r.remaining_quantity AS recordRemainingQuantity,
|
||||
COALESCE(SUM(i.total_quantity), 0) AS itemTotalQuantity,
|
||||
COALESCE(SUM(i.remaining_quantity), 0) AS itemRemainingQuantity,
|
||||
COUNT(i.id) AS itemCount
|
||||
FROM qipai_product_storage_records r
|
||||
LEFT JOIN qipai_product_storage_items i
|
||||
ON i.tenant_id = r.tenant_id AND i.store_id = r.store_id
|
||||
AND i.storage_id = r.id
|
||||
WHERE r.tenant_id = ? AND r.store_id = ? AND r.source_order_id = ?
|
||||
GROUP BY r.id, r.storage_no, r.status, r.total_quantity, r.remaining_quantity
|
||||
ORDER BY r.id`,
|
||||
[actor.tenantId, order.storeId, order.id]
|
||||
);
|
||||
return buildProductOrderReconciliation(detail, inventoryLedger, storageSummaries);
|
||||
});
|
||||
}
|
||||
|
||||
async managementAction(
|
||||
actor: ProductOrderActor,
|
||||
orderId: string,
|
||||
@@ -1437,6 +1485,106 @@ function publicPayment(row: PaymentRow, idempotent: boolean) {
|
||||
};
|
||||
}
|
||||
|
||||
export function buildProductOrderReconciliation(
|
||||
detail: Record<string, any>,
|
||||
inventoryLedger: Array<Record<string, any>>,
|
||||
storageSummaries: Array<Record<string, any>>
|
||||
) {
|
||||
const items = Array.isArray(detail.items) ? detail.items : [];
|
||||
const payments = Array.isArray(detail.payments) ? detail.payments : [];
|
||||
const refunds = Array.isArray(detail.refunds) ? detail.refunds : [];
|
||||
const itemAmountCents = items.reduce(
|
||||
(sum: number, item: Record<string, any>) => sum + numeric(item.subtotalCents), 0
|
||||
);
|
||||
const itemQuantity = items.reduce(
|
||||
(sum: number, item: Record<string, any>) => sum + numeric(item.quantity), 0
|
||||
);
|
||||
const capturedAmountCents = payments
|
||||
.filter((payment: Record<string, any>) => ['SUCCEEDED', 'REFUNDED'].includes(payment.status))
|
||||
.reduce((sum: number, payment: Record<string, any>) => sum + numeric(payment.amountCents), 0);
|
||||
const paymentRefundedCents = payments.reduce(
|
||||
(sum: number, payment: Record<string, any>) => sum + numeric(payment.refundedAmountCents), 0
|
||||
);
|
||||
const succeededRefundCents = refunds
|
||||
.filter((refund: Record<string, any>) => refund.status === 'SUCCEEDED')
|
||||
.reduce((sum: number, refund: Record<string, any>) => sum + numeric(refund.amountCents), 0);
|
||||
const reservationBalance = inventoryLedger.reduce(
|
||||
(sum, entry) => sum + numeric(entry.reservationDelta), 0
|
||||
);
|
||||
const saleBalance = inventoryLedger.reduce(
|
||||
(sum, entry) => sum + numeric(entry.saleDelta), 0
|
||||
);
|
||||
const expectedInventory = detail.inventoryStatus === 'LOCKED'
|
||||
? { reservationBalance: numeric(detail.totalQuantity), saleBalance: 0 }
|
||||
: detail.inventoryStatus === 'DEDUCTED'
|
||||
? { reservationBalance: 0, saleBalance: numeric(detail.totalQuantity) }
|
||||
: { reservationBalance: 0, saleBalance: 0 };
|
||||
const storages = storageSummaries.map((storage) => ({
|
||||
id: String(storage.id), storageNo: storage.storageNo, status: storage.status,
|
||||
recordTotalQuantity: numeric(storage.recordTotalQuantity),
|
||||
recordRemainingQuantity: numeric(storage.recordRemainingQuantity),
|
||||
itemTotalQuantity: numeric(storage.itemTotalQuantity),
|
||||
itemRemainingQuantity: numeric(storage.itemRemainingQuantity),
|
||||
itemCount: numeric(storage.itemCount),
|
||||
consistent: numeric(storage.recordTotalQuantity) === numeric(storage.itemTotalQuantity)
|
||||
&& numeric(storage.recordRemainingQuantity) === numeric(storage.itemRemainingQuantity)
|
||||
&& numeric(storage.itemRemainingQuantity) >= 0
|
||||
&& numeric(storage.itemRemainingQuantity) <= numeric(storage.itemTotalQuantity)
|
||||
}));
|
||||
const checks = [
|
||||
reconciliationCheck('ORDER_ITEM_AMOUNT', numeric(detail.totalAmountCents), itemAmountCents),
|
||||
reconciliationCheck('ORDER_ITEM_QUANTITY', numeric(detail.totalQuantity), itemQuantity),
|
||||
reconciliationCheck('ORDER_ITEM_COUNT', numeric(detail.itemCount), items.length),
|
||||
reconciliationCheck('PAYMENT_CAPTURED_AMOUNT', numeric(detail.paidAmountCents), capturedAmountCents),
|
||||
reconciliationCheck('PAYMENT_REFUNDED_AMOUNT', numeric(detail.refundedAmountCents), paymentRefundedCents),
|
||||
reconciliationCheck('REFUND_SUCCEEDED_AMOUNT', numeric(detail.refundedAmountCents), succeededRefundCents),
|
||||
reconciliationCheck(
|
||||
'INVENTORY_RESERVATION_BALANCE', expectedInventory.reservationBalance, reservationBalance
|
||||
),
|
||||
reconciliationCheck('INVENTORY_SALE_BALANCE', expectedInventory.saleBalance, saleBalance),
|
||||
{
|
||||
key: 'STORAGE_ITEM_BALANCE', passed: storages.every((storage) => storage.consistent),
|
||||
expected: storages.length, actual: storages.filter((storage) => storage.consistent).length
|
||||
}
|
||||
];
|
||||
return {
|
||||
order: {
|
||||
id: String(detail.id), orderNo: detail.orderNo, status: detail.status,
|
||||
inventoryStatus: detail.inventoryStatus
|
||||
},
|
||||
amounts: {
|
||||
orderAmountCents: numeric(detail.totalAmountCents), itemAmountCents,
|
||||
paidAmountCents: numeric(detail.paidAmountCents), capturedAmountCents,
|
||||
refundedAmountCents: numeric(detail.refundedAmountCents),
|
||||
paymentRefundedCents, succeededRefundCents,
|
||||
netRevenueCents: numeric(detail.paidAmountCents) - numeric(detail.refundedAmountCents)
|
||||
},
|
||||
inventory: {
|
||||
status: detail.inventoryStatus, reservationBalance, saleBalance,
|
||||
ledger: inventoryLedger.map((entry) => ({
|
||||
...entry, id: String(entry.id), inventoryId: String(entry.inventoryId),
|
||||
skuId: String(entry.skuId), availableDelta: numeric(entry.availableDelta),
|
||||
lockedDelta: numeric(entry.lockedDelta), lossDelta: numeric(entry.lossDelta),
|
||||
reservationDelta: numeric(entry.reservationDelta), saleDelta: numeric(entry.saleDelta),
|
||||
availableAfter: numeric(entry.availableAfter), lockedAfter: numeric(entry.lockedAfter),
|
||||
lossAfter: numeric(entry.lossAfter), versionAfter: numeric(entry.versionAfter)
|
||||
}))
|
||||
},
|
||||
storages,
|
||||
checks,
|
||||
consistent: checks.every((item) => item.passed)
|
||||
};
|
||||
}
|
||||
|
||||
function reconciliationCheck(key: string, expected: number, actual: number) {
|
||||
return { key, passed: expected === actual, expected, actual };
|
||||
}
|
||||
|
||||
function numeric(value: unknown) {
|
||||
const parsed = Number(value ?? 0);
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
}
|
||||
|
||||
function assertId(value: string) {
|
||||
if (!idPattern.test(value)) throw new ProductOrderError('PRODUCT_ORDER_INPUT_INVALID');
|
||||
}
|
||||
|
||||
@@ -192,6 +192,7 @@ async function requireInventoryActor(
|
||||
auth.session.user.id
|
||||
);
|
||||
const manager = access.capabilities.includes('tenant.manage')
|
||||
|| access.capabilities.includes('platform.manage')
|
||||
|| access.roles.includes('PLATFORM_ADMIN');
|
||||
const allowed = manager || (write
|
||||
? access.capabilities.includes('inventory.adjust')
|
||||
|
||||
@@ -68,7 +68,8 @@ const testCallbackSchema = z.object({
|
||||
export interface ProductOrderRouteOptions {
|
||||
service: Pick<ProductOrderService,
|
||||
'create' | 'listForCustomer' | 'getForCustomer' | 'cancelForCustomer'
|
||||
| 'listForManagement' | 'getForManagement' | 'managementAction'
|
||||
| 'listForManagement' | 'getForManagement' | 'reconcileForManagement'
|
||||
| 'managementAction'
|
||||
| 'createPaymentForCustomer' | 'completeTestPaymentForCustomer'
|
||||
| 'completeTestRefundForManagement'>;
|
||||
authRepository: Pick<AuthRepository, 'validateSession'>;
|
||||
@@ -187,6 +188,17 @@ export async function registerProductOrderRoutes(
|
||||
}));
|
||||
});
|
||||
|
||||
app.get(`${prefix}/product-orders/:orderId/reconciliation`, async (request, reply) => {
|
||||
const actor = await authenticate(request, reply, options, true, false);
|
||||
const params = orderParams.safeParse(request.params);
|
||||
if (!actor || !params.success) return actor ? invalid(reply, request.traceId) : undefined;
|
||||
return handle(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.service.reconcileForManagement(actor, params.data.orderId),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
|
||||
app.post(`${prefix}/product-orders/:orderId/actions`, async (request, reply) => {
|
||||
const actor = await authenticate(request, reply, options, true, true);
|
||||
const params = orderParams.safeParse(request.params);
|
||||
@@ -243,6 +255,7 @@ async function authenticate(
|
||||
);
|
||||
if (management) {
|
||||
const manager = access.capabilities.includes('tenant.manage')
|
||||
|| access.capabilities.includes('platform.manage')
|
||||
|| access.roles.includes('PLATFORM_ADMIN');
|
||||
const capability = write ? 'goods.order.manage' : 'goods.order.read';
|
||||
if (!manager && !access.capabilities.includes(capability)
|
||||
|
||||
@@ -95,6 +95,17 @@ const forbidden = await app.inject({
|
||||
assert.equal(forbidden.statusCode, 403);
|
||||
assert.equal(forbidden.json().code, 'INVENTORY_OPERATION_FORBIDDEN');
|
||||
|
||||
currentAccess = {
|
||||
roles: ['STAFF'], capabilities: ['platform.manage'], storeIds: []
|
||||
};
|
||||
assert.equal((await app.inject({
|
||||
method: 'GET', url: '/admin-api/inventory/stocks?storeId=11', headers
|
||||
})).statusCode, 200, 'platform.manage must authorize inventory reads');
|
||||
assert.equal((await app.inject({
|
||||
method: 'POST', url: '/admin-api/inventory/stocks/5/inbound', headers,
|
||||
payload: { storeId: '11', requestId: 'platform-inbound', reason: 'platform check', quantity: 1 }
|
||||
})).statusCode, 200, 'platform.manage must authorize inventory writes');
|
||||
|
||||
currentAccess = {
|
||||
roles: ['STAFF'], capabilities: ['inventory.read'], storeIds: ['11']
|
||||
};
|
||||
@@ -105,7 +116,7 @@ const listed = await app.inject({
|
||||
});
|
||||
assert.equal(listed.statusCode, 200);
|
||||
assert.deepEqual(listed.json().data, { items: [], total: 0, page: 2, pageSize: 25 });
|
||||
const listCall = calls.find((call) => call.method === 'listStocks');
|
||||
const listCall = calls.findLast((call) => call.method === 'listStocks');
|
||||
assert.equal(listCall.args[0].tenantId, '7');
|
||||
assert.equal(listCall.args[0].traceId, 'm09d1-inventory-route');
|
||||
assert.deepEqual(listCall.args[1], {
|
||||
|
||||
@@ -338,6 +338,13 @@ await assert.rejects(
|
||||
() => service.listStocks(scopedOut, { storeId: '11', page: 1, pageSize: 20 }),
|
||||
(error) => error instanceof InventoryError && error.code === 'INVENTORY_STORE_SCOPE_FORBIDDEN'
|
||||
);
|
||||
const platformManager = {
|
||||
...manager,
|
||||
access: { roles: ['STAFF'], capabilities: ['platform.manage'], storeIds: [] }
|
||||
};
|
||||
assert.equal((await service.listStocks(
|
||||
platformManager, { storeId: '11', page: 1, pageSize: 20 }
|
||||
)).total, 4, 'platform.manage must bypass store grants for inventory reads');
|
||||
|
||||
state.lockOrder.length = 0;
|
||||
const batchBase = {
|
||||
|
||||
@@ -4228,6 +4228,30 @@ async function assertProductOrderPaymentInventory(pool, context) {
|
||||
callbackId: 'm09d2-first-refund-callback', amountCents: 1400
|
||||
}
|
||||
);
|
||||
const completedReconciliation = await service.reconcileForManagement(
|
||||
adminActor, firstOrder.id
|
||||
);
|
||||
assert.equal(completedReconciliation.consistent, true);
|
||||
assert.equal(completedReconciliation.amounts.netRevenueCents, 0);
|
||||
assert.equal(completedReconciliation.inventory.saleBalance, 2);
|
||||
assert.ok(completedReconciliation.inventory.ledger.some(
|
||||
(entry) => entry.operation === 'LOCK'
|
||||
));
|
||||
assert.ok(completedReconciliation.inventory.ledger.some(
|
||||
(entry) => entry.operation === 'DEDUCT'
|
||||
));
|
||||
assert.ok(completedReconciliation.checks.every((check) => check.passed));
|
||||
|
||||
const platformActor = {
|
||||
...adminActor,
|
||||
access: { roles: ['STAFF'], capabilities: ['platform.manage'], storeIds: [] }
|
||||
};
|
||||
assert.equal((await service.reconcileForManagement(
|
||||
platformActor, firstOrder.id
|
||||
)).consistent, true);
|
||||
assert.ok((await inventory.listStocks(
|
||||
platformActor, { storeId, page: 1, pageSize: 20 }
|
||||
)).total >= 1);
|
||||
|
||||
const restockOrder = await service.create(customerActor, {
|
||||
...firstInput, requestId: 'm09d2-order-restock', note: 'restock order',
|
||||
@@ -4526,6 +4550,14 @@ async function assertProductStorageLifecycle(pool, context) {
|
||||
assert.equal(retrieved.movements.reduce(
|
||||
(sum, movement) => sum + Number(movement.quantity), 0
|
||||
), 3);
|
||||
const storageReconciliation = await orders.reconcileForManagement(
|
||||
adminActor, sourceOrder.id
|
||||
);
|
||||
assert.equal(storageReconciliation.consistent, true);
|
||||
assert.equal(storageReconciliation.storages.length, 1);
|
||||
assert.equal(storageReconciliation.storages[0].recordTotalQuantity, 3);
|
||||
assert.equal(storageReconciliation.storages[0].recordRemainingQuantity, 0);
|
||||
assert.ok(storageReconciliation.checks.every((check) => check.passed));
|
||||
|
||||
await assert.rejects(
|
||||
() => storages.listForManagement({
|
||||
|
||||
@@ -38,6 +38,9 @@ const service = {
|
||||
items: [order], total: 1, page: input.page, pageSize: input.pageSize
|
||||
})),
|
||||
getForManagement: record('getForManagement', () => order),
|
||||
reconcileForManagement: record('reconcileForManagement', () => ({
|
||||
order: { id: '101', orderNo: 'PG101' }, consistent: true, checks: []
|
||||
})),
|
||||
managementAction: record('managementAction', (_actor, _id, input) => ({
|
||||
...order, status: input.action === 'ACCEPT' ? 'ACCEPTED' : order.status
|
||||
})),
|
||||
@@ -146,6 +149,11 @@ const managementList = await app.inject({
|
||||
assert.equal(managementList.statusCode, 200);
|
||||
const managementCall = calls.find((call) => call.method === 'listForManagement');
|
||||
assert.equal(managementCall.args[0].source, 'MANAGEMENT');
|
||||
const reconciliation = await app.inject({
|
||||
method: 'GET', url: '/admin-api/product-orders/101/reconciliation', headers
|
||||
});
|
||||
assert.equal(reconciliation.statusCode, 200);
|
||||
assert.equal(reconciliation.json().data.consistent, true);
|
||||
|
||||
const readOnlyAction = await app.inject({
|
||||
method: 'POST', url: '/admin-api/product-orders/101/actions', headers,
|
||||
@@ -170,5 +178,16 @@ const refundCompleted = await app.inject({
|
||||
assert.equal(refundCompleted.statusCode, 200);
|
||||
assert.equal(refundCompleted.json().data.status, 'SUCCEEDED');
|
||||
|
||||
currentAccess = {
|
||||
roles: ['STAFF'], capabilities: ['platform.manage'], storeIds: []
|
||||
};
|
||||
assert.equal((await app.inject({
|
||||
method: 'GET', url: '/admin-api/product-orders?storeId=11', headers
|
||||
})).statusCode, 200, 'platform.manage must authorize product-order reads');
|
||||
assert.equal((await app.inject({
|
||||
method: 'POST', url: '/admin-api/product-orders/101/actions', headers,
|
||||
payload: { requestId: 'platform-accept-1', action: 'ACCEPT', reason: '' }
|
||||
})).statusCode, 200, 'platform.manage must authorize product-order writes');
|
||||
|
||||
await app.close();
|
||||
console.log('PASS: M09-D2 product order routes enforce customer ownership, management permissions and test payment boundaries.');
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { buildProductOrderReconciliation } from '../dist/products/product-order-service.js';
|
||||
|
||||
const detail = {
|
||||
id: '101', orderNo: 'PG101', status: 'COMPLETED', inventoryStatus: 'DEDUCTED',
|
||||
itemCount: 1, totalQuantity: 2, totalAmountCents: 500,
|
||||
paidAmountCents: 500, refundedAmountCents: 0,
|
||||
items: [{ id: '201', quantity: 2, subtotalCents: 500 }],
|
||||
payments: [{ id: '301', status: 'SUCCEEDED', amountCents: 500, refundedAmountCents: 0 }],
|
||||
refunds: []
|
||||
};
|
||||
const inventoryLedger = [
|
||||
{
|
||||
id: '401', inventoryId: '51', skuId: '5', operation: 'LOCK',
|
||||
availableDelta: -2, lockedDelta: 2, lossDelta: 0,
|
||||
reservationDelta: 2, saleDelta: 0,
|
||||
availableAfter: 8, lockedAfter: 2, lossAfter: 0, versionAfter: 2
|
||||
},
|
||||
{
|
||||
id: '402', inventoryId: '51', skuId: '5', operation: 'DEDUCT',
|
||||
availableDelta: 0, lockedDelta: -2, lossDelta: 0,
|
||||
reservationDelta: -2, saleDelta: 2,
|
||||
availableAfter: 8, lockedAfter: 0, lossAfter: 0, versionAfter: 3
|
||||
}
|
||||
];
|
||||
const storages = [{
|
||||
id: '601', storageNo: 'PS601', status: 'PARTIALLY_RETRIEVED',
|
||||
recordTotalQuantity: 2, recordRemainingQuantity: 1,
|
||||
itemTotalQuantity: 2, itemRemainingQuantity: 1, itemCount: 1
|
||||
}];
|
||||
|
||||
const consistent = buildProductOrderReconciliation(detail, inventoryLedger, storages);
|
||||
assert.equal(consistent.consistent, true);
|
||||
assert.equal(consistent.amounts.netRevenueCents, 500);
|
||||
assert.deepEqual(
|
||||
[consistent.inventory.reservationBalance, consistent.inventory.saleBalance], [0, 2]
|
||||
);
|
||||
assert.equal(consistent.storages[0].consistent, true);
|
||||
assert.ok(consistent.checks.every((check) => check.passed));
|
||||
|
||||
const inconsistent = buildProductOrderReconciliation(
|
||||
{ ...detail, paidAmountCents: 400, refundedAmountCents: 100 },
|
||||
inventoryLedger,
|
||||
[{ ...storages[0], itemRemainingQuantity: 2 }]
|
||||
);
|
||||
assert.equal(inconsistent.consistent, false);
|
||||
assert.equal(
|
||||
inconsistent.checks.find((check) => check.key === 'PAYMENT_CAPTURED_AMOUNT').passed, false
|
||||
);
|
||||
assert.equal(
|
||||
inconsistent.checks.find((check) => check.key === 'REFUND_SUCCEEDED_AMOUNT').passed, false
|
||||
);
|
||||
assert.equal(
|
||||
inconsistent.checks.find((check) => check.key === 'STORAGE_ITEM_BALANCE').passed, false
|
||||
);
|
||||
|
||||
const released = buildProductOrderReconciliation({
|
||||
...detail, status: 'CANCELLED', inventoryStatus: 'RELEASED',
|
||||
paidAmountCents: 0, payments: [], refunds: []
|
||||
}, [
|
||||
inventoryLedger[0],
|
||||
{ ...inventoryLedger[1], operation: 'RELEASE', reservationDelta: -2, saleDelta: 0 }
|
||||
], []);
|
||||
assert.equal(released.consistent, true);
|
||||
assert.deepEqual([released.inventory.reservationBalance, released.inventory.saleBalance], [0, 0]);
|
||||
|
||||
console.log('PASS: M09-REGRESSION detects amount, payment, refund, inventory and storage drift.');
|
||||
@@ -0,0 +1,17 @@
|
||||
# 2026-08-11 M09-REGRESSION 商品逐单对账 API
|
||||
|
||||
## 新增接口
|
||||
|
||||
- `GET /admin-api/product-orders/:orderId/reconciliation`
|
||||
- `GET /app-api/management/product-orders/:orderId/reconciliation`
|
||||
|
||||
接口要求 `goods.order.read`、`goods.order.manage`、`tenant.manage` 或 `platform.manage`,并继续执行租户与门店范围校验。响应包含订单金额聚合、支付退款聚合、净收入、库存占用/销售余额与不可变流水、订单来源寄存聚合、九项一致性检查及总一致性结果;接口只读,不自动修账。
|
||||
|
||||
## 权限一致性修复
|
||||
|
||||
- 商品订单管理路由与库存管理路由/服务将 `platform.manage` 视为租户级管理能力,与既有后台菜单和商品订单服务口径一致。
|
||||
- 只有 `platform.manage` 不会绕过 tenantId;目标租户仍由已验证会话取得。普通门店员工没有授权门店时继续返回门店范围禁止错误。
|
||||
|
||||
## 兼容修复
|
||||
|
||||
库存列表和库存流水分页不再把 MySQL `LIMIT/OFFSET` 作为预处理参数传递。服务先把 page/pageSize 约束为安全整数,再将计算出的分页常量写入 SQL,以兼容 MySQL 8.4 驱动执行路径。
|
||||
@@ -0,0 +1,22 @@
|
||||
# 2026-08-11 M09-REGRESSION 商品全链路对账
|
||||
|
||||
## 工程结果
|
||||
|
||||
- 商品订单服务新增管理侧逐单对账,只读聚合不可变订单项、成功支付与退款、库存流水 metadata 以及订单来源寄存记录/明细。
|
||||
- 对账输出九项确定性检查,覆盖订单金额、数量、行数、实收、退款、库存占用余额、库存销售余额和寄存账实;任一漂移会返回失败项的应有值与实际值。
|
||||
- Vue 商品运营工作台新增逐单对账视图,并在普通订单详情展示支付单、退款单和不可变状态事件,便于从聚合差异追溯原始业务流水。
|
||||
- 修正商品订单与库存的 `platform.manage` 权限口径,使菜单可见、路由鉴权和服务层门店范围保持一致;空门店范围的平台运营账号可按租户显式门店参数工作,普通门店员工仍被拒绝。
|
||||
- 真实 MySQL 回归发现库存列表/流水的分页预处理参数在 MySQL 8.4 + Node 26 下返回 `ER_WRONG_ARGUMENTS`;分页整数经边界校验后安全内联,列表与流水同类路径一并修复。
|
||||
|
||||
## 验证结果
|
||||
|
||||
- `node scripts/check-m09-regression.mjs`:PASS。
|
||||
- 后端 TypeScript build、全量单元与路由测试:PASS;新增一致、金额/退款/寄存漂移、取消释放库存三组纯对账样例。
|
||||
- Vue TypeScript 检查、生产构建与导航测试:PASS;订单详情和对账视图独立懒加载分块 gzip 约 8.19 KiB。
|
||||
- WSL 原生副本 + MySQL 8.4.10:`up → verify → down → up → verify` PASS,217 条 up、159 条 verify、199 条 down;实测全额退款净收入、LOCK/DEDUCT 销售余额、订单寄存取完余额和 `platform.manage` 空门店范围。
|
||||
- 中断清理探针 PASS,临时数据库、账号和 `/home/f/qipai-wsl-test` 已清理。
|
||||
- Windows `scripts/dev/windows/test-all.ps1`:PASS。
|
||||
|
||||
## 阶段状态
|
||||
|
||||
`M09-REGRESSION=DONE`,M09 内部工程闭环;真实微信商品支付继续按既有规则保持 `BLOCKED_EXTERNAL`,不阻断唯一游标进入 `M10-A`。
|
||||
@@ -56,6 +56,8 @@
|
||||
|
||||
> 2026-08-11 M09-D4:当前伴随工程为 `PRD-001`、`INV-001`、`GOODS-102`、`GOODS-104` 接入端到端体验。登录顾客可读取门店目录,在小程序选购、创建/取消商品订单、整单寄存、部分取出、换码和取消寄存;领取码只在签发当次页面内存显示。Vue 后台第 13 个权限路由统一目录、库存、订单和寄存,按各域 capability 独立加载,写操作继续由服务端复验。Windows 全量 71.6 秒与 MySQL 8.4.10 的 217 up、159 verify、199 down 往返通过,游标进入 M09-REGRESSION。
|
||||
|
||||
> 2026-08-11 M09-REGRESSION:当前伴随工程新增商品订单逐单对账,只读聚合订单明细、成功支付/退款、库存不可变流水及订单来源寄存余额,九项账实检查可直接定位金额、数量、占用、销售和寄存漂移;运营端同步展示支付、退款与不可变事件。回归修正 `platform.manage` 在库存/商品订单路由和服务层的空门店范围放行,并将已校验分页整数安全内联,解决 MySQL 8.4 预处理 `LIMIT/OFFSET` 参数兼容问题。Windows 全量与 MySQL 8.4.10 的 217 up、159 verify、199 down 往返通过,M09 内部工程完成,游标进入 M10-A。
|
||||
|
||||
| ID | 功能 | 模块子阶段 | 状态 | 最近提交 | 测试/验收证据 | 阻塞原因 | 下一步 |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| ENV-001 | 固定 Windows 工作区 | M00-A/M00-B | DONE | 远端 HEAD | 已确认 `D:\qipai`,Git 根、main 分支、固定 origin 和 push 校验通过。 | - | 后续每模块继续复检。 |
|
||||
@@ -81,10 +83,10 @@
|
||||
| CLN-003 | 保洁统计 | M08-B/M09 | DONE | `1f10715` | 保洁员与管理端统计统一按有效成员份额复算:免清洁不计奖励,DRAFT/CONFIRMED 仍计未付款,只有 PAID 计已结算;负责人/协作者、任务状态、结算状态、门店、日期和每日趋势口径一致。统计页、任务/结算明细和整数分 CSV 可相互校验;真实 MySQL 覆盖协作成员分步付款和最终任务结算。 | - | M10 以生产数据抽样复算,不重开统计状态机。 |
|
||||
| CLN-004 | 保洁结算 | M09-C | PARTIAL | `1f10715` | 同一任务成员份额由数据库生成唯一键保证最多一条有效结算明细,并发生成只有一个赢家;活动结算冻结成员奖励。取消保留原单/明细并生成负向冲正和状态事件,可重新生成;出款前 CAS、稳定请求号、商户隔离回调、成功重放、失败重试、人工已付、同源列表/导出和整数分均已闭环。协作任务按收款人分别付款,全部有效份额 PAID 后任务才进入 SETTLED。 | 真实微信商户凭据、生产小额转账和回调仍为 BLOCKED_EXTERNAL。 | 取得生产商户条件后执行小额转账、迟到回调和账单对账验收;内部工程不阻断 M09-D1。 |
|
||||
| CLN-005 | 保洁驳回/免清洁 | M08-B/M09 | PARTIAL | `eb838f5` | 已支持管理侧驳回、批量驳回和保洁员驳回补做;已新增 `POST /admin-api/cleaning/tasks/:taskId/exempt`,后台任务列表提供免清洁按钮、批量免清洁和 `EXEMPT` 筛选,仓储将可处理任务置为 `EXEMPT`、奖励归零并移除未结算协作者;待结算候选继续只取 `COMPLETED` 且未结算任务,免清洁任务不进入结算;管理统计 summary、门店明细、每日趋势和统计导出均新增免清洁数量,便于核对不结算口径。 | 真实商户现场执行仍待继续。 | 继续 M08-B/M09,补现场验收记录和更完整运营管理界面。 |
|
||||
| PRD-001 | 商品目录与门店售卖 | M09-D1/M09-D4 | DONE | `93af128` + M09-D4 当前暂存工程 | 分类、商品、SKU、门店上架、履约方式、销售开关/人工暂停及最多 8 段每周营业时段已实现;M09-D4 新增登录态顾客门店目录,小程序与运营后台复用同一商品主数据。 | - | M09-REGRESSION 对账目录销售时段、价格快照和门店隔离。 |
|
||||
| PRD-001 | 商品目录与门店售卖 | M09-D1/M09-D4 | DONE | `93af128`、`e91b979` | 分类、商品、SKU、门店上架、履约方式、销售开关/人工暂停及最多 8 段每周营业时段已实现;M09-D4 新增登录态顾客门店目录,小程序与运营后台复用同一商品主数据。 | - | M10 按通知和统计需求复用商品主数据。 |
|
||||
| INV-001 | 库存策略与不可变流水 | M09-D1/M09-D2 | DONE | `93af128`、`1a3ea7b` | TRACKED/UNLIMITED 策略、入库、调整、盘点、损耗、批量锁定/释放/扣减已实现;M09-D2 已把订单创建、支付、取消、超时和退款接入稳定业务号与同事务库存变更。请求指纹、版本 CAS、数量边界和不可变流水保证并发回调只扣一次、重复释放/返库幂等且不出现负库存。 | - | M09-D3 寄存数量已使用独立账本,不回写已完成订单项快照。 |
|
||||
| GOODS-102 | 商品订单与支付库存闭环 | M09-D2/M09-D4 | DONE | `1a3ea7b` + M09-D4 当前暂存工程 | 商品订单与支付单分离,订单项与状态事件不可变;M09-D4 小程序接入选购、购物车、创建与取消,Vue 后台接入筛选、详情、接单、配送/待取、完成、取消和退款。库存、支付、退款仍由服务端状态机和幂等事务控制。 | 真实微信商品支付入口仍缺生产商户条件。 | M09-REGRESSION 完成模拟支付全链路对账;真实支付纳入 BLOCKED_EXTERNAL。 |
|
||||
| GOODS-104 | 商品寄存与安全取出 | M09-D3/M09-D4 | DONE | `60264cb` + M09-D4 当前暂存工程 | 寄存状态、商品快照、一次性哈希领取码、部分取出自动轮换、并发防超领、审计与 outbox 已由 M09-D3 完成;M09-D4 接入顾客寄存列表/详情/取出/换码/取消和运营领取工作台,原码不写本地持久化。 | - | M09-REGRESSION 对账订单来源、剩余数量、凭证版本和不可变流水。 |
|
||||
| GOODS-102 | 商品订单与支付库存闭环 | M09-D2/M09-D4 | DONE | `1a3ea7b`、`e91b979` + M09-REGRESSION 当前工程 | 商品订单与支付单分离,订单项与状态事件不可变;小程序与 Vue 运营端已接入完整状态处置,逐单对账覆盖金额、成功支付/退款、库存占用/销售余额并显示原始流水。 | 真实微信商品支付入口仍缺生产商户条件。 | M10-A 将商品订单与退款事件接入统一通知中心;真实支付纳入 BLOCKED_EXTERNAL。 |
|
||||
| GOODS-104 | 商品寄存与安全取出 | M09-D3/M09-D4 | DONE | `60264cb`、`e91b979` + M09-REGRESSION 当前工程 | 寄存状态、商品快照、一次性哈希领取码、部分取出自动轮换、并发防超领、审计与 outbox 已完成;逐单对账验证订单来源寄存总量、剩余量与明细聚合一致。 | - | M10-A 消费既有寄存 outbox 并接入通知重试。 |
|
||||
| STAFF-001 | 员工账号 | M02-D/M08 | PARTIAL | `93f06d2` | 已实现创建员工、分配角色/门店、禁用、备注、重置会话和审计;管理员小程序已展示员工概况,Vue 后台可按角色/状态搜索并完成完整生命周期管理,权限变化即时撤销活动会话。 | 真实员工微信绑定和生产账号生命周期验收待补。 | M10 以真实员工账号验证绑定、门店切换、禁用和重新登录。 |
|
||||
| BKG-002 | 后台用户管理 | M02-D/M08 | PARTIAL | `0511008` | 已实现分页筛选、注册/登录时间、状态、角色、门店范围、手机号/IP 脱敏及会员/STAFF 分栏;租户管理员可为同租户有效 STAFF 设置唯一登录名和强密码,更新凭据会撤销其活动会话且不返回密码哈希。 | 尚缺平台级跨租户用户视图和真实生产数据验收。 | M10 以生产平台/租户账号复验跨租户用户视图、凭据重置和审计。 |
|
||||
| TEN-001 | 多门店支持 | M02/M03 | PARTIAL | `15139b3` | 门店 CRUD 与门店管理员数据范围已实现;跨门店写操作在 Windows 和 WSL MySQL 实测被拒绝。 | 地图选店、前端切换与完整订单数据范围待后续阶段。 | M03-C 接入地图选店,M04 起业务资源继承门店范围。 |
|
||||
|
||||
@@ -2,17 +2,17 @@
|
||||
|
||||
```yaml
|
||||
execution_cursor:
|
||||
current_module: M09
|
||||
current_stage: M09-REGRESSION
|
||||
current_module: M10
|
||||
current_stage: M10-A
|
||||
stage_status: TODO
|
||||
last_completed_stage: M09-D4
|
||||
next_stage: M10-A
|
||||
last_engineering_commit: `60264cb`
|
||||
pending_engineering_commit: "M09-D4 已完成工程(随本次中文工程提交固化)"
|
||||
last_completed_stage: M09-REGRESSION
|
||||
next_stage: M10-B
|
||||
last_engineering_commit: `e91b979`
|
||||
pending_engineering_commit: "M09-REGRESSION 已完成工程(随本次中文工程提交固化)"
|
||||
last_push_verified: true
|
||||
base_branch: main
|
||||
blocked_reason: "M08-A 真机合法域名、真实微信支付和实物开门验证缺微信后台/商户/硬件现场条件;M08-B 可独立工程内容已完成,真实商户凭据、微信后台配置、生产小额转账和实物硬件结果保持 BLOCKED_EXTERNAL;M08-C 可独立工程内容已完成,真实美团/抖音/快手商家授权与真机验券保持 BLOCKED_EXTERNAL,已按规则旁路进入 M08-D。M06-G 真实硬件联调仍缺生产 MQTT 账号/ACL、DeviceID、控制箱、门锁、插座和现场配线。"
|
||||
updated_at: 2026-08-11T15:10:00+08:00
|
||||
updated_at: 2026-08-11T08:30:00+08:00
|
||||
```
|
||||
|
||||
| 模块 | 状态 | 最近提交 | 最近开发日志 | 备注 |
|
||||
@@ -26,6 +26,6 @@ execution_cursor:
|
||||
| M06 设备、MQTT 与真实硬件联动 | PARTIAL | `da5751f` | docs/devlogs/2026-06-24-M06-E-智慧插座.md | M06-C/E-R1 已按原件校正控制箱、Sub-1G 与智慧插座 Wire/事件/脱敏;M06-R 已通过 Windows 后端全量、MQTT fixture、四项静态检查的中文/空格路径回归及 WSL MySQL 8.4.10 完整往返。M06-G 仍缺生产 MQTT 账号/ACL、DeviceID 和实物配线,保持 BLOCKED_EXTERNAL。 |
|
||||
| M07 会员、余额、充值、优惠券和套餐营销 | DONE | `ed0d455` | docs/devlogs/2026-06-24-M07-D-会员管理.md | M07-A 已完成现金/赠送双余额账本;M07-B 已完成充值优惠;M07-C 已完成优惠券/套餐权益底表和冻结、确认、退回的可靠补偿核销服务;M07-D 已完成会员画像、订单、消费、余额、充值、优惠券和套餐聚合查询。 |
|
||||
| M08 微信原生小程序完整业务 | PARTIAL | `3faf868` | docs/devlogs/2026-08-10-M08-D-Vue平台后台.md | M08-A/B/C/D 功能切片与 M08-D-R1 架构返工已完成;12 个权限路由按领域懒加载,最大 JS 分块 217,195 B,三档响应式门禁通过。真实微信、团购和硬件现场验收按 BLOCKED_EXTERNAL 保持 PARTIAL。 |
|
||||
| M09 保洁任务、结算、商品和库存 | DOING | `60264cb` + M09-D4 已完成工程 | docs/devlogs/2026-08-11-M09-D4-商品端到端体验.md | M09-A/B/C 与 M09-D1/D2/D3 已完成并推送。M09-D4 已接入登录态顾客目录、小程序选购/商品订单/寄存取出闭环,以及按目录、库存、订单、寄存权限分域的 Vue 运营工作台;Windows 全量和 MySQL 8.4 往返通过,游标进入 M09-REGRESSION。 |
|
||||
| M10 部署、域名、验收和运维闭环 | TODO | - | - | - |
|
||||
| M09 保洁任务、结算、商品和库存 | DONE | `e91b979` + M09-REGRESSION 已完成工程 | docs/devlogs/2026-08-11-M09-REGRESSION-商品全链路对账.md | M09-A/B/C 与 M09-D1/D2/D3/D4 均已完成并推送。模块回归新增商品订单金额、支付退款、库存不可变流水和订单来源寄存的逐单对账,修正库存分页 MySQL 8.4 预处理兼容及 `platform.manage` 权限一致性;Windows 全量和 MySQL 8.4 往返通过,游标进入 M10-A。 |
|
||||
| M10 部署、域名、验收和运维闭环 | TODO | M09-REGRESSION 当前工程 | docs/devlogs/2026-08-11-M09-REGRESSION-商品全链路对账.md | 下一步实现通知中心。 |
|
||||
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
const root = process.cwd();
|
||||
const read = (path) => readFileSync(join(root, path), 'utf8');
|
||||
|
||||
const orderService = read('backend/src/products/product-order-service.ts');
|
||||
for (const pattern of [
|
||||
'reconcileForManagement', 'buildProductOrderReconciliation',
|
||||
'qipai_product_inventory_ledger', 'qipai_product_storage_records',
|
||||
'PAYMENT_CAPTURED_AMOUNT', 'REFUND_SUCCEEDED_AMOUNT',
|
||||
'INVENTORY_RESERVATION_BALANCE', 'INVENTORY_SALE_BALANCE',
|
||||
'STORAGE_ITEM_BALANCE'
|
||||
]) assert.ok(orderService.includes(pattern), `product reconciliation is missing ${pattern}`);
|
||||
|
||||
const orderRoutes = read('backend/src/routes/product-orders.ts');
|
||||
assert.match(orderRoutes, /product-orders\/:orderId\/reconciliation/);
|
||||
assert.match(orderRoutes, /service\.reconcileForManagement/);
|
||||
assert.match(orderRoutes, /capabilities\.includes\('platform\.manage'\)/);
|
||||
|
||||
const inventoryRoute = read('backend/src/routes/inventory.ts');
|
||||
const inventoryService = read('backend/src/inventory/inventory-service.ts');
|
||||
assert.match(inventoryRoute, /capabilities\.includes\('platform\.manage'\)/);
|
||||
assert.match(inventoryService, /capabilities\.includes\('platform\.manage'\)/);
|
||||
|
||||
const api = read('admin/src/api.ts');
|
||||
const types = read('admin/src/types.ts');
|
||||
const panel = read('admin/src/components/ProductOperationsPanel.vue');
|
||||
assert.match(api, /getProductOrderReconciliation/);
|
||||
assert.match(types, /interface ProductOrderReconciliation/);
|
||||
for (const pattern of [
|
||||
'逐单对账', '一致性检查', '库存不可变流水', '关联寄存',
|
||||
'支付流水', '退款流水', '不可变事件'
|
||||
]) assert.ok(panel.includes(pattern), `product operations reconciliation is missing ${pattern}`);
|
||||
|
||||
const reconciliationTest = read('backend/tests/product-reconciliation.test.mjs');
|
||||
for (const pattern of [
|
||||
'detects amount, payment, refund, inventory and storage drift',
|
||||
"inventoryStatus: 'RELEASED'",
|
||||
'reservationBalance', 'STORAGE_ITEM_BALANCE'
|
||||
]) assert.ok(reconciliationTest.includes(pattern), `reconciliation unit test is missing ${pattern}`);
|
||||
|
||||
const routeTest = read('backend/tests/product-order-route.test.mjs');
|
||||
const inventoryRouteTest = read('backend/tests/inventory-route.test.mjs');
|
||||
const inventoryServiceTest = read('backend/tests/inventory-service.test.mjs');
|
||||
for (const source of [routeTest, inventoryRouteTest, inventoryServiceTest]) {
|
||||
assert.match(source, /platform\.manage/);
|
||||
assert.match(source, /storeIds: \[\]/);
|
||||
}
|
||||
|
||||
const mysqlTest = read('backend/tests/mysql-migration-roundtrip.test.mjs');
|
||||
for (const pattern of [
|
||||
'completedReconciliation', 'storageReconciliation', 'platformActor',
|
||||
'reconcileForManagement', 'netRevenueCents', 'recordRemainingQuantity'
|
||||
]) assert.ok(mysqlTest.includes(pattern), `live MySQL reconciliation is missing ${pattern}`);
|
||||
|
||||
console.log(
|
||||
'PASS: M09 regression reconciliation, platform scope, immutable ledgers and drift gates are present.'
|
||||
);
|
||||
@@ -46,6 +46,8 @@ node scripts/check-m09-d3-product-storage.mjs
|
||||
Assert-NativeSuccess "check-m09-d3-product-storage"
|
||||
node scripts/check-m09-d4-product-experience.mjs
|
||||
Assert-NativeSuccess "check-m09-d4-product-experience"
|
||||
node scripts/check-m09-regression.mjs
|
||||
Assert-NativeSuccess "check-m09-regression"
|
||||
|
||||
if (Test-Path "admin/package.json") {
|
||||
Push-Location admin
|
||||
|
||||
Reference in New Issue
Block a user