style(admin): unify dialog layouts and dense readonly presentation
This commit is contained in:
@@ -8,10 +8,12 @@ import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { useAuthStore } from '@/core/auth/auth'
|
||||
import { adjustContainerFee, createContainerFee, getContainerSettlementDetails, listApplicableFeeItems, listContainerFeeSupplierOptions, listContainerFees, uploadContainerFeePaymentVoucher } from '../api/containerFees'
|
||||
import ContainerFeesPanel from './ContainerFeesPanel.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import { createContainerFeeAllocationGroup } from '../api/containerFees'
|
||||
|
||||
vi.mock('../api/containerFees', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../api/containerFees')>()
|
||||
return { ...actual, listContainerFees: vi.fn(), listApplicableFeeItems: vi.fn(), listContainerFeeSupplierOptions: vi.fn(), getContainerSettlementDetails: vi.fn(), createContainerFee: vi.fn(), adjustContainerFee: vi.fn(), uploadContainerFeePaymentVoucher: vi.fn(), approveContainerFee: vi.fn(), rejectContainerFee: vi.fn(), voidContainerFee: vi.fn() }
|
||||
return { ...actual, listContainerFees: vi.fn(), listApplicableFeeItems: vi.fn(), listContainerFeeSupplierOptions: vi.fn(), getContainerSettlementDetails: vi.fn(), createContainerFee: vi.fn(), createContainerFeeAllocationGroup: vi.fn(), adjustContainerFee: vi.fn(), uploadContainerFeePaymentVoucher: vi.fn(), approveContainerFee: vi.fn(), rejectContainerFee: vi.fn(), voidContainerFee: vi.fn() }
|
||||
})
|
||||
|
||||
const panel = readFileSync(resolve(process.cwd(), 'src/modules/loading-lists/components/ContainerFeesPanel.vue'), 'utf8')
|
||||
@@ -24,7 +26,7 @@ async function mountPanel(permissions: string[], customers = [{ accountId: '11',
|
||||
const pinia = createPinia(); setActivePinia(pinia)
|
||||
const auth = useAuthStore(); auth.token = 'token'; auth.permissions = permissions
|
||||
useAccessPreviewStore().initializeAfterLogin()
|
||||
const wrapper = mount(ContainerFeesPanel, { attachTo: document.body, props: { planId: '9', containerBookingId, customers, businessReferences: [{ accountId: '11', customerName: '客户A', orderId: '501', orderNo: 'FL-501', inboundId: '601', inboundCode: 'NYEXX2608300001' }] }, global: { plugins: [pinia, ElementPlus] } })
|
||||
const wrapper = mount(ContainerFeesPanel, { attachTo: document.body, props: { planId: '9', cabinetNo: 'CAB-9', containerNo: 'CONT-9', containerBookingId, customers, businessReferences: [{ accountId: '11', customerName: '客户A', orderId: '501', orderNo: 'FL-501', inboundId: '601', inboundCode: 'NYEXX2608300001' }] }, global: { plugins: [pinia, ElementPlus] } })
|
||||
await flushPromises(); return wrapper
|
||||
}
|
||||
|
||||
@@ -35,10 +37,50 @@ beforeEach(() => {
|
||||
vi.mocked(getContainerSettlementDetails).mockReset().mockResolvedValue({ customerId: '11', containerBookingId: '99', settledSourceFeeIds: [], costs: [] }); vi.mocked(createContainerFee).mockReset()
|
||||
vi.mocked(adjustContainerFee).mockReset().mockResolvedValue({} as never)
|
||||
vi.mocked(uploadContainerFeePaymentVoucher).mockReset().mockResolvedValue({ fileId: '9001', fileName: '付款凭证.pdf' })
|
||||
vi.mocked(createContainerFeeAllocationGroup).mockReset().mockResolvedValue({} as never)
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
describe('ContainerFeesPanel contract', () => {
|
||||
it('连续分摊表保留两个客户归属,合计不符时阻止保存,全屏后按原归属提交', async () => {
|
||||
vi.mocked(listApplicableFeeItems).mockResolvedValue([{ id: '7', name: '操作费', feeCode: 'OPERATION', defaultPrice: null, supplierCategory: null, warehouseReceivable: true, warehousePayable: false, pickupReceivable: false, pickupPayable: false }])
|
||||
const wrapper = await mountPanel(['firstleg:inbound:fee-detail:view', 'firstleg:inbound:fee-detail:edit', 'firstleg:fee-item:view'], [{ accountId: '11', customerName: '客户A' }, { accountId: '22', customerName: '客户B' }])
|
||||
await wrapper.setProps({ businessReferences: [
|
||||
{ accountId: '11', customerName: '客户A', orderId: '501', orderNo: 'FL-501', inboundId: '601', inboundCode: 'NYEXX2608300001' },
|
||||
{ accountId: '22', customerName: '客户B', orderId: '502', orderNo: 'FL-502', inboundId: null, inboundCode: null },
|
||||
] })
|
||||
const state = (wrapper.vm.$ as unknown as { setupState: {
|
||||
accountId: string; openCreate(): void; save(): Promise<void>; form: { feeItemId: string; amount: number; remark: string }
|
||||
} }).setupState
|
||||
state.accountId = '11'
|
||||
await flushPromises()
|
||||
state.openCreate()
|
||||
await flushPromises()
|
||||
state.form.feeItemId = '7'; state.form.amount = 100; state.form.remark = '分摊测试备注'
|
||||
const dialog = wrapper.findAllComponents(AppDialog).find(item => item.props('title').startsWith('新增柜次费用'))!
|
||||
const rows = dialog.findAll('table[aria-label="业务归属分摊"] tbody tr')
|
||||
expect(rows).toHaveLength(2)
|
||||
expect(rows[0]!.text()).toContain('客户A')
|
||||
expect(rows[1]!.text()).toContain('客户B')
|
||||
expect(rows[1]!.text()).toContain('无入仓码')
|
||||
rows[0]!.findComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 40)
|
||||
rows[1]!.findComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 50)
|
||||
await state.save()
|
||||
expect(createContainerFeeAllocationGroup).not.toHaveBeenCalled()
|
||||
rows[1]!.findComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 60)
|
||||
await dialog.get('[aria-label="展开全屏"]').trigger('click')
|
||||
expect(dialog.find('textarea').element.value).toBe('分摊测试备注')
|
||||
await state.save()
|
||||
expect(createContainerFeeAllocationGroup).toHaveBeenCalledWith('9', {
|
||||
fee: expect.objectContaining({ amount: 100, feeItemId: '7', remark: '分摊测试备注', direction: 'RECEIVABLE' }),
|
||||
allocations: [
|
||||
{ customerId: '11', orderId: '501', orderNo: 'FL-501', inboundId: '601', inboundCode: 'NYEXX2608300001', amount: 40 },
|
||||
{ customerId: '22', orderId: '502', orderNo: 'FL-502', inboundId: null, inboundCode: null, amount: 60 },
|
||||
],
|
||||
})
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it.each(['success', 'failure'])('ignores a closed editor request that finishes late: %s', async (outcome) => {
|
||||
const item = { id: '7', name: '操作费', feeCode: 'OPERATION', defaultPrice: 60, supplierCategory: null, warehouseReceivable: true, warehousePayable: false, pickupReceivable: false, pickupPayable: false }
|
||||
let resolveOld!: (items: typeof item[]) => void
|
||||
@@ -158,7 +200,15 @@ describe('ContainerFeesPanel contract', () => {
|
||||
const item = { id: '2', loadingPlanId: '9', containerBookingId: '99', containerNo: null, feeItemId: '7', feeItemName: '拖车费', direction: 'PAYABLE' as const, supplierId: '41', supplierName: '承运商A', supplierCategory: 'TRUCKING' as const, businessDate: '2026-08-12', amount: 100, currency: 'CNY' as const, reviewStatus: 'APPROVED' as const, sourceStatus: 'ACTIVE' as const, sourceVersion: 3, remark: '原备注', reviewerName: null, reviewedAt: null, reviewReason: null }
|
||||
const state = (wrapper.vm.$ as unknown as { setupState: { openAdjust(value: typeof item): void; save(): Promise<void>; form: { businessDate: string; amount: number } } }).setupState
|
||||
state.openAdjust(item); await flushPromises()
|
||||
const dialog = wrapper.findAllComponents(AppDialog).find(item => item.props('title').startsWith('调整已审核费用'))!
|
||||
expect(dialog.props('title')).toBe('调整已审核费用 · CAB-9')
|
||||
const fields = dialog.findAllComponents({ name: 'ElFormItem' })
|
||||
expect(fields.find(field => field.props('label') === '费用项目')!.findComponent({ name: 'ElSelect' }).exists()).toBe(false)
|
||||
expect(fields.find(field => field.props('label') === '供应商')!.text()).toContain('承运商A')
|
||||
state.form.businessDate = '2026-08-13'; state.form.amount = 120
|
||||
await dialog.get('[aria-label="展开全屏"]').trigger('click')
|
||||
expect(dialog.find('.el-dialog.is-fullscreen').exists()).toBe(true)
|
||||
expect(state.form.amount).toBe(120)
|
||||
await state.save()
|
||||
expect(adjustContainerFee).toHaveBeenCalledWith('9', '11', '2', { expectedSourceVersion: 3, businessDate: '2026-08-13', amount: 120 })
|
||||
expect(wrapper.text()).toContain('已审核费用仍可调整日期与金额')
|
||||
|
||||
@@ -16,10 +16,16 @@ const props = withDefaults(defineProps<{
|
||||
title?: string
|
||||
planId: string
|
||||
containerBookingId: string
|
||||
cabinetNo?: string | null
|
||||
containerNo?: string | null
|
||||
customers: Array<{ accountId: string; customerName: string | null }>
|
||||
businessReferences: Array<{ accountId: string; customerName: string | null; orderId: string; orderNo: string; inboundId: string | null; inboundCode: string | null }>
|
||||
readonly?: boolean
|
||||
}>(), { title: '柜次费用', readonly: false })
|
||||
function feeDialogTitle(action: string) {
|
||||
const code = props.cabinetNo?.trim() || props.containerNo?.trim()
|
||||
return code ? `${action} · ${code}` : action
|
||||
}
|
||||
const access = useAccessPreviewStore()
|
||||
const accountId = ref('')
|
||||
const fees = ref<ContainerFeeResource[]>([])
|
||||
@@ -333,7 +339,7 @@ async function reject() {
|
||||
}
|
||||
async function voidFee(item: ContainerFeeResource) {
|
||||
if (!canReview.value || !accountId.value) return
|
||||
await ElMessageBox.confirm(`确认作废“${item.feeItemName}”费用?作废后将同步财务来源状态。`, '作废费用', { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '确认作废' })
|
||||
await ElMessageBox.confirm(`确认作废“${item.feeItemName}”费用?作废后将同步财务来源状态。`, feeDialogTitle('作废费用'), { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '确认作废' })
|
||||
await (item.allocationGroupId
|
||||
? voidContainerFeeAllocationGroup(props.planId, item.allocationGroupId, item.sourceVersion)
|
||||
: voidContainerFee(props.planId, accountId.value, item.id, item.sourceVersion))
|
||||
@@ -370,7 +376,7 @@ onMounted(() => void load())
|
||||
<template>
|
||||
<section class="fees-panel admin-density" aria-label="柜次费用维护">
|
||||
<header class="fees-header">
|
||||
<div><h2>{{ title }}</h2><p>费用与财务分摊均来自服务端真实记录</p></div>
|
||||
<div><h2>{{ title }}</h2></div>
|
||||
<div class="fees-header__actions">
|
||||
<label v-if="customerOptions.length > 1" class="customer-context"><span>客户</span><el-select v-model="accountId" placeholder="请选择费用归属客户"><el-option v-for="item in customerOptions" :key="item.value" :label="item.label" :value="item.value" /></el-select></label>
|
||||
<el-button v-if="canEdit && canViewFeeItems && accountId" type="primary" @click="openCreate"><Plus :size="16" />新增费用</el-button>
|
||||
@@ -396,72 +402,50 @@ onMounted(() => void load())
|
||||
<div v-if="accountId && fees.length" class="allocation-section"><header><div><h3>财务体积分摊</h3><p>{{ selectedCustomer?.label }} · 仅展示 Finance 已持久化结果</p></div><el-button :loading="financeLoading" @click="loadFinanceDetails">{{ financeLoaded ? '刷新财务状态' : '加载财务状态' }}</el-button></header><LoadErrorState v-if="financeError" compact title="财务状态加载失败" :message="financeError.message" @retry="loadFinanceDetails"><template #details><ApiErrorMeta :details="financeError" /></template></LoadErrorState><el-empty v-else-if="!financeLoaded" description="点击加载财务状态后查看结算与成本分摊" :image-size="64" /><el-empty v-else-if="!financeLoading && costs.length === 0" description="尚无与该柜次关联的财务分摊结果" :image-size="64" /><article v-for="cost in costs" :key="cost.id" class="cost-block"><div class="cost-summary"><strong>{{ cost.feeItemName || '柜次成本' }}</strong><span>供应商:{{ cost.supplierName || '服务端未返回' }}</span><span>金额:¥{{ money(cost.amount) }}</span></div><DataTable :data="cost.allocations" :columns="allocationColumns" :preference-key="`container-cost-${cost.id}`" row-key="id" empty-text="服务端未返回分摊明细"><template #orderNo="{ row }">{{ orderNumbers.get(row.orderId) || '订单编码待补充' }}</template><template #inboundCode="{ row }">{{ row.inboundId ? (inboundCodes.get(row.inboundId) || '入仓码待补充') : '—' }}</template><template #volume="{ row }">{{ row.volumeCbm.toFixed(3) }}</template><template #rate="{ row }">{{ (row.allocationRate * 100).toFixed(2) }}%</template><template #allocated="{ row }">{{ money(row.allocatedAmount) }}</template></DataTable></article></div>
|
||||
</template>
|
||||
|
||||
<AppDialog class="admin-density-dialog" v-model="editorOpen" :title="target ? '调整已审核费用' : '新增柜次费用'" confirm-text="保存" :confirm-loading="saving" @confirm="save">
|
||||
<div v-if="target" class="adjust-snapshot">
|
||||
<p>调整仅允许修改业务日期和金额;如需变更方向、费用项目、供应商或其他信息,请先作废后新增。</p>
|
||||
<el-descriptions :column="3" border size="small">
|
||||
<el-descriptions-item label="方向">{{ target.direction === 'RECEIVABLE' ? '应收' : '应付' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="费用项目">{{ target.feeItemName }}</el-descriptions-item>
|
||||
<el-descriptions-item label="供应商">{{ target.supplierName || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="结算方式">{{ settlementLabel(target) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="已付摘要">{{ paidSummary(target) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="币种">{{ target.currency }}</el-descriptions-item>
|
||||
<el-descriptions-item label="备注">{{ target.remark || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="费用归属">{{ target.allocationOrderNo || '—' }} / {{ target.allocationInboundCode || '无入仓码' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</div>
|
||||
<template v-else>
|
||||
<AppDialog class="admin-density-dialog" v-model="editorOpen" :title="feeDialogTitle(target ? '调整已审核费用' : '新增柜次费用')" width="min(1080px, calc(100vw - 28px))" fullscreenable confirm-text="保存" :confirm-loading="saving" @confirm="save">
|
||||
<p v-if="target" class="density-dialog-note">调整仅允许修改业务日期和金额;如需变更方向、费用项目、供应商或其他信息,请先作废后新增。</p>
|
||||
<template v-if="!target">
|
||||
<el-alert v-if="!canViewSuppliers" type="warning" :closable="false" show-icon title="当前账号缺少供应商查看权限,仅可维护应收费用" />
|
||||
<el-alert v-else-if="suppliersError" type="warning" :closable="false" show-icon :title="suppliersError.message" />
|
||||
<el-alert v-if="feeItemsError" type="warning" :closable="false" show-icon :title="feeItemsError.message" />
|
||||
</template>
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-position="top" class="fee-form density-form">
|
||||
<template v-if="!target">
|
||||
<el-form-item label="方向" prop="direction"><el-segmented v-model="form.direction" :options="[{ label: '应收', value: 'RECEIVABLE' }, { label: '应付', value: 'PAYABLE', disabled: !canViewSuppliers }]" /></el-form-item>
|
||||
<el-form-item label="费用项目" prop="feeItemId"><el-select v-model="form.feeItemId" filterable :loading="feeItemsLoading" :disabled="!canViewFeeItems" placeholder="请选择启用且适用的费用项目" @change="applyFeeItemDefaultPrice"><el-option v-for="item in applicableItems" :key="item.id" :label="item.name" :value="item.id" /></el-select></el-form-item>
|
||||
<el-form-item v-if="form.direction === 'PAYABLE'" label="供应商" required><el-select v-model="form.supplierId" filterable :loading="suppliersLoading" :disabled="!canViewSuppliers || !selectedFeeItem || !!suppliersError" placeholder="请先选择费用项目"><el-option v-for="supplier in suppliers" :key="supplier.id" :label="supplier.supplierName" :value="supplier.id" /></el-select></el-form-item>
|
||||
<el-form-item v-if="form.direction === 'PAYABLE'" label="结算方式" required><el-radio-group v-model="settlementModeModel"><el-radio-button value="ON_ACCOUNT">账期应付</el-radio-button><el-radio-button value="DIRECT_PAID">已付成本</el-radio-button></el-radio-group></el-form-item>
|
||||
<template v-if="form.direction === 'PAYABLE' && form.settlementMode === 'DIRECT_PAID'">
|
||||
<el-form-item label="已付时间" required><el-date-picker v-model="form.paidAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择实际付款时间" /></el-form-item>
|
||||
<el-form-item label="付款方式" required><el-select v-model="form.paymentMethod" placeholder="请选择付款方式"><el-option label="线上支付" value="ONLINE" /><el-option label="银行转账" value="BANK_TRANSFER" /><el-option label="现金" value="CASH" /><el-option label="其他" value="OTHER" /></el-select></el-form-item>
|
||||
<el-form-item label="付款流水号"><el-input v-model="form.paymentReferenceNo" maxlength="128" placeholder="流水号与付款凭证至少填写一项" /></el-form-item>
|
||||
<el-form-item label="付款凭证" class="voucher-field"><div class="voucher-control"><label class="voucher-picker" :class="{ 'is-disabled': voucherUploading }"><FileUp :size="15" /><span>{{ voucherUploading ? '上传并校验中' : '选择凭证' }}</span><input type="file" accept=".pdf,.jpg,.jpeg,.png,application/pdf,image/jpeg,image/png" :disabled="voucherUploading" @change="selectVoucher" /></label><span class="voucher-hint">FINANCE_VOUCHER · 最多 20 个</span></div><el-alert v-if="voucherError" type="error" :closable="true" show-icon @close="voucherError = null"><template #title>{{ voucherError.message }}</template><ApiErrorMeta :details="voucherError" /></el-alert><div v-if="voucherFiles.length" class="voucher-list"><span v-for="file in voucherFiles" :key="file.fileId"><span :title="file.fileName">{{ file.fileName }}</span><button type="button" :aria-label="`移除${file.fileName}`" @click="removeVoucher(file.fileId)"><X :size="14" /></button></span></div></el-form-item>
|
||||
</template>
|
||||
</template>
|
||||
<el-form-item label="业务日期" prop="businessDate"><el-date-picker v-model="form.businessDate" type="date" value-format="YYYY-MM-DD" /></el-form-item>
|
||||
<el-form-item label="金额(CNY)" prop="amount"><el-input-number v-model="form.amount" :min="0" :precision="2" controls-position="right" placeholder="请输入金额" /></el-form-item>
|
||||
<el-form-item v-if="allocationMode" label="业务归属分摊" class="allocation-editor" required>
|
||||
<section class="density-dialog-section" data-tone="work">
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-position="left" class="density-form density-form--flush-end">
|
||||
<table class="density-table density-table--editable fee-editor-table" aria-label="柜次费用填写"><tbody>
|
||||
<tr><td><el-form-item label-position="left" label-width="110px" label="方向" :prop="target ? undefined : 'direction'"><span v-if="target">{{ target.direction === 'RECEIVABLE' ? '应收' : '应付' }}</span><el-segmented v-else v-model="form.direction" :options="[{ label: '应收', value: 'RECEIVABLE' }, { label: '应付', value: 'PAYABLE', disabled: !canViewSuppliers }]" /></el-form-item></td><td><el-form-item label-position="left" label-width="110px" label="费用项目" :prop="target ? undefined : 'feeItemId'"><span v-if="target">{{ target.feeItemName }}</span><el-select v-else v-model="form.feeItemId" filterable :loading="feeItemsLoading" :disabled="!canViewFeeItems" placeholder="请选择启用且适用的费用项目" @change="applyFeeItemDefaultPrice"><el-option v-for="item in applicableItems" :key="item.id" :label="item.name" :value="item.id" /></el-select></el-form-item></td></tr>
|
||||
<tr v-if="target || form.direction === 'PAYABLE'"><td><el-form-item label-position="left" label-width="110px" label="供应商" :required="!target"><span v-if="target">{{ target.supplierName || '—' }}</span><el-select v-else v-model="form.supplierId" filterable :loading="suppliersLoading" :disabled="!canViewSuppliers || !selectedFeeItem || !!suppliersError" placeholder="请先选择费用项目"><el-option v-for="supplier in suppliers" :key="supplier.id" :label="supplier.supplierName" :value="supplier.id" /></el-select></el-form-item></td><td><el-form-item label-position="left" label-width="110px" label="结算方式" :required="!target"><span v-if="target">{{ settlementLabel(target) }}</span><el-radio-group v-else v-model="settlementModeModel"><el-radio-button value="ON_ACCOUNT">账期应付</el-radio-button><el-radio-button value="DIRECT_PAID">已付成本</el-radio-button></el-radio-group></el-form-item></td></tr>
|
||||
<tr v-if="target"><td><el-form-item label="已付摘要" label-position="left" label-width="110px"><span>{{ paidSummary(target) }}</span></el-form-item></td><td><el-form-item label="币种" label-position="left" label-width="110px"><span>{{ target.currency }}</span></el-form-item></td></tr>
|
||||
<tr v-if="!target && form.direction === 'PAYABLE' && form.settlementMode === 'DIRECT_PAID'"><td><el-form-item label-position="left" label-width="110px" label="已付时间" required><el-date-picker v-model="form.paidAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择实际付款时间" /></el-form-item></td><td><el-form-item label-position="left" label-width="110px" label="付款方式" required><el-select v-model="form.paymentMethod" placeholder="请选择付款方式"><el-option label="线上支付" value="ONLINE" /><el-option label="银行转账" value="BANK_TRANSFER" /><el-option label="现金" value="CASH" /><el-option label="其他" value="OTHER" /></el-select></el-form-item></td></tr>
|
||||
<tr v-if="!target && form.direction === 'PAYABLE' && form.settlementMode === 'DIRECT_PAID'"><td colspan="2"><el-form-item label-position="left" label-width="110px" label="付款流水号"><el-input v-model="form.paymentReferenceNo" maxlength="128" placeholder="流水号与付款凭证至少填写一项" /></el-form-item></td></tr>
|
||||
<tr v-if="!target && form.direction === 'PAYABLE' && form.settlementMode === 'DIRECT_PAID'"><td colspan="2"><el-form-item label-position="left" label-width="110px" label="付款凭证" class="voucher-field"><div class="voucher-control"><label class="voucher-picker" :class="{ 'is-disabled': voucherUploading }"><FileUp :size="15" /><span>{{ voucherUploading ? '上传并校验中' : '选择凭证' }}</span><input type="file" accept=".pdf,.jpg,.jpeg,.png,application/pdf,image/jpeg,image/png" :disabled="voucherUploading" @change="selectVoucher" /></label><span class="voucher-hint">支持 PDF、JPG、PNG,最多 20 个</span></div><el-alert v-if="voucherError" type="error" :closable="true" show-icon @close="voucherError = null"><template #title>{{ voucherError.message }}</template><ApiErrorMeta :details="voucherError" /></el-alert><div v-if="voucherFiles.length" class="voucher-list"><span v-for="file in voucherFiles" :key="file.fileId"><span :title="file.fileName">{{ file.fileName }}</span><button type="button" :aria-label="`移除${file.fileName}`" @click="removeVoucher(file.fileId)"><X :size="14" /></button></span></div></el-form-item></td></tr>
|
||||
<tr><td><el-form-item label-position="left" label-width="110px" label="业务日期" prop="businessDate"><el-date-picker v-model="form.businessDate" type="date" value-format="YYYY-MM-DD" /></el-form-item></td><td><el-form-item label-position="left" label-width="110px" label="金额(CNY)" prop="amount"><el-input-number v-model="form.amount" :min="0" :precision="2" controls-position="right" placeholder="请输入金额" /></el-form-item></td></tr>
|
||||
<tr v-if="target"><td><el-form-item label="备注" label-position="left" label-width="110px"><span>{{ target.remark || '—' }}</span></el-form-item></td><td><el-form-item label="费用归属" label-position="left" label-width="110px"><span>{{ (target.allocationOrderNo || '—') + ' / ' + (target.allocationInboundCode || '无入仓码') }}</span></el-form-item></td></tr>
|
||||
<tr v-if="!target"><td colspan="2"><el-form-item label-position="left" label-width="110px" label="备注"><el-input v-model="form.remark" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="请输入备注(选填)" /></el-form-item></td></tr>
|
||||
</tbody></table>
|
||||
<el-form-item label-position="top" v-if="allocationMode" label="业务归属分摊" class="allocation-editor" required>
|
||||
<el-alert type="info" :closable="false" show-icon title="客户必须明确;入仓码没有时按订单归属,分摊合计必须等于费用总额。" />
|
||||
<div class="allocation-editor__rows">
|
||||
<div v-for="(row, index) in allocationDraft" :key="`${row.customerId}-${row.orderId}-${row.inboundId || 'none'}`" class="allocation-editor__row">
|
||||
<span><strong>{{ customerLabel(row.customerId || targetGroupRows[index]?.customerId || '') }}</strong><small>{{ row.orderNo }} · {{ row.inboundCode || '无入仓码' }}</small></span>
|
||||
<el-input-number v-model="row.amount" :min="0" :precision="2" controls-position="right" placeholder="请输入分摊金额" />
|
||||
</div>
|
||||
</div>
|
||||
<p class="allocation-editor__sum" :class="{ 'is-error': Math.abs(allocationSum - form.amount) > 0.001 }">已分摊 ¥{{ money(allocationSum) }} / 总额 ¥{{ money(form.amount) }}</p>
|
||||
<table class="density-table density-table--editable allocation-editor-table" aria-label="业务归属分摊"><thead><tr><th>客户</th><th>订单号</th><th>入仓码</th><th class="allocation-amount">分摊金额(CNY)</th></tr></thead><tbody>
|
||||
<tr v-for="(row, index) in allocationDraft" :key="`${row.customerId}-${row.orderId}-${row.inboundId || 'none'}`"><td>{{ customerLabel(row.customerId || targetGroupRows[index]?.customerId || '') }}</td><td>{{ row.orderNo }}</td><td>{{ row.inboundCode || '无入仓码' }}</td><td><el-input-number v-model="row.amount" :min="0" :precision="2" controls-position="right" placeholder="请输入分摊金额" /></td></tr>
|
||||
</tbody><tfoot><tr><td colspan="4"><span class="allocation-editor__sum" :class="{ 'is-error': Math.abs(allocationSum - form.amount) > 0.001 }">已分摊 ¥{{ money(allocationSum) }} / 总额 ¥{{ money(form.amount) }}</span></td></tr></tfoot></table>
|
||||
</el-form-item>
|
||||
<template v-if="!target">
|
||||
<el-form-item label="备注"><el-input v-model="form.remark" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="请输入备注(选填)" /></el-form-item>
|
||||
</template>
|
||||
</el-form>
|
||||
</el-form>
|
||||
</section>
|
||||
<LoadErrorState v-if="editorError" compact title="费用保存失败" :message="editorError.message" :show-retry="false"><template #details><ApiErrorMeta :details="editorError" /></template></LoadErrorState>
|
||||
</AppDialog>
|
||||
<AppDialog class="admin-density-dialog" v-model="rejectOpen" title="驳回柜次费用" confirm-text="确认驳回" :confirm-loading="saving" @confirm="reject"><el-input v-model="rejection" type="textarea" :rows="4" maxlength="500" show-word-limit placeholder="请输入驳回原因" /></AppDialog>
|
||||
<AppDialog class="admin-density-dialog" v-model="rejectOpen" :title="feeDialogTitle('驳回柜次费用')" confirm-text="确认驳回" :confirm-loading="saving" @confirm="reject"><el-input v-model="rejection" type="textarea" :rows="4" maxlength="500" show-word-limit placeholder="请输入驳回原因" /></AppDialog>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.fees-panel { min-width: 0; overflow: hidden; padding-top: 0; border-top: 0; }
|
||||
.fees-header, .allocation-section > header { display: flex; align-items: center; justify-content: space-between; gap: var(--admin-space-5); padding: 0 0 var(--admin-space-5); }
|
||||
.fees-header h2, .allocation-section h3 { margin: 0; font-size: var(--admin-font-section); }.fees-header p, .allocation-section p { margin: var(--admin-space-1) 0 0; color: var(--cc-muted); font-size: var(--admin-font-caption); }
|
||||
.fees-header h2, .allocation-section h3 { margin: 0; font-size: var(--admin-font-section); }.allocation-section p { margin: var(--admin-space-1) 0 0; color: var(--cc-muted); font-size: var(--admin-font-caption); }
|
||||
.fees-header__actions, .customer-context { display: flex; align-items: center; gap: var(--admin-space-4); }
|
||||
.customer-context > span { color: var(--cc-muted); font-size: var(--admin-font-body); white-space: nowrap; }.customer-context :deep(.el-select) { width: 260px; }
|
||||
.fees-table-title { display: flex; align-items: baseline; gap: var(--admin-space-3); }.fees-table-title strong { font-size: var(--admin-font-section); }.fees-table-title span { color: var(--cc-muted); font-size: var(--admin-font-caption); }
|
||||
.row-actions { flex-wrap: wrap; }.allocation-section { padding-top: var(--admin-space-5); border-top: 1px solid var(--cc-border); }.cost-block { border-top: 1px solid var(--cc-border); }.cost-summary { display: flex; flex-wrap: wrap; gap: var(--admin-space-4) var(--admin-space-6); padding: var(--admin-space-4) var(--admin-space-5); background: #f8fafc; font-size: var(--admin-font-body); }.cost-summary span { color: var(--cc-muted); }.fee-form { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 var(--admin-space-5); margin-top: var(--admin-space-5); }.fee-form :deep(.el-form-item:last-child) { grid-column: 1 / -1; }
|
||||
.voucher-field { grid-column: 1 / -1; }.voucher-control { display: flex; align-items: center; flex-wrap: wrap; gap: var(--admin-space-4); width: 100%; }.voucher-picker { display: inline-flex; align-items: center; gap: var(--admin-space-2); padding: 7px var(--admin-space-5); border: 1px solid var(--cc-border); border-radius: var(--admin-radius); color: var(--el-color-primary); cursor: pointer; }.voucher-picker.is-disabled { cursor: not-allowed; opacity: .6; }.voucher-picker input { position: absolute; width: 1px; height: 1px; opacity: 0; }.voucher-hint { color: var(--cc-muted); font-size: var(--admin-font-caption); }.voucher-list { display: grid; width: 100%; gap: var(--admin-space-2); margin-top: var(--admin-space-3); }.voucher-list > span { display: flex; align-items: center; justify-content: space-between; gap: var(--admin-space-3); padding: var(--admin-space-2) 9px; border-radius: 5px; background: var(--el-fill-color-light); }.voucher-list > span > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.voucher-list button { display: inline-flex; padding: 2px; border: 0; background: transparent; color: var(--el-color-danger); cursor: pointer; }
|
||||
.adjust-snapshot p { margin: 0 0 var(--admin-space-5); color: var(--cc-muted); font-size: var(--admin-font-body); line-height: 1.5; }
|
||||
.row-actions { flex-wrap: wrap; }.allocation-section { padding-top: var(--admin-space-5); border-top: 1px solid var(--cc-border); }.cost-block { border-top: 1px solid var(--cc-border); }.cost-summary { display: flex; flex-wrap: wrap; gap: var(--admin-space-4) var(--admin-space-6); padding: var(--admin-space-4) var(--admin-space-5); background: #f8fafc; font-size: var(--admin-font-body); }.cost-summary span { color: var(--cc-muted); }.fee-editor-table { table-layout: fixed; }
|
||||
.voucher-control { display: flex; align-items: center; flex-wrap: wrap; gap: var(--admin-space-4); width: 100%; }.voucher-picker { display: inline-flex; align-items: center; gap: var(--admin-space-2); padding: 7px var(--admin-space-5); border: 1px solid var(--cc-border); border-radius: var(--admin-radius); color: var(--el-color-primary); cursor: pointer; }.voucher-picker.is-disabled { cursor: not-allowed; opacity: .6; }.voucher-picker input { position: absolute; width: 1px; height: 1px; opacity: 0; }.voucher-hint { color: var(--cc-muted); font-size: var(--admin-font-caption); }.voucher-list { display: grid; width: 100%; gap: var(--admin-space-2); margin-top: var(--admin-space-3); }.voucher-list > span { display: flex; align-items: center; justify-content: space-between; gap: var(--admin-space-3); padding: var(--admin-space-2) 9px; border-radius: 5px; background: var(--el-fill-color-light); }.voucher-list > span > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.voucher-list button { display: inline-flex; padding: 2px; border: 0; background: transparent; color: var(--el-color-danger); cursor: pointer; }
|
||||
.business-reference { display: grid; gap: 2px; }.business-reference small { color: var(--cc-muted); }
|
||||
.allocation-editor { grid-column: 1 / -1; }.allocation-editor :deep(.el-form-item__content) { display: block; }
|
||||
.allocation-editor__rows { display: grid; gap: var(--admin-space-3); margin-top: var(--admin-space-4); }.allocation-editor__row { display: grid; grid-template-columns: minmax(0, 1fr) 160px; align-items: center; gap: var(--admin-space-5); padding: var(--admin-space-3) var(--admin-space-4); border: 1px solid var(--cc-border); border-radius: var(--admin-radius); }.allocation-editor__row > span { display: grid; min-width: 0; }.allocation-editor__row small { overflow: hidden; color: var(--cc-muted); text-overflow: ellipsis; white-space: nowrap; }.allocation-editor__sum { margin: var(--admin-space-3) 0 0; text-align: right; color: var(--el-color-success); }.allocation-editor__sum.is-error { color: var(--el-color-danger); }
|
||||
@media (max-width: 640px) { .fees-header { align-items: stretch; flex-direction: column; }.fees-header .el-button { width: 100%; }.fee-form { grid-template-columns: 1fr; }.fee-form :deep(.el-form-item:last-child) { grid-column: auto; }.customer-context { align-items: stretch; flex-direction: column; } }
|
||||
.allocation-editor { margin-top: var(--admin-space-5); }.allocation-editor :deep(.el-form-item__content) { display: block; }.allocation-editor-table { margin-top: var(--admin-space-3); }.allocation-amount { width: 180px; }.allocation-editor__sum { color: var(--el-color-success); }.allocation-editor__sum.is-error { color: var(--el-color-danger); }
|
||||
@media (max-width: 640px) { .fees-header { align-items: stretch; flex-direction: column; }.fees-header .el-button { width: 100%; }.fee-editor-table, .fee-editor-table tbody, .fee-editor-table tr, .fee-editor-table td { display: block; width: 100%; }.customer-context { align-items: stretch; flex-direction: column; } }
|
||||
</style>
|
||||
|
||||
@@ -166,7 +166,6 @@ function formatNumber(value?: number | null, digits = 3) {
|
||||
<el-button link type="primary" class="detail-page-header__back" :class="{ 'density-back': mode === 'preload' || dense }" @click="emit('back')"><ArrowLeft :size="16" />{{ backLabel || '返回列表' }}</el-button>
|
||||
<div v-if="!embedded" class="detail-page-header__identity">
|
||||
<h1>{{ title }}</h1>
|
||||
<p v-if="mode !== 'preload'">查看已完成装柜明细和操作历史</p>
|
||||
</div>
|
||||
<div v-if="detail && summary" class="detail-page-header__actions">
|
||||
<el-button v-if="mode === 'confirmed'" :disabled="loading" @click="printLoadingList"><Printer :size="16" />打印装柜清单</el-button>
|
||||
@@ -256,7 +255,6 @@ function formatNumber(value?: number | null, digits = 3) {
|
||||
.detail-page-header__back { margin-right: 2px; }
|
||||
.detail-page-header__identity { min-width: 0; }
|
||||
.detail-page-header__identity h1 { margin: 0; color: var(--cc-text); font-size: 18px; line-height: 1.35; }
|
||||
.detail-page-header__identity p { margin: 3px 0 0; color: var(--cc-muted); font-size: var(--admin-font-caption, 12px); }
|
||||
.detail-page-header__actions { display: flex; align-items: center; justify-content: flex-end; gap: var(--admin-space-3, 8px); }
|
||||
.detail-page-header__actions :deep(.el-button + .el-button) { margin-left: 0; }
|
||||
.detail-summary { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: var(--admin-space-2, 6px) 28px; padding: var(--admin-space-4, 10px) 0 2px; border-top: 1px solid var(--cc-border); }
|
||||
|
||||
@@ -1,17 +1,21 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import ElementPlus from 'element-plus'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import { nextTick } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import PalletLabelPrintDialog from './PalletLabelPrintDialog.vue'
|
||||
|
||||
const preparePalletLabels = vi.fn()
|
||||
const printPalletLabels = vi.fn()
|
||||
|
||||
vi.mock('../api/platformLoadingLists', () => ({
|
||||
preparePalletLabels: (...args: unknown[]) => preparePalletLabels(...args),
|
||||
printPalletLabels: vi.fn(),
|
||||
printPalletLabels: (...args: unknown[]) => printPalletLabels(...args),
|
||||
}))
|
||||
|
||||
describe('PalletLabelPrintDialog', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} })
|
||||
preparePalletLabels.mockReset().mockResolvedValue({
|
||||
planId: '10', planNo: 'LP-1', containerNo: 'HLCU1723867', totalCount: 2,
|
||||
groups: [{
|
||||
@@ -25,6 +29,35 @@ describe('PalletLabelPrintDialog', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it.each([false, true])('保留按仓补打的PDF范围和文件名,dense=%s', async (dense) => {
|
||||
URL.createObjectURL = vi.fn(() => 'blob:pallet-labels')
|
||||
URL.revokeObjectURL = vi.fn()
|
||||
const downloads: string[] = []
|
||||
const click = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function (this: HTMLAnchorElement) { downloads.push(this.download) })
|
||||
printPalletLabels.mockReset().mockResolvedValue(new Blob(['pdf'], { type: 'application/pdf' }))
|
||||
const wrapper = mount(PalletLabelPrintDialog, { props: { planId: '10', warehouseId: '20', dense }, global: { plugins: [ElementPlus] } })
|
||||
await wrapper.get('button').trigger('click')
|
||||
await flushPromises()
|
||||
const dialog = wrapper.getComponent(AppDialog)
|
||||
expect(dialog.props('title')).toBe(dense ? '散货托盘码 · HLCU1723867' : '散货托盘码')
|
||||
wrapper.getComponent({ name: 'ElRadioGroup' }).vm.$emit('update:modelValue', 'WAREHOUSE')
|
||||
await flushPromises()
|
||||
wrapper.getComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 1)
|
||||
if (dense) {
|
||||
await dialog.get('[aria-label="展开全屏"]').trigger('click')
|
||||
expect(dialog.find('.el-dialog.is-fullscreen').exists()).toBe(true)
|
||||
expect(wrapper.getComponent({ name: 'ElInputNumber' }).props('modelValue')).toBe(1)
|
||||
}
|
||||
expect(dialog.props('confirmDisabled')).toBe(false)
|
||||
dialog.vm.$emit('confirm')
|
||||
await flushPromises()
|
||||
expect(printPalletLabels).toHaveBeenCalledWith('10', '20', 'ACY2', 1)
|
||||
expect(downloads).toEqual(['HLCU1723867-托盘码.pdf'])
|
||||
expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:pallet-labels')
|
||||
wrapper.unmount()
|
||||
click.mockRestore()
|
||||
})
|
||||
|
||||
it('shows the server-generated internal code rule and both warehouse codes', async () => {
|
||||
const wrapper = mount(PalletLabelPrintDialog, {
|
||||
props: { planId: '10', warehouseId: '20' },
|
||||
|
||||
@@ -17,6 +17,10 @@ const manifest = ref<PalletLabelManifest | null>(null)
|
||||
const scope = ref<'ALL' | 'WAREHOUSE'>('ALL')
|
||||
const warehouseCode = ref('')
|
||||
const count = ref(1)
|
||||
const dialogTitle = computed(() => {
|
||||
const code = manifest.value?.containerNo?.trim() || manifest.value?.planNo?.trim()
|
||||
return props.dense && code ? `散货托盘码 · ${code}` : '散货托盘码'
|
||||
})
|
||||
const selectedGroup = computed(() => manifest.value?.groups.find((group) => group.destinationWarehouseCode === warehouseCode.value) || null)
|
||||
const disabledConfirm = computed(() => !manifest.value?.totalCount || (scope.value === 'WAREHOUSE' && (!selectedGroup.value || count.value < 1 || count.value > selectedGroup.value.totalCount)))
|
||||
|
||||
@@ -48,14 +52,18 @@ async function print() {
|
||||
|
||||
<template>
|
||||
<el-button :disabled="disabled" @click="show"><Printer :size="16" />打印托盘码</el-button>
|
||||
<AppDialog :class="{ 'admin-density-dialog': dense }" v-model="open" title="散货托盘码" width="min(760px, calc(100vw - 28px))" confirm-text="生成 PDF" :loading="loading" :confirm-loading="printing" :confirm-disabled="disabledConfirm" @confirm="print">
|
||||
<AppDialog :class="{ 'admin-density-dialog': dense }" v-model="open" :title="dialogTitle" :width="dense ? 'min(920px, calc(100vw - 28px))' : 'min(760px, calc(100vw - 28px))'" :fullscreenable="dense" confirm-text="生成 PDF" :loading="loading" :confirm-loading="printing" :confirm-disabled="disabledConfirm" @confirm="print">
|
||||
<LoadErrorState v-if="error" compact title="托盘码操作失败" :message="error.message" :show-retry="false" />
|
||||
<template v-if="manifest">
|
||||
<div v-if="manifest" :class="{ 'density-dialog-sections': dense }">
|
||||
<section :class="{ 'density-dialog-section': dense }" :data-tone="dense ? 'work' : undefined">
|
||||
<el-alert type="info" :closable="false" :title="`编号规则:${manifest.containerNo}-FBA地址内部编号-三位序号;一件代发托盘不在此清单。`" />
|
||||
<el-radio-group v-model="scope" class="print-scope"><el-radio value="ALL">全部打印({{ manifest.totalCount }} 张)</el-radio><el-radio value="WAREHOUSE">按 FBA 仓补打</el-radio></el-radio-group>
|
||||
<div v-if="scope === 'WAREHOUSE'" class="print-options"><el-select v-model="warehouseCode" placeholder="选择 FBA 仓" @change="onWarehouseChange"><el-option v-for="group in manifest.groups" :key="group.destinationWarehouseCode" :label="`${group.destinationWarehouseCode} · ${group.totalCount} 张`" :value="group.destinationWarehouseCode" /></el-select><el-input-number v-model="count" placeholder="打印张数" :min="1" :max="selectedGroup?.totalCount || 1" /><span>张(从该仓第 1 张起)</span></div>
|
||||
<el-table :data="manifest.groups" border max-height="300"><el-table-column label="FBA 仓编码" prop="destinationWarehouseCode" width="140" /><el-table-column label="内部编号" prop="destinationWarehouseInternalCode" width="120" /><el-table-column label="FBA 仓名称" prop="destinationWarehouseName" min-width="160"><template #default="{ row }">{{ row.destinationWarehouseName || '—' }}</template></el-table-column><el-table-column label="托盘数" prop="totalCount" width="90" align="right" /><el-table-column label="编号范围" min-width="230"><template #default="{ row }">{{ row.labels[0]?.palletCode || '—' }}<template v-if="row.labels.length > 1"> ~ {{ row.labels[row.labels.length - 1]?.palletCode }}</template></template></el-table-column></el-table>
|
||||
</template>
|
||||
<el-radio-group v-model="scope" class="print-scope" :class="{ 'print-scope--dense': dense }"><el-radio value="ALL">全部打印({{ manifest.totalCount }} 张)</el-radio><el-radio value="WAREHOUSE">按 FBA 仓补打</el-radio></el-radio-group>
|
||||
<div v-if="scope === 'WAREHOUSE'" class="print-options" :class="{ 'density-form': dense }"><el-select v-model="warehouseCode" placeholder="选择 FBA 仓" @change="onWarehouseChange"><el-option v-for="group in manifest.groups" :key="group.destinationWarehouseCode" :label="`${group.destinationWarehouseCode} · ${group.totalCount} 张`" :value="group.destinationWarehouseCode" /></el-select><el-input-number v-model="count" placeholder="打印张数" :min="1" :max="selectedGroup?.totalCount || 1" /><span>张(从该仓第 1 张起)</span></div>
|
||||
</section>
|
||||
<section :class="{ 'density-dialog-section': dense }" :data-tone="dense ? 'source' : undefined">
|
||||
<el-table :data="manifest.groups" border :max-height="dense ? undefined : 300"><el-table-column label="FBA 仓编码" prop="destinationWarehouseCode" width="140" /><el-table-column label="内部编号" prop="destinationWarehouseInternalCode" width="120" /><el-table-column label="FBA 仓名称" prop="destinationWarehouseName" min-width="160" :show-overflow-tooltip="dense"><template #default="{ row }">{{ row.destinationWarehouseName || '—' }}</template></el-table-column><el-table-column label="托盘数" prop="totalCount" width="90" align="right" /><el-table-column label="编号范围" min-width="230"><template #default="{ row }">{{ row.labels[0]?.palletCode || '—' }}<template v-if="row.labels.length > 1"> ~ {{ row.labels[row.labels.length - 1]?.palletCode }}</template></template></el-table-column></el-table>
|
||||
</section>
|
||||
</div>
|
||||
</AppDialog>
|
||||
</template>
|
||||
|
||||
@@ -63,4 +71,6 @@ async function print() {
|
||||
.print-scope { display:flex; margin:18px 0 var(--admin-space-5, 12px); }
|
||||
.print-options { display:flex; align-items:center; gap:var(--admin-space-5, 12px); margin-bottom:var(--admin-space-6, 16px); }
|
||||
.print-options .el-select { width:240px; }
|
||||
.print-scope--dense { margin: var(--admin-space-3) 0 0; flex-wrap: wrap; }
|
||||
.print-options.density-form { display: grid; grid-template-columns: minmax(0, 1fr) 160px auto; margin: var(--admin-space-3) 0 0; }
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import { createRequire } from 'node:module'
|
||||
import { mount, flushPromises, enableAutoUnmount } from '@vue/test-utils'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import { ApiResponseError, http } from '@/shared/api/http'
|
||||
import View from './PreloadPlanDetailView.vue'
|
||||
import { confirmPreloadPlan, getPreloadPlan, listDomesticPdaOperators, updateLoadingItemRemarks, updatePreloadTransport, type LoadingDetail, type PreloadSummary } from '../api/platformLoadingLists'
|
||||
|
||||
// Use the repository's supported CJS entry for the real async form validator.
|
||||
const elementPlus = createRequire(import.meta.url)('element-plus') as typeof import('element-plus')
|
||||
const ElementPlus = elementPlus.default
|
||||
const { ElInput, ElSelect } = elementPlus
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRoute: () => ({ params: { planId: '1900000000000000001' }, query: { warehouseId: '1900000000000000002' } }),
|
||||
useRouter: () => ({ replace: vi.fn(), push: vi.fn(), back: vi.fn() }),
|
||||
}))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
|
||||
vi.mock('@/core/workspace-tabs/workspaceTabs', () => ({ useWorkspaceTabsStore: () => ({ removeByIds: vi.fn() }) }))
|
||||
vi.mock('../api/platformLoadingLists', () => ({
|
||||
confirmPreloadPlan: vi.fn(), getConfirmedLoadingList: vi.fn(), getPreloadPlan: vi.fn(),
|
||||
listDomesticPdaOperators: vi.fn(), updateLoadingItemRemarks: vi.fn(), updatePreloadTransport: vi.fn(),
|
||||
}))
|
||||
|
||||
function fixture(containerNo: string | null = 'MSCU-OLD', version = 7, itemVersion = 3): LoadingDetail<PreloadSummary> {
|
||||
return {
|
||||
summary: {
|
||||
planId: '1900000000000000001', containerBookingId: '2900000000000000001', preloadNo: 'PL-TEST-1', cabinetNo: 'CAB-TEST-1', containerNo,
|
||||
customers: [{ accountId: '11', customerName: '客户甲' }], warehouseId: '1900000000000000002', warehouseName: '国内仓', vesselName: 'SHIP', voyageNo: 'V1', containerType: '40HQ',
|
||||
status: 'EDITING', skuCount: '1', totalPackageCount: '10', totalVolumeCbm: 1, totalGrossWeightKg: 100, createdAt: '2026-10-19T10:00:00', version,
|
||||
readiness: { containerNoReady: Boolean(containerNo), preloadLinkCount: 1, confirmedPreloadCount: 0, oceanBookingId: '31', oceanBookingStatus: 'RELEASED', pdaTaskId: null, pdaTaskStatus: null, confirmationStatus: 'PENDING', canConfirm: Boolean(containerNo), readyForPda: false, blockingReasons: [] },
|
||||
},
|
||||
items: [{
|
||||
itemId: '41', itemVersion, accountId: '11', customerName: '客户甲', customsTaxMode: 'TAX_REFUND', taxRefundFlag: true,
|
||||
orderId: '51', orderNo: 'ORD-TEST-1', inboundId: '61', inboundDetailId: '62', inboundCode: 'IN-TEST-1', containerNo,
|
||||
skuCode: null, destinationWarehouse: 'LAX9', chineseName: '测试货物', englishName: 'Goods', goodsImageUrl: null,
|
||||
unitType: 'PACKAGE', palletId: null, palletNo: null, indivisible: false, packageCount: 10, palletCount: 0,
|
||||
grossWeightKg: 100, netWeightKg: 90, volumeCbm: 1, dimensionalWeightKg: 167, productQuantity: '10', unitsPerPackage: 1,
|
||||
fbaTrackingNo: null, shipmentNo: null, shippingMark: null, internalRemark: '原内部备注', customerRemark: '客户内容\n第二行', warehouseRemark: '原仓库备注', customerServiceRemark: '原客服备注', loadSequence: 1, frozenAt: null,
|
||||
}], histories: [],
|
||||
}
|
||||
}
|
||||
|
||||
async function render(initial = fixture()) {
|
||||
vi.mocked(getPreloadPlan).mockResolvedValue(initial)
|
||||
const wrapper = mount(View, { global: { plugins: [ElementPlus], stubs: {
|
||||
AdminPage: { template: '<main><slot /></main>' }, OperationErrorToast: true,
|
||||
LoadingListDetailDrawer: { name: 'LoadingListDetailDrawer', template: '<section><slot name="pda-operator" /></section>' },
|
||||
} } })
|
||||
await flushPromises()
|
||||
return wrapper
|
||||
}
|
||||
function dialog(wrapper: Awaited<ReturnType<typeof render>>, prefix: string) {
|
||||
return wrapper.findAllComponents(AppDialog).find(item => item.props('title').startsWith(prefix))!
|
||||
}
|
||||
enableAutoUnmount(afterEach)
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} })
|
||||
vi.spyOn(http, 'get').mockResolvedValue({ code: '200', message: 'success', data: { planId: '1900000000000000001', detailResource: 'PRELOAD', planStatus: 'EDITING' } })
|
||||
vi.mocked(listDomesticPdaOperators).mockResolvedValue([{ userId: '71', displayName: '操作员甲' }])
|
||||
})
|
||||
afterEach(() => { vi.restoreAllMocks(); document.body.innerHTML = '' })
|
||||
|
||||
describe('预装清单统一弹窗', () => {
|
||||
it.each([null, 'MSCU-OLD'])('运输表保留规范化和纠错原因校验,原柜号=%s', async (original) => {
|
||||
const wrapper = await render(fixture(original))
|
||||
vi.mocked(updatePreloadTransport).mockResolvedValue(fixture('MSCU-NEW', 8))
|
||||
wrapper.getComponent({ name: 'LoadingListDetailDrawer' }).vm.$emit('edit')
|
||||
await flushPromises()
|
||||
const popup = dialog(wrapper, original ? '纠正' : '补录')
|
||||
expect(popup.props('title')).toContain('PL-TEST-1')
|
||||
const number = popup.findAllComponents(ElInput).find(input => input.props('placeholder') === '请输入提柜后取得的真实集装箱号')!
|
||||
number.vm.$emit('update:modelValue', ' mscu-new ')
|
||||
number.vm.$emit('blur', new FocusEvent('blur'))
|
||||
popup.findAllComponents(ElInput).find(input => input.props('placeholder') === '请输入航次')!.vm.$emit('update:modelValue', ' V2 ')
|
||||
if (original) {
|
||||
popup.vm.$emit('confirm'); await flushPromises()
|
||||
expect(updatePreloadTransport).not.toHaveBeenCalled()
|
||||
popup.findAllComponents(ElInput).find(input => input.props('type') === 'textarea')!.vm.$emit('update:modelValue', ' 更正柜号 ')
|
||||
}
|
||||
popup.vm.$emit('confirm'); await flushPromises()
|
||||
expect(updatePreloadTransport).toHaveBeenCalledWith('1900000000000000001', '1900000000000000002', { containerNo: 'MSCU-NEW', voyageNo: 'V2', operationReason: original ? '更正柜号' : null, expectedVersion: 7 })
|
||||
expect(popup.props('modelValue')).toBe(false)
|
||||
})
|
||||
|
||||
it('客户备注只读,全屏及版本冲突后保留输入并按最新两层版本重试', async () => {
|
||||
const initial = fixture()
|
||||
const wrapper = await render(initial)
|
||||
wrapper.getComponent({ name: 'LoadingListDetailDrawer' }).vm.$emit('edit-remarks', initial.items[0])
|
||||
await flushPromises()
|
||||
const popup = dialog(wrapper, '编辑备注')
|
||||
expect(popup.props('title')).toBe('编辑备注 · IN-TEST-1')
|
||||
const customer = popup.findAllComponents({ name: 'ElFormItem' }).find(field => field.props('label') === '客户备注(只读)')!
|
||||
expect(customer.text()).toContain('第二行')
|
||||
expect(customer.findComponent(ElInput).exists()).toBe(false)
|
||||
const fields = popup.findAllComponents(ElInput)
|
||||
fields[0]!.vm.$emit('update:modelValue', ' 新内部 ')
|
||||
fields[1]!.vm.$emit('update:modelValue', '')
|
||||
fields[2]!.vm.$emit('update:modelValue', '新客服')
|
||||
await popup.get('[aria-label="展开全屏"]').trigger('click')
|
||||
expect(fields[0]!.props('modelValue')).toBe(' 新内部 ')
|
||||
vi.mocked(getPreloadPlan).mockResolvedValue(fixture('MSCU-OLD', 8, 4))
|
||||
vi.mocked(updateLoadingItemRemarks).mockRejectedValueOnce(new ApiResponseError('版本变化', 'A0409')).mockResolvedValueOnce({ id: initial.summary.planId, version: 9, items: [{ id: '41', version: 5, internalRemark: '新内部', warehouseRemark: null, customerServiceRemark: '新客服' }] })
|
||||
popup.vm.$emit('confirm'); await flushPromises()
|
||||
expect(popup.props('modelValue')).toBe(true)
|
||||
expect(fields[0]!.props('modelValue')).toBe(' 新内部 ')
|
||||
popup.vm.$emit('confirm'); await flushPromises()
|
||||
const calls = vi.mocked(updateLoadingItemRemarks).mock.calls
|
||||
expect(calls[0]![2]).toMatchObject({ expectedPlanVersion: 7, expectedItemVersion: 3 })
|
||||
expect(calls[1]).toEqual([initial.summary.planId, '41', { internalRemark: '新内部', warehouseRemark: null, customerServiceRemark: '新客服', expectedPlanVersion: 8, expectedItemVersion: 4, operationId: calls[0]![2].operationId }])
|
||||
expect(popup.props('modelValue')).toBe(false)
|
||||
})
|
||||
|
||||
it('确认资料显示已选作业员,保留必选门禁和原推送操作号', async () => {
|
||||
const initial = fixture()
|
||||
const wrapper = await render(initial)
|
||||
const drawer = wrapper.getComponent({ name: 'LoadingListDetailDrawer' })
|
||||
drawer.vm.$emit('confirm-preload'); await flushPromises()
|
||||
expect(dialog(wrapper, '确认预装').props('modelValue')).toBe(false)
|
||||
wrapper.getComponent(ElSelect).vm.$emit('update:modelValue', '71')
|
||||
await flushPromises()
|
||||
drawer.vm.$emit('confirm-preload'); await flushPromises()
|
||||
const popup = dialog(wrapper, '确认预装')
|
||||
expect(popup.get('table[aria-label="确认预装资料"]').text()).toContain('操作员甲')
|
||||
vi.mocked(confirmPreloadPlan).mockResolvedValue({ planId: initial.summary.planId, containerBookingId: initial.summary.containerBookingId, confirmationStatus: 'CONFIRMED', version: 8, readiness: { ...initial.summary.readiness, confirmationStatus: 'CONFIRMED', confirmedPreloadCount: 1, pdaTaskId: '91', pdaTaskStatus: 'PENDING', canConfirm: false, readyForPda: true } })
|
||||
popup.vm.$emit('confirm'); await flushPromises()
|
||||
expect(confirmPreloadPlan).toHaveBeenCalledWith(initial.summary.planId, 7, '71', expect.stringContaining('admin-preload-confirm-'))
|
||||
expect(popup.props('modelValue')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -119,7 +119,7 @@ describe('PreloadPlanDetailView prototype contract', () => {
|
||||
expect(source).toContain('<OperationErrorToast :details="remarkError || editError || confirmError" />')
|
||||
expect(source).toContain(':message="confirmError.message"')
|
||||
expect(source).toContain('width="min(520px, calc(100vw - 28px))"')
|
||||
expect(source).toContain("label=\"柜次号\"")
|
||||
expect(source).toContain("label=\"集装箱号\"")
|
||||
const confirmation = source.match(/<table[^>]*aria-label="确认预装资料"[\s\S]*?<\/table>/)?.[0] || ''
|
||||
for (const label of ['预装单号', '柜次号', '集装箱号', 'PDA 操作员']) expect(confirmation).toContain(`<th>${label}</th>`)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -64,6 +64,10 @@ const canCorrectTransport = computed(() => {
|
||||
})
|
||||
const isContainerCorrection = computed(() => Boolean(detail.value?.summary.containerNo))
|
||||
const editDialogTitle = computed(() => isContainerCorrection.value ? '纠正集装箱号 / 航次' : '补录集装箱号 / 航次')
|
||||
function preloadDialogTitle(action: string, code?: string | null) {
|
||||
const businessNo = code?.trim() || detail.value?.summary.preloadNo?.trim() || detail.value?.summary.cabinetNo?.trim()
|
||||
return businessNo ? `${action} · ${businessNo}` : action
|
||||
}
|
||||
const editRules: FormRules = {
|
||||
containerNo: [
|
||||
{ required: true, message: '请输入集装箱号', trigger: 'blur' },
|
||||
@@ -291,34 +295,42 @@ watch([planId, warehouseId], () => {
|
||||
</LoadingListDetailDrawer>
|
||||
</div>
|
||||
|
||||
<AppDialog v-model="editOpen" class="loading-transport-dialog admin-density-dialog" :title="editDialogTitle" width="min(620px, calc(100vw - 28px))" confirm-text="保存" :confirm-loading="editSaving" :confirm-disabled="!canEdit" @confirm="saveTransport">
|
||||
<el-form ref="editFormRef" class="transport-form" :model="editForm" :rules="editRules" label-position="top">
|
||||
<el-form-item label="真实集装箱号" prop="containerNo"><el-input v-model="editForm.containerNo" maxlength="64" placeholder="请输入提柜后取得的真实集装箱号" @blur="editForm.containerNo = normalizedContainerNo(editForm.containerNo)" /></el-form-item>
|
||||
<el-form-item label="航次" prop="voyageNo"><el-input v-model="editForm.voyageNo" maxlength="64" placeholder="请输入航次" /></el-form-item>
|
||||
<el-form-item :label="isContainerCorrection ? '纠错原因' : '操作原因(可选)'" prop="operationReason"><el-input v-model="editForm.operationReason" type="textarea" :rows="3" maxlength="500" :placeholder="isContainerCorrection ? '必填,将记录在操作历史中' : '可填写补录说明'" /></el-form-item>
|
||||
</el-form>
|
||||
<AppDialog v-model="editOpen" class="loading-transport-dialog admin-density-dialog" :title="preloadDialogTitle(editDialogTitle)" width="min(620px, calc(100vw - 28px))" confirm-text="保存" :confirm-loading="editSaving" :confirm-disabled="!canEdit" @confirm="saveTransport">
|
||||
<section class="density-dialog-section" data-tone="work">
|
||||
<el-form ref="editFormRef" class="density-form" :model="editForm" :rules="editRules">
|
||||
<table class="density-table density-table--editable preload-editor-table" aria-label="运输信息填写"><tbody>
|
||||
<tr><td><el-form-item label-position="left" label-width="120px" label="真实集装箱号" prop="containerNo"><el-input v-model="editForm.containerNo" maxlength="64" placeholder="请输入提柜后取得的真实集装箱号" @blur="editForm.containerNo = normalizedContainerNo(editForm.containerNo)" /></el-form-item></td></tr>
|
||||
<tr><td><el-form-item label-position="left" label-width="120px" label="航次" prop="voyageNo"><el-input v-model="editForm.voyageNo" maxlength="64" placeholder="请输入航次" /></el-form-item></td></tr>
|
||||
<tr><td><el-form-item label-position="left" label-width="120px" :label="isContainerCorrection ? '纠错原因' : '操作原因(可选)'" prop="operationReason"><el-input v-model="editForm.operationReason" type="textarea" :rows="3" maxlength="500" :placeholder="isContainerCorrection ? '必填,将记录在操作历史中' : '可填写补录说明'" /></el-form-item></td></tr>
|
||||
</tbody></table>
|
||||
</el-form>
|
||||
</section>
|
||||
</AppDialog>
|
||||
|
||||
<AppDialog v-model="remarkOpen" class="remarks-dialog admin-density-dialog" title="编辑备注" width="min(680px, calc(100vw - 28px))" confirm-text="保存备注" cancel-text="取消" :confirm-loading="remarkSaving" :confirm-disabled="!canEdit" @confirm="saveRemarks">
|
||||
<AppDialog v-model="remarkOpen" class="remarks-dialog admin-density-dialog" :title="preloadDialogTitle('编辑备注', remarkTarget?.inboundCode || remarkTarget?.orderNo)" width="min(800px, calc(100vw - 28px))" fullscreenable confirm-text="保存备注" cancel-text="取消" :confirm-loading="remarkSaving" :confirm-disabled="!canEdit" @confirm="saveRemarks">
|
||||
<el-alert type="info" :closable="false" show-icon title="客户备注来自客户下单且不可修改;其余备注可维护" />
|
||||
<el-form label-position="top" class="remarks-form">
|
||||
<el-form-item label="客户备注(只读)"><el-input :model-value="remarkTarget?.customerRemark || '—'" disabled /><small>来源:客户下单</small></el-form-item>
|
||||
<el-form-item label="内部备注"><el-input v-model="remarkForm.internalRemark" type="textarea" :rows="3" maxlength="1000" show-word-limit placeholder="可填写、修改或清空内部备注" /></el-form-item>
|
||||
<el-form-item label="仓库备注"><el-input v-model="remarkForm.warehouseRemark" type="textarea" :rows="3" maxlength="1000" show-word-limit placeholder="可填写、修改或清空仓库备注" /></el-form-item>
|
||||
<el-form-item label="客服备注"><el-input v-model="remarkForm.customerServiceRemark" type="textarea" :rows="3" maxlength="1000" show-word-limit placeholder="可填写、修改或清空客服备注" /></el-form-item>
|
||||
</el-form>
|
||||
<section class="density-dialog-section" data-tone="work">
|
||||
<el-form class="density-form">
|
||||
<table class="density-table density-table--editable preload-editor-table" aria-label="预装明细备注"><tbody>
|
||||
<tr><td><el-form-item label-position="left" label-width="128px" label="客户备注(只读)"><span class="customer-remark-text">{{ remarkTarget?.customerRemark || '—' }}</span></el-form-item></td></tr>
|
||||
<tr><td><el-form-item label-position="left" label-width="128px" label="内部备注"><el-input v-model="remarkForm.internalRemark" type="textarea" :rows="3" maxlength="1000" show-word-limit placeholder="可填写、修改或清空内部备注" /></el-form-item></td></tr>
|
||||
<tr><td><el-form-item label-position="left" label-width="128px" label="仓库备注"><el-input v-model="remarkForm.warehouseRemark" type="textarea" :rows="3" maxlength="1000" show-word-limit placeholder="可填写、修改或清空仓库备注" /></el-form-item></td></tr>
|
||||
<tr><td><el-form-item label-position="left" label-width="128px" label="客服备注"><el-input v-model="remarkForm.customerServiceRemark" type="textarea" :rows="3" maxlength="1000" show-word-limit placeholder="可填写、修改或清空客服备注" /></el-form-item></td></tr>
|
||||
</tbody></table>
|
||||
</el-form>
|
||||
</section>
|
||||
<LoadErrorState v-if="remarkError" compact title="备注保存失败" :message="remarkError.message" :show-retry="false"><template #details><ApiErrorMeta :details="remarkError" /></template></LoadErrorState>
|
||||
</AppDialog>
|
||||
|
||||
<AppDialog v-model="confirmOpen" class="preload-confirm-dialog admin-density-dialog" title="确认预装并推送 PDA" width="min(520px, calc(100vw - 28px))" confirm-text="确认并推送" :confirm-loading="confirmSaving" :confirm-disabled="!detail?.summary.readiness.canConfirm || !assigneeId || assigneeLoading" @confirm="submitConfirmPreload">
|
||||
<AppDialog v-model="confirmOpen" class="preload-confirm-dialog admin-density-dialog" :title="preloadDialogTitle('确认预装并推送 PDA')" width="min(520px, calc(100vw - 28px))" confirm-text="确认并推送" :confirm-loading="confirmSaving" :confirm-disabled="!detail?.summary.readiness.canConfirm || !assigneeId || assigneeLoading" @confirm="submitConfirmPreload">
|
||||
<div class="confirm-content">
|
||||
<el-alert type="warning" :closable="false" show-icon title="本操作将确认整份预装计划" />
|
||||
<el-descriptions v-if="detail" :column="1" border>
|
||||
<el-descriptions-item label="预装单号">{{ detail.summary.preloadNo }}</el-descriptions-item>
|
||||
<el-descriptions-item label="柜次号">{{ detail.summary.cabinetNo || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="集装箱号">{{ detail.summary.containerNo || '待补录' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<el-descriptions :column="1" border><el-descriptions-item label="PDA 操作员">{{ selectedAssignee?.displayName || '未选择' }}</el-descriptions-item></el-descriptions>
|
||||
<section class="density-dialog-section" data-tone="summary">
|
||||
<table class="density-table preload-confirm-table" aria-label="确认预装资料"><tbody>
|
||||
<template v-if="detail"><tr><th>预装单号</th><td>{{ detail.summary.preloadNo }}</td></tr><tr><th>柜次号</th><td>{{ detail.summary.cabinetNo || '—' }}</td></tr><tr><th>集装箱号</th><td>{{ detail.summary.containerNo || '待补录' }}</td></tr></template>
|
||||
<tr><th>PDA 操作员</th><td>{{ selectedAssignee?.displayName || '未选择' }}</td></tr>
|
||||
</tbody></table>
|
||||
</section>
|
||||
<LoadErrorState v-if="confirmError" compact title="推送失败" :message="confirmError.message" :show-retry="false" />
|
||||
</div>
|
||||
</AppDialog>
|
||||
@@ -327,16 +339,13 @@ watch([planId, warehouseId], () => {
|
||||
|
||||
<style scoped>
|
||||
.preload-detail-page { display: grid; min-width: 0; width: 100%; gap: var(--admin-space-5, 12px); }
|
||||
.transport-form { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 var(--admin-space-6, 16px); }
|
||||
.transport-form :deep(.el-form-item:last-child) { grid-column: 1 / -1; }
|
||||
.preload-editor-table { table-layout: fixed; }
|
||||
.preload-confirm-table th { width: 120px; }
|
||||
.customer-remark-text { white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
.confirm-content { display: grid; gap: var(--admin-space-5, 12px); }
|
||||
.pda-operator-picker { display: flex; align-items: center; flex-wrap: wrap; gap: 6px 8px; }
|
||||
.pda-operator-picker label { flex: 0 0 auto; font-size: var(--admin-font-body, 13px); white-space: nowrap; }
|
||||
.pda-operator-picker label span { color: var(--el-color-warning-dark-2); font-size: var(--admin-font-caption, 12px); }
|
||||
.pda-operator-picker :deep(.el-select) { width: 180px; }
|
||||
.remarks-form { margin-top: 14px; }
|
||||
.remarks-form small { display: block; margin-top: var(--admin-space-1, 4px); color: var(--cc-muted); }
|
||||
:global(.loading-transport-dialog.el-dialog) { max-height: 90vh; overflow: hidden; }
|
||||
@media (min-width: 1920px) { .preload-detail-page { width: 100%; max-width: none; gap: var(--admin-space-5, 12px); } }
|
||||
@media (max-width: 720px) { .transport-form { grid-template-columns: 1fr; } .transport-form :deep(.el-form-item:last-child) { grid-column: auto; } }
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user