优化
This commit is contained in:
@@ -159,6 +159,14 @@ function plan(raw: SmartLoadingPlan): SmartLoadingPlan {
|
||||
fullPackageCount: numberValue(group.fullPackageCount),
|
||||
memberInboundIds: (group.memberInboundIds || []).map(id),
|
||||
})),
|
||||
palletMembers: (raw.palletMembers || []).map((pallet) => ({
|
||||
palletId: id(pallet.palletId),
|
||||
members: (pallet.members || []).map((member) => ({
|
||||
...member,
|
||||
accountId: id(member.accountId), inboundId: id(member.inboundId), orderId: id(member.orderId),
|
||||
availablePackageCount: numberValue(member.availablePackageCount),
|
||||
})),
|
||||
})),
|
||||
items: (raw.items || []).map(item),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,4 +15,18 @@ describe('sealed pallet composition', () => {
|
||||
expect(wrapper.text()).not.toContain('13 箱')
|
||||
expect(wrapper.findAll('button')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('renders the same composition when it comes from an assigned plan pallet', () => {
|
||||
const wrapper = mount(SmartLoadingCandidateInbounds, { props: {
|
||||
sealedPallet: true,
|
||||
fallbackInboundCode: 'I180',
|
||||
members: [
|
||||
{ accountId: '1', inboundId: '180', orderId: '280', orderNo: 'O180', inboundCode: 'I180', customerName: '客户', availablePackageCount: 6, orderStatusSnapshot: null, expectedArrivalAt: null },
|
||||
{ accountId: '1', inboundId: '181', orderId: '281', orderNo: 'O181', inboundCode: 'I181', customerName: '客户', availablePackageCount: 5, orderStatusSnapshot: null, expectedArrivalAt: null },
|
||||
],
|
||||
} })
|
||||
expect(wrapper.get('summary').text()).toBe('2 个入仓码 · 展开')
|
||||
expect(wrapper.text()).toContain('I180:6 箱')
|
||||
expect(wrapper.text()).toContain('I181:5 箱')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,15 +1,26 @@
|
||||
<script setup lang="ts">
|
||||
import type { SmartLoadingCandidate } from '../types'
|
||||
import { computed } from 'vue'
|
||||
import type { SmartLoadingCandidate, SmartLoadingCandidateGroupMember } from '../types'
|
||||
|
||||
defineProps<{ candidate: SmartLoadingCandidate }>()
|
||||
const props = defineProps<{
|
||||
candidate?: SmartLoadingCandidate
|
||||
members?: SmartLoadingCandidateGroupMember[]
|
||||
sealedPallet?: boolean
|
||||
fallbackInboundCode?: string | null
|
||||
}>()
|
||||
const displayedMembers = computed(() => props.members || props.candidate?.groupMembers || [])
|
||||
const isSealedPallet = computed(() => props.candidate
|
||||
? props.candidate.unitType === 'SEALED_PALLET'
|
||||
: props.sealedPallet === true)
|
||||
const inboundCode = computed(() => props.fallbackInboundCode ?? props.candidate?.inboundCode)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<details v-if="candidate.unitType === 'SEALED_PALLET' && candidate.groupMembers.length">
|
||||
<summary>{{ candidate.groupMembers.length }} 个入仓码 · 展开</summary>
|
||||
<div v-for="member in candidate.groupMembers" :key="`${member.accountId}:${member.inboundId}`">
|
||||
<details v-if="isSealedPallet && displayedMembers.length">
|
||||
<summary>{{ displayedMembers.length }} 个入仓码 · 展开</summary>
|
||||
<div v-for="member in displayedMembers" :key="`${member.accountId}:${member.inboundId}`">
|
||||
{{ member.inboundCode || '—' }}:{{ member.availablePackageCount }} 箱
|
||||
</div>
|
||||
</details>
|
||||
<span v-else>{{ candidate.inboundCode || '—' }}</span>
|
||||
<span v-else>{{ inboundCode || '—' }}</span>
|
||||
</template>
|
||||
|
||||
@@ -467,7 +467,7 @@ describe('ContainersView', () => {
|
||||
})
|
||||
|
||||
it('updates an ACTIVE TRUCKING fleet inline and records actual empty return once', async () => {
|
||||
vi.mocked(listSuppliers).mockResolvedValue({ records: [{ id: '502', name: '新车队', category: 'TRUCKING', status: 'ACTIVE' } as never], page: 1, size: 200, total: 1, pages: 1 })
|
||||
vi.mocked(listSuppliers).mockResolvedValue({ records: [{ id: '502', name: '新车队', category: 'TRUCKING', truckingScope: 'OVERSEAS', status: 'ACTIVE' } as never], page: 1, size: 200, total: 1, pages: 1 })
|
||||
const router = createTestRouter()
|
||||
await router.push('/'); await router.isReady()
|
||||
const wrapper = mount(ContainersView, { props: { workbench: 'OVERSEAS' }, global: { plugins: [ElementPlus, router] } })
|
||||
@@ -514,11 +514,11 @@ describe('ContainersView', () => {
|
||||
|
||||
const oldSearch = vm.searchTerminalFleets('旧关键词')
|
||||
const latestSearch = vm.searchTerminalFleets('新车队')
|
||||
expect(listSuppliers).toHaveBeenNthCalledWith(1, { page: 1, size: 20, category: 'TRUCKING', status: 'ACTIVE', keyword: '旧关键词' })
|
||||
expect(listSuppliers).toHaveBeenNthCalledWith(2, { page: 1, size: 20, category: 'TRUCKING', status: 'ACTIVE', keyword: '新车队' })
|
||||
resolveLatest({ records: [{ id: '503', name: '新车队', category: 'TRUCKING', status: 'ACTIVE' } as never], page: 1, size: 20, total: 1, pages: 1 })
|
||||
expect(listSuppliers).toHaveBeenNthCalledWith(1, { page: 1, size: 20, category: 'TRUCKING', truckingScope: 'OVERSEAS', status: 'ACTIVE', keyword: '旧关键词' })
|
||||
expect(listSuppliers).toHaveBeenNthCalledWith(2, { page: 1, size: 20, category: 'TRUCKING', truckingScope: 'OVERSEAS', status: 'ACTIVE', keyword: '新车队' })
|
||||
resolveLatest({ records: [{ id: '503', name: '新车队', category: 'TRUCKING', truckingScope: 'OVERSEAS', status: 'ACTIVE' } as never, { id: '504', name: '国内车队', category: 'TRUCKING', truckingScope: 'DOMESTIC', status: 'ACTIVE' } as never], page: 1, size: 20, total: 2, pages: 1 })
|
||||
await latestSearch
|
||||
resolveOld({ records: [{ id: '502', name: '旧车队', category: 'TRUCKING', status: 'ACTIVE' } as never], page: 1, size: 20, total: 1, pages: 1 })
|
||||
resolveOld({ records: [{ id: '502', name: '旧车队', category: 'TRUCKING', truckingScope: 'OVERSEAS', status: 'ACTIVE' } as never], page: 1, size: 20, total: 1, pages: 1 })
|
||||
await oldSearch
|
||||
expect(vm.terminalFleetOptions).toEqual([expect.objectContaining({ id: '503', name: '新车队' })])
|
||||
wrapper.unmount()
|
||||
@@ -742,7 +742,7 @@ describe('ContainersView', () => {
|
||||
})
|
||||
|
||||
it('backfills a legacy trucking supplier name and submits the frozen id-name pair', async () => {
|
||||
vi.mocked(listSuppliers).mockResolvedValue({ records: [{ id: '9007199254740998', tenantId: '1', name: '测试拖车供应商', category: 'TRUCKING', carrierCodes: [], region: 'CN', legalRepresentative: '张三', contactName: '李四', phone: '13800000000', email: null, address: null, businessLicenseNo: null, licenseFileId: null, status: 'ACTIVE', settlementPeriod: 'MONTHLY_30', remark: null, createdBy: null, createdAt: '2026-08-25T10:00:00', updatedBy: null, updatedAt: '2026-08-25T10:00:00', deleted: false, version: 0 }], page: 1, size: 200, total: 1, pages: 1 })
|
||||
vi.mocked(listSuppliers).mockResolvedValue({ records: [{ id: '9007199254740998', tenantId: '1', name: '测试拖车供应商', category: 'TRUCKING', truckingScope: 'OVERSEAS', carrierCodes: [], region: 'CN', legalRepresentative: '张三', contactName: '李四', phone: '13800000000', email: null, address: null, businessLicenseNo: null, licenseFileId: null, status: 'ACTIVE', settlementPeriod: 'MONTHLY_30', remark: null, createdBy: null, createdAt: '2026-08-25T10:00:00', updatedBy: null, updatedAt: '2026-08-25T10:00:00', deleted: false, version: 0 }], page: 1, size: 200, total: 1, pages: 1 })
|
||||
vi.mocked(getContainer).mockResolvedValueOnce({ id: '9001', allowedActions: ['EDIT_CONTAINER'], planStatus: 'WAIT_LOADING', containerType: '40HQ', warehouseId: '8', terminalTruckingCompanyId: null, terminalTruckingCompany: '测试拖车供应商' } as never)
|
||||
vi.mocked(updateContainer).mockResolvedValue({} as never)
|
||||
const router = createTestRouter()
|
||||
@@ -762,7 +762,7 @@ describe('ContainersView', () => {
|
||||
})
|
||||
|
||||
it('clears the trucking supplier id and name together', async () => {
|
||||
vi.mocked(listSuppliers).mockResolvedValue({ records: [{ id: '9007199254740998', tenantId: '1', name: '测试拖车供应商', category: 'TRUCKING', carrierCodes: [], region: 'CN', legalRepresentative: '张三', contactName: '李四', phone: '13800000000', email: null, address: null, businessLicenseNo: null, licenseFileId: null, status: 'ACTIVE', settlementPeriod: 'MONTHLY_30', remark: null, createdBy: null, createdAt: '2026-08-25T10:00:00', updatedBy: null, updatedAt: '2026-08-25T10:00:00', deleted: false, version: 0 }], page: 1, size: 200, total: 1, pages: 1 })
|
||||
vi.mocked(listSuppliers).mockResolvedValue({ records: [{ id: '9007199254740998', tenantId: '1', name: '测试拖车供应商', category: 'TRUCKING', truckingScope: 'OVERSEAS', carrierCodes: [], region: 'CN', legalRepresentative: '张三', contactName: '李四', phone: '13800000000', email: null, address: null, businessLicenseNo: null, licenseFileId: null, status: 'ACTIVE', settlementPeriod: 'MONTHLY_30', remark: null, createdBy: null, createdAt: '2026-08-25T10:00:00', updatedBy: null, updatedAt: '2026-08-25T10:00:00', deleted: false, version: 0 }], page: 1, size: 200, total: 1, pages: 1 })
|
||||
vi.mocked(getContainer).mockResolvedValueOnce({ id: '9001', allowedActions: ['EDIT_CONTAINER'], planStatus: 'WAIT_LOADING', containerType: '40HQ', warehouseId: '8', terminalTruckingCompanyId: '9007199254740998', terminalTruckingCompany: '测试拖车供应商' } as never)
|
||||
vi.mocked(updateContainer).mockResolvedValue({} as never)
|
||||
const router = createTestRouter()
|
||||
@@ -822,9 +822,9 @@ describe('ContainersView trucking supplier contract', () => {
|
||||
|
||||
it('uses active TRUCKING suppliers without allowing free-text carrier names', () => {
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainersView.vue'), 'utf8')
|
||||
expect(source).toContain("listSuppliers({ page: 1, size: 200, category: 'TRUCKING', status: 'ACTIVE' })")
|
||||
expect(source).toContain("page: 1, size: 20, category: 'TRUCKING', status: 'ACTIVE', keyword: keyword.trim() || undefined")
|
||||
expect(source).toContain("supplier.category === 'TRUCKING' && supplier.status === 'ACTIVE'")
|
||||
expect(source).toContain("listSuppliers({ page: 1, size: 200, category: 'TRUCKING', truckingScope: 'OVERSEAS', status: 'ACTIVE' })")
|
||||
expect(source).toContain("page: 1, size: 20, category: 'TRUCKING', truckingScope: 'OVERSEAS', status: 'ACTIVE', keyword: keyword.trim() || undefined")
|
||||
expect(source).toContain("supplier.category === 'TRUCKING' && supplier.truckingScope === 'OVERSEAS' && supplier.status === 'ACTIVE'")
|
||||
expect(source).toContain('filterable remote reserve-keyword remote-show-suffix')
|
||||
expect(source).toContain(':remote-method="searchTerminalFleets"')
|
||||
expect(source).toContain('selectTruckingSupplier')
|
||||
|
||||
@@ -197,7 +197,7 @@ const canViewPreload = computed(() => access.can('platform:firstleg:preload:list
|
||||
const canViewLoadingList = computed(() => access.can('platform:firstleg:loading-list:list'))
|
||||
const editorTitle = computed(() => form.id ? '编辑' : '新增')
|
||||
const shippingCompanies = computed(() => partners.value.filter((partner) => partner.status === 'ACTIVE' && partner.types.includes('CARRIER')))
|
||||
const truckingCompanies = computed(() => truckingSuppliers.value.filter((supplier) => supplier.status === 'ACTIVE' && supplier.category === 'TRUCKING'))
|
||||
const truckingCompanies = computed(() => truckingSuppliers.value.filter((supplier) => supplier.status === 'ACTIVE' && supplier.category === 'TRUCKING' && supplier.truckingScope === 'OVERSEAS'))
|
||||
interface PortOption { value: string; code: string | null; name: string | null }
|
||||
function portValue(code: string | null | undefined, name: string | null | undefined) { return code || name ? JSON.stringify([code || '', name || '']) : '' }
|
||||
function collectPorts(direction: 'departure' | 'destination'): PortOption[] {
|
||||
@@ -465,8 +465,8 @@ async function loadTruckingSuppliers(): Promise<Supplier[]> {
|
||||
if (truckingSuppliersLoaded) return truckingSuppliers.value
|
||||
if (truckingSuppliersPromise) return truckingSuppliersPromise
|
||||
truckingSuppliersPromise = (async () => {
|
||||
const result = await listSuppliers({ page: 1, size: 200, category: 'TRUCKING', status: 'ACTIVE' })
|
||||
const records = result.records.filter((supplier) => supplier.category === 'TRUCKING' && supplier.status === 'ACTIVE')
|
||||
const result = await listSuppliers({ page: 1, size: 200, category: 'TRUCKING', truckingScope: 'OVERSEAS', status: 'ACTIVE' })
|
||||
const records = result.records.filter((supplier) => supplier.category === 'TRUCKING' && supplier.truckingScope === 'OVERSEAS' && supplier.status === 'ACTIVE')
|
||||
truckingSuppliers.value = records
|
||||
truckingSuppliersLoaded = true
|
||||
return records
|
||||
@@ -479,10 +479,10 @@ async function searchTerminalFleets(keyword = '') {
|
||||
terminalFleetLoading.value = true
|
||||
try {
|
||||
const result = await listSuppliers({
|
||||
page: 1, size: 20, category: 'TRUCKING', status: 'ACTIVE', keyword: keyword.trim() || undefined,
|
||||
page: 1, size: 20, category: 'TRUCKING', truckingScope: 'OVERSEAS', status: 'ACTIVE', keyword: keyword.trim() || undefined,
|
||||
})
|
||||
if (sequence !== terminalFleetSearchSequence) return
|
||||
terminalFleetOptions.value = result.records.filter((supplier) => supplier.category === 'TRUCKING' && supplier.status === 'ACTIVE')
|
||||
terminalFleetOptions.value = result.records.filter((supplier) => supplier.category === 'TRUCKING' && supplier.truckingScope === 'OVERSEAS' && supplier.status === 'ACTIVE')
|
||||
} catch (error) {
|
||||
if (sequence !== terminalFleetSearchSequence) return
|
||||
terminalFleetOptions.value = []
|
||||
|
||||
@@ -77,6 +77,7 @@ const plan = {
|
||||
canAssign: true, canEdit: true, blockedReason: null, finalCompletionStatus: 'NOT_STARTED' as const,
|
||||
pdaTaskId: null, pdaTaskStatus: null, pdaTaskVersion: null,
|
||||
coLoadGroups: [],
|
||||
palletMembers: [],
|
||||
items: [{ id: '101', loadingPlanId: '99', accountId: '1', inboundId: '2', inboundDetailId: '3', orderId: '4', orderNo: 'ORD-1', inboundCode: 'IN-1', customerName: '示例企业', destinationWarehouseId: '9', destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', skuCode: 'SKU-1', productNameZh: '服装', unitType: 'PACKAGE' as const, palletId: null, palletNo: null, indivisible: false, packageCount: 6, productQuantity: 6, grossWeightKg: 60, volumeCbm: 1.2, loadSequence: 1, version: 1, pickedPackageCount: 0, minPackageCount: 1, canAdjust: true, canRemove: true }],
|
||||
}
|
||||
const candidate = {
|
||||
@@ -953,6 +954,31 @@ describe('GlobalSmartLoadingView', () => {
|
||||
expect(removeSmartLoadingItem).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows every inbound code and its box count for an assigned sealed pallet', async () => {
|
||||
const palletItem = {
|
||||
...plan.items[0], inboundId: '180', inboundCode: 'I180', unitType: 'SEALED_PALLET' as const,
|
||||
palletId: '801', palletNo: 'DP-260914-0025', indivisible: true, packageCount: 11,
|
||||
pickedPackageCount: 0, minPackageCount: 11, canAdjust: false, canRemove: true,
|
||||
}
|
||||
vi.mocked(getSmartLoadingPlanByBooking).mockResolvedValue({
|
||||
...plan,
|
||||
items: [palletItem],
|
||||
palletMembers: [{ palletId: '801', members: [
|
||||
{ accountId: '1', inboundId: '180', orderId: '280', orderNo: 'O180', inboundCode: 'I180', customerName: '客户', availablePackageCount: 6, orderStatusSnapshot: null, expectedArrivalAt: null },
|
||||
{ accountId: '1', inboundId: '181', orderId: '281', orderNo: 'O181', inboundCode: 'I181', customerName: '客户', availablePackageCount: 5, orderStatusSnapshot: null, expectedArrivalAt: null },
|
||||
] }],
|
||||
})
|
||||
|
||||
const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
|
||||
const row = wrapper.findAll('.plan-pane .el-table__body-wrapper tr').find((candidateRow) => candidateRow.text().includes('DP-260914-0025'))
|
||||
expect(row).toBeDefined()
|
||||
expect(row!.text()).toContain('2 个入仓码 · 展开')
|
||||
expect(row!.text()).toContain('I180:6 箱')
|
||||
expect(row!.text()).toContain('I181:5 箱')
|
||||
})
|
||||
|
||||
it('renders final completion capability as read-only with the server reason', async () => {
|
||||
vi.mocked(getSmartLoadingPlanByBooking).mockResolvedValue({
|
||||
...plan,
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
} from '../api/platformContainerLoading'
|
||||
import type {
|
||||
SmartLoadingCandidate,
|
||||
SmartLoadingCandidateGroupMember,
|
||||
SmartLoadingPlan,
|
||||
SmartLoadingWorkbenchContainer,
|
||||
} from '../types'
|
||||
@@ -146,7 +147,10 @@ const displayedCandidates = computed<SmartLoadingCandidateRow[]>(() => {
|
||||
}
|
||||
})
|
||||
})
|
||||
type SmartLoadingPlanRow = SmartLoadingInboundItem & { coLoadGroup: SmartLoadingPlanCoLoadGroup | null; memberInboundCodes: string[]; memberOrderSummaries: string[] }
|
||||
const planPalletMembersByPalletId = computed(() => new Map(
|
||||
(plan.value?.palletMembers || []).map((entry) => [entry.palletId, entry.members]),
|
||||
))
|
||||
type SmartLoadingPlanRow = SmartLoadingInboundItem & { coLoadGroup: SmartLoadingPlanCoLoadGroup | null; palletMembers: SmartLoadingCandidateGroupMember[]; memberInboundCodes: string[]; memberOrderSummaries: string[] }
|
||||
const displayedPlanItems = computed<SmartLoadingPlanRow[]>(() => {
|
||||
const inboundRows = groupSmartLoadingItems(plan.value?.items || [])
|
||||
const result: SmartLoadingPlanRow[] = []
|
||||
@@ -154,7 +158,7 @@ const displayedPlanItems = computed<SmartLoadingPlanRow[]>(() => {
|
||||
for (const row of inboundRows) {
|
||||
const group = planGroupsByInboundId.value.get(row.inboundId)
|
||||
if (!group) {
|
||||
result.push({ ...row, coLoadGroup: null, memberInboundCodes: [row.inboundCode || '入仓码暂缺'], memberOrderSummaries: memberOrderSummaries([row]) })
|
||||
result.push({ ...row, coLoadGroup: null, palletMembers: row.palletId ? planPalletMembersByPalletId.value.get(row.palletId) || [] : [], memberInboundCodes: [row.inboundCode || '入仓码暂缺'], memberOrderSummaries: memberOrderSummaries([row]) })
|
||||
continue
|
||||
}
|
||||
grouped.set(group.groupId, [...(grouped.get(group.groupId) || []), row])
|
||||
@@ -178,6 +182,7 @@ const displayedPlanItems = computed<SmartLoadingPlanRow[]>(() => {
|
||||
sourceItems: rows.flatMap((row) => row.sourceItems),
|
||||
detailCount: rows.reduce((sum, row) => sum + row.detailCount, 0),
|
||||
coLoadGroup: group,
|
||||
palletMembers: [],
|
||||
memberInboundCodes: rows.map((row) => row.inboundCode || '入仓码暂缺'),
|
||||
memberOrderSummaries: memberOrderSummaries(rows),
|
||||
})
|
||||
@@ -243,7 +248,7 @@ const candidateColumns: DataTableColumn[] = [
|
||||
]
|
||||
const itemColumns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 90, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'inboundCode', prop: 'inboundCode', label: '入仓码', minWidth: 140, fixed: 'left', showOverflowTooltip: true },
|
||||
{ key: 'inboundCode', label: '入仓码', minWidth: 220, fixed: 'left', slotName: 'planInbounds' },
|
||||
{ key: 'palletNo', prop: 'palletNo', label: '托盘号', minWidth: 145, showOverflowTooltip: true },
|
||||
{ key: 'coLoadGroup', label: '装柜规则', minWidth: 170, slotName: 'coLoadGroup' },
|
||||
{ key: 'customerName', prop: 'customerName', label: '客户', minWidth: 70, slotName: 'customerName' },
|
||||
@@ -821,6 +826,7 @@ onMounted(() => { void load(); void loadCustomers() })
|
||||
</div>
|
||||
<DataTable :data="displayedPlanItems" :columns="itemColumns" :loading="selectionLoading || mutating" stripe preference-key="global-smart-loading-items" column-settings-mode="hide" empty-text="当前柜次暂无装柜明细">
|
||||
<template #actions="{ row }"><div class="row-actions"><el-button v-if="canDelete && editable && row.canRemove" size="small" type="danger" text :disabled="mutating" @click="remove(row)"><Trash2 :size="15" />删除</el-button></div></template>
|
||||
<template #planInbounds="{ row }"><SmartLoadingCandidateInbounds :members="row.palletMembers" :sealed-pallet="row.unitType === 'SEALED_PALLET'" :fallback-inbound-code="row.inboundCode" /></template>
|
||||
<template #customerName="{ row }"><LineClampText :content="row.customerName" /></template>
|
||||
<template #coLoadGroup="{ row }"><details v-if="row.coLoadGroup" class="candidate-group"><summary>整组加入 · 不可拆</summary><el-tag size="small" type="warning"><LineClampText :content="`${row.coLoadGroup.groupNo} · ${groupMemberLabel(row.coLoadGroup)}`" /></el-tag><LineClampText :content="row.memberOrderSummaries.join(';')" /><LineClampText :content="`固定 ${row.coLoadGroup.fullPackageCount} 箱 · ${row.coLoadGroup.lockedPlanId ? '已锁定' : '未锁定'}`" /></details><LineClampText v-else :content="row.unitType === 'SEALED_PALLET' ? '整托加入 · 不可拆' : '按箱加入'" /></template>
|
||||
<template #packageCount="{ row }"><LineClampText v-if="row.unitType === 'SEALED_PALLET'" :content="`${row.packageCount} 箱(整托固定)`" /><LineClampText v-else-if="row.coLoadGroup" :content="`${row.packageCount} 箱(整组固定)`" /><div v-else class="package-count-editor"><el-input-number v-if="editingQuantityId === row.id" v-model="quantityDrafts[row.id]" :min="row.minPackageCount" :max="adjustmentMax(row)" :step="1" :controls="false" :disabled="mutating" placeholder="请输入使用箱数" @blur="adjust(row)" @keyup.enter="adjust(row)" /><div v-else class="package-count-summary"><div class="package-count-used"><strong>使用:{{ row.packageCount }}</strong><el-button v-if="canAssign && editable && row.canAdjust" link circle :disabled="mutating" aria-label="调整箱数" @click="startQuantityEdit(row)"><Pencil :size="14" /></el-button></div><small v-if="(row.remainingPackageCount ?? 0) > 0">未用:{{ row.remainingPackageCount }}</small></div><LineClampText v-if="row.detailCount > 1 && editable" content="多规格,需删除后重加" /><LineClampText v-if="row.pickedPackageCount > 0" :content="`已拣 ${row.pickedPackageCount} 箱`" /></div></template>
|
||||
|
||||
@@ -29,7 +29,7 @@ const container = {
|
||||
const planResponse = {
|
||||
id: '99', bookingId: container.id, planNo: 'LP-99', status: 'EDITING' as const, containerNo: 'MSCU-1', voyageNo: null, version: 2,
|
||||
canAssign: true, canEdit: true, blockedReason: null, finalCompletionStatus: 'NOT_STARTED' as const,
|
||||
pdaTaskId: null, pdaTaskStatus: null, pdaTaskVersion: null, coLoadGroups: [], items: [],
|
||||
pdaTaskId: null, pdaTaskStatus: null, pdaTaskVersion: null, coLoadGroups: [], palletMembers: [], items: [],
|
||||
}
|
||||
const candidate = { accountId: '1', inboundId: '2', orderId: '4', orderNo: 'ORD-1', inboundCode: 'IN-1', warehouseId: '8', channelCode: 'ZBYHK', channelName: '美森正班海卡包税', destinationWarehouseId: '9', destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', customerName: '示例企业', goodsSummary: '服装', availablePackageCount: 12, unitGrossWeightKg: 10, unitNetWeightKg: 9, averageUnitVolumeCbm: 0.2, availableVolumeCbm: 2.4, coLoadGroupId: null, groupNo: null, memberCount: null, groupTotalBoxes: null, locked: null, blockReason: null }
|
||||
function candidatePage(records: unknown[]) {
|
||||
@@ -78,6 +78,33 @@ describe('SmartLoadingWorkbenchView', () => {
|
||||
expect(wrapper.text()).toContain('150% 容量上限')
|
||||
})
|
||||
|
||||
it('shows the full inbound composition for an assigned sealed pallet', async () => {
|
||||
vi.mocked(getSmartLoadingPlanByBooking).mockResolvedValue({
|
||||
...planResponse,
|
||||
items: [{
|
||||
id: '101', loadingPlanId: '99', accountId: '1', inboundId: '180', inboundDetailId: '201', orderId: '280',
|
||||
orderNo: 'O180', inboundCode: 'I180', customerName: '客户', destinationWarehouseId: '9',
|
||||
destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', skuCode: 'SKU-1', productNameZh: '货物',
|
||||
unitType: 'SEALED_PALLET', palletId: '801', palletNo: 'DP-260914-0025', indivisible: true,
|
||||
packageCount: 11, productQuantity: 110, grossWeightKg: 109.2, volumeCbm: 1.87, loadSequence: 1,
|
||||
version: 1, pickedPackageCount: 0, minPackageCount: 11, canAdjust: false, canRemove: true,
|
||||
}],
|
||||
palletMembers: [{ palletId: '801', members: [
|
||||
{ accountId: '1', inboundId: '180', orderId: '280', orderNo: 'O180', inboundCode: 'I180', customerName: '客户', availablePackageCount: 6, orderStatusSnapshot: null, expectedArrivalAt: null },
|
||||
{ accountId: '1', inboundId: '181', orderId: '281', orderNo: 'O181', inboundCode: 'I181', customerName: '客户', availablePackageCount: 5, orderStatusSnapshot: null, expectedArrivalAt: null },
|
||||
] }],
|
||||
})
|
||||
const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/container-loading/:containerId/smart-loading', component: SmartLoadingWorkbenchView }] })
|
||||
await router.push(`/container-loading/${container.id}/smart-loading`); await router.isReady()
|
||||
const wrapper = mount(SmartLoadingWorkbenchView, { global: { plugins: [ElementPlus, router] } })
|
||||
await flushPromises()
|
||||
|
||||
const row = wrapper.findAll('.el-table__body-wrapper tr').find((candidateRow) => candidateRow.text().includes('DP-260914-0025'))
|
||||
expect(row).toBeDefined()
|
||||
expect(row!.text()).toContain('I180:6 箱')
|
||||
expect(row!.text()).toContain('I181:5 箱')
|
||||
})
|
||||
|
||||
it('uses loading plan capabilities to keep PDA increments editable', async () => {
|
||||
vi.mocked(getContainer).mockResolvedValue({ ...container, planStatus: 'LOADING' })
|
||||
vi.mocked(getSmartLoadingPlanByBooking).mockResolvedValue({ ...planResponse, status: 'LOADING', pdaTaskId: '700', pdaTaskStatus: 'LOADING', pdaTaskVersion: 4 })
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
getSmartLoadingPlanByBooking, listSmartLoadingCandidates,
|
||||
recommendSmartLoading, removeSmartLoadingGroup, removeSmartLoadingItem,
|
||||
} from '../api/platformContainerLoading'
|
||||
import type { ContainerBookingResource, SmartLoadingCandidate, SmartLoadingPlan } from '../types'
|
||||
import type { ContainerBookingResource, SmartLoadingCandidate, SmartLoadingCandidateGroupMember, SmartLoadingPlan } from '../types'
|
||||
import { groupSmartLoadingItems, type SmartLoadingInboundItem } from '../smartLoadingItems'
|
||||
import { loadSmartLoadingPlanCoLoadGroups, type SmartLoadingPlanCoLoadGroup } from '../smartLoadingPlanGroups'
|
||||
import { getSmartLoadingChannelGroup } from '../smartLoadingChannelGroups'
|
||||
@@ -80,7 +80,10 @@ const displayedCandidates = computed<SmartLoadingCandidateRow[]>(() => {
|
||||
}
|
||||
})
|
||||
})
|
||||
type SmartLoadingPlanRow = SmartLoadingInboundItem & { coLoadGroup: SmartLoadingPlanCoLoadGroup | null; memberInboundCodes: string[]; memberOrderSummaries: string[] }
|
||||
const planPalletMembersByPalletId = computed(() => new Map(
|
||||
(plan.value?.palletMembers || []).map((entry) => [entry.palletId, entry.members]),
|
||||
))
|
||||
type SmartLoadingPlanRow = SmartLoadingInboundItem & { coLoadGroup: SmartLoadingPlanCoLoadGroup | null; palletMembers: SmartLoadingCandidateGroupMember[]; memberInboundCodes: string[]; memberOrderSummaries: string[] }
|
||||
const displayedPlanItems = computed<SmartLoadingPlanRow[]>(() => {
|
||||
const inboundRows = groupSmartLoadingItems(plan.value?.items || [])
|
||||
const result: SmartLoadingPlanRow[] = []
|
||||
@@ -88,7 +91,7 @@ const displayedPlanItems = computed<SmartLoadingPlanRow[]>(() => {
|
||||
for (const row of inboundRows) {
|
||||
const group = planGroupsByInboundId.value.get(row.inboundId)
|
||||
if (!group) {
|
||||
result.push({ ...row, coLoadGroup: null, memberInboundCodes: [row.inboundCode || '入仓码暂缺'], memberOrderSummaries: memberOrderSummaries([row]) })
|
||||
result.push({ ...row, coLoadGroup: null, palletMembers: row.palletId ? planPalletMembersByPalletId.value.get(row.palletId) || [] : [], memberInboundCodes: [row.inboundCode || '入仓码暂缺'], memberOrderSummaries: memberOrderSummaries([row]) })
|
||||
continue
|
||||
}
|
||||
grouped.set(group.groupId, [...(grouped.get(group.groupId) || []), row])
|
||||
@@ -114,6 +117,7 @@ const displayedPlanItems = computed<SmartLoadingPlanRow[]>(() => {
|
||||
sourceItems: rows.flatMap((row) => row.sourceItems),
|
||||
detailCount: rows.reduce((sum, row) => sum + row.detailCount, 0),
|
||||
coLoadGroup: group,
|
||||
palletMembers: [],
|
||||
memberInboundCodes: rows.map((row) => row.inboundCode || '入仓码暂缺'),
|
||||
memberOrderSummaries: memberOrderSummaries(rows),
|
||||
})
|
||||
@@ -384,7 +388,7 @@ onMounted(load)
|
||||
<DataTable :data="displayedPlanItems" :columns="itemColumns" :loading="loading || mutating" stripe preference-key="smart-loading-items" column-settings-mode="hide" empty-text="暂无已分配货物">
|
||||
<template #toolbar-start><div><h2>{{ displayContainerNo(container) }} 装柜方案</h2><p>智能建议必须经人工核对。</p></div></template>
|
||||
<template #actions="{ row }"><div class="row-actions density-actions"><el-button data-tone="danger" class="density-action" v-if="canDelete && editable && row.canRemove" size="small" type="danger" plain @click="remove(row)">删除</el-button></div></template>
|
||||
<template #order="{ row }"><strong>{{ row.inboundCode || '—' }}</strong></template>
|
||||
<template #order="{ row }"><SmartLoadingCandidateInbounds :members="row.palletMembers" :sealed-pallet="row.unitType === 'SEALED_PALLET'" :fallback-inbound-code="row.inboundCode" /></template>
|
||||
<template #customerName="{ row }"><LineClampText :content="row.customerName" /></template>
|
||||
<template #coLoadGroup="{ row }"><details v-if="row.coLoadGroup" class="candidate-group"><summary>整组加入 · 不可拆</summary><el-tag size="small" type="warning"><LineClampText :content="`${row.coLoadGroup.groupNo} · ${groupMemberLabel(row.coLoadGroup)}`" /></el-tag><LineClampText :content="row.memberOrderSummaries.join(';')" /><LineClampText :content="`全部入仓片段联动,固定 ${row.coLoadGroup.fullPackageCount} 箱`" /></details><LineClampText v-else :content="row.unitType === 'SEALED_PALLET' ? '整托加入 · 不可拆' : '按箱加入'" /></template>
|
||||
<template #goods="{ row }"><div class="goods-cell"><LineClampText :content="row.productNameZh || '未命名货物'" /><LineClampText class="goods-cell__sku" :content="row.skuCode || '无 SKU'" /></div></template>
|
||||
|
||||
@@ -15,6 +15,7 @@ const plan = {
|
||||
pdaTaskStatus: null, pdaTaskVersion: null,
|
||||
coLoadGroups: [{ groupId: '9001', groupNo: 'CLG-9001', lockedPlanId: '99', memberOrderCount: 2,
|
||||
inboundFragmentCount: 2, fullPackageCount: 12, memberInboundIds: ['201', '202'] }],
|
||||
palletMembers: [],
|
||||
items: [item, { ...item, id: '702', inboundId: '202', inboundDetailId: '802', orderId: '302', orderNo: 'ORD-2', inboundCode: 'IN-202', packageCount: 7 }],
|
||||
} satisfies SmartLoadingPlan
|
||||
|
||||
|
||||
@@ -454,5 +454,9 @@ export interface SmartLoadingPlan {
|
||||
fullPackageCount: number
|
||||
memberInboundIds: string[]
|
||||
}>
|
||||
palletMembers: Array<{
|
||||
palletId: string
|
||||
members: SmartLoadingCandidateGroupMember[]
|
||||
}>
|
||||
items: SmartLoadingItem[]
|
||||
}
|
||||
|
||||
@@ -79,7 +79,7 @@ describe('Finance G3/G4 views', () => {
|
||||
document.body.innerHTML = ''
|
||||
const customer = { id: '10001', tenantName: '客户A', status: 'ACTIVE' } as PlatformTenantResource
|
||||
vi.mocked(listPlatformTenants).mockReset().mockResolvedValue({ records: [customer], page: 1, size: 200, total: 1, pages: 1 })
|
||||
vi.mocked(listSuppliers).mockReset().mockResolvedValue({ records: [{ id: '301', tenantId: '0', name: '拖车供应商A', category: 'TRUCKING', carrierCodes: [], scope: 'DOMESTIC', region: '宁波', legalRepresentative: '', contactName: '', phone: '', email: null, address: null, businessLicenseNo: null, licenseFileId: null, status: 'ACTIVE', settlementPeriod: 'MONTHLY_30', remark: null, createdBy: null, createdAt: '2026-08-01', updatedBy: null, updatedAt: '2026-08-01', deleted: false, version: 0 } as Supplier], page: 1, size: 200, total: 1, pages: 1 })
|
||||
vi.mocked(listSuppliers).mockReset().mockResolvedValue({ records: [{ id: '301', tenantId: '0', name: '拖车供应商A', category: 'TRUCKING', truckingScope: 'DOMESTIC', carrierCodes: [], scope: 'DOMESTIC', region: '宁波', legalRepresentative: '', contactName: '', phone: '', email: null, address: null, businessLicenseNo: null, licenseFileId: null, status: 'ACTIVE', settlementPeriod: 'MONTHLY_30', remark: null, createdBy: null, createdAt: '2026-08-01', updatedBy: null, updatedAt: '2026-08-01', deleted: false, version: 0 } as Supplier], page: 1, size: 200, total: 1, pages: 1 })
|
||||
vi.mocked(listFinanceStatements).mockReset().mockResolvedValue([statement])
|
||||
vi.mocked(getReconciliationStatement).mockReset().mockResolvedValue(statementDetail)
|
||||
vi.mocked(actionReconciliationStatement).mockReset().mockResolvedValue(statementDetail)
|
||||
|
||||
@@ -8,7 +8,7 @@ vi.mock('@/shared/api/http', async (original) => ({
|
||||
http: { get: vi.fn(), post: vi.fn(), put: vi.fn(), patch: vi.fn() },
|
||||
}))
|
||||
|
||||
const payload: SaveSupplierRequest = { name: '测试供应商', category: 'BOOKING', carrierCodes: ['MATSON', 'SML'], region: '宁波', legalRepresentative: '张三', contactName: '李四', phone: '13800000000', email: null, address: null, businessLicenseNo: null, licenseFileId: '9007199254740995', status: 'ACTIVE', settlementPeriod: 'MONTHLY_30', remark: null }
|
||||
const payload: SaveSupplierRequest = { name: '测试供应商', category: 'BOOKING', truckingScope: null, carrierCodes: ['MATSON', 'SML'], region: '宁波', legalRepresentative: '张三', contactName: '李四', phone: '13800000000', email: null, address: null, businessLicenseNo: null, licenseFileId: '9007199254740995', status: 'ACTIVE', settlementPeriod: 'MONTHLY_30', remark: null }
|
||||
const resource = { id: 9007199254740993, tenantId: 0, ...payload, createdBy: 9007199254740994, createdAt: '2026-08-13T10:00:00', updatedBy: null, updatedAt: '2026-08-13T10:00:00', deleted: false, version: 1 }
|
||||
|
||||
describe('platform supplier API', () => {
|
||||
@@ -37,4 +37,11 @@ describe('platform supplier API', () => {
|
||||
expect(http.put).toHaveBeenCalledWith('/platform/v1/suppliers/77', payload, { params: { tenantId: 0 } })
|
||||
expect(http.patch).toHaveBeenCalledWith('/platform/v1/suppliers/77/status', { status: 'INACTIVE' }, { params: { tenantId: 0 } })
|
||||
})
|
||||
it('passes the trucking scope filter and keeps the authoritative type in resources', async () => {
|
||||
vi.mocked(http.get).mockResolvedValue({ code: '200', data: { records: [{ ...resource, category: 'TRUCKING', carrierCodes: [], truckingScope: 'OVERSEAS' }], page: 1, size: 20, total: 1, pages: 1 } })
|
||||
const page = await listSuppliers({ category: 'TRUCKING', truckingScope: 'OVERSEAS', status: 'ACTIVE' })
|
||||
expect(http.get).toHaveBeenCalledWith('/platform/v1/suppliers', { params: expect.objectContaining({ category: 'TRUCKING', truckingScope: 'OVERSEAS', status: 'ACTIVE' }) })
|
||||
expect(page.records[0]?.truckingScope).toBe('OVERSEAS')
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
@@ -21,7 +21,7 @@ export async function listSuppliers(query: SupplierQuery): Promise<SupplierPage>
|
||||
page: query.page ?? 1, size: query.size ?? 20, category: query.category,
|
||||
region: query.region?.trim() || undefined,
|
||||
status: query.status || undefined, keyword: query.keyword?.trim() || undefined,
|
||||
carrierCode: query.carrierCode || undefined,
|
||||
carrierCode: query.carrierCode || undefined, truckingScope: query.truckingScope,
|
||||
},
|
||||
})
|
||||
const data = unwrapApiResponse(response)
|
||||
|
||||
@@ -12,7 +12,7 @@ import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import SupplierLicenseDialog from '@/modules/suppliers/components/SupplierLicenseDialog.vue'
|
||||
import { getSupplier } from '@/modules/suppliers/api/platformSuppliers'
|
||||
import { categoryLabels, formatCarrierCodes, settlementLabels, statusLabels, type Supplier } from '@/modules/suppliers/types'
|
||||
import { formatTruckingScope, categoryLabels, formatCarrierCodes, settlementLabels, statusLabels, type Supplier } from '@/modules/suppliers/types'
|
||||
|
||||
const route = useRoute(); const router = useRouter(); const access = useAccessPreviewStore()
|
||||
const canView = computed(() => access.can('platform:supplier:list'))
|
||||
@@ -21,6 +21,7 @@ const supplier = ref<Supplier | null>(null); const loading = ref(false); const e
|
||||
let loadSequence = 0
|
||||
const items = computed(() => supplier.value ? [
|
||||
['供应商名称', supplier.value.name], ['供应商类别', categoryLabels[supplier.value.category]], ['可代理船公司', supplier.value.category === 'BOOKING' ? formatCarrierCodes(supplier.value.carrierCodes) : '—'], ['地区', supplier.value.region],
|
||||
...(supplier.value.category === 'TRUCKING' ? [['拖车类型', formatTruckingScope(supplier.value.truckingScope)]] : []),
|
||||
['法人代表', supplier.value.legalRepresentative], ['联系人', supplier.value.contactName], ['联系电话', supplier.value.phone], ['电子邮箱', supplier.value.email || '—'],
|
||||
['营业执照号', supplier.value.businessLicenseNo || '—'], ['结算周期', settlementLabels[supplier.value.settlementPeriod]], ['公司地址', supplier.value.address || '—'], ['备注', supplier.value.remark || '—'],
|
||||
] : [])
|
||||
|
||||
@@ -14,7 +14,7 @@ import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
|
||||
import { createSupplier, getSupplier, updateSupplier } from '@/modules/suppliers/api/platformSuppliers'
|
||||
import { carrierCodeOptions, categoryLabels, settlementLabels, statusLabels, type SaveSupplierRequest } from '@/modules/suppliers/types'
|
||||
import { truckingScopeLabels, carrierCodeOptions, categoryLabels, settlementLabels, statusLabels, type TruckingScope, type SaveSupplierRequest } from '@/modules/suppliers/types'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -27,10 +27,13 @@ const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const loadError = ref<ApiErrorDetails | null>(null)
|
||||
const submitError = ref<ApiErrorDetails | null>(null)
|
||||
const initial = (): SaveSupplierRequest => ({ name: '', category: 'BOOKING', carrierCodes: [], region: '', legalRepresentative: '', contactName: '', phone: '', email: null, address: null, businessLicenseNo: null, licenseFileId: null, status: 'ACTIVE', settlementPeriod: 'MONTHLY_30', remark: null })
|
||||
const initial = (): SaveSupplierRequest => ({ name: '', category: 'BOOKING', truckingScope: null, carrierCodes: [], region: '', legalRepresentative: '', contactName: '', phone: '', email: null, address: null, businessLicenseNo: null, licenseFileId: null, status: 'ACTIVE', settlementPeriod: 'MONTHLY_30', remark: null })
|
||||
const form = reactive(initial())
|
||||
const saveAttempted = ref(false)
|
||||
const truckingScopeError = computed(() => saveAttempted.value && form.category === 'TRUCKING' && !form.truckingScope ? '请选择国内拖车或海外车队' : '')
|
||||
const rules: FormRules<SaveSupplierRequest> = {
|
||||
name: [{ required: true, message: '请输入供应商名称', trigger: 'blur' }], category: [{ required: true, message: '请选择供应商类别', trigger: 'change' }],
|
||||
truckingScope: [{ required: true, type: 'enum', enum: ['DOMESTIC', 'OVERSEAS'], message: '请选择国内拖车或海外车队', trigger: 'change' }],
|
||||
carrierCodes: [{ validator: (_rule, value, callback) => form.category === 'BOOKING' && (!Array.isArray(value) || value.length === 0) ? callback(new Error('请至少选择一家可代理船公司')) : callback(), trigger: 'change' }],
|
||||
region: [{ required: true, message: '请输入地区', trigger: 'blur' }], legalRepresentative: [{ required: true, message: '请输入法人代表', trigger: 'blur' }],
|
||||
contactName: [{ required: true, message: '请输入联系人', trigger: 'blur' }], phone: [{ required: true, message: '请输入联系电话', trigger: 'blur' }],
|
||||
@@ -42,11 +45,11 @@ const { captureBaseline } = useUnsavedWorkRegistration({ key: 'supplier-editor',
|
||||
function optional(value: string | null) { return value?.trim() || null }
|
||||
function payload(): SaveSupplierRequest {
|
||||
const { carrierCodes, ...fields } = form
|
||||
const normalized = { ...fields, name: form.name.trim(), region: form.region.trim(), legalRepresentative: form.legalRepresentative.trim(), contactName: form.contactName.trim(), phone: form.phone.trim(), email: optional(form.email), address: optional(form.address), businessLicenseNo: optional(form.businessLicenseNo), licenseFileId: form.licenseFileId, remark: optional(form.remark) }
|
||||
const normalized = { ...fields, truckingScope: form.category === 'TRUCKING' ? form.truckingScope : null, name: form.name.trim(), region: form.region.trim(), legalRepresentative: form.legalRepresentative.trim(), contactName: form.contactName.trim(), phone: form.phone.trim(), email: optional(form.email), address: optional(form.address), businessLicenseNo: optional(form.businessLicenseNo), licenseFileId: form.licenseFileId, remark: optional(form.remark) }
|
||||
return form.category === 'BOOKING' ? { ...normalized, carrierCodes: [...(carrierCodes || [])] } : normalized
|
||||
}
|
||||
let loadSequence = 0
|
||||
function resetForm() { Object.assign(form, initial()) }
|
||||
function resetForm() { Object.assign(form, initial()); saveAttempted.value = false }
|
||||
async function load() {
|
||||
const requestedId = supplierId.value
|
||||
const sequence = ++loadSequence
|
||||
@@ -59,7 +62,7 @@ async function load() {
|
||||
try {
|
||||
const data = await getSupplier(requestedId)
|
||||
if (sequence !== loadSequence || requestedId !== supplierId.value) return
|
||||
Object.assign(form, { name: data.name, category: data.category, carrierCodes: data.category === 'BOOKING' ? [...data.carrierCodes] : [], region: data.region, legalRepresentative: data.legalRepresentative, contactName: data.contactName, phone: data.phone, email: data.email, address: data.address, businessLicenseNo: data.businessLicenseNo, licenseFileId: data.licenseFileId, status: data.status, settlementPeriod: data.settlementPeriod, remark: data.remark })
|
||||
Object.assign(form, { name: data.name, category: data.category, truckingScope: data.truckingScope, carrierCodes: data.category === 'BOOKING' ? [...data.carrierCodes] : [], region: data.region, legalRepresentative: data.legalRepresentative, contactName: data.contactName, phone: data.phone, email: data.email, address: data.address, businessLicenseNo: data.businessLicenseNo, licenseFileId: data.licenseFileId, status: data.status, settlementPeriod: data.settlementPeriod, remark: data.remark })
|
||||
captureBaseline()
|
||||
} catch (error) {
|
||||
if (sequence === loadSequence && requestedId === supplierId.value) loadError.value = getApiErrorDetails(error, '供应商详情加载失败')
|
||||
@@ -67,6 +70,8 @@ async function load() {
|
||||
}
|
||||
async function save() {
|
||||
if (!access.can(requiredPermission.value)) return
|
||||
saveAttempted.value = true
|
||||
if (truckingScopeError.value) return
|
||||
if (!await formRef.value?.validate().catch(() => false)) return
|
||||
saving.value = true; submitError.value = null
|
||||
try {
|
||||
@@ -86,7 +91,7 @@ onActivated(() => {
|
||||
else activatedOnce = true
|
||||
})
|
||||
onDeactivated(() => { active = false })
|
||||
watch(() => form.category, (category) => { if (category !== 'BOOKING') form.carrierCodes = [] })
|
||||
watch(() => form.category, (category) => { if (category !== 'BOOKING') form.carrierCodes = []; if (category !== 'TRUCKING') form.truckingScope = null })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -103,6 +108,7 @@ watch(() => form.category, (category) => { if (category !== 'BOOKING') form.carr
|
||||
<el-form-item label="供应商名称" prop="name"><el-input v-model="form.name" maxlength="160" placeholder="请输入供应商名称" /></el-form-item>
|
||||
<el-form-item label="供应商类别" prop="category"><el-select v-model="form.category"><el-option v-for="(label, value) in categoryLabels" :key="value" :label="label" :value="value" /></el-select></el-form-item>
|
||||
<el-form-item v-if="form.category === 'BOOKING'" label="可代理船公司" prop="carrierCodes"><el-select v-model="form.carrierCodes" multiple collapse-tags collapse-tags-tooltip placeholder="请选择可代理船公司"><el-option v-for="option in carrierCodeOptions" :key="option.value" :label="option.label" :value="option.value" /></el-select></el-form-item>
|
||||
<el-form-item v-if="form.category === 'TRUCKING'" label="拖车类型" prop="truckingScope" :error="truckingScopeError" required><el-radio-group :model-value="form.truckingScope ?? undefined" @update:model-value="form.truckingScope = $event as TruckingScope"><el-radio v-for="(label, value) in truckingScopeLabels" :key="value" :value="value">{{ label }}</el-radio></el-radio-group></el-form-item>
|
||||
<el-form-item label="地区" prop="region"><el-input v-model="form.region" maxlength="80" placeholder="请输入地区" /></el-form-item>
|
||||
<el-form-item label="法人代表" prop="legalRepresentative"><el-input v-model="form.legalRepresentative" maxlength="80" placeholder="请输入法人代表" /></el-form-item>
|
||||
<el-form-item label="联系人" prop="contactName"><el-input v-model="form.contactName" maxlength="80" placeholder="请输入联系人" /></el-form-item>
|
||||
|
||||
@@ -26,7 +26,7 @@ vi.mock('@/modules/suppliers/api/platformSuppliers', () => ({
|
||||
}))
|
||||
|
||||
function supplier(id: string, name: string): Supplier {
|
||||
return { id, tenantId: '0', name, category: 'BOOKING', carrierCodes: ['MATSON', 'SML'], region: '宁波', legalRepresentative: '法人', contactName: '联系人', phone: '13800000000', email: null, address: null, businessLicenseNo: null, licenseFileId: null, status: 'ACTIVE', settlementPeriod: 'MONTHLY_30', remark: null, createdBy: null, createdAt: '', updatedBy: null, updatedAt: '', deleted: false, version: 1 }
|
||||
return { id, tenantId: '0', name, category: 'BOOKING', truckingScope: null, carrierCodes: ['MATSON', 'SML'], region: '宁波', legalRepresentative: '法人', contactName: '联系人', phone: '13800000000', email: null, address: null, businessLicenseNo: null, licenseFileId: null, status: 'ACTIVE', settlementPeriod: 'MONTHLY_30', remark: null, createdBy: null, createdAt: '', updatedBy: null, updatedAt: '', deleted: false, version: 1 }
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
@@ -152,4 +152,24 @@ describe('supplier route reuse', () => {
|
||||
expect(mocks.listSuppliers).toHaveBeenCalledTimes(2)
|
||||
expect(mocks.getSupplierStats).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
it('requires one trucking type, saves overseas and clears the type after changing category', async () => {
|
||||
mocks.getSupplier.mockResolvedValue({ ...supplier('A', '车队'), category: 'TRUCKING', carrierCodes: [], truckingScope: null })
|
||||
const wrapper = mount(SupplierFormView, { global: { plugins: [ElementPlus], stubs } })
|
||||
await flushPromises()
|
||||
const save = wrapper.findAll('button').find((button) => button.text().includes('保存'))!
|
||||
await save.trigger('click'); await flushPromises()
|
||||
expect(mocks.updateSupplier).not.toHaveBeenCalled()
|
||||
expect(wrapper.findAllComponents({ name: 'ElFormItem' }).find(item => item.props('prop') === 'truckingScope')?.props('error')).toBe('请选择国内拖车或海外车队')
|
||||
const radios = wrapper.findComponent({ name: 'ElRadioGroup' })
|
||||
expect(radios.text()).toContain('国内拖车')
|
||||
expect(radios.text()).toContain('海外车队')
|
||||
radios.vm.$emit('update:modelValue', 'OVERSEAS')
|
||||
await flushPromises(); await save.trigger('click'); await flushPromises()
|
||||
expect(mocks.updateSupplier).toHaveBeenLastCalledWith('A', expect.objectContaining({ category: 'TRUCKING', truckingScope: 'OVERSEAS' }))
|
||||
wrapper.findAllComponents({ name: 'ElSelect' })[0]!.vm.$emit('update:modelValue', 'CUSTOMS')
|
||||
await flushPromises(); await save.trigger('click'); await flushPromises()
|
||||
expect(wrapper.findComponent({ name: 'ElRadioGroup' }).exists()).toBe(false)
|
||||
expect(mocks.updateSupplier).toHaveBeenLastCalledWith('A', expect.objectContaining({ category: 'CUSTOMS', truckingScope: null }))
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
@@ -16,7 +16,7 @@ import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import SupplierLicenseDialog from '@/modules/suppliers/components/SupplierLicenseDialog.vue'
|
||||
import { changeSupplierStatus, getSupplierStats, listSuppliers } from '@/modules/suppliers/api/platformSuppliers'
|
||||
import { carrierCodeOptions, categoryLabels, formatCarrierCodes, settlementLabels, statusLabels, type CarrierCode, type Supplier, type SupplierCategory, type SupplierStats, type SupplierStatus } from '@/modules/suppliers/types'
|
||||
import { truckingScopeOptions, formatTruckingScope, type TruckingScope, carrierCodeOptions, categoryLabels, formatCarrierCodes, settlementLabels, statusLabels, type CarrierCode, type Supplier, type SupplierCategory, type SupplierStats, type SupplierStatus } from '@/modules/suppliers/types'
|
||||
|
||||
const router = useRouter()
|
||||
const access = useAccessPreviewStore()
|
||||
@@ -32,14 +32,15 @@ const loadError = ref<ApiErrorDetails | null>(null)
|
||||
const actionId = ref('')
|
||||
const licenseOpen = ref(false)
|
||||
const licenseSupplier = ref<Supplier | null>(null)
|
||||
type Filters = QueryModel & { status: SupplierStatus | ''; region: string; keyword: string; carrierCode: CarrierCode | '' }
|
||||
const filters = ref<Filters>({ status: '', region: '', keyword: '', carrierCode: '' })
|
||||
type Filters = QueryModel & { status: SupplierStatus | ''; region: string; keyword: string; carrierCode: CarrierCode | ''; truckingScope: TruckingScope | '' }
|
||||
const filters = ref<Filters>({ status: '', region: '', keyword: '', carrierCode: '', truckingScope: '' })
|
||||
const applied = ref<Filters>({ ...filters.value })
|
||||
let sequence = 0
|
||||
|
||||
const queryFields = computed<QueryField[]>(() => [
|
||||
{ key: 'status', type: 'select', label: '状态', placeholder: '全部状态', width: 150, options: [{ label: '合作中', value: 'ACTIVE' }, { label: '停用', value: 'INACTIVE' }] },
|
||||
...(activeCategory.value === 'BOOKING' ? [{ key: 'carrierCode', type: 'select' as const, label: '可代理船公司', placeholder: '全部船公司', width: 190, options: carrierCodeOptions }] : []),
|
||||
...(activeCategory.value === 'TRUCKING' ? [{ key: 'truckingScope', type: 'select' as const, label: '拖车类型', placeholder: '全部拖车类型', width: 170, options: truckingScopeOptions }] : []),
|
||||
{ key: 'region', type: 'input', label: '地区', placeholder: '请输入地区', width: 170, props: { maxlength: 80 } },
|
||||
{ key: 'keyword', type: 'input', label: '名称或联系人', placeholder: '供应商名称/联系人', width: 220, props: { maxlength: 160 } },
|
||||
])
|
||||
@@ -48,7 +49,8 @@ const columns = computed<DataTableColumn[]>(() => [
|
||||
{ key: 'actions', label: '操作', width: 260, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'name', label: '供应商名称', minWidth: 200, slotName: 'name' },
|
||||
{ key: 'category', label: '类别', width: 70, slotName: 'category' },
|
||||
{ key: 'carrierCodes', label: '可代理船公司', minWidth: 200, slotName: 'carrierCodes' },
|
||||
...(activeCategory.value === 'BOOKING' ? [{ key: 'carrierCodes', label: '可代理船公司', minWidth: 200, slotName: 'carrierCodes' }] : []),
|
||||
...(activeCategory.value === 'TRUCKING' ? [{ key: 'truckingScope', label: '拖车类型', width: 110, slotName: 'truckingScope' }] : []),
|
||||
{ key: 'region', label: '地区', minWidth: 100 },
|
||||
{ key: 'legalRepresentative', label: '法人', minWidth: 100 },
|
||||
{ key: 'contactName', label: '联系人', minWidth: 120 },
|
||||
@@ -72,7 +74,7 @@ async function load() {
|
||||
loadError.value = null
|
||||
try {
|
||||
const [result, counts] = await Promise.all([
|
||||
listSuppliers({ page: page.value, size: pageSize.value, category: activeCategory.value, status: applied.value.status, region: applied.value.region, keyword: applied.value.keyword, carrierCode: activeCategory.value === 'BOOKING' ? applied.value.carrierCode || undefined : undefined }),
|
||||
listSuppliers({ page: page.value, size: pageSize.value, category: activeCategory.value, status: applied.value.status, region: applied.value.region, keyword: applied.value.keyword, truckingScope: activeCategory.value === 'TRUCKING' ? applied.value.truckingScope || undefined : undefined, carrierCode: activeCategory.value === 'BOOKING' ? applied.value.carrierCode || undefined : undefined }),
|
||||
getSupplierStats(),
|
||||
])
|
||||
if (current !== sequence) return
|
||||
@@ -152,6 +154,7 @@ onActivated(() => {
|
||||
</template>
|
||||
<template #name="{ row }"><LineClampText :content="row.name" /></template>
|
||||
<template #category="{ row }">{{ categoryLabels[row.category as SupplierCategory] }}</template>
|
||||
<template #truckingScope="{ row }">{{ formatTruckingScope(supplierRow(row).truckingScope) }}</template>
|
||||
<template #carrierCodes="{ row }"><LineClampText :content="supplierRow(row).category === 'BOOKING' ? formatCarrierCodes(supplierRow(row).carrierCodes) : null" /></template>
|
||||
<template #license="{ row }"><el-button data-tone="neutral" class="density-action" size="small" @click="showLicense(supplierRow(row))">查看</el-button></template>
|
||||
<template #status="{ row }"><StatusBadge :status="statusLabels[supplierRow(row).status] ?? '—'" /></template>
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
export type SupplierCategory = 'BOOKING' | 'CUSTOMS' | 'TRUCKING' | 'CERTIFICATE'
|
||||
export type TruckingScope = 'DOMESTIC' | 'OVERSEAS'
|
||||
export const truckingScopeLabels: Record<TruckingScope, string> = { DOMESTIC: '国内拖车', OVERSEAS: '海外车队' }
|
||||
export const truckingScopeOptions = Object.entries(truckingScopeLabels).map(([value, label]) => ({ value, label }))
|
||||
export function formatTruckingScope(scope: TruckingScope | null) { return scope ? truckingScopeLabels[scope] : '—' }
|
||||
export type SupplierStatus = 'ACTIVE' | 'INACTIVE'
|
||||
export type SettlementPeriod = 'MONTHLY_30' | 'MONTHLY_45' | 'MONTHLY_60' | 'MONTHLY_90' | 'PER_TRANSACTION' | 'IMMEDIATE'
|
||||
export type CarrierCode = 'MATSON' | 'SML' | 'ZIM' | 'COSCO' | 'EMC' | 'HEDE' | 'OTHER'
|
||||
@@ -8,6 +12,7 @@ export interface Supplier {
|
||||
tenantId: string
|
||||
name: string
|
||||
category: SupplierCategory
|
||||
truckingScope: TruckingScope | null
|
||||
carrierCodes: CarrierCode[]
|
||||
region: string
|
||||
legalRepresentative: string
|
||||
@@ -42,6 +47,7 @@ export interface SupplierQuery {
|
||||
page?: number
|
||||
size?: number
|
||||
category?: SupplierCategory
|
||||
truckingScope?: TruckingScope
|
||||
region?: string
|
||||
status?: SupplierStatus | ''
|
||||
keyword?: string
|
||||
@@ -51,6 +57,7 @@ export interface SupplierQuery {
|
||||
export interface SaveSupplierRequest {
|
||||
name: string
|
||||
category: SupplierCategory
|
||||
truckingScope: TruckingScope | null
|
||||
carrierCodes?: CarrierCode[]
|
||||
region: string
|
||||
legalRepresentative: string
|
||||
|
||||
@@ -9,7 +9,7 @@ import { getOceanBooking } from '@/modules/ocean-bookings'
|
||||
import { listTruckAppointmentCandidates } from '../api/platformTrucking'
|
||||
|
||||
vi.mock('@/modules/suppliers', () => ({
|
||||
listSuppliers: vi.fn().mockResolvedValue({ records: [{ id: 'carrier-1', name: '平台拖车公司', category: 'TRUCKING', status: 'ACTIVE', contactName: '张调度', phone: '13800000001' }, { id: 'carrier-2', name: '第二车队', category: 'TRUCKING', status: 'ACTIVE', contactName: '李调度', phone: '13800000002' }] }),
|
||||
listSuppliers: vi.fn().mockResolvedValue({ records: [{ id: 'carrier-1', name: '平台拖车公司', category: 'TRUCKING', truckingScope: 'DOMESTIC', status: 'ACTIVE', contactName: '张调度', phone: '13800000001' }, { id: 'carrier-2', name: '第二车队', category: 'TRUCKING', truckingScope: 'DOMESTIC', status: 'ACTIVE', contactName: '李调度', phone: '13800000002' }, { id: 'overseas-1', name: '海外车队候选', category: 'TRUCKING', truckingScope: 'OVERSEAS', status: 'ACTIVE' }] }),
|
||||
}))
|
||||
vi.mock('@/modules/ocean-bookings', () => ({
|
||||
getOceanBooking: vi.fn(),
|
||||
@@ -172,8 +172,9 @@ describe('TruckAppointmentForm booking-only behavior', () => {
|
||||
it('copies supplier contacts and replaces or clears them when switching suppliers', async () => {
|
||||
const wrapper = mount(TruckAppointmentForm, { props: { modelValue: { ...model } }, global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
expect(listSuppliers).toHaveBeenCalledWith({ page: 1, size: 200, category: 'TRUCKING', status: 'ACTIVE' })
|
||||
expect(listSuppliers).toHaveBeenCalledWith({ page: 1, size: 200, category: 'TRUCKING', truckingScope: 'DOMESTIC', status: 'ACTIVE' })
|
||||
const supplier = wrapper.findAllComponents({ name: 'ElSelect' })[1]!
|
||||
expect(supplier.findAllComponents({ name: 'ElOption' }).map(option => option.props('value'))).not.toContain('overseas-1')
|
||||
supplier.vm.$emit('update:modelValue', 'carrier-1')
|
||||
await flushPromises()
|
||||
expect(wrapper.emitted('update:modelValue')!.at(-1)![0]).toMatchObject({ carrierId: 'carrier-1', fleetContact: '张调度', fleetPhone: '13800000001', currency: 'RMB' })
|
||||
|
||||
@@ -53,7 +53,7 @@ const optionsError = ref('')
|
||||
let bookingLoadSequence = 0
|
||||
let optionsLoadSequence = 0
|
||||
const selectableCarriers = computed(() => carriers.value.filter((item) => (
|
||||
item.status === 'ACTIVE'
|
||||
item.status === 'ACTIVE' && item.truckingScope === 'DOMESTIC'
|
||||
)))
|
||||
const bookings = computed(() => {
|
||||
const result = new Map<string, { id: string; bookingNo: string; billOfLadingNo: string | null; warehouseId: string; warehouseName: string | null; bookingDocumentReady: boolean }>()
|
||||
@@ -130,11 +130,11 @@ async function loadOptions() {
|
||||
optionsLoading.value = true; optionsError.value = ''
|
||||
candidates.value = []
|
||||
const results = await Promise.allSettled([
|
||||
listSuppliers({ page: 1, size: 200, category: 'TRUCKING', status: 'ACTIVE' }),
|
||||
listSuppliers({ page: 1, size: 200, category: 'TRUCKING', truckingScope: 'DOMESTIC', status: 'ACTIVE' }),
|
||||
relationLocked.value ? Promise.resolve({ records: [] as TruckAppointmentCandidate[] }) : listTruckAppointmentCandidates({ page: 1, size: 200 }),
|
||||
])
|
||||
if (sequence !== optionsLoadSequence) return
|
||||
if (results[0].status === 'fulfilled') carriers.value = results[0].value.records.filter((item) => item.category === 'TRUCKING' && item.status === 'ACTIVE')
|
||||
if (results[0].status === 'fulfilled') carriers.value = results[0].value.records.filter((item) => item.category === 'TRUCKING' && item.status === 'ACTIVE' && item.truckingScope === 'DOMESTIC')
|
||||
if (results[1].status === 'fulfilled') candidates.value = results[1].value.records
|
||||
const failed = ['拖车供应商', '可用订舱'].filter((_, index) => results[index]?.status === 'rejected')
|
||||
if (failed.length) optionsError.value = `${failed.join('、')}选项加载失败`
|
||||
|
||||
Reference in New Issue
Block a user