优化
This commit is contained in:
@@ -109,7 +109,7 @@ describe('CustomerDetailView', () => {
|
||||
await flushPromises()
|
||||
|
||||
expect([...document.querySelectorAll('.detail-tabs button')].map((button) => button.getAttribute('aria-label'))).toEqual([
|
||||
'基本信息', '业务概况', '运单记录', '财务账单', '计费规则', '操作日志',
|
||||
'基本信息', '运单记录', '财务账单', '计费规则', '操作日志',
|
||||
])
|
||||
expect(document.body.textContent).not.toContain('累计运单')
|
||||
expect(document.body.textContent).not.toContain('累计应收')
|
||||
@@ -125,6 +125,7 @@ describe('CustomerDetailView', () => {
|
||||
await flushPromises()
|
||||
expect(document.body.textContent).toContain('BY20260807001')
|
||||
expect(document.body.textContent).toContain('海运整箱 FCL')
|
||||
expect(document.body.textContent).not.toContain('暂无尺寸计算规则查看权限')
|
||||
expect(listPlatformFirstLegOrders).toHaveBeenCalledWith({ tenantId: tenant.id, page: 1, size: 10 })
|
||||
|
||||
const billingTab = document.querySelector<HTMLButtonElement>('.detail-tabs button[aria-label="计费规则"]')
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import {
|
||||
ArrowLeft, BarChart3, Building2, Eye,
|
||||
ArrowLeft, Building2, Eye,
|
||||
FileCheck2, FileText, Pencil, Power,
|
||||
Ship, UserRound, UsersRound,
|
||||
} from '@lucide/vue'
|
||||
@@ -39,7 +39,7 @@ import CustomerOverseasStorageFeePanel from '@/modules/customers/CustomerOversea
|
||||
import { CustomerStatementsPanel } from '@/modules/finance'
|
||||
import { CustomerAuditLogsPanel } from '@/modules/audit'
|
||||
|
||||
type DetailTab = 'basic' | 'business' | 'orders' | 'statements' | 'billing' | 'logs'
|
||||
type DetailTab = 'basic' | 'orders' | 'statements' | 'billing' | 'logs'
|
||||
type PreviewState = { url: string; kind: 'image' | 'pdf'; loading: boolean; failed: boolean }
|
||||
|
||||
const route = useRoute()
|
||||
@@ -72,7 +72,6 @@ const tabList = ref<HTMLElement | null>(null)
|
||||
|
||||
const tabs: Array<{ key: DetailTab; label: string; icon: string }> = [
|
||||
{ key: 'basic', label: '基本信息', icon: '📋' },
|
||||
// { key: 'business', label: '业务概况', icon: '📊' },
|
||||
{ key: 'orders', label: '运单记录', icon: '📦' },
|
||||
{ key: 'statements', label: '财务账单', icon: '🧾' },
|
||||
{ key: 'billing', label: '计费规则', icon: '⚖️' },
|
||||
@@ -391,11 +390,6 @@ watch(orderPage, (page, previousPage) => {
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- <section v-else-if="activeTab === 'business'" class="detail-panel module-panel">
|
||||
<div class="panel-title"><div><h2>业务概况</h2><p>基于当前已接入服务的客户业务摘要</p></div><BarChart3 :size="19" /></div>
|
||||
<div class="contract-note"><strong>趋势、港口与收入分析暂未接入</strong><p>原型中的近 6 个月趋势、运输方式分布、目的港和收入贡献依赖后续统计与财务服务;当前不展示模拟图表。</p></div>
|
||||
</section>
|
||||
|
||||
<section v-else-if="activeTab === 'orders'" class="detail-panel module-panel">
|
||||
<div class="panel-title"><div><h2>运单记录</h2><p>客户关联头程运单,共 {{ customerOrderTotal }} 票</p></div><Ship :size="19" /></div>
|
||||
<template v-if="access.can('platform:order:list')">
|
||||
@@ -413,7 +407,7 @@ watch(orderPage, (page, previousPage) => {
|
||||
</DataTable>
|
||||
</template>
|
||||
<div v-else class="module-empty"><Ship :size="30" /><strong>暂无运单查看权限</strong><p>请联系管理员申请头程订单查看权限。</p></div>
|
||||
</section> -->
|
||||
</section>
|
||||
|
||||
<section v-else-if="activeTab === 'statements'" class="detail-panel module-panel"><CustomerStatementsPanel :customer-id="tenantId" :customer-name="customer.tenantName" /></section>
|
||||
<section v-else-if="activeTab === 'logs'" class="detail-panel module-panel"><CustomerAuditLogsPanel :customer-id="tenantId" :customer-name="customer.tenantName" /></section>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import ElementPlus from 'element-plus'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
@@ -68,4 +70,10 @@ describe('CustomerStatementsPanel', () => {
|
||||
expect(getReconciliationStatement).toHaveBeenCalledWith('statement-1')
|
||||
expect(wrapper.text()).toContain('入库操作费')
|
||||
})
|
||||
|
||||
it('uses a small primary entity button for the embedded statement detail action', () => {
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/modules/finance/components/CustomerStatementsPanel.vue'), 'utf8')
|
||||
expect(source).toContain('<el-button size="small" plain type="primary"')
|
||||
expect(source).not.toContain('<el-button link')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -73,7 +73,7 @@ watch(() => props.customerId, () => { void load() }, { immediate: true })
|
||||
<div v-if="!access.can('platform:finance:customer-statement:list')" class="module-empty"><FileText :size="30" /><strong>暂无财务账单查看权限</strong><p>请联系管理员开通客户对账单查看权限。</p></div>
|
||||
<LoadErrorState v-else-if="loadError" title="客户财务账单加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
|
||||
<DataTable v-else :data="rows" :columns="columns" :loading="loading" row-key="id" aria-label="客户财务账单列表" empty-text="当前客户暂无财务账单" border stripe>
|
||||
<template #actions="{ row }"><el-button link type="primary" :aria-label="`查看对账单${(row as FinanceStatementReadModel).billNo}详情`" @click="openDetail(row as FinanceStatementReadModel)"><Eye :size="15" />详情</el-button></template>
|
||||
<template #actions="{ row }"><el-button size="small" plain type="primary" :disabled="loading" :aria-label="`查看对账单${(row as FinanceStatementReadModel).billNo}详情`" @click="openDetail(row as FinanceStatementReadModel)"><Eye :size="15" />详情</el-button></template>
|
||||
<template #totalAmount="{ row }">{{ formatAmount((row as FinanceStatementReadModel).totalAmount) }}</template>
|
||||
<template #receivedAmount="{ row }">{{ formatAmount((row as FinanceStatementReadModel).receivedAmount) }}</template>
|
||||
<template #outstandingAmount="{ row }">{{ formatAmount((row as FinanceStatementReadModel).outstandingAmount) }}</template>
|
||||
|
||||
@@ -90,6 +90,15 @@ describe('FinanceCustomerAccountsView delivery', () => {
|
||||
expect(page).not.toContain('max-width: 520px')
|
||||
})
|
||||
|
||||
it('renders every account row operation as a small plain primary button with an icon', () => {
|
||||
expect(page).toContain("{ key: 'actions', label: '操作', width: 160, fixed: 'left', slotName: 'actions' }")
|
||||
expect(page).toContain('<el-button size="small" plain type="primary" @click="openAccount(row, false)"><Eye :size="15" />查看</el-button>')
|
||||
expect(page).toContain('<el-button v-if="canUpdateTerms" size="small" plain type="primary" @click="openAccount(row, true)"><Pencil :size="15" />账期</el-button>')
|
||||
expect(page).not.toMatch(/<template #actions[\s\S]*?<el-button[^>]*\blink\b/)
|
||||
expect(page).toContain('.row-actions {')
|
||||
expect(page).toContain('white-space: nowrap')
|
||||
})
|
||||
|
||||
it('shows only derived CNY summaries and no wallet actions', () => {
|
||||
for (const marker of ['未收', '待出账', '本期已收', '应付', 'CNY']) expect(page).toContain(marker)
|
||||
for (const forbidden of ['手动充值', '钱包', '外币', '发票', '利润']) expect(page).not.toContain(forbidden)
|
||||
|
||||
@@ -60,7 +60,7 @@ const queryFields = computed<QueryField[]>(() => [
|
||||
{ key: 'status', type: 'select', label: '状态', width: 150, placeholder: '全部状态', options: [{ label: '正常', value: 'ACTIVE' }, { label: '停用', value: 'DISABLED' }] },
|
||||
])
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 150, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'actions', label: '操作', width: 160, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'customerName', label: '客户', minWidth: 180, slotName: 'customerName' },
|
||||
{ key: 'customerCode', label: '客户编号', width: 150, slotName: 'customerCode' },
|
||||
{ key: 'contactName', label: '联系人', width: 130, slotName: 'contactName' },
|
||||
@@ -259,7 +259,7 @@ watch(() => [route.name, String(route.params.customerId || '')], ([name, custome
|
||||
<section v-if="!stateError" class="surface-panel table-panel">
|
||||
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="finance-customer-accounts-table" aria-label="客户账户列表" row-key="customerId" empty-text="暂无客户账户" @update:page="load()" @update:page-size="page = 1; load()">
|
||||
<template #toolbar-actions><el-button v-if="canExport" :loading="accountExportBusy" :disabled="loading || accountExportBusy" @click="exportAccounts"><Download :size="16" />导出Excel</el-button></template>
|
||||
<template #actions="{ row }"><div class="row-actions"><el-button link type="primary" @click="openAccount(row, false)"><Eye :size="15" />查看</el-button><el-button v-if="canUpdateTerms" link type="primary" @click="openAccount(row, true)"><Pencil :size="15" />账期</el-button></div></template>
|
||||
<template #actions="{ row }"><div class="row-actions"><el-button size="small" plain type="primary" @click="openAccount(row, false)"><Eye :size="15" />查看</el-button><el-button v-if="canUpdateTerms" size="small" plain type="primary" @click="openAccount(row, true)"><Pencil :size="15" />账期</el-button></div></template>
|
||||
<template #customerName="{ row }">{{ customerName(row.customerId, row.customerName) }}</template>
|
||||
<template #customerCode="{ row }">{{ customerField(row.customerCode) }}</template><template #contactName="{ row }">{{ customerField(row.contactName) }}</template><template #contactPhone="{ row }">{{ customerField(row.contactPhone) }}</template>
|
||||
<template #lastChangedAt="{ row }">{{ formatLastChangedAt(row.lastChangedAt) }}</template>
|
||||
@@ -346,6 +346,10 @@ watch(() => [route.name, String(route.params.customerId || '')], ([name, custome
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.row-actions {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.detail-heading {
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
|
||||
@@ -208,6 +208,15 @@ describe('FinanceCustomerArapView G2 operations', () => {
|
||||
expect(page).not.toContain('max-width: 520px')
|
||||
})
|
||||
|
||||
it('renders all permitted fee-source row operations as small plain primary buttons with icons', () => {
|
||||
const page = readFileSync(resolve(process.cwd(), 'src/modules/finance/pages/FinanceCustomerArapView.vue'), 'utf8')
|
||||
expect(page).toContain('<el-button v-if="canViewSource" size="small" plain type="primary" @click="openDetail(row)"><Eye :size="15" />详情</el-button>')
|
||||
expect(page).toContain('<el-button v-if="canReviewSource" size="small" plain type="primary" @click="openReview(row)"><FileCheck2 :size="15" />审核</el-button>')
|
||||
expect(page).toContain('<el-button v-if="canSettleSource" size="small" plain type="primary" @click="openInclusion(row)"><Link2 :size="15" />参与结算</el-button>')
|
||||
expect(page).not.toMatch(/<template #actions[\s\S]*?<el-button[^>]*\blink\b/)
|
||||
expect(page).toContain('white-space: nowrap')
|
||||
})
|
||||
|
||||
it('resolves blank detail customer names and keeps source labels business-facing', async () => {
|
||||
vi.mocked(getFinanceFeeSource).mockResolvedValueOnce({ ...detail, customerName: '', sourceType: 'NEW_SOURCE' })
|
||||
const wrapper = await mountView(['platform:finance:customer-arap:list'], '/finance/customer-arap/42')
|
||||
|
||||
@@ -321,7 +321,7 @@ watch(() => [route.name, String(route.params.feeSourceId || '')], ([name, feeSou
|
||||
<section v-if="!customersError && !loadError" class="surface-panel table-panel">
|
||||
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="finance-customer-arap-table" aria-label="客户应收应付列表" row-key="id" empty-text="暂无客户应收应付数据" @selection-change="onSelectionChange" @update:page="load()" @update:page-size="page = 1; load()">
|
||||
<template #toolbar-actions><div class="generation-actions"><el-button v-if="canExport" :loading="exportBusy" :disabled="loading || exportBusy" @click="exportFeeSources"><Download :size="16" />导出Excel</el-button><template v-if="canCreateStatement"><el-button @click="openGeneration('BY_CUTOFF')"><FilePlus2 :size="16" />按截止日期生成</el-button><el-button type="primary" :disabled="!selectedRows.length" @click="openGeneration('SELECTED')"><FilePlus2 :size="16" />按勾选生成</el-button></template></div></template>
|
||||
<template #actions="{ row }"><div class="row-actions"><template v-if="row.feeSourceId"><el-button v-if="canViewSource" link type="primary" @click="openDetail(row)"><Eye :size="15" />详情</el-button><el-button v-if="canReviewSource" link type="primary" @click="openReview(row)"><FileCheck2 :size="15" />审核</el-button><el-button v-if="canSettleSource" link type="primary" @click="openInclusion(row)"><Link2 :size="15" />结算</el-button></template><span v-else class="muted-action">手工成本</span></div></template>
|
||||
<template #actions="{ row }"><div class="row-actions"><template v-if="row.feeSourceId"><el-button v-if="canViewSource" size="small" plain type="primary" @click="openDetail(row)"><Eye :size="15" />详情</el-button><el-button v-if="canReviewSource" size="small" plain type="primary" @click="openReview(row)"><FileCheck2 :size="15" />审核</el-button><el-button v-if="canSettleSource" size="small" plain type="primary" @click="openInclusion(row)"><Link2 :size="15" />参与结算</el-button></template><span v-else class="muted-action">手工成本</span></div></template>
|
||||
<template #customerName="{ row }">{{ customerName(row.customerId, row.customerName) }}</template><template #direction="{ row }"><el-tag :type="row.direction === 'RECEIVABLE' ? 'success' : row.direction === 'PAYABLE' ? 'warning' : 'info'">{{ financeDirectionLabel(row.direction) }}</el-tag></template>
|
||||
<template #businessReference="{ row }">{{ businessReference(row as unknown as ArapRow) }}</template><template #sourceType="{ row }">{{ sourceTypeDisplay(row.sourceType) }}</template><template #supplierName="{ row }">{{ supplierDisplay(row as unknown as ArapRow) }}</template><template #sourceStatus="{ row }">{{ sourceStatusDisplay(row.status) }}</template>
|
||||
<template #status="{ row }"><el-tag :type="row.reviewStatus === 'APPROVED' ? 'success' : row.reviewStatus === 'REJECTED' ? 'danger' : 'warning'">{{ statusLabel(row.reviewStatus) }}</el-tag></template>
|
||||
|
||||
@@ -111,6 +111,46 @@ describe('FinanceCustomerStatementsView', () => {
|
||||
expect(source).not.toContain('<el-descriptions :column="3" border>')
|
||||
})
|
||||
|
||||
it('uses entity buttons for row actions and only folds the fourth action into more', () => {
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/modules/finance/pages/FinanceCustomerStatementsView.vue'), 'utf8')
|
||||
const actions = source.slice(source.indexOf('<template #actions="{ row }">'), source.indexOf('<template #status="{ row }">'))
|
||||
expect(actions).not.toContain(' link')
|
||||
for (const label of ['详情', '提交', '通过', '驳回', '出账', '调整', '重开']) {
|
||||
const actionLine = actions.split('\n').find((line) => line.includes(`${label}</el-button>`))
|
||||
expect(actionLine).toContain('size="small" plain type="primary"')
|
||||
}
|
||||
expect(actions).toContain('size="small" plain type="danger"')
|
||||
expect(actions).toContain('aria-label="更多对账单操作" title="更多对账单操作"')
|
||||
expect(actions).toContain("row.status === 'PENDING_REVIEW' && canReview && canVoid")
|
||||
expect(actions.match(/<el-dropdown-item/g)).toHaveLength(1)
|
||||
expect(actions).toContain('class="danger-menu-item"')
|
||||
})
|
||||
|
||||
it('keeps three available row actions visible with primary and danger semantics', async () => {
|
||||
const { wrapper } = await mountPage([
|
||||
'platform:finance:customer-statement:list',
|
||||
'platform:finance:reconciliation:adjust',
|
||||
'platform:finance:reconciliation:void',
|
||||
])
|
||||
const actionButtons = [...document.querySelectorAll('button')].filter((button) => ['详情', '调整', '作废'].includes(button.textContent?.trim() || ''))
|
||||
const detailButton = actionButtons.find((button) => button.textContent?.trim() === '详情')
|
||||
const adjustButton = actionButtons.find((button) => button.textContent?.trim() === '调整')
|
||||
const voidButton = actionButtons.find((button) => button.textContent?.trim() === '作废')
|
||||
expect(new Set(actionButtons.map((button) => button.textContent?.trim()))).toEqual(new Set(['详情', '调整', '作废']))
|
||||
expect(detailButton?.classList).toContain('el-button--small')
|
||||
expect(detailButton?.classList).toContain('is-plain')
|
||||
expect(detailButton?.classList).toContain('el-button--primary')
|
||||
expect(adjustButton?.classList).toContain('el-button--primary')
|
||||
expect(voidButton?.classList).toContain('el-button--danger')
|
||||
|
||||
const vm = wrapper.vm as unknown as { actionBusy: boolean; adjustBusy: boolean }
|
||||
vm.actionBusy = true
|
||||
vm.adjustBusy = true
|
||||
await flushPromises()
|
||||
expect(adjustButton?.hasAttribute('disabled')).toBe(true)
|
||||
expect(voidButton?.hasAttribute('disabled')).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps the payment history in the desktop detail layout without duplicating a card container', async () => {
|
||||
const { wrapper } = await mountPage()
|
||||
const vm = wrapper.vm as unknown as { openDetail(row: FinanceStatementReadModel): Promise<void>; activeDetailTab: 'lines' | 'payments' }
|
||||
|
||||
@@ -518,17 +518,19 @@ watch(detailRouteId, (statementId) => {
|
||||
<el-button v-if="canCreate" type="primary" :disabled="!canGenerate" :title="canGenerate ? '生成对账单' : '缺少客户列表权限,无法选择客户'" @click="openCreate"><Plus :size="15" />生成对账单</el-button>
|
||||
</template>
|
||||
<template #actions="{ row }">
|
||||
<el-button link type="primary" @click.stop="openDetail(row as FinanceStatementReadModel)"><Eye :size="15" />详情</el-button>
|
||||
<el-button v-if="row.status === 'DRAFT' && canCreate" link :loading="actionBusy" @click.stop="performAction(row as FinanceStatementReadModel, 'SUBMIT')"><Send :size="15" />提交</el-button>
|
||||
<el-button v-if="row.status === 'PENDING_REVIEW' && canReview" link :loading="actionBusy" @click.stop="performAction(row as FinanceStatementReadModel, 'APPROVE')"><ShieldCheck :size="15" />通过</el-button>
|
||||
<el-dropdown v-if="row.status === 'PENDING_REVIEW' && (canReview || canVoid)" trigger="click" @command="(command: ReconciliationActionPayload['action']) => performAction(row as FinanceStatementReadModel, command)">
|
||||
<el-button link aria-label="更多对账单操作" title="更多对账单操作" @click.stop><MoreHorizontal :size="16" /></el-button>
|
||||
<template #dropdown><el-dropdown-menu><el-dropdown-item v-if="canReview" command="REJECT"><XCircle :size="15" />驳回</el-dropdown-item><el-dropdown-item v-if="canVoid" command="VOID" divided><XCircle :size="15" />作废</el-dropdown-item></el-dropdown-menu></template>
|
||||
<el-button size="small" plain type="primary" :disabled="loading" @click.stop="openDetail(row as FinanceStatementReadModel)"><Eye :size="15" />详情</el-button>
|
||||
<el-button v-if="row.status === 'DRAFT' && canCreate" size="small" plain type="primary" :loading="actionBusy" @click.stop="performAction(row as FinanceStatementReadModel, 'SUBMIT')"><Send :size="15" />提交</el-button>
|
||||
<el-button v-if="row.status === 'PENDING_REVIEW' && canReview" size="small" plain type="primary" :loading="actionBusy" @click.stop="performAction(row as FinanceStatementReadModel, 'APPROVE')"><ShieldCheck :size="15" />通过</el-button>
|
||||
<el-button v-if="row.status === 'PENDING_REVIEW' && canReview" size="small" plain type="primary" :loading="actionBusy" @click.stop="performAction(row as FinanceStatementReadModel, 'REJECT')"><XCircle :size="15" />驳回</el-button>
|
||||
<el-dropdown v-if="row.status === 'PENDING_REVIEW' && canReview && canVoid" trigger="click" @command="(command: ReconciliationActionPayload['action']) => performAction(row as FinanceStatementReadModel, command)">
|
||||
<el-button size="small" plain :loading="actionBusy" aria-label="更多对账单操作" title="更多对账单操作" @click.stop><MoreHorizontal :size="16" /></el-button>
|
||||
<template #dropdown><el-dropdown-menu><el-dropdown-item command="VOID" class="danger-menu-item"><XCircle :size="15" />作废</el-dropdown-item></el-dropdown-menu></template>
|
||||
</el-dropdown>
|
||||
<el-button v-if="row.status === 'APPROVED' && canIssue" link :loading="actionBusy" @click.stop="performAction(row as FinanceStatementReadModel, 'ISSUE')"><Send :size="15" />出账</el-button>
|
||||
<el-button v-if="row.status === 'ISSUED' && canAdjust" link :loading="adjustBusy" @click.stop="openAdjustment(row as FinanceStatementReadModel)"><RotateCcw :size="15" />调整</el-button>
|
||||
<el-button v-if="['REJECTED','VOIDED'].includes(String(row.status)) && canVoid" link :loading="actionBusy" @click.stop="performAction(row as FinanceStatementReadModel, 'REOPEN')"><RotateCcw :size="15" />重开</el-button>
|
||||
<el-button v-if="['DRAFT','APPROVED','ISSUED'].includes(String(row.status)) && canVoid" link type="danger" :loading="actionBusy" @click.stop="performAction(row as FinanceStatementReadModel, 'VOID')"><XCircle :size="15" />作废</el-button>
|
||||
<el-button v-if="row.status === 'PENDING_REVIEW' && !canReview && canVoid" size="small" plain type="danger" :loading="actionBusy" @click.stop="performAction(row as FinanceStatementReadModel, 'VOID')"><XCircle :size="15" />作废</el-button>
|
||||
<el-button v-if="row.status === 'APPROVED' && canIssue" size="small" plain type="primary" :loading="actionBusy" @click.stop="performAction(row as FinanceStatementReadModel, 'ISSUE')"><Send :size="15" />出账</el-button>
|
||||
<el-button v-if="row.status === 'ISSUED' && canAdjust" size="small" plain type="primary" :loading="adjustBusy" @click.stop="openAdjustment(row as FinanceStatementReadModel)"><RotateCcw :size="15" />调整</el-button>
|
||||
<el-button v-if="['REJECTED','VOIDED'].includes(String(row.status)) && canVoid" size="small" plain type="primary" :loading="actionBusy" @click.stop="performAction(row as FinanceStatementReadModel, 'REOPEN')"><RotateCcw :size="15" />重开</el-button>
|
||||
<el-button v-if="['DRAFT','APPROVED','ISSUED'].includes(String(row.status)) && canVoid" size="small" plain type="danger" :loading="actionBusy" @click.stop="performAction(row as FinanceStatementReadModel, 'VOID')"><XCircle :size="15" />作废</el-button>
|
||||
</template>
|
||||
<template #status="{ row }"><el-tag :type="financeStatusTone(row.status)">{{ financeStatusLabel(row.status) }}</el-tag></template>
|
||||
<template #customerName="{ row }">{{ customerDisplayName(row.customerId, row.customerName) }}</template>
|
||||
@@ -586,6 +588,7 @@ watch(detailRouteId, (statementId) => {
|
||||
.statement-line-table { min-width: 1840px; }
|
||||
.statement-payment-table { min-width: 1300px; }
|
||||
.revision-collapse { margin-top: 12px; }
|
||||
:deep(.danger-menu-item:not(.is-disabled)) { color: var(--el-color-danger); }
|
||||
|
||||
@media (min-width: 1920px) {
|
||||
.statement-detail-page :deep(.detail-page-layout) {
|
||||
|
||||
@@ -13,6 +13,22 @@ describe('Finance formal settlement desktop presentation', () => {
|
||||
expect(source).not.toContain('<template #toolbar-actions>')
|
||||
})
|
||||
|
||||
it('renders up to three row actions as compact icon buttons without wrapping', () => {
|
||||
expect(source.match(/key: 'actions', label: '操作', width: 250/g)).toHaveLength(2)
|
||||
expect(source).toContain('class="settlement-row-actions"')
|
||||
expect(source).toContain('<el-button size="small" plain type="primary" @click="openDetail(row)"><Eye :size="15" />详情</el-button>')
|
||||
expect(source).toContain('size="small" type="primary" :loading="receiptBusy" @click="openReceipt(row)"')
|
||||
expect(source).toContain('size="small" type="primary" @click="openPayment(row)"')
|
||||
expect(source).toContain('size="small" plain type="danger" :loading="actionBusy"')
|
||||
expect(source).toContain('size="small" plain type="primary" :loading="actionBusy"')
|
||||
expect(source).toContain('.settlement-row-actions { display: flex; align-items: center; flex-wrap: nowrap; gap: 6px; white-space: nowrap; }')
|
||||
expect(source).not.toContain('<el-button link')
|
||||
expect(source).not.toContain('MoreHorizontal')
|
||||
expect(source).toContain('v-if="canReceive && [\'ISSUED\',\'PARTIALLY_PAID\'].includes(String(row.status))"')
|
||||
expect(source).toContain('v-if="canPay && [\'ISSUED\',\'PARTIALLY_PAID\'].includes(String(row.status))"')
|
||||
expect(source).toContain('v-if="canVoid && row.status === \'ISSUED\'"')
|
||||
})
|
||||
|
||||
it('uses a continuous detail layout with locally scrollable wide tables', () => {
|
||||
expect(source).toContain('<DetailPageLayout v-else-if="detailMode" class="settlement-detail-page surface-panel"')
|
||||
expect(source).toContain('class="settlement-detail-content"')
|
||||
|
||||
@@ -125,7 +125,7 @@ const totals = computed(() => filteredRows.value.reduce((result, row) => ({
|
||||
}), { amount: 0, paid: 0, outstanding: 0 }))
|
||||
const columns = computed<DataTableColumn[]>(() => props.kind === 'receivable'
|
||||
? [
|
||||
{ key: 'actions', label: '操作', width: 190, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'actions', label: '操作', width: 250, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'settlementNo', label: '结算单号', minWidth: 180 },
|
||||
{ key: 'customerId', label: '客户', minWidth: 180, slotName: 'customer' },
|
||||
{ key: 'feeItemSummary', label: '费用类型', minWidth: 150, slotName: 'feeItemSummary' },
|
||||
@@ -144,7 +144,7 @@ const columns = computed<DataTableColumn[]>(() => props.kind === 'receivable'
|
||||
{ key: 'status', label: '结算状态', width: 120, slotName: 'settlementStatus' },
|
||||
]
|
||||
: [
|
||||
{ key: 'actions', label: '操作', width: 190, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'actions', label: '操作', width: 250, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'settlementNo', label: '结算单号', minWidth: 180 },
|
||||
{ key: 'supplierName', label: '供应商', minWidth: 180, slotName: 'supplier' },
|
||||
{ key: 'customerId', label: '客户', minWidth: 180, slotName: 'customer' },
|
||||
@@ -591,11 +591,13 @@ onMounted(() => {
|
||||
<section class="surface-panel table-panel">
|
||||
<DataTable :data="filteredRows" :columns="columns" :loading="loading" :aria-label="`${title}列表`" row-key="id" empty-text="暂无符合条件的结算单">
|
||||
<template #actions="{ row }">
|
||||
<el-button link type="primary" @click="openDetail(row)"><Eye :size="15" />详情</el-button>
|
||||
<el-button v-if="canReceive && ['ISSUED','PARTIALLY_PAID'].includes(String(row.status))" link :loading="receiptBusy" @click="openReceipt(row)"><HandCoins :size="15" />收款</el-button>
|
||||
<el-button v-if="canPay && ['ISSUED','PARTIALLY_PAID'].includes(String(row.status))" link @click="openPayment(row)"><CreditCard :size="15" />付款</el-button>
|
||||
<el-button v-if="canVoid && row.status === 'ISSUED'" link type="danger" :loading="actionBusy" @click="performAction(row as SettlementRow, 'VOID')"><XCircle :size="15" />作废</el-button>
|
||||
<el-button v-if="canVoid && row.status === 'VOIDED'" link :loading="actionBusy" @click="performAction(row as SettlementRow, 'REOPEN')"><RotateCcw :size="15" />重开</el-button>
|
||||
<div class="settlement-row-actions">
|
||||
<el-button size="small" plain type="primary" @click="openDetail(row)"><Eye :size="15" />详情</el-button>
|
||||
<el-button v-if="canReceive && ['ISSUED','PARTIALLY_PAID'].includes(String(row.status))" size="small" type="primary" :loading="receiptBusy" @click="openReceipt(row)"><HandCoins :size="15" />收款</el-button>
|
||||
<el-button v-if="canPay && ['ISSUED','PARTIALLY_PAID'].includes(String(row.status))" size="small" type="primary" @click="openPayment(row)"><CreditCard :size="15" />付款</el-button>
|
||||
<el-button v-if="canVoid && row.status === 'ISSUED'" size="small" plain type="danger" :loading="actionBusy" @click="performAction(row as SettlementRow, 'VOID')"><XCircle :size="15" />作废</el-button>
|
||||
<el-button v-if="canVoid && row.status === 'VOIDED'" size="small" plain type="primary" :loading="actionBusy" @click="performAction(row as SettlementRow, 'REOPEN')"><RotateCcw :size="15" />重开</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<template #customer="{ row }">{{ customerName(String(row.customerId), row.customerName) }}</template>
|
||||
<template #supplier="{ row }">{{ row.supplierName || '—' }}</template>
|
||||
@@ -647,6 +649,8 @@ onMounted(() => {
|
||||
.settlement-summary { flex: 1; gap: 6px 0; }
|
||||
.settlement-list-actions { flex: 0 0 auto; justify-content: flex-end; gap: 8px; }
|
||||
.settlement-list-actions :deep(.el-button + .el-button) { margin-left: 0; }
|
||||
.settlement-row-actions { display: flex; align-items: center; flex-wrap: nowrap; gap: 6px; white-space: nowrap; }
|
||||
.settlement-row-actions :deep(.el-button + .el-button) { margin-left: 0; }
|
||||
.summary-item { display: flex; align-items: baseline; gap: 8px; min-width: 0; }
|
||||
.settlement-summary .summary-item + .summary-item { margin-left: 18px; padding-left: 18px; border-left: 1px solid var(--cc-border, #e3e8ef); }
|
||||
.summary-item span { color: var(--cc-text-secondary, #64748b); font-size: 13px; }
|
||||
|
||||
@@ -110,6 +110,10 @@ describe('InboundTaskList measurement scope', () => {
|
||||
expect(columnsSource).toContain("key: 'measureCompletedAt', label: '提交时间'")
|
||||
expect(columnsSource).not.toContain("key: 'id'")
|
||||
expect(columnsSource).not.toContain("key: 'orderId'")
|
||||
expect(source).toContain('{{ formatNumber((row as InboundOrderSummary).actualGrossWeightKg) }}')
|
||||
expect(source).toContain('{{ formatNumber((row as InboundOrderSummary).actualNetWeightKg) }}')
|
||||
expect(source).not.toContain('actualGrossWeightKg ||')
|
||||
expect(source).not.toContain('actualNetWeightKg ||')
|
||||
})
|
||||
|
||||
it('renders missing list facts as an explicit dash instead of guessing values', () => {
|
||||
|
||||
@@ -39,12 +39,11 @@ describe('InboundWorkflowWorkspace', () => {
|
||||
expect(source).toContain('@click="reviewDialogOpen = true"')
|
||||
expect(source).toContain(':key="`review-dialog-${revision}`"')
|
||||
expect(source).toContain('@changed="handleReviewChanged"')
|
||||
expect(source).toContain('<template #footer>')
|
||||
expect(source).not.toContain('<template #footer>')
|
||||
expect(source).toContain('@updated="handleDomainChanged"')
|
||||
expect(source).toContain('@submitted="handleDomainChanged"')
|
||||
expect(source).toContain("reviewPanelRef?.openReasonDialog('reject')")
|
||||
expect(source).toContain('reviewPanelRef?.confirmApprove()')
|
||||
expect(source).toContain('inbound-operation-dialog__footer')
|
||||
expect(source).not.toContain('reviewPanelRef')
|
||||
expect(source).not.toContain('inbound-operation-dialog__footer')
|
||||
expect(source).not.toContain('approveMeasurement')
|
||||
expect(source).not.toContain('submitInboundMeasurement')
|
||||
})
|
||||
@@ -93,8 +92,7 @@ describe('InboundWorkflowWorkspace', () => {
|
||||
expect(source).toContain('<el-button type="primary" @click="reviewDialogOpen = true">')
|
||||
expect(measurementDialogSource).toContain('type="primary"')
|
||||
expect(measurementDialogSource).toContain('plain')
|
||||
expect(source).toContain('type="danger" plain')
|
||||
expect(source).toContain('type="warning" plain')
|
||||
expect(source).not.toContain('异常放行')
|
||||
expect(source).toContain('<Pencil :size="16" />编辑测量数据')
|
||||
expect(source).toContain('<ClipboardCheck :size="16" />审核测量数据')
|
||||
expect(source).toContain('@media (max-width: 360px)')
|
||||
|
||||
@@ -28,7 +28,6 @@ const access = useAccessPreviewStore()
|
||||
const activeTab = ref('overview')
|
||||
const measurementDialogOpen = ref(false)
|
||||
const reviewDialogOpen = ref(false)
|
||||
const reviewPanelRef = ref<InstanceType<typeof MeasurementReviewPanel> | null>(null)
|
||||
const loading = ref(false)
|
||||
const loadError = ref<ApiErrorDetails | null>(null)
|
||||
const detail = ref<InboundOrderDetail | null>(null)
|
||||
@@ -208,21 +207,12 @@ onBeforeUnmount(() => {
|
||||
<div class="inbound-operation-dialog__body">
|
||||
<MeasurementReviewPanel
|
||||
v-if="canViewMeasurement"
|
||||
ref="reviewPanelRef"
|
||||
:key="`review-dialog-${revision}`"
|
||||
:tenant-id="customerId"
|
||||
:inbound-id="inboundId"
|
||||
@changed="handleReviewChanged"
|
||||
/>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="inbound-operation-dialog__footer">
|
||||
<el-button :disabled="Boolean(reviewPanelRef?.actionLoading)" @click="reviewDialogOpen = false">取消</el-button>
|
||||
<el-button v-if="reviewPanelRef?.isPending && reviewPanelRef?.canReview" type="danger" plain :loading="reviewPanelRef?.actionLoading === 'reject'" @click="reviewPanelRef?.openReasonDialog('reject')">驳回</el-button>
|
||||
<el-button v-if="reviewPanelRef?.isPending && reviewPanelRef?.canReview" type="primary" :loading="reviewPanelRef?.actionLoading === 'approve'" @click="reviewPanelRef?.confirmApprove()">通过并进入待分柜</el-button>
|
||||
<el-button v-if="reviewPanelRef?.isRejected && reviewPanelRef?.canRelease" type="warning" plain :loading="reviewPanelRef?.actionLoading === 'release'" @click="reviewPanelRef?.openReasonDialog('release')">异常放行</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</section>
|
||||
</template>
|
||||
@@ -243,13 +233,9 @@ onBeforeUnmount(() => {
|
||||
.workflow-tabs :deep(.el-tabs__item.is-active) { color: var(--cc-primary); font-weight: 650; }
|
||||
.workflow-action-row { display: flex; min-width: 0; align-items: center; justify-content: space-between; gap: 16px; padding: 0 0 14px; border-bottom: 1px solid var(--cc-border); color: var(--cc-muted); font-size: 12px; line-height: 1.5; }
|
||||
.inbound-operation-dialog__body { min-width: 0; }
|
||||
.inbound-operation-dialog__footer { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 10px; }
|
||||
.inbound-operation-dialog__footer :deep(.el-button) { margin-left: 0; }
|
||||
:global(.inbound-operation-dialog.el-dialog) { display: flex; max-width: calc(100vw - 32px); height: 92vh; max-height: 92vh; margin-bottom: 0; flex-direction: column; overflow: hidden; }
|
||||
:global(.inbound-operation-dialog .el-dialog__header) { flex: 0 0 auto; margin: 0; padding: 18px 24px; border-bottom: 1px solid var(--cc-border); }
|
||||
:global(.inbound-operation-dialog .el-dialog__body) { min-height: 0; flex: 1 1 auto; overflow-y: auto; padding: 20px 24px; }
|
||||
:global(.inbound-operation-dialog .el-dialog__footer) { flex: 0 0 auto; padding: 14px 24px; border-top: 1px solid var(--cc-border); }
|
||||
:global(.inbound-review-dialog .review-actions .action-buttons) { display: none; }
|
||||
@media (max-width: 720px) {
|
||||
.workspace-header { align-items: stretch; flex-direction: column; padding: 16px; }
|
||||
.workspace-header :deep(.el-button) { width: 100%; min-height: 44px; }
|
||||
@@ -262,8 +248,6 @@ onBeforeUnmount(() => {
|
||||
:global(.inbound-operation-dialog.el-dialog) { width: calc(100vw - 20px) !important; max-width: calc(100vw - 20px); height: 92vh; }
|
||||
:global(.inbound-operation-dialog .el-dialog__header) { padding: 16px; }
|
||||
:global(.inbound-operation-dialog .el-dialog__body) { padding: 14px; }
|
||||
.inbound-operation-dialog__footer { display: grid; grid-template-columns: 1fr; }
|
||||
.inbound-operation-dialog__footer :deep(.el-button) { width: 100%; min-height: 44px; }
|
||||
}
|
||||
@media (max-width: 360px) {
|
||||
:global(.inbound-operation-dialog.el-dialog) { width: calc(100vw - 12px) !important; max-width: calc(100vw - 12px); }
|
||||
|
||||
@@ -18,7 +18,15 @@ describe('InboundMeasurementDialog', () => {
|
||||
expect(source).not.toContain('class="surface-panel measurement-dialog-weight"')
|
||||
expect(source).toContain('border-bottom: 1px solid var(--cc-border)')
|
||||
expect(source).toContain('@summary-changed="handleWeightSummaryChanged"')
|
||||
expect(source).toContain('measurementPanelRef.value?.updateActualGrossWeight(actualGrossWeightKg)')
|
||||
expect(source).toContain('measurementPanelRef.value?.updateActualWeights(actualGrossWeightKg, actualNetWeightKg)')
|
||||
})
|
||||
|
||||
it('shows the inbound code once in the dialog title and not as a second body heading', () => {
|
||||
expect(source).toContain('`测量 · ${props.inboundCode?.trim() || props.inboundId}`')
|
||||
expect(source).toContain(':title="title"')
|
||||
expect(panelSource).not.toContain('<span>入仓码</span>')
|
||||
expect(panelSource).not.toContain('<strong>{{ detail.inboundCode')
|
||||
expect(panelSource).toContain('class="measurement-summary-context"')
|
||||
})
|
||||
|
||||
it('is the only measurement dialog opened by both formal list entries', () => {
|
||||
|
||||
@@ -43,8 +43,8 @@ async function beforeClose(done: () => void) {
|
||||
if (await mayClose()) done()
|
||||
}
|
||||
|
||||
function handleWeightSummaryChanged(actualGrossWeightKg: number | null) {
|
||||
measurementPanelRef.value?.updateActualGrossWeight(actualGrossWeightKg)
|
||||
function handleWeightSummaryChanged(actualGrossWeightKg: number | null, actualNetWeightKg: number | null) {
|
||||
measurementPanelRef.value?.updateActualWeights(actualGrossWeightKg, actualNetWeightKg)
|
||||
}
|
||||
|
||||
function handleUpdated() {
|
||||
|
||||
@@ -66,6 +66,7 @@ function measurement(overrides: Partial<InboundMeasurementDetail> = {}): Inbound
|
||||
originalPackageCount: 10,
|
||||
actualPackageCount: 10,
|
||||
actualGrossWeightKg: 120,
|
||||
actualNetWeightKg: 110,
|
||||
actualVolumeCbm: 0.6,
|
||||
volumeWeightKg: 100.2,
|
||||
actualGoodsName: '蓝牙音箱',
|
||||
@@ -216,7 +217,53 @@ describe('InboundMeasurementPanel', () => {
|
||||
expect(source).toContain('.measurement-data-grid { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr));')
|
||||
expect(source).toContain('.measurement-table { width: 100%; font-size: 12px; }')
|
||||
expect(source).toContain('.measurement-data-grid :deep(.el-input__wrapper)')
|
||||
expect(source).toContain('defineExpose({ saveDraft, submitForReview, updateActualGrossWeight, mayMaintain, maySubmit, saving, submitting, canEdit, canSubmit, isDirty })')
|
||||
expect(source).toContain('defineExpose({ saveDraft, submitForReview, updateActualWeights, mayMaintain, maySubmit, saving, submitting, canEdit, canSubmit, isDirty })')
|
||||
})
|
||||
|
||||
it('renders customer declaration facts as plain read-only values', async () => {
|
||||
const wrapper = await mountPanel()
|
||||
const declaration = wrapper.find('.measurement-declaration')
|
||||
|
||||
expect(declaration.text()).toContain('申报箱数')
|
||||
expect(declaration.text()).toContain('10')
|
||||
expect(declaration.text()).toContain('申报毛重(kg)')
|
||||
expect(declaration.text()).toContain('118.5')
|
||||
expect(declaration.text()).toContain('申报体积(m³)')
|
||||
expect(declaration.text()).toContain('0.8')
|
||||
expect(declaration.text()).toContain('原单据号码')
|
||||
expect(declaration.text()).toContain('DOC-001')
|
||||
expect(declaration.findAll('.measurement-readonly-field')).toHaveLength(4)
|
||||
expect(declaration.findAll('input')).toHaveLength(1)
|
||||
expect(declaration.find('.measurement-goods-name input').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('shows all declaration fields as plain text when measurement is not editable', async () => {
|
||||
vi.mocked(getInboundMeasurement).mockResolvedValueOnce(measurement({ processStatus: 'WAIT_SPLIT' }))
|
||||
const wrapper = await mountPanel()
|
||||
const declaration = wrapper.find('.measurement-declaration')
|
||||
|
||||
expect(declaration.find('input').exists()).toBe(false)
|
||||
expect(declaration.text()).toContain('蓝牙音箱')
|
||||
expect(declaration.findAll('.measurement-readonly-field')).toHaveLength(5)
|
||||
})
|
||||
|
||||
it('saves the corrected actual goods name without overriding declaration facts', async () => {
|
||||
const wrapper = await mountPanel()
|
||||
await wrapper.find('.measurement-goods-name input').setValue('修正后的蓝牙音箱')
|
||||
const save = wrapper.findAll('button').find((button) => button.text().includes('保存测量数据'))
|
||||
await save?.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(saveInboundMeasurementDraft).toHaveBeenCalledWith('10001', '7001', expect.objectContaining({
|
||||
measurementId: '9001',
|
||||
version: 2,
|
||||
actualGoodsName: '修正后的蓝牙音箱',
|
||||
items: expect.any(Array),
|
||||
}))
|
||||
const payload = vi.mocked(saveInboundMeasurementDraft).mock.calls[0]?.[2]
|
||||
expect(payload).not.toHaveProperty('originalDocumentNo')
|
||||
expect(payload).not.toHaveProperty('originalGrossWeightKg')
|
||||
expect(payload).not.toHaveProperty('originalVolumeCbm')
|
||||
})
|
||||
|
||||
it('updates the server weight summary without discarding unsaved measurement edits', async () => {
|
||||
@@ -224,10 +271,11 @@ describe('InboundMeasurementPanel', () => {
|
||||
const countInput = wrapper.find('.measurement-count-editor input')
|
||||
await countInput.setValue('9')
|
||||
|
||||
wrapper.vm.updateActualGrossWeight(125.75)
|
||||
wrapper.vm.updateActualWeights(125.75, 115.75)
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('.measurement-metrics').text()).toContain('125.75')
|
||||
expect(wrapper.find('.measurement-metrics').text()).toContain('115.75')
|
||||
expect((countInput.element as HTMLInputElement).value).toBe('9')
|
||||
})
|
||||
|
||||
@@ -237,6 +285,9 @@ describe('InboundMeasurementPanel', () => {
|
||||
expect(wrapper.classes()).toContain('surface-panel')
|
||||
expect(wrapper.find('.measurement-summary-header').exists()).toBe(true)
|
||||
expect(wrapper.find('.measurement-summary-status').text()).toContain('测量中')
|
||||
expect(wrapper.find('.measurement-summary-header').text()).not.toContain('RK202608040001')
|
||||
expect(wrapper.find('.measurement-summary-header').text()).toContain('测试企业')
|
||||
expect(wrapper.find('.measurement-summary-header').text()).toContain('美西海运')
|
||||
})
|
||||
|
||||
it('saves only the changed confirmed package count with the current measurement version', async () => {
|
||||
@@ -374,7 +425,6 @@ describe('InboundMeasurementPanel', () => {
|
||||
await flushPromises()
|
||||
|
||||
expect(getInboundMeasurement).toHaveBeenLastCalledWith('10002', '7002')
|
||||
expect(wrapper.find('.measurement-summary-header').text()).toContain('RK202608040002')
|
||||
expect(wrapper.find('.measurement-summary-header').text()).toContain('第二企业')
|
||||
expect(wrapper.find('.measurement-summary-header').text()).not.toContain('测试企业')
|
||||
})
|
||||
|
||||
@@ -37,9 +37,6 @@ interface EditableMeasurementItem extends SaveInboundMeasurementItemRequest {
|
||||
interface MeasurementFormModel {
|
||||
measurementId: string | null
|
||||
version: number
|
||||
originalDocumentNo: string
|
||||
originalGrossWeightKg: number | null
|
||||
originalVolumeCbm: number | null
|
||||
actualGoodsName: string
|
||||
items: EditableMeasurementItem[]
|
||||
}
|
||||
@@ -79,9 +76,6 @@ let itemSequence = 0
|
||||
const form = reactive<MeasurementFormModel>({
|
||||
measurementId: null,
|
||||
version: 0,
|
||||
originalDocumentNo: '',
|
||||
originalGrossWeightKg: null,
|
||||
originalVolumeCbm: null,
|
||||
actualGoodsName: '',
|
||||
items: [],
|
||||
})
|
||||
@@ -239,9 +233,6 @@ function editableItem(item: InboundMeasurementItem): EditableMeasurementItem {
|
||||
function clearForm() {
|
||||
form.measurementId = null
|
||||
form.version = 0
|
||||
form.originalDocumentNo = ''
|
||||
form.originalGrossWeightKg = null
|
||||
form.originalVolumeCbm = null
|
||||
form.actualGoodsName = ''
|
||||
form.items.splice(0)
|
||||
baseline.value = JSON.stringify(form)
|
||||
@@ -252,9 +243,6 @@ function applyDetail(resource: InboundMeasurementDetail) {
|
||||
detail.value = resource
|
||||
form.measurementId = resource.measurementId == null ? null : String(resource.measurementId)
|
||||
form.version = resource.version ?? 0
|
||||
form.originalDocumentNo = resource.originalDocumentNo || ''
|
||||
form.originalGrossWeightKg = resource.originalGrossWeightKg
|
||||
form.originalVolumeCbm = resource.originalVolumeCbm
|
||||
form.actualGoodsName = resource.actualGoodsName || ''
|
||||
form.items.splice(0, form.items.length, ...resource.items.map(editableItem))
|
||||
baseline.value = JSON.stringify(form)
|
||||
@@ -340,24 +328,13 @@ async function saveDraft() {
|
||||
conflictDetected.value = false
|
||||
try {
|
||||
const originalCounts = new Map((detail.value?.items || []).map((item) => [String(item.id), item.packageCount]))
|
||||
const originalDraft = detail.value
|
||||
? { originalDocumentNo: detail.value.originalDocumentNo || '', originalGrossWeightKg: detail.value.originalGrossWeightKg, originalVolumeCbm: detail.value.originalVolumeCbm, actualGoodsName: detail.value.actualGoodsName || '' }
|
||||
: null
|
||||
const draftChanged = Boolean(originalDraft && (
|
||||
originalDraft.originalDocumentNo !== form.originalDocumentNo
|
||||
|| originalDraft.originalGrossWeightKg !== form.originalGrossWeightKg
|
||||
|| originalDraft.originalVolumeCbm !== form.originalVolumeCbm
|
||||
|| originalDraft.actualGoodsName !== form.actualGoodsName
|
||||
))
|
||||
const draftChanged = Boolean(detail.value && (detail.value.actualGoodsName || '') !== form.actualGoodsName)
|
||||
const changedItems = form.items.filter((item) => originalCounts.get(item.itemId) !== item.packageCount)
|
||||
let version = form.version
|
||||
if (draftChanged) {
|
||||
const saved = await saveInboundMeasurementDraft(customerId.value, props.inboundId, {
|
||||
measurementId: form.measurementId,
|
||||
version,
|
||||
originalDocumentNo: form.originalDocumentNo.trim() || null,
|
||||
originalGrossWeightKg: form.originalGrossWeightKg,
|
||||
originalVolumeCbm: form.originalVolumeCbm,
|
||||
actualGoodsName: form.actualGoodsName.trim() || null,
|
||||
items: form.items.map(({ itemId: _itemId, clientKey: _clientKey, serverValues: _serverValues, ...item }) => item),
|
||||
})
|
||||
@@ -446,9 +423,9 @@ function processStatusLabel(status: string | undefined) {
|
||||
return inboundStatusLabel(status, inboundProcessStatusLabels)
|
||||
}
|
||||
|
||||
function updateActualGrossWeight(actualGrossWeightKg: number | null) {
|
||||
function updateActualWeights(actualGrossWeightKg: number | null, actualNetWeightKg: number | null) {
|
||||
if (!detail.value) return
|
||||
detail.value = { ...detail.value, actualGrossWeightKg }
|
||||
detail.value = { ...detail.value, actualGrossWeightKg, actualNetWeightKg }
|
||||
}
|
||||
|
||||
function measurementRowClassName({ row }: { row: EditableMeasurementItem }) {
|
||||
@@ -472,7 +449,7 @@ watch(
|
||||
|
||||
onBeforeUnmount(releaseImagePreview)
|
||||
|
||||
defineExpose({ saveDraft, submitForReview, updateActualGrossWeight, mayMaintain, maySubmit, saving, submitting, canEdit, canSubmit, isDirty })
|
||||
defineExpose({ saveDraft, submitForReview, updateActualWeights, mayMaintain, maySubmit, saving, submitting, canEdit, canSubmit, isDirty })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -523,10 +500,9 @@ defineExpose({ saveDraft, submitForReview, updateActualGrossWeight, mayMaintain,
|
||||
<legend class="sr-only">测量草稿</legend>
|
||||
<section class="measurement-section measurement-summary-section">
|
||||
<header class="measurement-summary-header">
|
||||
<div>
|
||||
<span>入仓码</span>
|
||||
<strong>{{ detail.inboundCode || '—' }}</strong>
|
||||
<p>{{ detail.companyName || '客户未命名' }} · {{ detail.channelName || detail.channelCode || '渠道未填写' }}</p>
|
||||
<div class="measurement-summary-context">
|
||||
<p><span>客户</span>{{ detail.companyName || '客户未命名' }}</p>
|
||||
<p><span>渠道</span>{{ detail.channelName || detail.channelCode || '渠道未填写' }}</p>
|
||||
</div>
|
||||
<div class="measurement-summary-status">
|
||||
<el-tag effect="light">{{ processStatusLabel(detail.processStatus) }}</el-tag>
|
||||
@@ -539,7 +515,8 @@ defineExpose({ saveDraft, submitForReview, updateActualGrossWeight, mayMaintain,
|
||||
<dt>实测箱数</dt>
|
||||
<dd>{{ formatNumber(confirmedPackageCount, 0) }} <small>/ 申报 {{ formatNumber(expectedPackageCount, 0) }}</small></dd>
|
||||
</div>
|
||||
<div class="measurement-metric--primary"><dt>货物重量</dt><dd>{{ formatNumber(detail.actualGrossWeightKg) }} <small>kg</small></dd></div>
|
||||
<div><dt>实测毛重</dt><dd>{{ formatNumber(detail.actualGrossWeightKg) }} <small>kg</small></dd></div>
|
||||
<div class="measurement-metric--primary"><dt>货物重量</dt><dd>{{ formatNumber(detail.actualNetWeightKg) }} <small>kg</small></dd></div>
|
||||
<div><dt>总体积</dt><dd>{{ formatNumber(detail.actualVolumeCbm, 4) }} <small>m³</small></dd></div>
|
||||
<div><dt>材积重</dt><dd>{{ formatNumber(detail.volumeWeightKg) }} <small>kg</small></dd></div>
|
||||
</dl>
|
||||
@@ -547,11 +524,12 @@ defineExpose({ saveDraft, submitForReview, updateActualGrossWeight, mayMaintain,
|
||||
<section class="measurement-declaration" aria-labelledby="measurement-declaration-title">
|
||||
<h4 id="measurement-declaration-title">申报信息</h4>
|
||||
<div class="measurement-data-grid">
|
||||
<label><span>申报箱数</span><el-input :model-value="formatNumber(expectedPackageCount, 0)" readonly /></label>
|
||||
<label><span>申报毛重(kg)</span><el-input-number v-model="form.originalGrossWeightKg" :min="0" :precision="3" controls-position="right" /></label>
|
||||
<label><span>申报体积(m³)</span><el-input-number v-model="form.originalVolumeCbm" :min="0" :precision="4" controls-position="right" /></label>
|
||||
<label><span>实际品名</span><el-input v-model="form.actualGoodsName" placeholder="请输入实际品名" maxlength="128" /></label>
|
||||
<label><span>原单据号码</span><el-input v-model="form.originalDocumentNo" placeholder="请输入原单据号码" maxlength="128" /></label>
|
||||
<div class="measurement-readonly-field"><span>申报箱数</span><strong>{{ formatNumber(expectedPackageCount, 0) }}</strong></div>
|
||||
<div class="measurement-readonly-field"><span>申报毛重(kg)</span><strong>{{ formatNumber(detail.originalGrossWeightKg) }}</strong></div>
|
||||
<div class="measurement-readonly-field"><span>申报体积(m³)</span><strong>{{ formatNumber(detail.originalVolumeCbm, 4) }}</strong></div>
|
||||
<label v-if="mayMaintain" class="measurement-goods-name"><span>实际品名</span><el-input v-model="form.actualGoodsName" placeholder="请输入实际品名" maxlength="255" /></label>
|
||||
<div v-else class="measurement-readonly-field measurement-goods-name"><span>实际品名</span><strong>{{ detail.actualGoodsName || '—' }}</strong></div>
|
||||
<div class="measurement-readonly-field"><span>原单据号码</span><strong>{{ detail.originalDocumentNo || '—' }}</strong></div>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
@@ -696,10 +674,9 @@ defineExpose({ saveDraft, submitForReview, updateActualGrossWeight, mayMaintain,
|
||||
.measurement-section { padding: 16px 20px; border-top: 1px solid var(--cc-border); }
|
||||
.measurement-summary-section { display: grid; gap: 12px; border-top: 0; }
|
||||
.measurement-summary-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; }
|
||||
.measurement-summary-header > div:first-child { display: grid; min-width: 0; gap: 3px; }
|
||||
.measurement-summary-context { display: flex; min-width: 0; flex-wrap: wrap; align-items: center; gap: 8px 20px; }
|
||||
.measurement-summary-header span { color: var(--cc-muted); font-size: 11px; }
|
||||
.measurement-summary-header strong { overflow-wrap: anywhere; color: var(--cc-text); font-size: 20px; line-height: 1.3; }
|
||||
.measurement-summary-header p { margin: 0; color: var(--cc-muted); font-size: 12px; }
|
||||
.measurement-summary-context p { display: inline-flex; min-width: 0; align-items: baseline; gap: 6px; margin: 0; overflow-wrap: anywhere; color: var(--cc-text); font-size: 12px; font-weight: 600; }
|
||||
.measurement-summary-status { display: flex; flex: 0 0 auto; align-items: center; gap: 9px; }
|
||||
.measurement-metrics { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); margin: 2px 0; }
|
||||
.measurement-metrics > div { display: grid; min-width: 0; min-height: 58px; align-content: center; gap: 4px; padding: 5px 18px; border-left: 1px solid var(--cc-border); }
|
||||
@@ -718,7 +695,10 @@ defineExpose({ saveDraft, submitForReview, updateActualGrossWeight, mayMaintain,
|
||||
.measurement-form label { display: flex; min-width: 0; flex-direction: column; gap: 7px; color: #344054; font-size: 12px; font-weight: 650; }
|
||||
.measurement-form :deep(.el-input-number), .measurement-form :deep(.el-input) { width: 100%; }
|
||||
.measurement-data-grid label { display: flex; min-width: 0; flex-direction: column; gap: 5px; color: #344054; font-size: 12px; font-weight: 600; }
|
||||
.measurement-data-grid :deep(.el-input__wrapper) { border-radius: 3px; background: #f5f5f5; box-shadow: 0 0 0 1px #e5e7eb inset; }
|
||||
.measurement-data-grid :deep(.el-input__wrapper) { border-radius: 3px; }
|
||||
.measurement-readonly-field { display: grid; min-width: 0; align-content: start; gap: 7px; }
|
||||
.measurement-readonly-field span { color: #344054; font-size: 12px; font-weight: 600; }
|
||||
.measurement-readonly-field strong { min-height: 32px; padding: 7px 0; overflow-wrap: anywhere; color: var(--cc-text); font-size: 13px; line-height: 18px; }
|
||||
.measurement-image-preview { display: grid; min-height: 240px; place-items: center; }
|
||||
.measurement-image-preview img { display: block; width: auto; max-width: 100%; max-height: 68vh; object-fit: contain; }
|
||||
.manual-entry-alert { margin-bottom: 16px; }
|
||||
|
||||
@@ -11,9 +11,6 @@ vi.mock('@/shared/api/http', () => ({
|
||||
const draft: SaveInboundMeasurementDraftRequest = {
|
||||
measurementId: '9001',
|
||||
version: 2,
|
||||
originalDocumentNo: 'DOC-01',
|
||||
originalGrossWeightKg: 120,
|
||||
originalVolumeCbm: 1.2,
|
||||
actualGoodsName: '蓝牙音箱',
|
||||
items: [{
|
||||
boxCode: 'RK202608040001#10-1',
|
||||
@@ -62,6 +59,9 @@ describe('inbound measurement API', () => {
|
||||
draft,
|
||||
{ params: { tenantId: '10001' } },
|
||||
)
|
||||
expect(draft).not.toHaveProperty('originalDocumentNo')
|
||||
expect(draft).not.toHaveProperty('originalGrossWeightKg')
|
||||
expect(draft).not.toHaveProperty('originalVolumeCbm')
|
||||
})
|
||||
|
||||
it('submits the server measurement id and optimistic-lock version', async () => {
|
||||
|
||||
@@ -84,6 +84,7 @@ export interface InboundMeasurementDetail {
|
||||
originalPackageCount: number | null
|
||||
actualPackageCount: number | null
|
||||
actualGrossWeightKg: number | null
|
||||
actualNetWeightKg: number | null
|
||||
actualVolumeCbm: number | null
|
||||
volumeWeightKg: number | null
|
||||
actualGoodsName: string | null
|
||||
@@ -113,9 +114,6 @@ export interface SaveInboundMeasurementItemRequest {
|
||||
export interface SaveInboundMeasurementDraftRequest {
|
||||
measurementId: MeasurementIdentifier | null
|
||||
version: number
|
||||
originalDocumentNo: string | null
|
||||
originalGrossWeightKg: number | null
|
||||
originalVolumeCbm: number | null
|
||||
actualGoodsName: string | null
|
||||
items: SaveInboundMeasurementItemRequest[]
|
||||
}
|
||||
|
||||
@@ -4,32 +4,33 @@ import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { useAuthStore } from '@/core/auth/auth'
|
||||
import { downloadWeightImage, downloadWeightThumbnail } from '../weight/api/weightOcr'
|
||||
import WeightImageThumbnail from '../weight/components/WeightImageThumbnail.vue'
|
||||
import { approveMeasurement, getMeasurementReviewDetail, listMeasurementReviewHistory, rejectMeasurement } from './api'
|
||||
import { approveMeasurement, getMeasurementReviewDetail, listMeasurementReviewHistory } from './api'
|
||||
import MeasurementReviewPanel from './MeasurementReviewPanel.vue'
|
||||
import type { MeasurementReviewDetail } from './types'
|
||||
|
||||
vi.mock('./api', () => ({
|
||||
approveMeasurement: vi.fn(),
|
||||
getMeasurementReviewDetail: vi.fn(),
|
||||
listMeasurementReviewHistory: vi.fn(),
|
||||
rejectMeasurement: vi.fn(),
|
||||
releaseRejectedMeasurement: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../weight/api/weightOcr', () => ({
|
||||
downloadWeightImage: vi.fn(),
|
||||
downloadWeightThumbnail: vi.fn(),
|
||||
vi.mock('../weight/components/WeightOcrPanel.vue', () => ({
|
||||
default: {
|
||||
name: 'WeightOcrPanel',
|
||||
props: { customerId: String, inboundId: String, reviewMode: Boolean, correctionMode: Boolean },
|
||||
emits: ['changed'],
|
||||
template: '<button class="mock-weight-change" @click="$emit(\'changed\')">重量证据</button>',
|
||||
},
|
||||
}))
|
||||
|
||||
const detail = {
|
||||
const detail: MeasurementReviewDetail = {
|
||||
inboundId: '90001', inboundCode: 'RK90001', companyName: '柏悦客户', channelCode: 'SEA', channelName: '海运',
|
||||
processStatus: 'MEASURE_PENDING', measureReviewStatus: 'PENDING', recommendedPalletType: 'STANDARD', reviewReason: null, reviewerId: null, reviewerName: null,
|
||||
measurementId: '70001', sourceType: 'DEVICE', measurementStatus: 'SUBMITTED', inboundVersion: 7, version: 4,
|
||||
originalDocumentNo: 'ORD-001', originalGrossWeightKg: 100, originalVolumeCbm: 2, originalPackageCount: 10,
|
||||
actualPackageCount: 10, actualGrossWeightKg: 110, actualVolumeCbm: 2.2, volumeWeightKg: 367.4, actualGoodsName: '测试货物',
|
||||
items: [{ id: '1', boxCode: 'RK90001#10-1', itemNo: '001', boxSpecification: '60×40×30', packageCount: 10, lengthCm: 60, widthCm: 40, heightCm: 30, girthCm: 240, unitWeightKg: 11, unitVolumeCbm: 0.072, totalWeightKg: 110, totalVolumeCbm: 0.72, volumeWeightKg: 120.24, intelligentImageFileId: '30001', machineNo: 'MJ20210327', measuredAt: '2026-08-04T10:30:00+08:00', overLimitWarning: '周长超过渠道限制,请人工核验尺寸后再作出审核决定', outerLengthCm: 61, outerWidthCm: 41, outerHeightCm: 31, remark: '外箱存在轻微变形' }],
|
||||
weightResults: [{ id: '2', fileId: 'file-1', palletId: '50001', palletCode: 'TP-001', palletType: '木托盘', palletCapacityCbm: 1.2, palletTareWeightKg: 10, ocrProvider: 'OCR', ocrRawText: '设备屏幕原始识别内容 120.5 KG,包含一段需要自动换行展示的较长文本', ocrWeightValue: 120.5, ocrWeightUnit: 'KG', confidence: 0.98, adjustedWeightValue: 120, adjustedWeightUnit: 'KG', grossWeightKg: 120, netWeightKg: 110, recognitionStatus: 'CONFIRMED', adjustmentReason: '修正 OCR 毛重读数', adjustedBy: '20001', adjustedAt: '2026-08-04T09:58:00', confirmedBy: '20002', confirmedAt: '2026-08-04T10:00:00', version: 1 }],
|
||||
actualPackageCount: 10, actualGrossWeightKg: 120, actualNetWeightKg: 110, actualVolumeCbm: 2.2, volumeWeightKg: 367.4, actualGoodsName: '测试货物',
|
||||
items: [],
|
||||
weightResults: [],
|
||||
}
|
||||
|
||||
enableAutoUnmount(afterEach)
|
||||
@@ -37,181 +38,86 @@ enableAutoUnmount(afterEach)
|
||||
describe('MeasurementReviewPanel', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
Object.defineProperty(URL, 'createObjectURL', {
|
||||
configurable: true,
|
||||
value: vi.fn((blob: Blob) => blob.size > 5 ? 'blob:weight-original' : 'blob:weight-thumbnail'),
|
||||
})
|
||||
Object.defineProperty(URL, 'revokeObjectURL', { configurable: true, value: vi.fn() })
|
||||
setActivePinia(createPinia())
|
||||
const auth = useAuthStore()
|
||||
auth.token = 'token'
|
||||
auth.permissions = ['firstleg:inbound:measurement-review', 'firstleg:inbound:measurement-review-history', 'firstleg:inbound:exception-release']
|
||||
auth.permissions = [
|
||||
'firstleg:inbound:measurement-review',
|
||||
'firstleg:inbound:measurement-review-history',
|
||||
'platform:firstleg:inbound:measure:view',
|
||||
'platform:firstleg:inbound:weight:edit',
|
||||
'platform:firstleg:inbound:weight:confirm',
|
||||
]
|
||||
useAccessPreviewStore().initializeAfterLogin()
|
||||
vi.mocked(getMeasurementReviewDetail).mockResolvedValue(detail)
|
||||
vi.mocked(listMeasurementReviewHistory).mockResolvedValue({ records: [], page: 1, size: 20, total: 0, pages: 0 })
|
||||
vi.mocked(downloadWeightThumbnail).mockResolvedValue(new Blob(['thumb'], { type: 'image/jpeg' }))
|
||||
vi.mocked(downloadWeightImage).mockResolvedValue(new Blob(['original-image'], { type: 'image/jpeg' }))
|
||||
})
|
||||
|
||||
it('keeps the review focused on weight evidence and omits carton measurement details', async () => {
|
||||
it('keeps the pending decision and refresh actions in a sticky first-screen toolbar', async () => {
|
||||
const wrapper = mount(MeasurementReviewPanel, { props: { customerId: '10001', inboundId: '90001' }, global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('入仓码')
|
||||
expect(wrapper.text()).toContain('RK90001')
|
||||
expect(wrapper.text()).toContain('柏悦客户 · 海运')
|
||||
expect(wrapper.text()).toContain('合计货物重量')
|
||||
expect(wrapper.text()).toContain('全部称重图片')
|
||||
expect(wrapper.text()).toContain('托盘数量')
|
||||
expect(wrapper.text()).toContain('1 托')
|
||||
expect(wrapper.text()).toContain('称重图片')
|
||||
expect(wrapper.text()).toContain('货物重量')
|
||||
expect(wrapper.text()).toContain('110 kg')
|
||||
expect(wrapper.text()).toContain('托盘类型:木托盘')
|
||||
expect(wrapper.text()).toContain('服务端确认毛重:120 kg')
|
||||
expect(wrapper.text()).toContain('托盘皮重:10 kg')
|
||||
expect(wrapper.text()).not.toContain('TP-001')
|
||||
expect(wrapper.text()).not.toContain('申报毛重')
|
||||
expect(wrapper.text()).not.toContain('OCR 原文')
|
||||
expect(wrapper.text()).not.toContain('设备屏幕原始识别内容')
|
||||
expect(wrapper.text()).not.toContain('置信度')
|
||||
expect(wrapper.text()).not.toContain('调整原因')
|
||||
expect(wrapper.findAllComponents(WeightImageThumbnail)).toHaveLength(1)
|
||||
expect(wrapper.text()).not.toContain('测量明细')
|
||||
expect(wrapper.text()).not.toContain('实测长 × 宽 × 高(cm)')
|
||||
expect(wrapper.text()).not.toContain('设备实测体积')
|
||||
expect(wrapper.text()).not.toContain('材积重')
|
||||
expect(wrapper.text()).not.toContain('租户')
|
||||
expect(wrapper.text()).toContain('通过并进入待分柜')
|
||||
expect(wrapper.text()).not.toContain('确认费用')
|
||||
expect(getMeasurementReviewDetail).toHaveBeenCalledWith('10001', '90001')
|
||||
expect(downloadWeightThumbnail).toHaveBeenCalledWith('10001', '90001', 'file-1')
|
||||
const toolbar = wrapper.get('.review-toolbar')
|
||||
expect(toolbar.text()).toContain('待审核')
|
||||
expect(toolbar.text()).toContain('修改重量')
|
||||
expect(toolbar.text()).toContain('通过并进入待分柜')
|
||||
expect(toolbar.text()).toContain('刷新')
|
||||
expect(toolbar.text()).toContain('客户:柏悦客户')
|
||||
expect(toolbar.text()).toContain('渠道:海运')
|
||||
expect(wrapper.find('.review-toolbar__context').exists()).toBe(true)
|
||||
expect(wrapper.find('.review-context').exists()).toBe(false)
|
||||
expect(wrapper.text()).not.toContain('RK90001')
|
||||
expect(wrapper.text()).not.toContain('入仓码')
|
||||
expect(wrapper.text()).not.toContain('驳回')
|
||||
expect(wrapper.text()).not.toContain('异常放行')
|
||||
const source = await import('./MeasurementReviewPanel.vue?raw').then((module) => module.default)
|
||||
expect(source).toContain('defineExpose({ confirmApprove, openReasonDialog, isPending, isRejected, canReview, canRelease, actionLoading })')
|
||||
expect(source).not.toContain('measurementColumns')
|
||||
expect(source.indexOf('全部称重图片')).toBeLessThan(source.indexOf('审核结论'))
|
||||
expect(source).not.toContain('<DataTable')
|
||||
expect(source).toContain('<el-collapse class="review-history">')
|
||||
expect(source).toContain('position: sticky')
|
||||
expect(source).toContain('review-toolbar__main')
|
||||
expect(source).toContain('review-toolbar__context')
|
||||
expect(source.indexOf('class="review-toolbar"')).toBeLessThan(source.indexOf('<WeightOcrPanel'))
|
||||
expect(source).not.toContain('reasonDialog')
|
||||
expect(source).not.toContain('saveInboundMeasurementDraft')
|
||||
expect(source).not.toContain('submitInboundMeasurement')
|
||||
})
|
||||
|
||||
it('shows every weight image at once with its cargo weight below', async () => {
|
||||
vi.mocked(getMeasurementReviewDetail).mockResolvedValue({
|
||||
...detail,
|
||||
actualGrossWeightKg: 330,
|
||||
weightResults: [
|
||||
detail.weightResults[0],
|
||||
{ ...detail.weightResults[0], id: '3', fileId: 'file-2', netWeightKg: 100 },
|
||||
{ ...detail.weightResults[0], id: '4', fileId: 'file-3', netWeightKg: 120 },
|
||||
],
|
||||
})
|
||||
it('reuses WeightOcrPanel and enables only its review correction mode', async () => {
|
||||
const wrapper = mount(MeasurementReviewPanel, { props: { customerId: '10001', inboundId: '90001' }, global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.findAll('.weight-evidence-card')).toHaveLength(3)
|
||||
expect(wrapper.findAllComponents(WeightImageThumbnail)).toHaveLength(3)
|
||||
expect(wrapper.findAll('.weight-evidence-card__weight').map((card) => card.text())).toEqual([
|
||||
'货物重量110 kg',
|
||||
'货物重量100 kg',
|
||||
'货物重量120 kg',
|
||||
])
|
||||
expect(wrapper.find('.weight-overview').text()).toContain('330 kg')
|
||||
expect(wrapper.find('.weight-overview').text()).toContain('3 托')
|
||||
expect(downloadWeightThumbnail).toHaveBeenCalledTimes(3)
|
||||
const weightPanel = wrapper.getComponent({ name: 'WeightOcrPanel' })
|
||||
expect(weightPanel.props('reviewMode')).toBe(true)
|
||||
expect(weightPanel.props('correctionMode')).toBe(false)
|
||||
await wrapper.findAll('button').find((button) => button.text().includes('修改重量'))?.trigger('click')
|
||||
expect(weightPanel.props('correctionMode')).toBe(true)
|
||||
})
|
||||
|
||||
it('opens the authenticated original image with the reviewed weight facts', async () => {
|
||||
const wrapper = mount(MeasurementReviewPanel, { props: { customerId: '10001', inboundId: '90001' }, global: { plugins: [ElementPlus] }, attachTo: document.body })
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.find('.weight-evidence-card .weight-thumb').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(downloadWeightImage).toHaveBeenCalledWith('10001', '90001', 'file-1')
|
||||
const preview = document.body.querySelector('.weight-preview')
|
||||
expect(preview?.textContent).toContain('托盘类型')
|
||||
expect(preview?.textContent).toContain('木托盘')
|
||||
expect(preview?.textContent).not.toContain('TP-001')
|
||||
expect(preview?.textContent).toContain('120 kg')
|
||||
expect(preview?.textContent).toContain('10 kg')
|
||||
expect(preview?.textContent).toContain('110 kg')
|
||||
expect(preview?.querySelector('img')?.getAttribute('src')).toBe('blob:weight-original')
|
||||
})
|
||||
|
||||
it('shows the empty pallet type explicitly without falling back to the pallet code', async () => {
|
||||
vi.mocked(getMeasurementReviewDetail).mockResolvedValueOnce({
|
||||
...detail,
|
||||
weightResults: [{ ...detail.weightResults[0], palletType: '' }],
|
||||
})
|
||||
const wrapper = mount(MeasurementReviewPanel, { props: { customerId: '10001', inboundId: '90001' }, global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('托盘类型:未记录类型')
|
||||
expect(wrapper.text()).not.toContain('TP-001')
|
||||
})
|
||||
|
||||
it('shows the rejected release action only when the permission and rejected state exist', async () => {
|
||||
vi.mocked(getMeasurementReviewDetail).mockResolvedValue({ ...detail, processStatus: 'MEASURE_REJECTED', measureReviewStatus: 'REJECTED', reviewReason: '重量图片不清晰' })
|
||||
const wrapper = mount(MeasurementReviewPanel, { props: { customerId: '10001', inboundId: '90001' }, global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
expect(wrapper.text()).toContain('异常放行')
|
||||
expect(wrapper.text()).toContain('重量图片不清晰')
|
||||
|
||||
const auth = useAuthStore()
|
||||
auth.permissions = ['firstleg:inbound:measurement-review-history']
|
||||
useAccessPreviewStore().initializeAfterLogin()
|
||||
await wrapper.vm.$nextTick()
|
||||
expect(wrapper.findAll('button').some((button) => button.text().includes('异常放行'))).toBe(false)
|
||||
})
|
||||
|
||||
it('emits changed after a successful review write', async () => {
|
||||
it('refreshes inboundVersion after a weight correction before approval', async () => {
|
||||
vi.mocked(getMeasurementReviewDetail)
|
||||
.mockResolvedValueOnce(detail)
|
||||
.mockResolvedValueOnce({ ...detail, inboundVersion: 8, actualGrossWeightKg: 125, actualNetWeightKg: 115 })
|
||||
vi.mocked(approveMeasurement).mockResolvedValue({ inboundId: '90001', measurementId: '70001', processStatus: 'WAIT_SPLIT', feeStatus: 'CALCULATED', initialPricingSucceeded: true, initialPricingMessage: null, initialFee: null })
|
||||
vi.spyOn(ElMessageBox, 'confirm').mockResolvedValue(undefined as never)
|
||||
const wrapper = mount(MeasurementReviewPanel, { props: { customerId: '10001', inboundId: '90001' }, global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
const buttons = wrapper.findAll('button')
|
||||
const approve = buttons.find((button) => button.text().includes('通过并进入待分柜'))
|
||||
expect(approve).toBeDefined()
|
||||
await approve?.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(ElMessageBox.confirm).toHaveBeenCalledWith(
|
||||
expect.stringContaining('初算失败不影响审核通过'),
|
||||
'确认测量审核通过',
|
||||
expect.objectContaining({ confirmButtonText: '通过并进入待分柜' }),
|
||||
)
|
||||
expect(approveMeasurement).toHaveBeenCalledWith('10001', '90001', { measurementId: '70001', expectedVersion: 7 })
|
||||
expect(wrapper.emitted('changed')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('keeps approval successful when initial pricing fails', async () => {
|
||||
vi.mocked(approveMeasurement).mockResolvedValue({ inboundId: '90001', measurementId: '70001', processStatus: 'WAIT_SPLIT', feeStatus: 'UNPRICED', initialPricingSucceeded: false, initialPricingMessage: '缺少有效价格', initialFee: null })
|
||||
vi.spyOn(ElMessageBox, 'confirm').mockResolvedValue(undefined as never)
|
||||
const wrapper = mount(MeasurementReviewPanel, { props: { customerId: '10001', inboundId: '90001' }, global: { plugins: [ElementPlus] } })
|
||||
await wrapper.get('.mock-weight-change').trigger('click')
|
||||
await flushPromises()
|
||||
await wrapper.findAll('button').find((button) => button.text().includes('通过并进入待分柜'))?.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(approveMeasurement).toHaveBeenCalledOnce()
|
||||
expect(getMeasurementReviewDetail).toHaveBeenCalledTimes(3)
|
||||
expect(approveMeasurement).toHaveBeenCalledWith('10001', '90001', { measurementId: '70001', expectedVersion: 8 })
|
||||
expect(wrapper.emitted('changed')).toHaveLength(1)
|
||||
expect(wrapper.text()).not.toContain('初算失败不阻断审核')
|
||||
})
|
||||
|
||||
it('rejects with the inbound optimistic-lock version instead of the measurement version', async () => {
|
||||
vi.mocked(rejectMeasurement).mockResolvedValue(undefined)
|
||||
it('shows historical rejected data read-only without new reject or release operations', async () => {
|
||||
vi.mocked(getMeasurementReviewDetail).mockResolvedValue({ ...detail, processStatus: 'MEASURE_REJECTED', measureReviewStatus: 'REJECTED', reviewReason: '历史审核说明' })
|
||||
const wrapper = mount(MeasurementReviewPanel, { props: { customerId: '10001', inboundId: '90001' }, global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
|
||||
const reject = wrapper.findAll('button').find((button) => button.text().includes('驳回'))
|
||||
await reject?.trigger('click')
|
||||
await wrapper.find('textarea').setValue('重量图片需要重新核对')
|
||||
const confirm = wrapper.findAll('button').find((button) => button.text().includes('确认驳回'))
|
||||
await confirm?.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(rejectMeasurement).toHaveBeenCalledWith('10001', '90001', {
|
||||
measurementId: '70001',
|
||||
expectedVersion: 7,
|
||||
reason: '重量图片需要重新核对',
|
||||
})
|
||||
expect(wrapper.text()).toContain('历史记录为审核驳回,仅供查看')
|
||||
expect(wrapper.text()).toContain('历史审核说明')
|
||||
expect(wrapper.text()).not.toContain('修改重量')
|
||||
expect(wrapper.text()).not.toContain('异常放行')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,21 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Check, CircleAlert, RotateCcw, ShieldAlert, X } from '@lucide/vue'
|
||||
import { Check, Pencil, RotateCcw } from '@lucide/vue'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import StatusBadge from '@/shared/components/StatusBadge.vue'
|
||||
import { inboundReviewStatusLabels, inboundStatusLabel } from '../status'
|
||||
import { downloadWeightImage } from '../weight/api/weightOcr'
|
||||
import WeightImageThumbnail from '../weight/components/WeightImageThumbnail.vue'
|
||||
import {
|
||||
approveMeasurement,
|
||||
getMeasurementReviewDetail,
|
||||
listMeasurementReviewHistory,
|
||||
rejectMeasurement,
|
||||
releaseRejectedMeasurement,
|
||||
} from './api'
|
||||
import WeightOcrPanel from '../weight/components/WeightOcrPanel.vue'
|
||||
import { approveMeasurement, getMeasurementReviewDetail, listMeasurementReviewHistory } from './api'
|
||||
import type { MeasurementReviewDetail, MeasurementReviewRecord } from './types'
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -25,9 +18,7 @@ const props = defineProps<{
|
||||
inboundId: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
changed: []
|
||||
}>()
|
||||
const emit = defineEmits<{ changed: [] }>()
|
||||
|
||||
const access = useAccessPreviewStore()
|
||||
const detail = ref<MeasurementReviewDetail | null>(null)
|
||||
@@ -37,35 +28,17 @@ const historyLoading = ref(false)
|
||||
const actionLoading = ref('')
|
||||
const loadError = ref<ApiErrorDetails | null>(null)
|
||||
const historyError = ref<ApiErrorDetails | null>(null)
|
||||
const reasonDialog = ref<'reject' | 'release' | null>(null)
|
||||
const reason = ref('')
|
||||
const correctionMode = ref(false)
|
||||
const requestSerial = ref(0)
|
||||
const previewOpen = ref(false)
|
||||
const previewUrl = ref('')
|
||||
const previewLoading = ref(false)
|
||||
const previewError = ref<ApiErrorDetails | null>(null)
|
||||
const previewResult = ref<MeasurementReviewDetail['weightResults'][number] | null>(null)
|
||||
const previewIndex = ref(0)
|
||||
let previewRequestSerial = 0
|
||||
const reasonDialogOpen = computed({
|
||||
get: () => reasonDialog.value !== null,
|
||||
set: (open: boolean) => { if (!open) reasonDialog.value = null },
|
||||
})
|
||||
|
||||
const canReview = computed(() => access.can('firstleg:inbound:measurement-review'))
|
||||
const canRelease = computed(() => access.can('firstleg:inbound:exception-release'))
|
||||
const customerId = computed(() => props.customerId || props.tenantId || '')
|
||||
const isPending = computed(() => detail.value?.measureReviewStatus === 'PENDING' && detail.value?.processStatus === 'MEASURE_PENDING')
|
||||
const isRejected = computed(() => detail.value?.measureReviewStatus === 'REJECTED' && detail.value?.processStatus === 'MEASURE_REJECTED')
|
||||
const reviewStatusLabel = computed(() => inboundStatusLabel(detail.value?.measureReviewStatus, inboundReviewStatusLabels, '未提交'))
|
||||
|
||||
function formatNumber(value: number | null | undefined, unit = '') {
|
||||
if (value === null || value === undefined || Number.isNaN(value)) return '-'
|
||||
return `${value.toLocaleString('zh-CN', { maximumFractionDigits: 3 })}${unit}`
|
||||
}
|
||||
|
||||
function formatDate(value: string | null | undefined) {
|
||||
return value ? value.replace('T', ' ') : '-'
|
||||
return value ? value.replace('T', ' ') : '—'
|
||||
}
|
||||
|
||||
function resultLabel(value: string | null | undefined) {
|
||||
@@ -74,35 +47,6 @@ function resultLabel(value: string | null | undefined) {
|
||||
return '未知结果'
|
||||
}
|
||||
|
||||
function releasePreview() {
|
||||
if (previewUrl.value) URL.revokeObjectURL(previewUrl.value)
|
||||
previewUrl.value = ''
|
||||
}
|
||||
|
||||
async function openWeightImage(row: unknown, index: number) {
|
||||
previewResult.value = row as MeasurementReviewDetail['weightResults'][number]
|
||||
previewIndex.value = index
|
||||
previewOpen.value = true
|
||||
await loadPreviewImage()
|
||||
}
|
||||
|
||||
async function loadPreviewImage() {
|
||||
const row = previewResult.value
|
||||
if (!row?.fileId) return
|
||||
const serial = ++previewRequestSerial
|
||||
releasePreview()
|
||||
previewError.value = null
|
||||
previewLoading.value = true
|
||||
try {
|
||||
const blob = await downloadWeightImage(customerId.value, props.inboundId, row.fileId)
|
||||
if (serial === previewRequestSerial) previewUrl.value = URL.createObjectURL(blob)
|
||||
} catch (error) {
|
||||
if (serial === previewRequestSerial) previewError.value = getApiErrorDetails(error, '称重原图读取失败,请重新加载')
|
||||
} finally {
|
||||
if (serial === previewRequestSerial) previewLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function isConflict(error: unknown) {
|
||||
const details = getApiErrorDetails(error)
|
||||
return details.message.includes('版本已变化') || details.message.includes('并发修改')
|
||||
@@ -129,8 +73,7 @@ async function loadHistory() {
|
||||
historyError.value = null
|
||||
historyLoading.value = true
|
||||
try {
|
||||
const result = await listMeasurementReviewHistory(customerId.value, props.inboundId)
|
||||
history.value = result.records
|
||||
history.value = (await listMeasurementReviewHistory(customerId.value, props.inboundId)).records
|
||||
} catch (error) {
|
||||
history.value = []
|
||||
historyError.value = getApiErrorDetails(error, '审核历史加载失败')
|
||||
@@ -143,6 +86,10 @@ async function reload() {
|
||||
await Promise.all([loadReview(), loadHistory()])
|
||||
}
|
||||
|
||||
async function handleWeightChanged() {
|
||||
await loadReview()
|
||||
}
|
||||
|
||||
async function confirmApprove() {
|
||||
if (!detail.value || !isPending.value || !canReview.value) return
|
||||
try {
|
||||
@@ -160,11 +107,8 @@ async function confirmApprove() {
|
||||
measurementId: detail.value.measurementId,
|
||||
expectedVersion: detail.value.inboundVersion,
|
||||
})
|
||||
if (result.initialPricingSucceeded) {
|
||||
ElMessage.success('审核已通过,入库单已进入待分柜,费用已完成初算')
|
||||
} else {
|
||||
ElMessage.warning(`审核已通过,入库单已进入待分柜;费用初算未完成${result.initialPricingMessage ? `:${result.initialPricingMessage}` : ''}`)
|
||||
}
|
||||
if (result.initialPricingSucceeded) ElMessage.success('审核已通过,入库单已进入待分柜,费用已完成初算')
|
||||
else ElMessage.warning(`审核已通过,入库单已进入待分柜;费用初算未完成${result.initialPricingMessage ? `:${result.initialPricingMessage}` : ''}`)
|
||||
await reload()
|
||||
emit('changed')
|
||||
} catch (error) {
|
||||
@@ -175,48 +119,8 @@ async function confirmApprove() {
|
||||
}
|
||||
}
|
||||
|
||||
function openReasonDialog(kind: 'reject' | 'release') {
|
||||
reason.value = ''
|
||||
reasonDialog.value = kind
|
||||
}
|
||||
|
||||
async function submitReason() {
|
||||
if (!detail.value || !reasonDialog.value) return
|
||||
const value = reason.value.trim()
|
||||
if (!value) {
|
||||
ElMessage.warning(reasonDialog.value === 'reject' ? '请填写驳回原因' : '请填写异常放行原因')
|
||||
return
|
||||
}
|
||||
const kind = reasonDialog.value
|
||||
actionLoading.value = kind
|
||||
try {
|
||||
if (kind === 'reject') {
|
||||
await rejectMeasurement(customerId.value, props.inboundId, {
|
||||
measurementId: detail.value.measurementId,
|
||||
expectedVersion: detail.value.inboundVersion,
|
||||
reason: value,
|
||||
})
|
||||
ElMessage.success('测量已驳回,已退回测量修正')
|
||||
} else {
|
||||
await releaseRejectedMeasurement(customerId.value, props.inboundId, {
|
||||
measurementId: detail.value.measurementId,
|
||||
expectedVersion: detail.value.inboundVersion,
|
||||
reason: value,
|
||||
})
|
||||
ElMessage.success('异常放行已提交,后续计费入库将继续处理')
|
||||
}
|
||||
reasonDialog.value = null
|
||||
await reload()
|
||||
emit('changed')
|
||||
} catch (error) {
|
||||
ElMessage.error(isConflict(error) ? '数据已被更新,请刷新后重试' : getApiErrorDetails(error, kind === 'reject' ? '测量驳回失败' : '异常放行失败').message)
|
||||
if (isConflict(error)) await reload()
|
||||
} finally {
|
||||
actionLoading.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => [customerId.value, props.inboundId], () => {
|
||||
correctionMode.value = false
|
||||
if (customerId.value && props.inboundId) void reload()
|
||||
}, { immediate: true })
|
||||
|
||||
@@ -224,87 +128,45 @@ onMounted(() => {
|
||||
if (!customerId.value || !props.inboundId) loadError.value = { message: '缺少所属企业或入库单标识,无法加载审核数据' }
|
||||
})
|
||||
|
||||
watch(previewOpen, (open) => {
|
||||
if (!open) {
|
||||
previewRequestSerial += 1
|
||||
previewResult.value = null
|
||||
previewError.value = null
|
||||
releasePreview()
|
||||
}
|
||||
})
|
||||
onBeforeUnmount(releasePreview)
|
||||
|
||||
defineExpose({ confirmApprove, openReasonDialog, isPending, isRejected, canReview, canRelease, actionLoading })
|
||||
defineExpose({ confirmApprove, isPending, canReview, actionLoading })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="review-panel" aria-labelledby="measurement-review-title">
|
||||
<header class="review-header">
|
||||
<div>
|
||||
<span>入仓码</span>
|
||||
<h2 id="measurement-review-title">{{ detail?.inboundCode || '-' }}</h2>
|
||||
<p>{{ detail ? `${detail.companyName || '客户未命名'} · ${detail.channelName || detail.channelCode || '渠道未填写'}` : '称重结果' }}</p>
|
||||
<h2 id="measurement-review-title" class="sr-only">测量审核内容</h2>
|
||||
<div class="review-toolbar" aria-label="测量审核操作">
|
||||
<div class="review-toolbar__main">
|
||||
<div class="review-toolbar__status">
|
||||
<StatusBadge v-if="detail" :status="reviewStatusLabel" />
|
||||
<span v-if="isPending">通过后进入待分柜</span>
|
||||
<span v-else-if="isRejected">历史记录为审核驳回,仅供查看</span>
|
||||
<span v-else>当前记录不可审核</span>
|
||||
</div>
|
||||
<div class="review-toolbar__actions">
|
||||
<el-button v-if="isPending && canReview" type="primary" plain @click="correctionMode = true"><Pencil :size="16" />修改重量</el-button>
|
||||
<el-button v-if="isPending && canReview" type="primary" :loading="actionLoading === 'approve'" @click="confirmApprove"><Check :size="16" />通过并进入待分柜</el-button>
|
||||
<el-button :loading="loading || historyLoading" aria-label="刷新审核数据" @click="reload"><RotateCcw :size="16" />刷新</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="review-header-status">
|
||||
<StatusBadge v-if="detail" :status="reviewStatusLabel" />
|
||||
<el-button text :loading="loading || historyLoading" aria-label="刷新审核数据" @click="reload"><RotateCcw :size="16" />刷新</el-button>
|
||||
</div>
|
||||
</header>
|
||||
<p v-if="detail" class="review-toolbar__context">客户:{{ detail.companyName || '客户未命名' }} · 渠道:{{ detail.channelName || detail.channelCode || '渠道未填写' }}</p>
|
||||
</div>
|
||||
|
||||
<el-alert v-if="loadError" type="error" :closable="false" show-icon role="alert">
|
||||
<template #title><div class="review-alert-title"><span>{{ loadError.message }}</span><ApiErrorMeta :details="loadError" /></div></template>
|
||||
<el-button link type="danger" @click="reload">重新加载</el-button>
|
||||
</el-alert>
|
||||
|
||||
<el-skeleton v-if="loading && !detail" :rows="8" animated />
|
||||
|
||||
<template v-else-if="detail">
|
||||
<dl class="weight-overview" aria-label="重量审核摘要">
|
||||
<div class="weight-overview__primary"><dt>合计货物重量</dt><dd>{{ formatNumber(detail.actualGrossWeightKg, ' kg') }}</dd></div>
|
||||
<div><dt>托盘数量</dt><dd>{{ detail.weightResults.length }} 托</dd></div>
|
||||
</dl>
|
||||
<el-alert v-if="detail.reviewReason" type="warning" :closable="false" show-icon :title="`最近审核说明:${detail.reviewReason}`" />
|
||||
|
||||
<div v-if="detail.reviewReason" class="review-reason">
|
||||
<CircleAlert :size="17" /><span><strong>最近审核说明:</strong>{{ detail.reviewReason }}</span>
|
||||
</div>
|
||||
|
||||
<div class="review-section weight-evidence-section">
|
||||
<div class="section-heading"><h3>全部称重图片</h3><span class="section-count">{{ detail.weightResults.length }} 张</span></div>
|
||||
<div v-if="detail.weightResults.length" class="weight-evidence-grid">
|
||||
<article v-for="(row, index) in detail.weightResults" :key="row.id" class="weight-evidence-card">
|
||||
<div class="weight-evidence-card__image">
|
||||
<WeightImageThumbnail
|
||||
v-if="row.fileId"
|
||||
:customer-id="customerId"
|
||||
:inbound-id="inboundId"
|
||||
:file-id="row.fileId"
|
||||
:alt="`称重图片 ${index + 1}`"
|
||||
@open="openWeightImage(row, index)"
|
||||
/>
|
||||
<span v-else class="missing-evidence">未关联图片</span>
|
||||
</div>
|
||||
<div class="weight-evidence-card__weight">
|
||||
<span>货物重量</span>
|
||||
<strong>{{ formatNumber(row.netWeightKg, ' kg') }}</strong>
|
||||
</div>
|
||||
<div class="weight-evidence-card__record">
|
||||
<span>托盘类型:{{ row.palletType || '未记录类型' }}</span>
|
||||
<span>服务端确认毛重:{{ formatNumber(row.grossWeightKg, ' kg') }}</span>
|
||||
<span>托盘皮重:{{ formatNumber(row.palletTareWeightKg, ' kg') }}</span>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
<el-empty v-else description="暂无称重图片" :image-size="56" />
|
||||
</div>
|
||||
|
||||
<div class="review-actions" :class="{ 'review-actions--disabled': !isPending && !isRejected }">
|
||||
<div><strong>审核结论</strong><span v-if="isPending">通过后进入待分柜</span><span v-else-if="isRejected">已驳回,可修正后重新提交</span><span v-else>当前记录不可审核</span></div>
|
||||
<div class="action-buttons">
|
||||
<el-button v-if="isPending && canReview" type="danger" plain :loading="actionLoading === 'reject'" @click="openReasonDialog('reject')"><X :size="16" />驳回</el-button>
|
||||
<el-button v-if="isPending && canReview" type="primary" :loading="actionLoading === 'approve'" @click="confirmApprove"><Check :size="16" />通过并进入待分柜</el-button>
|
||||
<el-button v-if="isRejected && canRelease" type="warning" plain :loading="actionLoading === 'release'" @click="openReasonDialog('release')"><ShieldAlert :size="16" />异常放行</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<WeightOcrPanel
|
||||
review-mode
|
||||
:correction-mode="correctionMode"
|
||||
:customer-id="customerId"
|
||||
:inbound-id="inboundId"
|
||||
@changed="handleWeightChanged"
|
||||
/>
|
||||
|
||||
<el-collapse class="review-history">
|
||||
<el-collapse-item name="history" :title="`审核历史(${history.length})`">
|
||||
@@ -320,64 +182,39 @@ defineExpose({ confirmApprove, openReasonDialog, isPending, isRejected, canRevie
|
||||
</el-collapse-item>
|
||||
</el-collapse>
|
||||
</template>
|
||||
|
||||
<el-dialog v-model="previewOpen" :title="`称重原图 ${previewIndex + 1}`" width="min(880px, calc(100vw - 28px))" destroy-on-close append-to-body>
|
||||
<div v-if="previewResult" class="weight-preview">
|
||||
<div v-loading="previewLoading" class="weight-preview__canvas">
|
||||
<img v-if="previewUrl" :src="previewUrl" :alt="`称重原图 ${previewIndex + 1}`" />
|
||||
<el-alert v-else-if="previewError" type="error" :closable="false" show-icon>
|
||||
<template #title><div class="review-alert-title"><span>{{ previewError.message }}</span><ApiErrorMeta :details="previewError" /></div></template>
|
||||
<el-button link type="danger" :loading="previewLoading" @click="loadPreviewImage">重新加载原图</el-button>
|
||||
</el-alert>
|
||||
</div>
|
||||
<dl class="weight-preview__facts">
|
||||
<div><dt>托盘类型</dt><dd>{{ previewResult.palletType || '未记录类型' }}</dd></div>
|
||||
<div><dt>服务端确认毛重</dt><dd>{{ formatNumber(previewResult.grossWeightKg, ' kg') }}</dd></div>
|
||||
<div><dt>托盘皮重</dt><dd>{{ formatNumber(previewResult.palletTareWeightKg, ' kg') }}</dd></div>
|
||||
<div><dt>货物重量</dt><dd>{{ formatNumber(previewResult.netWeightKg, ' kg') }}</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="reasonDialogOpen" :title="reasonDialog === 'reject' ? '填写驳回原因' : '填写异常放行原因'" width="min(520px, calc(100vw - 28px))" destroy-on-close>
|
||||
<el-alert v-if="reasonDialog === 'release'" title="异常放行不会抹去驳回记录;请说明为什么允许该测量继续进入自动计费入库。" type="warning" :closable="false" show-icon />
|
||||
<el-input v-model="reason" type="textarea" :rows="5" maxlength="1000" show-word-limit :placeholder="reasonDialog === 'reject' ? '请说明需要修正的测量问题' : '请填写异常放行的业务原因'" />
|
||||
<template #footer><el-button :disabled="Boolean(actionLoading)" @click="reasonDialog = null">取消</el-button><el-button :type="reasonDialog === 'reject' ? 'danger' : 'warning'" :loading="Boolean(actionLoading)" @click="submitReason">确认{{ reasonDialog === 'reject' ? '驳回' : '放行' }}</el-button></template>
|
||||
</el-dialog>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.review-panel { display: grid; gap: 16px; color: var(--cc-text); }
|
||||
.review-header { display: flex; justify-content: space-between; align-items: flex-start; gap: 18px; padding-bottom: 4px; }
|
||||
.review-header > div:first-child { display: grid; min-width: 0; gap: 3px; }
|
||||
.review-header > div:first-child > span { color: var(--cc-muted); font-size: 11px; }
|
||||
.review-header h2 { margin: 0; overflow-wrap: anywhere; font-size: 20px; line-height: 1.25; }
|
||||
.review-header p, .section-heading p { margin: 0; color: var(--cc-muted); font-size: 13px; line-height: 1.55; }
|
||||
.review-header-status { display: flex; align-items: center; gap: 10px; white-space: nowrap; }
|
||||
.review-toolbar { position: sticky; z-index: 4; top: 0; display: grid; gap: 10px; margin: -20px -24px 0; padding: 14px 24px 12px; border-bottom: 1px solid var(--cc-border); background: #fff; box-shadow: 0 4px 12px rgb(16 24 40 / 6%); }
|
||||
.review-toolbar__main { display: flex; min-width: 0; align-items: center; justify-content: space-between; gap: 14px; }
|
||||
.review-toolbar__status, .review-toolbar__actions { display: flex; min-width: 0; align-items: center; flex-wrap: wrap; gap: 8px; }
|
||||
.review-toolbar__status > span { color: var(--cc-muted); font-size: 12px; }
|
||||
.review-toolbar__actions { justify-content: flex-end; }
|
||||
.review-toolbar__actions :deep(.el-button) { margin-left: 0; }
|
||||
.review-toolbar__context { margin: 0; padding-top: 10px; border-top: 1px solid var(--cc-border); color: var(--cc-muted); font-size: 13px; line-height: 1.55; overflow-wrap: anywhere; }
|
||||
.review-alert-title { display: inline-flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
.weight-overview { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); margin: 0; overflow: hidden; border: 1px solid var(--cc-border); border-radius: 6px; background: var(--cc-surface); }
|
||||
.weight-overview > div { display: grid; align-content: center; min-width: 0; min-height: 82px; gap: 7px; padding: 14px 18px; border-right: 1px solid var(--cc-border); }
|
||||
.weight-overview > div:last-child { border-right: 0; }.weight-overview dt { color: var(--cc-muted); font-size: 12px; }.weight-overview dd { min-width: 0; margin: 0; color: var(--cc-text); font-size: 17px; font-weight: 700; overflow-wrap: anywhere; }.weight-overview__primary { background: #f0f7ff; }.weight-overview__primary dd { color: var(--cc-primary); font-size: 24px; }
|
||||
.review-reason { display: flex; gap: 8px; align-items: flex-start; padding: 12px 14px; border: 1px solid #f3d19e; border-radius: 8px; background: #fdf6ec; color: #8a5a00; font-size: 13px; line-height: 1.55; }
|
||||
.review-section { display: grid; gap: 13px; padding-top: 2px; }.section-heading { display: flex; align-items: flex-end; justify-content: space-between; gap: 12px; }.section-heading h3 { display: flex; align-items: center; gap: 7px; margin: 0 0 4px; font-size: 16px; }.section-count { color: var(--cc-muted); font-size: 12px; white-space: nowrap; }
|
||||
.weight-evidence-section { min-width: 0; }
|
||||
.weight-evidence-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 14px; }
|
||||
.weight-evidence-card { min-width: 0; overflow: hidden; border: 1px solid var(--cc-border); border-radius: 6px; background: #fff; }
|
||||
.weight-evidence-card__image { display: grid; aspect-ratio: 4 / 3; overflow: hidden; place-items: center; background: #f2f4f7; }
|
||||
.weight-evidence-card__image :deep(.weight-thumb) { height: 100%; border-radius: 0; }
|
||||
.weight-evidence-card__weight { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 12px 14px; }
|
||||
.weight-evidence-card__weight span { color: var(--cc-muted); font-size: 12px; }
|
||||
.weight-evidence-card__weight strong { color: #067647; font-size: 20px; overflow-wrap: anywhere; text-align: right; }
|
||||
.weight-evidence-card__record { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 6px 12px; padding: 0 14px 12px; color: var(--cc-muted); font-size: 12px; }
|
||||
.missing-evidence { color: var(--cc-muted); font-size: 12px; }
|
||||
.weight-preview { display: grid; gap: 14px; }.weight-preview__canvas { display: grid; min-height: 320px; max-height: 62vh; overflow: hidden; place-items: center; border: 1px solid var(--cc-border); border-radius: 6px; background: #f2f4f7; }.weight-preview__canvas img { display: block; max-width: 100%; max-height: 62vh; object-fit: contain; }.weight-preview__canvas .el-alert { width: min(520px, calc(100% - 24px)); }.weight-preview__facts { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); margin: 0; overflow: hidden; border: 1px solid var(--cc-border); border-radius: 6px; }.weight-preview__facts > div { display: grid; min-width: 0; gap: 5px; padding: 12px 14px; border-right: 1px solid var(--cc-border); }.weight-preview__facts > div:last-child { border-right: 0; }.weight-preview__facts dt { color: var(--cc-muted); font-size: 12px; }.weight-preview__facts dd { min-width: 0; margin: 0; font-weight: 700; overflow-wrap: anywhere; }
|
||||
.review-actions { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 15px 16px; border: 1px solid #b3d8ff; border-radius: 10px; background: #ecf5ff; }.review-actions > div:first-child { display: grid; gap: 4px; min-width: 0; }.review-actions span { color: #606266; font-size: 12px; line-height: 1.5; }.review-actions--disabled { border-color: var(--cc-border); background: var(--cc-surface-muted); }.action-buttons { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; }
|
||||
.history-list { display: grid; gap: 0; margin: 0; padding: 0; list-style: none; }.history-list li { display: grid; grid-template-columns: 16px minmax(0, 1fr); gap: 10px; position: relative; padding-bottom: 16px; }.history-list li:not(:last-child)::after { content: ''; position: absolute; top: 16px; bottom: 0; left: 7px; width: 1px; background: var(--cc-border); }.history-marker { z-index: 1; width: 14px; height: 14px; margin-top: 2px; border: 3px solid #909399; border-radius: 50%; background: var(--cc-surface); }.history-marker--success { border-color: #67c23a; }.history-marker--danger { border-color: #f56c6c; }.history-content { min-width: 0; }.history-content > div { display: flex; justify-content: space-between; gap: 12px; }.history-content time, .history-content > span { color: var(--cc-muted); font-size: 11px; }.history-content p { margin: 5px 0 4px; color: var(--cc-text); font-size: 13px; overflow-wrap: anywhere; }
|
||||
.review-history { border-top: 1px solid var(--cc-border); border-bottom: 0; }
|
||||
.review-history :deep(.el-collapse-item__header) { height: 42px; color: var(--cc-muted); font-size: 12px; }
|
||||
.review-history :deep(.el-collapse-item__wrap) { border-bottom: 0; }
|
||||
.el-button { min-height: 36px; }.el-button :deep(svg) { flex: 0 0 auto; }.review-panel :deep(.el-dialog__body) { display: grid; gap: 14px; }
|
||||
@media (max-width: 720px) { .review-header, .section-heading, .review-actions { align-items: flex-start; flex-direction: column; }.review-header-status, .action-buttons { width: 100%; justify-content: space-between; }.weight-overview { grid-template-columns: 1fr; }.weight-overview > div { min-height: 68px; border-right: 0; border-bottom: 1px solid var(--cc-border); }.weight-overview > div:last-child { border-bottom: 0; }.weight-evidence-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }.weight-preview__facts { grid-template-columns: repeat(2, minmax(0, 1fr)); }.weight-preview__facts > div:nth-child(2) { border-right: 0; }.weight-preview__facts > div:nth-child(-n + 2) { border-bottom: 1px solid var(--cc-border); }.review-actions .action-buttons .el-button { flex: 1 1 140px; }.review-actions .action-buttons { justify-content: stretch; }.review-actions .action-buttons :deep(.el-button) { width: 100%; } }
|
||||
@media (max-width: 380px) { .review-header h2 { font-size: 19px; }.review-header p { font-size: 12px; }.weight-overview { grid-template-columns: 1fr; }.weight-overview > div { min-height: 68px; border-right: 0; border-bottom: 1px solid var(--cc-border); }.weight-overview > div:last-child { border-bottom: 0; }.weight-overview__primary dd { font-size: 21px; }.weight-evidence-grid { grid-template-columns: 1fr; }.review-actions { padding: 13px; } }
|
||||
.history-list { display: grid; gap: 0; margin: 0; padding: 0; list-style: none; }
|
||||
.history-list li { display: grid; grid-template-columns: 16px minmax(0, 1fr); gap: 10px; position: relative; padding-bottom: 16px; }
|
||||
.history-list li:not(:last-child)::after { content: ''; position: absolute; top: 16px; bottom: 0; left: 7px; width: 1px; background: var(--cc-border); }
|
||||
.history-marker { z-index: 1; width: 14px; height: 14px; margin-top: 2px; border: 3px solid #909399; border-radius: 50%; background: var(--cc-surface); }
|
||||
.history-marker--success { border-color: #67c23a; }
|
||||
.history-marker--danger { border-color: #f56c6c; }
|
||||
.history-content { min-width: 0; }
|
||||
.history-content > div { display: flex; justify-content: space-between; gap: 12px; }
|
||||
.history-content time, .history-content > span { color: var(--cc-muted); font-size: 11px; }
|
||||
.history-content p { margin: 5px 0 4px; color: var(--cc-text); font-size: 13px; overflow-wrap: anywhere; }
|
||||
@media (max-width: 720px) {
|
||||
.review-toolbar { margin: -14px -14px 0; padding: 12px 14px; }
|
||||
.review-toolbar__main { align-items: stretch; flex-direction: column; }
|
||||
.review-toolbar__actions { display: grid; width: 100%; grid-template-columns: 1fr; justify-content: stretch; }
|
||||
.review-toolbar__actions :deep(.el-button) { width: 100%; min-height: 44px; }
|
||||
}
|
||||
@media (max-width: 360px) {
|
||||
.review-toolbar { margin: -10px -10px 0; padding: 10px; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -4,8 +4,6 @@ import {
|
||||
approveMeasurement,
|
||||
getMeasurementReviewDetail,
|
||||
listMeasurementReviewHistory,
|
||||
rejectMeasurement,
|
||||
releaseRejectedMeasurement,
|
||||
} from './api'
|
||||
|
||||
vi.mock('@/shared/api/http', () => ({
|
||||
@@ -37,11 +35,7 @@ describe('measurement review API', () => {
|
||||
|
||||
await expect(listMeasurementReviewHistory('10001', '90001', 2, 20)).resolves.toMatchObject({ page: 2, total: 3 })
|
||||
await approveMeasurement('10001', '90001', { measurementId: '7', expectedVersion: 4 }, 'approve-key')
|
||||
await rejectMeasurement('10001', '90001', { measurementId: '7', expectedVersion: 4, reason: '箱规照片不清晰' }, 'reject-key')
|
||||
await releaseRejectedMeasurement('10001', '90001', { measurementId: '7', expectedVersion: 5, reason: '客户确认后放行' }, 'release-key')
|
||||
|
||||
expect(http.post).toHaveBeenNthCalledWith(1, '/platform/v1/inbounds/90001/measurements/approve', { measurementId: '7', expectedVersion: 4 }, { params: { tenantId: '10001' }, headers: { 'Idempotency-Key': 'approve-key' } })
|
||||
expect(http.post).toHaveBeenNthCalledWith(2, '/platform/v1/inbounds/90001/measurements/reject', { measurementId: '7', expectedVersion: 4, reason: '箱规照片不清晰' }, { params: { tenantId: '10001' }, headers: { 'Idempotency-Key': 'reject-key' } })
|
||||
expect(http.post).toHaveBeenNthCalledWith(3, '/platform/v1/inbounds/90001/measurements/exception-release', { measurementId: '7', expectedVersion: 5, reason: '客户确认后放行' }, { params: { tenantId: '10001' }, headers: { 'Idempotency-Key': 'release-key' } })
|
||||
expect(http.post).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
import type {
|
||||
ExceptionReleaseRequest,
|
||||
MeasurementApprovalResponse,
|
||||
MeasurementReviewDetail,
|
||||
MeasurementReviewHistoryPage,
|
||||
@@ -70,31 +69,3 @@ export function approveMeasurement(
|
||||
key,
|
||||
)
|
||||
}
|
||||
|
||||
export function rejectMeasurement(
|
||||
tenantId: string,
|
||||
inboundId: string,
|
||||
request: MeasurementReviewRequest,
|
||||
key?: string,
|
||||
) {
|
||||
return postReview<void>(
|
||||
`/platform/v1/inbounds/${inboundId}/measurements/reject`,
|
||||
tenantId,
|
||||
request,
|
||||
key,
|
||||
)
|
||||
}
|
||||
|
||||
export function releaseRejectedMeasurement(
|
||||
tenantId: string,
|
||||
inboundId: string,
|
||||
request: ExceptionReleaseRequest,
|
||||
key?: string,
|
||||
) {
|
||||
return postReview<void>(
|
||||
`/platform/v1/inbounds/${inboundId}/measurements/exception-release`,
|
||||
tenantId,
|
||||
request,
|
||||
key,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -74,6 +74,7 @@ export interface MeasurementReviewDetail {
|
||||
originalPackageCount: number | null
|
||||
actualPackageCount: number | null
|
||||
actualGrossWeightKg: number | null
|
||||
actualNetWeightKg: number | null
|
||||
actualVolumeCbm: number | null
|
||||
volumeWeightKg: number | null
|
||||
actualGoodsName: string | null
|
||||
@@ -116,12 +117,6 @@ export interface MeasurementReviewRequest {
|
||||
reason?: string
|
||||
}
|
||||
|
||||
export interface ExceptionReleaseRequest {
|
||||
measurementId: string
|
||||
expectedVersion: number
|
||||
reason: string
|
||||
}
|
||||
|
||||
export interface MeasurementApprovalResponse {
|
||||
inboundId: string | null
|
||||
measurementId: string | null
|
||||
|
||||
@@ -68,6 +68,7 @@ const workspace: WeightWorkspaceResource = {
|
||||
inboundCode: 'RK-20001',
|
||||
processStatus: 'MEASURING',
|
||||
actualGrossWeightKg: null,
|
||||
actualNetWeightKg: null,
|
||||
weightResults: [pendingWeight],
|
||||
}
|
||||
|
||||
@@ -79,7 +80,7 @@ const allWeightPermissions = [
|
||||
'platform:firstleg:inbound:weight:confirm',
|
||||
]
|
||||
|
||||
function mountPanel(permissions = allWeightPermissions) {
|
||||
function mountPanel(permissions = allWeightPermissions, props: Record<string, unknown> = {}) {
|
||||
const pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
const auth = useAuthStore()
|
||||
@@ -88,7 +89,7 @@ function mountPanel(permissions = allWeightPermissions) {
|
||||
useAccessPreviewStore().initializeAfterLogin()
|
||||
return mount(WeightOcrPanel, {
|
||||
attachTo: document.body,
|
||||
props: { customerId: '10001', inboundId: '20001' },
|
||||
props: { customerId: '10001', inboundId: '20001', ...props },
|
||||
global: { plugins: [pinia, ElementPlus] },
|
||||
})
|
||||
}
|
||||
@@ -109,9 +110,9 @@ function dialogUnit(index: number) {
|
||||
return new DOMWrapper(option)
|
||||
}
|
||||
|
||||
function dialogConfirmButton() {
|
||||
function dialogConfirmButton(text = '确认重量') {
|
||||
const button = [...document.body.querySelectorAll<HTMLButtonElement>('.el-dialog__footer button')]
|
||||
.find((candidate) => candidate.textContent?.trim() === '确认重量')
|
||||
.find((candidate) => candidate.textContent?.trim() === text)
|
||||
if (!button) throw new Error('weight confirmation button is missing')
|
||||
return new DOMWrapper(button)
|
||||
}
|
||||
@@ -242,7 +243,8 @@ describe('WeightOcrPanel confirmation behavior', () => {
|
||||
.mockResolvedValueOnce(workspace)
|
||||
.mockResolvedValueOnce({
|
||||
...workspace,
|
||||
actualGrossWeightKg: 285.5,
|
||||
actualGrossWeightKg: 1787.5,
|
||||
actualNetWeightKg: 1747.5,
|
||||
weightResults: [{ ...pendingWeight, recognitionStatus: 'CONFIRMED', grossWeightKg: 297.5, netWeightKg: 285.5, version: 1 }],
|
||||
})
|
||||
const wrapper = mountPanel()
|
||||
@@ -252,10 +254,53 @@ describe('WeightOcrPanel confirmation behavior', () => {
|
||||
await dialogConfirmButton().trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.emitted('summaryChanged')).toEqual([[285.5]])
|
||||
expect(wrapper.emitted('summaryChanged')).toEqual([[1787.5, 1747.5]])
|
||||
expect(wrapper.emitted('changed')).toHaveLength(1)
|
||||
expect(wrapper.text()).toContain('货物总重量')
|
||||
expect(wrapper.text()).toContain('285.500 KG')
|
||||
expect(wrapper.text()).toContain('实测毛重')
|
||||
expect(wrapper.text()).toContain('货物重量')
|
||||
expect(wrapper.text()).toContain('1787.500 KG')
|
||||
expect(wrapper.text()).toContain('1747.500 KG')
|
||||
})
|
||||
|
||||
it('atomically corrects and confirms an approved weight during measurement review', async () => {
|
||||
const confirmed = {
|
||||
...pendingWeight,
|
||||
recognitionStatus: 'CONFIRMED',
|
||||
adjustedWeightValue: 120,
|
||||
adjustedWeightUnit: 'KG',
|
||||
grossWeightKg: 120,
|
||||
netWeightKg: 108,
|
||||
version: 1,
|
||||
}
|
||||
vi.mocked(getWeightWorkspace).mockResolvedValue({
|
||||
...workspace,
|
||||
processStatus: 'MEASURE_PENDING',
|
||||
actualGrossWeightKg: 120,
|
||||
actualNetWeightKg: 108,
|
||||
weightResults: [confirmed],
|
||||
})
|
||||
vi.mocked(confirmWeightResult).mockResolvedValue({ ...confirmed, grossWeightKg: 125, netWeightKg: 113, version: 2 })
|
||||
const wrapper = mountPanel(allWeightPermissions, { reviewMode: true, correctionMode: true })
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('input[type="file"]').exists()).toBe(false)
|
||||
expect(wrapper.text()).not.toContain('删除图片')
|
||||
await wrapper.findAll('button').find((button) => button.text().includes('修改含托盘毛重'))?.trigger('click')
|
||||
await flushPromises()
|
||||
await dialogWeightInput().setValue('125')
|
||||
const reason = document.body.querySelector<HTMLTextAreaElement>('textarea[placeholder="请填写本次毛重修正原因"]')
|
||||
expect(reason).not.toBeNull()
|
||||
await new DOMWrapper(reason!).setValue('复核磅秤读数')
|
||||
await dialogConfirmButton('保存重量修正').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(confirmWeightResult).toHaveBeenCalledWith('10001', '20001', '30001', {
|
||||
version: 1,
|
||||
adjustedWeightValue: 125,
|
||||
adjustedWeightUnit: 'KG',
|
||||
adjustmentReason: '复核磅秤读数',
|
||||
})
|
||||
expect(correctWeightResult).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('closes the confirmation dialog immediately after a successful confirmation', async () => {
|
||||
|
||||
@@ -6,9 +6,9 @@ const source = readFileSync(resolve(process.cwd(), 'src/modules/inbounds/weight/
|
||||
|
||||
describe('WeightOcrPanel contract', () => {
|
||||
it('uses customer language at the page boundary while retaining a compatibility input', () => {
|
||||
expect(source).toContain("defineProps<{ customerId?: string; tenantId?: string; inboundId: string }>()")
|
||||
expect(source).toContain('reviewMode?: boolean')
|
||||
expect(source).toContain("const customerId = computed(() => props.customerId || props.tenantId || '')")
|
||||
expect(source).toContain('summaryChanged: [actualGrossWeightKg: number | null]')
|
||||
expect(source).toContain('summaryChanged: [actualGrossWeightKg: number | null, actualNetWeightKg: number | null]')
|
||||
expect(source).toContain("defineExpose({ reload: loadWorkspace })")
|
||||
const template = source.match(/<template>([\s\S]*?)<\/template>/)?.[1] || ''
|
||||
expect(template).not.toContain('租户')
|
||||
@@ -29,11 +29,13 @@ describe('WeightOcrPanel contract', () => {
|
||||
expect(source).toContain('OCR 识别毛重')
|
||||
expect(source).toContain('确认含托盘毛重')
|
||||
expect(source).toContain('payload.adjustedWeightValue = Number(draft.value)')
|
||||
expect(source).toContain('payload.adjustmentReason = buildAdjustmentReason(row)')
|
||||
expect(source).toContain('货物总重量')
|
||||
expect(source).toContain("row.recognitionStatus === 'CONFIRMED' && reviewCorrectionEnabled.value")
|
||||
expect(source).toContain('draft.reason.trim()')
|
||||
expect(source).toContain('实测毛重')
|
||||
expect(source).toContain('货物重量')
|
||||
expect(source).toContain('palletTareWeightKg')
|
||||
expect(source).toContain('netWeightKg')
|
||||
expect(source).toContain("emit('summaryChanged', workspace.value?.actualGrossWeightKg ?? null)")
|
||||
expect(source).toContain("emit('summaryChanged', workspace.value?.actualGrossWeightKg ?? null, workspace.value?.actualNetWeightKg ?? null)")
|
||||
})
|
||||
|
||||
it('matches backend image limits and keeps technical OCR data out of the list', () => {
|
||||
@@ -65,6 +67,9 @@ describe('WeightOcrPanel contract', () => {
|
||||
expect(source).toContain("platform:firstleg:inbound:weight:edit")
|
||||
expect(source).toContain("platform:firstleg:inbound:weight:confirm")
|
||||
expect(source).toContain("new Set(['WAIT_MEASURE', 'MEASURING', 'MEASURE_REJECTED'])")
|
||||
expect(source).toContain("workspace.value?.processStatus === 'MEASURE_PENDING'")
|
||||
expect(source).toContain('v-if="canEdit && !reviewMode"')
|
||||
expect(source).toContain('v-if="canEdit && editable && !reviewMode"')
|
||||
})
|
||||
|
||||
it('provides error tracking, empty, loading, and narrow-screen states', () => {
|
||||
|
||||
@@ -26,10 +26,19 @@ import type { WeightPalletOption, WeightResultResource, WeightWorkspaceResource
|
||||
import WeightImageThumbnail from './WeightImageThumbnail.vue'
|
||||
import { inboundProcessStatusLabels, inboundStatusLabel } from '../../status'
|
||||
|
||||
const props = defineProps<{ customerId?: string; tenantId?: string; inboundId: string }>()
|
||||
const props = withDefaults(defineProps<{
|
||||
customerId?: string
|
||||
tenantId?: string
|
||||
inboundId: string
|
||||
reviewMode?: boolean
|
||||
correctionMode?: boolean
|
||||
}>(), {
|
||||
reviewMode: false,
|
||||
correctionMode: false,
|
||||
})
|
||||
const emit = defineEmits<{
|
||||
changed: []
|
||||
summaryChanged: [actualGrossWeightKg: number | null]
|
||||
summaryChanged: [actualGrossWeightKg: number | null, actualNetWeightKg: number | null]
|
||||
}>()
|
||||
|
||||
const access = useAccessPreviewStore()
|
||||
@@ -49,7 +58,7 @@ const previewError = ref<ApiErrorDetails | null>(null)
|
||||
const previewUrl = ref('')
|
||||
const previewResultId = ref('')
|
||||
const previewSnapshot = ref<WeightResultResource | null>(null)
|
||||
const weightDrafts = ref<Record<string, { value: number | null; unit: 'KG' | 'LB' }>>({})
|
||||
const weightDrafts = ref<Record<string, { value: number | null; unit: 'KG' | 'LB'; reason: string }>>({})
|
||||
let loadSequence = 0
|
||||
|
||||
const editableStatuses = new Set(['WAIT_MEASURE', 'MEASURING', 'MEASURE_REJECTED'])
|
||||
@@ -60,6 +69,11 @@ const canEdit = computed(() => access.can('platform:firstleg:inbound:weight:edit
|
||||
const canConfirm = computed(() => access.can('platform:firstleg:inbound:weight:confirm'))
|
||||
const canView = computed(() => access.can('platform:firstleg:inbound:measure:view'))
|
||||
const editable = computed(() => Boolean(workspace.value && editableStatuses.has(workspace.value.processStatus)))
|
||||
const reviewCorrectionEnabled = computed(() => Boolean(
|
||||
props.reviewMode
|
||||
&& props.correctionMode
|
||||
&& workspace.value?.processStatus === 'MEASURE_PENDING',
|
||||
))
|
||||
const rows = computed(() => workspace.value?.weightResults ?? [])
|
||||
const previewResult = computed(() => rows.value.find((row) => row.id === previewResultId.value)
|
||||
|| previewSnapshot.value)
|
||||
@@ -67,8 +81,17 @@ const previewConfirmDisabled = computed(() => {
|
||||
const row = previewResult.value
|
||||
return !row
|
||||
|| !isWeightDraftValid(row)
|
||||
|| (row.recognitionStatus === 'CONFIRMED' && reviewCorrectionEnabled.value
|
||||
&& (!hasWeightDraftChanged(row) || !weightDrafts.value[row.id]?.reason.trim()))
|
||||
|| Boolean(activeAction.value)
|
||||
})
|
||||
const previewCanConfirm = computed(() => Boolean(previewResult.value && canConfirm.value
|
||||
&& (editable.value || (reviewCorrectionEnabled.value && canEdit.value))))
|
||||
const previewInputEditable = computed(() => Boolean(previewResult.value && canEdit.value && canConfirm.value
|
||||
&& (editable.value || reviewCorrectionEnabled.value)))
|
||||
const previewConfirmText = computed(() => previewResult.value?.recognitionStatus === 'CONFIRMED'
|
||||
? '保存重量修正'
|
||||
: '确认重量')
|
||||
|
||||
function formatWeight(value: number | null, unit = 'KG') {
|
||||
return value == null ? '—' : `${Number(value).toFixed(3)} ${unit}`
|
||||
@@ -98,6 +121,7 @@ function syncWeightDrafts(results: WeightResultResource[]) {
|
||||
weightDrafts.value = Object.fromEntries(results.map((row) => [row.id, {
|
||||
value: row.adjustedWeightValue ?? row.ocrWeightValue,
|
||||
unit: normalizeUnit(row.adjustedWeightUnit ?? row.ocrWeightUnit),
|
||||
reason: '',
|
||||
}]))
|
||||
}
|
||||
|
||||
@@ -105,6 +129,7 @@ function ensureWeightDraft(row: WeightResultResource) {
|
||||
weightDrafts.value[row.id] ??= {
|
||||
value: row.adjustedWeightValue ?? row.ocrWeightValue,
|
||||
unit: normalizeUnit(row.adjustedWeightUnit ?? row.ocrWeightUnit),
|
||||
reason: '',
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,7 +185,7 @@ function buildAdjustmentReason(row: WeightResultResource) {
|
||||
}
|
||||
|
||||
function emitWorkspaceChanged() {
|
||||
emit('summaryChanged', workspace.value?.actualGrossWeightKg ?? null)
|
||||
emit('summaryChanged', workspace.value?.actualGrossWeightKg ?? null, workspace.value?.actualNetWeightKg ?? null)
|
||||
emit('changed')
|
||||
}
|
||||
|
||||
@@ -188,6 +213,12 @@ async function loadWorkspace() {
|
||||
}
|
||||
|
||||
async function loadPalletOptions() {
|
||||
if (props.reviewMode) {
|
||||
palletOptions.value = []
|
||||
selectedPalletId.value = ''
|
||||
palletError.value = null
|
||||
return
|
||||
}
|
||||
if (!customerId.value || !props.inboundId || !canView.value) {
|
||||
palletOptions.value = []
|
||||
selectedPalletId.value = ''
|
||||
@@ -282,11 +313,13 @@ async function confirmResult(row: WeightResultResource, closeAfterSuccess = fals
|
||||
}
|
||||
payload.adjustedWeightValue = Number(draft.value)
|
||||
payload.adjustedWeightUnit = draft.unit
|
||||
payload.adjustmentReason = buildAdjustmentReason(row)
|
||||
payload.adjustmentReason = row.recognitionStatus === 'CONFIRMED' && reviewCorrectionEnabled.value
|
||||
? draft.reason.trim()
|
||||
: buildAdjustmentReason(row)
|
||||
}
|
||||
await confirmWeightResult(customerId.value, props.inboundId, row.id, payload)
|
||||
if (closeAfterSuccess) previewOpen.value = false
|
||||
ElMessage.success('重量已确认')
|
||||
ElMessage.success(row.recognitionStatus === 'CONFIRMED' ? '含托盘毛重已修正,货物重量和合计已由服务端重算' : '重量已确认')
|
||||
await loadWorkspace()
|
||||
emitWorkspaceChanged()
|
||||
return true
|
||||
@@ -387,12 +420,13 @@ defineExpose({ reload: loadWorkspace })
|
||||
<h3 id="weight-ocr-title"><Scale :size="18" aria-hidden="true" />称重图片</h3>
|
||||
</div>
|
||||
<div class="weight-heading__actions">
|
||||
<div v-if="workspace" class="weight-summary" aria-live="polite">
|
||||
<span>货物总重量</span>
|
||||
<strong>{{ formatWeight(workspace.actualGrossWeightKg) }}</strong>
|
||||
<div v-if="workspace" class="weight-summary-group" aria-live="polite">
|
||||
<div class="weight-summary"><span>实测毛重</span><strong>{{ formatWeight(workspace.actualGrossWeightKg) }}</strong></div>
|
||||
<div class="weight-summary"><span>货物重量</span><strong>{{ formatWeight(workspace.actualNetWeightKg) }}</strong></div>
|
||||
</div>
|
||||
<div class="pallet-picker">
|
||||
<el-select
|
||||
v-if="!reviewMode"
|
||||
v-model="selectedPalletId"
|
||||
aria-label="选择称重托盘"
|
||||
placeholder="选择称重托盘"
|
||||
@@ -409,6 +443,7 @@ defineExpose({ reload: loadWorkspace })
|
||||
</el-select>
|
||||
</div>
|
||||
<input
|
||||
v-if="!reviewMode"
|
||||
ref="fileInput"
|
||||
class="visually-hidden-input"
|
||||
type="file"
|
||||
@@ -417,7 +452,7 @@ defineExpose({ reload: loadWorkspace })
|
||||
@change="handleFileChange"
|
||||
/>
|
||||
<el-button
|
||||
v-if="canEdit"
|
||||
v-if="canEdit && !reviewMode"
|
||||
type="primary"
|
||||
:loading="uploading"
|
||||
:disabled="loading || palletLoading || !editable || !selectedPalletId || Boolean(palletError)"
|
||||
@@ -464,7 +499,7 @@ defineExpose({ reload: loadWorkspace })
|
||||
>
|
||||
<el-button size="small" @click="loadPalletOptions"><RefreshCw :size="14" />重新加载</el-button>
|
||||
</el-alert>
|
||||
<el-alert v-if="workspace && !editable" type="info" :closable="false" show-icon class="weight-state-note">
|
||||
<el-alert v-if="workspace && !editable && !reviewCorrectionEnabled" type="info" :closable="false" show-icon class="weight-state-note">
|
||||
当前入库状态为 {{ inboundStatusLabel(workspace.processStatus, inboundProcessStatusLabels, '未知状态') }},称重数据仅供查看。
|
||||
</el-alert>
|
||||
|
||||
@@ -482,7 +517,7 @@ defineExpose({ reload: loadWorkspace })
|
||||
:alt="`称重图片 ${index + 1}`"
|
||||
@open="openPreview(row)"
|
||||
/>
|
||||
<el-tooltip v-if="canEdit && editable" content="删除图片" placement="top">
|
||||
<el-tooltip v-if="canEdit && editable && !reviewMode" content="删除图片" placement="top">
|
||||
<el-button
|
||||
plain
|
||||
type="danger"
|
||||
@@ -506,6 +541,13 @@ defineExpose({ reload: loadWorkspace })
|
||||
<div><dt>托盘皮重</dt><dd>{{ formatWeight(row.palletTareWeightKg) }}</dd></div>
|
||||
<div><dt>货物重量</dt><dd>{{ formatWeight(row.netWeightKg) }}</dd></div>
|
||||
</dl>
|
||||
<el-button
|
||||
v-if="reviewCorrectionEnabled && row.recognitionStatus === 'CONFIRMED' && canEdit && canConfirm"
|
||||
class="weight-card__correct"
|
||||
type="primary"
|
||||
plain
|
||||
@click="openPreview(row)"
|
||||
>修改含托盘毛重</el-button>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
@@ -519,8 +561,8 @@ defineExpose({ reload: loadWorkspace })
|
||||
:loading="previewLoading"
|
||||
:confirm-loading="Boolean(previewResult && isActionLoading('confirm', previewResult.id))"
|
||||
:confirm-disabled="previewConfirmDisabled"
|
||||
:show-confirm="Boolean(previewResult && previewResult.recognitionStatus !== 'CONFIRMED' && canConfirm && editable)"
|
||||
confirm-text="确认重量"
|
||||
:show-confirm="Boolean(previewResult && previewCanConfirm && (previewResult.recognitionStatus !== 'CONFIRMED' || reviewCorrectionEnabled))"
|
||||
:confirm-text="previewConfirmText"
|
||||
confirm-type="success"
|
||||
width="min(640px, calc(100vw - 28px))"
|
||||
@confirm="previewResult && confirmResult(previewResult, true)"
|
||||
@@ -551,16 +593,20 @@ defineExpose({ reload: loadWorkspace })
|
||||
:max="maximumSafeWeight"
|
||||
:precision="3"
|
||||
:controls="false"
|
||||
:disabled="previewResult.recognitionStatus === 'CONFIRMED' || Boolean(activeAction) || !editable || !canEdit || !canConfirm"
|
||||
:disabled="Boolean(activeAction) || !previewInputEditable"
|
||||
/>
|
||||
</label>
|
||||
<el-segmented
|
||||
v-model="weightDrafts[previewResult.id].unit"
|
||||
aria-label="当前图片重量单位"
|
||||
:options="['KG', 'LB']"
|
||||
:disabled="previewResult.recognitionStatus === 'CONFIRMED' || Boolean(activeAction) || !editable || !canEdit || !canConfirm"
|
||||
:disabled="Boolean(activeAction) || !previewInputEditable"
|
||||
/>
|
||||
</div>
|
||||
<label v-if="reviewCorrectionEnabled && previewResult.recognitionStatus === 'CONFIRMED' && weightDrafts[previewResult.id]" class="correction-reason">
|
||||
<span>修改原因 *</span>
|
||||
<el-input v-model="weightDrafts[previewResult.id].reason" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="请填写本次毛重修正原因" />
|
||||
</label>
|
||||
</template>
|
||||
</AppDialog>
|
||||
</section>
|
||||
@@ -575,6 +621,7 @@ defineExpose({ reload: loadWorkspace })
|
||||
.pallet-picker { width: clamp(360px, 36vw, 520px); min-width: 0; }
|
||||
.pallet-picker :deep(.el-select) { width: 100%; }
|
||||
.weight-summary { display: grid; gap: 2px; text-align: right; }
|
||||
.weight-summary-group { display: flex; align-items: center; gap: 18px; }
|
||||
.weight-summary span { color: var(--cc-muted); font-size: 12px; }
|
||||
.weight-summary strong { color: #067647; font-size: 18px; }
|
||||
.visually-hidden-input { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap; }
|
||||
@@ -593,6 +640,7 @@ defineExpose({ reload: loadWorkspace })
|
||||
.weight-card__facts div { display: flex; justify-content: space-between; gap: 8px; font-size: 11px; line-height: 1.4; }
|
||||
.weight-card__facts dt { color: var(--cc-muted); }
|
||||
.weight-card__facts dd { min-width: 0; margin: 0; color: #344054; overflow-wrap: anywhere; text-align: right; }
|
||||
.weight-card__correct { width: 100%; margin: 12px 0 0; }
|
||||
.weight-preview { display: block; width: 100%; max-height: min(50vh, 460px); object-fit: contain; border-radius: 6px; background: #f2f4f7; }
|
||||
.weight-key-facts { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 20px; margin-top: 14px; padding: 14px 0; border-block: 1px solid var(--cc-border); }
|
||||
.weight-key-facts div { display: grid; min-width: 0; gap: 4px; }
|
||||
@@ -601,6 +649,7 @@ defineExpose({ reload: loadWorkspace })
|
||||
.preview-weight { display: grid; grid-template-columns: minmax(0, 1fr) 120px; align-items: end; gap: 12px; margin-top: 14px; }
|
||||
.preview-weight label { display: grid; gap: 6px; color: #475467; font-size: 12px; }
|
||||
.preview-weight :deep(.el-input-number), .preview-weight :deep(.el-segmented) { width: 100%; }
|
||||
.correction-reason { display: grid; gap: 6px; margin-top: 14px; color: #475467; font-size: 12px; }
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.weight-heading { flex-direction: column; }
|
||||
@@ -609,6 +658,7 @@ defineExpose({ reload: loadWorkspace })
|
||||
.pallet-picker :deep(.el-select) { width: 100%; }
|
||||
.weight-heading__actions :deep(.el-button) { width: 100%; min-height: 44px; }
|
||||
.weight-summary { text-align: left; }
|
||||
.weight-summary-group { width: 100%; justify-content: space-between; }
|
||||
.weight-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
}
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ export interface WeightWorkspaceResource {
|
||||
inboundCode: string
|
||||
processStatus: string
|
||||
actualGrossWeightKg: number | null
|
||||
actualNetWeightKg: number | null
|
||||
weightResults: WeightResultResource[]
|
||||
}
|
||||
|
||||
|
||||
@@ -98,6 +98,23 @@ describe('unified orders API contract', () => {
|
||||
expect(http.post).toHaveBeenCalledWith('/platform/v1/orders/first-leg/90001/approve', undefined, { params: { tenantId: '208' } })
|
||||
})
|
||||
|
||||
it('keeps split rows bound to their own current-container schedule and preserves effective-time semantics', async () => {
|
||||
vi.mocked(http.get).mockResolvedValue({ data: {
|
||||
records: [
|
||||
{ orderId: '90001', tenantId: '208', inboundId: '701', mainStatus: 'LOADED', relation: { role: 'PARENT', rootOrderId: '90001', parentOrderId: null, splitSequence: null, childCount: 1 }, transport: { cabinetNo: 'OLD-CAB', estimatedSailingAt: '2026-08-20T08:00:00', actualSailingAt: '2026-08-21T09:00:00', effectiveSailingAt: '2026-08-21T09:00:00', effectiveSailingAtEstimated: false, estimatedPortArrivalAt: '2026-09-01T08:00:00', actualPortArrivalAt: null, effectivePortArrivalAt: '2026-09-01T08:00:00', effectivePortArrivalAtEstimated: true } },
|
||||
{ orderId: '90002', tenantId: '208', inboundId: '702', mainStatus: 'LOADED', relation: { role: 'CHILD', rootOrderId: '90001', parentOrderId: '90001', splitSequence: 1, childCount: 0 }, transport: { cabinetNo: 'NEW-CAB', estimatedSailingAt: '2026-08-25T08:00:00', actualSailingAt: null, effectiveSailingAt: '2026-08-25T08:00:00', effectiveSailingAtEstimated: true, estimatedPortArrivalAt: null, actualPortArrivalAt: null, effectivePortArrivalAt: null, effectivePortArrivalAtEstimated: null } },
|
||||
],
|
||||
page: 1, size: 20, total: 2, pages: 1,
|
||||
} })
|
||||
|
||||
const result = await listPlatformUnifiedOrders({ page: 1, size: 20 })
|
||||
|
||||
expect(result.records.map((row) => ({ inboundId: row.inboundId, transport: row.transport }))).toMatchObject([
|
||||
{ inboundId: '701', transport: { cabinetNo: 'OLD-CAB', effectiveSailingAt: '2026-08-21T09:00:00', effectiveSailingAtEstimated: false, effectivePortArrivalAt: '2026-09-01T08:00:00', effectivePortArrivalAtEstimated: true } },
|
||||
{ inboundId: '702', transport: { cabinetNo: 'NEW-CAB', effectiveSailingAt: '2026-08-25T08:00:00', effectiveSailingAtEstimated: true, effectivePortArrivalAt: null, effectivePortArrivalAtEstimated: null } },
|
||||
])
|
||||
})
|
||||
|
||||
it('uses the tenant-scoped batch approval contract and preserves long IDs', async () => {
|
||||
vi.mocked(http.post).mockResolvedValue({ data: {
|
||||
total: 2, successCount: 1, failureCount: 1,
|
||||
|
||||
@@ -57,8 +57,14 @@ export interface UnifiedOrderTransportSummary {
|
||||
cabinetNo: string | null
|
||||
vesselName: string | null
|
||||
voyageNo: string | null
|
||||
sailingAt: string | null
|
||||
portArrivalAt: string | null
|
||||
estimatedSailingAt: string | null
|
||||
actualSailingAt: string | null
|
||||
effectiveSailingAt: string | null
|
||||
effectiveSailingAtEstimated: boolean | null
|
||||
estimatedPortArrivalAt: string | null
|
||||
actualPortArrivalAt: string | null
|
||||
effectivePortArrivalAt: string | null
|
||||
effectivePortArrivalAtEstimated: boolean | null
|
||||
}
|
||||
|
||||
export interface UnifiedOrderRelationSummary {
|
||||
|
||||
@@ -100,7 +100,7 @@ describe('UnifiedOrdersView contract', () => {
|
||||
'actualVolumeCbm', 'totalNetWeightKg', 'chargeableWeightKg', 'chargeableVolumeCbm', 'expectedShipDate',
|
||||
'expectedArrivalAt', 'feeReviewStatus', 'skuNos', 'totalQuantity', 'expressCompany', 'expressTrackingNo',
|
||||
'shipmentId', 'referenceId', 'containerNo', 'cabinetNo',
|
||||
'sailingAt', 'portArrivalAt', 'customerRemark', 'latestInternalNote', 'createdAt', 'updatedAt',
|
||||
'effectiveSailingAt', 'effectivePortArrivalAt', 'customerRemark', 'latestInternalNote', 'createdAt', 'updatedAt',
|
||||
]
|
||||
for (const key of expectedColumnKeys) expect(columnSource).toContain(`key: '${key}'`)
|
||||
for (const uncontractedKey of [
|
||||
@@ -115,10 +115,18 @@ describe('UnifiedOrdersView contract', () => {
|
||||
expect(columnSource).not.toContain("label: '配载状态'")
|
||||
expect(columnSource).not.toContain("key: 'recommendedPalletType'")
|
||||
expect(columnSource).toContain("key: 'requestedPickupAt', prop: 'requestedPickupAt', label: '预约提货时间'")
|
||||
expect(columnSource.match(/defaultVisible: false/g)).toHaveLength(16)
|
||||
expect(columnSource.match(/defaultVisible: false/g)).toHaveLength(14)
|
||||
expect(columnSource).toContain("key: 'effectiveSailingAt', prop: 'effectiveSailingAt', label: '开船时间', minWidth: 180, slotName: 'field' }")
|
||||
expect(columnSource).toContain("key: 'effectivePortArrivalAt', prop: 'effectivePortArrivalAt', label: '到港时间', minWidth: 180, slotName: 'field' }")
|
||||
expect(source).toContain('preference-key="unified-orders-table-v2"')
|
||||
expect(source).toContain("property === 'requestedPickupAt' && (row.pickupMethod !== 'PICKUP' || !value)")
|
||||
expect(source).toContain("property === 'skuNos' ? value.join('、')")
|
||||
expect(source).not.toContain('<dt>推荐托盘类型</dt>')
|
||||
expect(source).toContain("estimated === true ? '预计 ' : ''")
|
||||
expect(source).toContain('row.transport?.effectiveSailingAtEstimated')
|
||||
expect(source).toContain('row.transport?.effectivePortArrivalAtEstimated')
|
||||
expect(source).not.toContain("transport.sailingAt")
|
||||
expect(source).not.toContain("transport.portArrivalAt")
|
||||
expect(source).toContain('.table-card { overflow: hidden; }')
|
||||
expect(source).toContain('slotName: \'measurementReviewStatus\'')
|
||||
expect(source).toContain('unifiedOrderReviewStatusLabel(value, \'未录入\')')
|
||||
|
||||
@@ -266,8 +266,8 @@ const columns = computed<DataTableColumn[]>(() => [
|
||||
{ key: 'referenceId', prop: 'referenceId', label: 'Reference ID', minWidth: 160, slotName: 'field', defaultVisible: false },
|
||||
{ key: 'containerNo', prop: 'containerNo', label: '真实集装箱号', minWidth: 160, slotName: 'field', defaultVisible: false },
|
||||
{ key: 'cabinetNo', prop: 'cabinetNo', label: '内部柜次号', minWidth: 150, slotName: 'field', defaultVisible: false },
|
||||
{ key: 'sailingAt', prop: 'sailingAt', label: '开船时间', minWidth: 150, slotName: 'field', defaultVisible: false },
|
||||
{ key: 'portArrivalAt', prop: 'portArrivalAt', label: '到港时间', minWidth: 150, slotName: 'field', defaultVisible: false },
|
||||
{ key: 'effectiveSailingAt', prop: 'effectiveSailingAt', label: '开船时间', minWidth: 180, slotName: 'field' },
|
||||
{ key: 'effectivePortArrivalAt', prop: 'effectivePortArrivalAt', label: '到港时间', minWidth: 180, slotName: 'field' },
|
||||
{ key: 'customerRemark', prop: 'customerRemark', label: '客户备注', minWidth: 180, slotName: 'field', defaultVisible: false },
|
||||
{ key: 'latestInternalNote', prop: 'latestInternalNote', label: '内部备注', minWidth: 180, slotName: 'field', defaultVisible: false },
|
||||
{ key: 'createdAt', prop: 'createdAt', label: '创建时间', minWidth: 150, slotName: 'field', defaultVisible: false },
|
||||
@@ -370,13 +370,18 @@ function displayBoolean(value: boolean | null | undefined) {
|
||||
return value === null || value === undefined ? '—' : value ? '是' : '否'
|
||||
}
|
||||
|
||||
function formatEffectiveTransportTime(value: string | null | undefined, estimated: boolean | null | undefined) {
|
||||
if (!value) return '—'
|
||||
return `${estimated === true ? '预计 ' : ''}${formatDate(value)}`
|
||||
}
|
||||
|
||||
function displayField(row: UnifiedOrderResource, property: string | undefined) {
|
||||
if (!property) return '—'
|
||||
const source = row as unknown as Record<string, unknown>
|
||||
const nested: Record<string, string> = {
|
||||
measurementReviewStatus: 'measurement.reviewStatus', expectedArrivalAt: 'expectedArrivalAt',
|
||||
containerNo: 'transport.containerNo', cabinetNo: 'transport.cabinetNo',
|
||||
sailingAt: 'transport.sailingAt', portArrivalAt: 'transport.portArrivalAt',
|
||||
effectiveSailingAt: 'transport.effectiveSailingAt', effectivePortArrivalAt: 'transport.effectivePortArrivalAt',
|
||||
chargeableWeightKg: 'measurement.chargeableWeightKg', measurementCompletedAt: 'measurement.completedAt',
|
||||
expressTrackingNo: 'delivery.trackingNo',
|
||||
}
|
||||
@@ -386,6 +391,8 @@ function displayField(row: UnifiedOrderResource, property: string | undefined) {
|
||||
), source)
|
||||
if ((value === null || value === undefined || value === '') && property === 'internalOrderStatus') value = source.mainStatus
|
||||
if (property === 'requestedPickupAt' && (row.pickupMethod !== 'PICKUP' || !value)) return '—'
|
||||
if (property === 'effectiveSailingAt') return formatEffectiveTransportTime(row.transport?.effectiveSailingAt, row.transport?.effectiveSailingAtEstimated)
|
||||
if (property === 'effectivePortArrivalAt') return formatEffectiveTransportTime(row.transport?.effectivePortArrivalAt, row.transport?.effectivePortArrivalAtEstimated)
|
||||
if (value === null || value === undefined || value === '') return '—'
|
||||
if (typeof value === 'boolean') return value ? '是' : '否'
|
||||
if (Array.isArray(value)) return value.length ? (property === 'skuNos' ? value.join('、') : `${value.length} 个文件`) : '—'
|
||||
@@ -942,8 +949,8 @@ onBeforeUnmount(() => {
|
||||
<div><dt>真实集装箱号</dt><dd>{{ detail.summary.transport?.containerNo || '—' }}</dd></div>
|
||||
<div><dt>内部柜次号</dt><dd>{{ detail.summary.transport?.cabinetNo || '—' }}</dd></div>
|
||||
<div><dt>船名 / 航次</dt><dd>{{ detail.summary.transport?.vesselName || '—' }} / {{ detail.summary.transport?.voyageNo || '—' }}</dd></div>
|
||||
<div><dt>开船时间</dt><dd>{{ formatDate(detail.summary.transport?.sailingAt) }}</dd></div>
|
||||
<div><dt>到港时间</dt><dd>{{ formatDate(detail.summary.transport?.portArrivalAt) }}</dd></div>
|
||||
<div><dt>开船时间</dt><dd>{{ formatEffectiveTransportTime(detail.summary.transport?.effectiveSailingAt, detail.summary.transport?.effectiveSailingAtEstimated) }}</dd></div>
|
||||
<div><dt>到港时间</dt><dd>{{ formatEffectiveTransportTime(detail.summary.transport?.effectivePortArrivalAt, detail.summary.transport?.effectivePortArrivalAtEstimated) }}</dd></div>
|
||||
<div><dt>配载状态</dt><dd>{{ unifiedOrderLoadingStatusLabel(detail.summary.transport?.loadingStatus) }}</dd></div>
|
||||
<div><dt>目的仓类型</dt><dd>{{ firstLegDestinationTypeLabel(detail.order.order?.destinationType) }}</dd></div>
|
||||
<div><dt>是否整柜</dt><dd>{{ firstLegContainerModeLabel(detail.order.order?.containerMode) }}</dd></div>
|
||||
@@ -1168,7 +1175,7 @@ onBeforeUnmount(() => {
|
||||
|
||||
<template #default>
|
||||
<div v-if="!loadError" id="unified-orders-results" class="surface-panel table-panel table-card" role="tabpanel">
|
||||
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="records" :columns="columns" preference-key="unified-orders-table" :loading="loading" :total="total" :page-sizes="[20, 50, 100]" row-key="orderId" border stripe empty-text="暂无符合条件的订单" aria-label="统一订单列表" @selection-change="handleApprovalSelectionChange">
|
||||
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="records" :columns="columns" preference-key="unified-orders-table-v2" :loading="loading" :total="total" :page-sizes="[20, 50, 100]" row-key="orderId" border stripe empty-text="暂无符合条件的订单" aria-label="统一订单列表" @selection-change="handleApprovalSelectionChange">
|
||||
<template #toolbar-actions>
|
||||
<el-button v-if="can('platform:order:approve')" type="success" :loading="batchApprovalSaving" :disabled="loading || !selectedApprovalOrders.length" @click="batchApproveOrders"><CircleCheck :size="16" />批量审核通过<span v-if="selectedApprovalOrders.length">({{ selectedApprovalOrders.length }})</span></el-button>
|
||||
<el-button v-if="can('platform:firstleg:smart-loading:list')" type="primary" @click="smartLoadingOpen = true"><PackageOpen :size="16" />智能装柜</el-button>
|
||||
|
||||
Reference in New Issue
Block a user