fix(M09-REGRESSION): 完成商品逐单对账与权限收口

This commit is contained in:
Codex
2026-08-11 08:25:12 +08:00
parent e91b979128
commit 41b9cf7349
21 changed files with 566 additions and 39 deletions
+7
View File
@@ -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
View File
@@ -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;