This commit is contained in:
2026-09-16 11:06:36 +08:00
parent d75e8381b6
commit 78a0d7916c
22 changed files with 197 additions and 51 deletions
+1 -1
View File
@@ -59,7 +59,7 @@
| 合柜报关 / 同柜不可拆组 | `modules/unified-orders/api/coLoadGroups.ts`、`pages/UnifiedOrdersView.vue`;装柜消费 `modules/container-loading/pages/SmartLoadingWorkbenchView.vue` | 前端只提交订单 ID;Backend `firstleg/loading/coload/` 展开并冻结每票全部可用入仓片段。成员数是入仓片段数,不等于订单票数;只要求同国内仓、同渠道,允许跨客户及不同目的仓港;分配、移除、取消均整组处理 |
| 全局智能装柜工作台、入仓码候选与箱数分配 | `modules/container-loading/components/SmartLoadingDialog.vue` 内嵌 `pages/GlobalSmartLoadingView.vue`,柜次页 `SmartLoadingWorkbenchView.vue`,以及 `smartLoadingChannelGroups.ts`、`smartLoadingItems.ts`、`api/platformContainerLoading.ts`;全局页当前不注册独立路由 | Backend `firstleg/` 的 `/inbound-candidates`、`/containers`、`/inbound-assignments`、`/plans/{planId}/pending-review-assignments`、`/group-assignments`、`/unpicked-items`。`orderStatuses` 最多四项且未传表示全部未完成状态;候选状态多选覆盖待审核、待测量、待分柜、装柜中,状态只用于筛选展示,按钮资格以服务端 `canAssign` 为准;待审核/待测量可提前占位但不得提前确认预装或启动 PDA。计划箱数编辑上限、已用和未用展示只消费服务端 `totalPackageCount/remainingPackageCount`,不得从候选页或本地规格重新拼算。人工加入允许跨渠道,智能推荐仍按柜次渠道。柜次容量和限重取服务端字段;同柜组作为单卡整组分页、分配和移除,任一成员已拣时整组保留 |
| 国内货柜履约订舱资料、打印、关联候选与出仓前船期 | `modules/ocean-bookings/` 的 `components/OceanBookingDocumentsPanel.vue`、`OceanBookingPrintDialog.vue`,以及 `modules/container-loading/`、`modules/port-schedules/` | 订舱草稿按当前版本维护资料,放舱后资料与表单只读;打印对草稿与已放舱订舱均开放,按当前已保存资料生成,未填写字段显示“—”。`platformOceanBookings.ts` 的 `/link-candidates` 只消费服务端分页,`containerCount` 是柜型需求 `quantity` 总和;Backend `firstleg/oceanbooking/`、`file/`、`print/`、`firstleg/portschedule/` |
| 订舱级拖车预约 | `modules/trucking/api/platformTrucking.ts` 的 `/candidates`、`components/TruckAppointmentForm.vue`、`pages/TruckAppointmentCreateView.vue`、`TruckAppointmentsView.vue` | 表单只选择服务端返回的 `RELEASED` 订舱与拖车供应商,不选择或提交客户、柜次、装柜计划或真实集装箱号;提单号和仓库由订舱冻结事实带入。一个订舱最多一张非终态拖车单,重复创建转入现有单继续编辑;PDA 完成不自动推进拖车。附件为平台作用域且不重复提单文件;Backend `TruckAppointmentService`、`truck-appointments-v1.yaml` |
| 订舱级拖车预约 | `modules/trucking/api/platformTrucking.ts` 的 `/candidates`、`components/TruckAppointmentForm.vue`、`pages/TruckAppointmentCreateView.vue`、`TruckAppointmentsView.vue` | 表单只选择服务端返回的 `RELEASED` 订舱与 `TRUCKING + truckingScope=DOMESTIC` 国内拖车供应商,不选择或提交客户、柜次、装柜计划或真实集装箱号;清关提柜固定选择 `OVERSEAS` 海外车队,类型在供应商主档单选维护;提单号和仓库由订舱冻结事实带入。一个订舱最多一张非终态拖车单,重复创建转入现有单继续编辑;PDA 完成不自动推进拖车。附件为平台作用域且不重复提单文件;Backend `TruckAppointmentService`、`truck-appointments-v1.yaml` |
| 目的港码头拖车与还空预警 | 海外清关提柜工作台/详情 `modules/container-loading/pages/OverseasContainersView.vue`、`OverseasContainerDetailView.vue`,API `modules/container-loading/api/terminalDrayages.ts`、`containerArrivalTimes.ts` | 与订舱级拖车预约是不同生命周期;按 `containerBookingId` 读取服务端状态、`allowedActions` 和历史,不能复用订舱级拖车表单或自行推进状态。列表“预计到仓”列直接提供人工日期时间选择器,保存时通过 Lastleg 柜级版本和幂等键原子更新该柜全部尾程订单,已有入库计划后禁止修改;“实际到仓”只读 `ll_receiving_container_session.receiving_at`,即海外 PDA 首次开始收货时间。还空截止、超时天/小时只展示服务端基于权威卸柜完成时间加 72 小时计算的结果;Backend `firstleg/terminaldrayage/`、`lastleg-service/`,邮件记录通过 `terminalDrayages.ts` 查询与重试。 |
| 历史报关协同与国内出仓 | `modules/customs/`、`modules/outbound-dispatch/` | `modules/customs/` 是历史报关案件/清单/代理提交与回执入口;装柜完成后的整柜退税/买单资料从货柜详情进入,不在旧模块复制;Backend `firstleg/outbound/dispatch/`、`firstleg/customs/`、`file/` |
| 主数据、下载、退避任务重试、审计 | `modules/master-data/`、`modules/downloads/pages/DownloadCenterView.vue`、`modules/audit/` | File 导出任务返回 `attemptCount/maxAttempts/nextRetryAt`;仅失败或已进入自动退避的等待任务显示重试,等待任务重试用于立即执行当前投递,不创建新任务;联查 `warehouse/`、`file/`、`audit-service/` |
@@ -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
+4
View File
@@ -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>
+7
View File
@@ -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('、')}选项加载失败`