feat(looseboard): align platform route contacts and required times

This commit is contained in:
2026-10-19 15:11:15 +08:00
parent f7f6637c45
commit 767c6b7589
7 changed files with 129 additions and 69 deletions
@@ -57,7 +57,7 @@ const procurementDetail = (): PlatformProcurement => ({
})
const truck: LocalTransportOrder = { fleetArrangement: null, id: '9007199254740995', customerId: '10002', orderNo: 'TK-200', vehicleType: 'PALLET_15', origin: 'Long Beach', destination: 'Ontario', palletCount: 2, packageCount: 20, receivedPalletCount: 0, allocatedPalletCount: 0, shippedPalletCount: 0, totalWeightKg: '1200.000', totalVolumeCbm: '16.500', budgetAmount: '850.00', currency: 'USD', originContactName: 'Bob', originContactPhone: '10010', destinationContactName: 'Alice', destinationContactPhone: '10020', pickupAt: '2026-08-28T09:30:00', deliveryAt: '2026-08-28T15:45:00', status: 'PENDING_REVIEW', version: 1, createdAt: '2026-08-01T10:00:00', updatedAt: '2026-08-02T10:00:00' }
const truckDetail: LocalTransportDetail = { ...truck, originAddress: 'Pier A', destinationAddress: 'Warehouse B', remark: null, createdBy: '7', pallets: [], histories: [] }
const looseboard: LocalLooseboardOrder = { fleetArrangement: null, id: '9007199254740997', customerId: '10003', orderNo: 'LB-300', transportMode: 'SHARED_TRUCK', palletRequired: 'YES', transportType: 'WAREHOUSE_TO_WAREHOUSE', origin: 'LA', destination: 'ONT', containerType: '40HQ', packageCount: 8, totalWeightKg: '80.5', totalVolumeCbm: '3.2', budgetAmount: '50.00', currency: 'USD', contactName: null, contactInfo: null, requestedDate: null, status: 'PENDING_ACCEPTANCE', version: 0, createdAt: '2026-08-01T10:00:00', updatedAt: '2026-08-01T10:00:00' }
const looseboard: LocalLooseboardOrder = { fleetArrangement: null, id: '9007199254740997', customerId: '10003', orderNo: 'LB-300', palletRequired: 'YES', transportType: 'WAREHOUSE_TO_WAREHOUSE', origin: 'LA', destination: 'ONT', productName: '家居用品', estimatedPalletCount: 2, packageCount: 8, totalWeightKg: '80.5', totalVolumeCbm: '3.2', budgetAmount: '50.00', currency: 'USD', originContactName: '发货人', originContactPhone: '1', destinationContactName: '收货人', destinationContactPhone: '2', pickupAt: '2026-10-20T09:30:00', deliveryAt: '2026-10-20T15:45:00', status: 'PENDING_ACCEPTANCE', version: 0, createdAt: '2026-08-01T10:00:00', updatedAt: '2026-08-01T10:00:00' }
const looseboardDetail: LocalLooseboardDetail = { ...looseboard, originAddress: null, destinationAddress: null, remark: null, createdBy: '7', histories: [] }
const arrangement: FleetArrangement = { sourceType: 'FLEET_CAPACITY_OFFER', businessType: 'US_TRUCK', businessOrderNo: 'TR-200', bookingNo: 'FB-TEST', tripNo: 'TRIP-TEST', plannedDepartureAt: null, originWarehouseName: '我仓', destinationLabel: 'ONT8', deliveryMode: 'SELF_DELIVERY', deliveryInstructions: null, fulfillmentStage: 'WAITING_INBOUND', stageOccurredAt: null, agreedFreightAmount: null, currency: 'USD', incomingNo: null, dispatchNo: null, deliveryNo: null, signedPackages: null, unsignedPackages: null, returnedPackages: null, exceptionSummary: null, readOnlyReason: '池来源订单按实际运输履约推进' }
@@ -133,12 +133,11 @@ describe('overseas business mounted pages', () => {
expect(wrapper.find('form').exists()).toBe(false); expect(wrapper.findAll('button').some(b => b.text() === '保存修改')).toBe(false)
expect(updateUsTruckOrder).not.toHaveBeenCalled(); expect(getUsTruckOrder).toHaveBeenCalledOnce()
})
it('distinguishes unknown pool pallet service from explicit no and yes in actual list cells', async () => {
const records = ([null, 'NO', 'YES'] as const).map((palletRequired, index) => ({ ...looseboard, id: String(100 + index), orderNo: `LB-NULL-${index}`, palletRequired, containerType: null, fleetArrangement: { ...arrangement, businessType: 'LOCAL_LOOSEBOARD' as const } }))
vi.mocked(listLocalLooseboardOrders).mockResolvedValue({ records, total: 3, page: 1, size: 20, pages: 1 })
it('shows declared product and forecast pallets without exposing obsolete container selection', async () => {
const wrapper = mountPage(TransportOrdersView, { domain: 'looseboard' }); await flushPromises()
const rows = wrapper.findAll('.el-table__body-wrapper tbody .el-table__row')
expect(rows.map(row => row.findAll('td')[3]!.text())).toEqual(['—', '否', '是'])
expect(wrapper.text()).toContain('家居用品')
expect(wrapper.text()).toContain('预计托数')
expect(wrapper.text()).not.toContain('柜型')
expect(listLocalLooseboardOrders).toHaveBeenCalledOnce()
})
it('shows unknown pool vehicle type as a dash without inventing a capacity', async () => {
@@ -147,14 +146,15 @@ describe('overseas business mounted pages', () => {
expect(wrapper.find('.el-table__body-wrapper tbody .el-table__row').findAll('td')[2]!.text()).toBe('—')
expect(wrapper.find('.el-table__body-wrapper').text()).not.toContain('40HQ')
})
it('still rejects an ordinary looseboard edit when its required pallet service is absent', async () => {
it('allows standalone demand editing without inventing pallet service or transport arrangements', async () => {
routing.route.params = { orderId: looseboard.orderNo }; routing.route.query = { customerId: looseboard.customerId }
vi.mocked(getLocalLooseboardOrder).mockResolvedValue({ ...looseboardDetail, palletRequired: null, fleetArrangement: null })
vi.mocked(getLocalLooseboardOrder).mockResolvedValue({ ...looseboardDetail, palletRequired: null, transportType: null })
const wrapper = mountPage(TransportOrderFormView, { domain: 'looseboard' }); await flushPromises()
expect(wrapper.findComponent({ name: 'ElForm' }).props('model').palletRequired).toBeNull()
expect(wrapper.text()).not.toContain('是否打盘')
expect(wrapper.text()).not.toContain('运输类型')
await wrapper.findAll('button').find(button => button.text().includes('保存修改'))!.trigger('click'); await flushPromises()
expect(updateLocalLooseboardOrder).not.toHaveBeenCalled()
expect(document.body.textContent).toContain('请选择是否打盘')
expect(updateLocalLooseboardOrder).toHaveBeenCalledWith(looseboard.orderNo, looseboard.customerId, expect.objectContaining({ productName: '家居用品', estimatedPalletCount: 2, pickupAt: '2026-10-20T09:30:00' }))
expect(updateLocalLooseboardOrder).toHaveBeenCalledOnce()
})
it('keeps all procurement facts in dense groups and business references in the heading', async () => {
routing.route.params = { purchaseId: 'internal-purchase-route' }; routing.route.query = { customerId: '10001' }
@@ -277,10 +277,29 @@ describe('overseas business mounted pages', () => {
it('mounts truck create with explicit customer selector and enabled submit', async () => { const wrapper = mountPage(TransportOrderFormView, { domain: 'truck' }); await flushPromises(); expect(listPlatformTenants).toHaveBeenCalledWith({ page: 1, size: 200, status: 'ACTIVE' }); expect(wrapper.text()).toContain('客户信息'); expect(wrapper.text()).toContain('请选择客户'); expect(wrapper.findAll('button').find((button) => button.text().includes('提交订单'))?.attributes('disabled')).toBeUndefined() })
it('reuses one idempotency key and preserves truck create input after retries fail', async () => { vi.mocked(createUsTruckOrder).mockRejectedValue(new ApiResponseError('temporary failure', 'S0500', 500)); const wrapper = mountPage(TransportOrderFormView, { domain: 'truck' }); await flushPromises(); const selects = wrapper.findAllComponents({ name: 'ElSelect' }); selects[0].vm.$emit('update:modelValue', '10001'); selects[1].vm.$emit('update:modelValue', 'PALLET_30'); const origin = wrapper.find('input[placeholder="城市 / 港口 / 州"]'); const destination = wrapper.find('input[placeholder="城市 / 州"]'); await origin.setValue('LA-MARKER'); await destination.setValue('ONT-MARKER'); for (const label of ['起运地联系人', '目的地联系人', '起运地联系电话', '目的地联系电话']) await wrapper.findAll('.el-form-item').find(item => item.text().startsWith(label))!.find('input').setValue(label.includes('电话') ? '+1-555-0100' : '联系人'); await wrapper.findAll('.el-form-item').find((item) => item.text().startsWith('总重量'))!.find('input').setValue('100'); await wrapper.findAll('.el-form-item').find((item) => item.text().startsWith('总体积'))!.find('input').setValue('2.5'); wrapper.findAllComponents({ name: 'ElDatePicker' })[0].vm.$emit('update:modelValue', '2026-08-31T09:30:00'); wrapper.findAllComponents({ name: 'ElDatePicker' })[1].vm.$emit('update:modelValue', '2026-08-31T15:45:00'); await wrapper.vm.$nextTick(); const submit = wrapper.findAll('button').find((button) => button.text().includes('提交订单')); await submit?.trigger('click'); await flushPromises(); await submit?.trigger('click'); await flushPromises(); expect(createUsTruckOrder).toHaveBeenCalledTimes(2); const keys = vi.mocked(createUsTruckOrder).mock.calls.map((call) => call[1].idempotencyKey); expect(keys[0]).toBeTruthy(); expect(keys[1]).toBe(keys[0]); expect((origin.element as HTMLInputElement).value).toBe('LA-MARKER'); expect((destination.element as HTMLInputElement).value).toBe('ONT-MARKER'); expect(document.body.textContent).toContain('服务暂时不可用'); expect(routing.push).not.toHaveBeenCalled() })
it('loads the real looseboard list', async () => { const wrapper = mountPage(TransportOrdersView, { domain: 'looseboard' }); await flushPromises(); expect(listLocalLooseboardOrders).toHaveBeenCalledWith({ page: 1, size: 20, orderNo: '', routeKeyword: '', status: '' }); expect(wrapper.text()).toContain('LB-300'); expect(wrapper.text()).toContain('待接单'); expect(wrapper.text()).toContain('2026-08-01 10:00'); expect(listUsTruckOrders).not.toHaveBeenCalled() })
it('loads and submits pending-acceptance looseboard edit with PUT adapter', async () => { routing.route.params = { orderId: 'LB-300' }; routing.route.query = { customerId: '10003' }; const wrapper = mountPage(TransportOrderFormView, { domain: 'looseboard' }); await flushPromises(); await wrapper.findAll('button').find((button) => button.text().includes('保存修改'))?.trigger('click'); await flushPromises(); expect(getLocalLooseboardOrder).toHaveBeenCalledWith('LB-300', '10003'); expect(updateLocalLooseboardOrder).toHaveBeenCalledWith('LB-300', '10003', expect.objectContaining({ version: 0, transportMode: 'SHARED_TRUCK', palletRequired: 'YES' })) })
it('keeps looseboard edit form and version unchanged after a 409', async () => { routing.route.params = { orderId: 'LB-300' }; routing.route.query = { customerId: '10003' }; vi.mocked(updateLocalLooseboardOrder).mockRejectedValue(new ApiResponseError('订单版本冲突', 'B0409', 409)); const wrapper = mountPage(TransportOrderFormView, { domain: 'looseboard' }); await flushPromises(); const destination = wrapper.find('input[placeholder="城市 / 州"]'); await destination.setValue('ONT-MARKER-EDIT'); await wrapper.findAll('button').find((button) => button.text().includes('保存修改'))?.trigger('click'); await flushPromises(); expect(updateLocalLooseboardOrder).toHaveBeenCalledWith('LB-300', '10003', expect.objectContaining({ version: 0, destination: 'ONT-MARKER-EDIT' })); expect((destination.element as HTMLInputElement).value).toBe('ONT-MARKER-EDIT'); expect(document.body.textContent).toContain('订单版本冲突'); expect(routing.push).not.toHaveBeenCalled() })
it('loads and submits pending-acceptance looseboard edit with PUT adapter', async () => { routing.route.params = { orderId: 'LB-300' }; routing.route.query = { customerId: '10003' }; const wrapper = mountPage(TransportOrderFormView, { domain: 'looseboard' }); await flushPromises(); await wrapper.findAll('button').find((button) => button.text().includes('保存修改'))?.trigger('click'); await flushPromises(); expect(getLocalLooseboardOrder).toHaveBeenCalledWith('LB-300', '10003'); expect(updateLocalLooseboardOrder).toHaveBeenCalledWith('LB-300', '10003', expect.objectContaining({ version: 0, estimatedPalletCount: 2 })) })
it('requires split contacts and both local times, rejecting reversed times', async () => {
const wrapper = mountPage(TransportOrderFormView, { domain: 'looseboard' }); await flushPromises()
const model = wrapper.findComponent({ name: 'ElForm' }).props('model')
Object.assign(model, { customerId: '10001', originContactName: 'A', originContactPhone: '1', destinationContactName: 'B', destinationContactPhone: '2', origin: 'LA', destination: 'ONT', productName: '家居用品', estimatedPalletCount: 3, packageCount: 8, totalWeightKg: '80', totalVolumeCbm: '1.8', deliveryAt: '2026-10-20T11:30:00' })
await wrapper.vm.$nextTick()
const save = wrapper.findAll('button').find(button => button.text().includes('提交订单'))!
await save.trigger('click'); await flushPromises()
expect(createLocalLooseboardOrder).not.toHaveBeenCalled()
model.pickupAt = '2026-10-20T12:30:00'
await save.trigger('click'); await flushPromises()
expect(createLocalLooseboardOrder).not.toHaveBeenCalled()
model.deliveryAt = '2026-10-20T15:45:00'
await save.trigger('click'); await flushPromises()
expect(createLocalLooseboardOrder).toHaveBeenCalledWith('10001', expect.objectContaining({ productName: '家居用品', estimatedPalletCount: 3, packageCount: 8, pickupAt: '2026-10-20T12:30:00', deliveryAt: '2026-10-20T15:45:00' }))
const payload = vi.mocked(createLocalLooseboardOrder).mock.calls[0]![1]
expect(payload).not.toHaveProperty('containerType')
expect(payload).not.toHaveProperty('palletRequired')
expect(payload).not.toHaveProperty('transportType')
})
it('keeps looseboard edit form and version unchanged after a 409', async () => { routing.route.params = { orderId: 'LB-300' }; routing.route.query = { customerId: '10003' }; vi.mocked(updateLocalLooseboardOrder).mockRejectedValue(new ApiResponseError('订单版本冲突', 'B0409', 409)); const wrapper = mountPage(TransportOrderFormView, { domain: 'looseboard' }); await flushPromises(); const destination = wrapper.findAll('input[placeholder="城市 / 州 / 详细地址"]')[1]; await destination.setValue('ONT-MARKER-EDIT'); await wrapper.findAll('button').find((button) => button.text().includes('保存修改'))?.trigger('click'); await flushPromises(); expect(updateLocalLooseboardOrder).toHaveBeenCalledWith('LB-300', '10003', expect.objectContaining({ version: 0, destination: 'ONT-MARKER-EDIT' })); expect((destination.element as HTMLInputElement).value).toBe('ONT-MARKER-EDIT'); expect(document.body.textContent).toContain('订单版本冲突'); expect(routing.push).not.toHaveBeenCalled() })
it('mounts looseboard create without a detail route or pending notice', async () => { const wrapper = mountPage(TransportOrderFormView, { domain: 'looseboard' }); await flushPromises(); expect(wrapper.text()).toContain('客户信息'); expect(wrapper.text()).toContain('请选择客户'); expect(wrapper.text()).not.toContain('接口能力待后端补齐'); expect(wrapper.findAll('button').find((button) => button.text().includes('提交订单'))?.attributes('disabled')).toBeUndefined() })
it('reuses one idempotency key and preserves looseboard create input after retries fail', async () => { vi.mocked(createLocalLooseboardOrder).mockRejectedValue(new ApiResponseError('temporary failure', 'S0500', 500)); const wrapper = mountPage(TransportOrderFormView, { domain: 'looseboard' }); await flushPromises(); const selects = wrapper.findAllComponents({ name: 'ElSelect' }); selects[0].vm.$emit('update:modelValue', '10001'); const groups = wrapper.findAllComponents({ name: 'ElRadioGroup' }); groups[0].vm.$emit('update:modelValue', 'SHARED_TRUCK'); groups[1].vm.$emit('update:modelValue', 'YES'); groups[2].vm.$emit('update:modelValue', 'WAREHOUSE_TO_WAREHOUSE'); const origin = wrapper.find('input[placeholder="城市 / 港口 / 州"]'); const destination = wrapper.find('input[placeholder="城市 / 州"]'); await origin.setValue('LA-MARKER'); await destination.setValue('ONT-MARKER'); await wrapper.findAll('.el-form-item').find((item) => item.text().startsWith('总重量'))!.find('input').setValue('80'); await wrapper.findAll('.el-form-item').find((item) => item.text().startsWith('总体积'))!.find('input').setValue('1.8'); await wrapper.vm.$nextTick(); const submit = wrapper.findAll('button').find((button) => button.text().includes('提交订单')); await submit?.trigger('click'); await flushPromises(); await submit?.trigger('click'); await flushPromises(); expect(createLocalLooseboardOrder).toHaveBeenCalledTimes(2); const keys = vi.mocked(createLocalLooseboardOrder).mock.calls.map((call) => call[1].idempotencyKey); expect(keys[0]).toBeTruthy(); expect(keys[1]).toBe(keys[0]); expect((origin.element as HTMLInputElement).value).toBe('LA-MARKER'); expect((destination.element as HTMLInputElement).value).toBe('ONT-MARKER'); expect(routing.push).not.toHaveBeenCalled() })
it('reuses one idempotency key and preserves looseboard create input after retries fail', async () => { vi.mocked(createLocalLooseboardOrder).mockRejectedValue(new ApiResponseError('temporary failure', 'S0500', 500)); const wrapper = mountPage(TransportOrderFormView, { domain: 'looseboard' }); await flushPromises(); const selects = wrapper.findAllComponents({ name: 'ElSelect' }); selects[0].vm.$emit('update:modelValue', '10001'); for (const label of ['起运地联系人', '目的地联系人', '起运地联系电话', '目的地联系电话']) await wrapper.findAll('.el-form-item').find(item => item.text().startsWith(label))!.find('input').setValue(label.includes('电话') ? '1' : '联系人'); const dates = wrapper.findAllComponents({ name: 'ElDatePicker' }); dates[0].vm.$emit('update:modelValue', '2026-10-20T09:30:00'); dates[1].vm.$emit('update:modelValue', '2026-10-20T15:45:00'); const origin = wrapper.findAll('input[placeholder="城市 / 州 / 详细地址"]')[0]; const destination = wrapper.findAll('input[placeholder="城市 / 州 / 详细地址"]')[1]; await origin.setValue('LA-MARKER'); await destination.setValue('ONT-MARKER'); await wrapper.findAll('.el-form-item').find((item) => item.text().startsWith('总重量'))!.find('input').setValue('80'); await wrapper.findAll('.el-form-item').find((item) => item.text().startsWith('总体积'))!.find('input').setValue('1.8'); await wrapper.vm.$nextTick(); const submit = wrapper.findAll('button').find((button) => button.text().includes('提交订单')); await submit?.trigger('click'); await flushPromises(); await submit?.trigger('click'); await flushPromises(); expect(createLocalLooseboardOrder).toHaveBeenCalledTimes(2); const keys = vi.mocked(createLocalLooseboardOrder).mock.calls.map((call) => call[1].idempotencyKey); expect(keys[0]).toBeTruthy(); expect(keys[1]).toBe(keys[0]); expect((origin.element as HTMLInputElement).value).toBe('LA-MARKER'); expect((destination.element as HTMLInputElement).value).toBe('ONT-MARKER'); expect(routing.push).not.toHaveBeenCalled() })
it.each([true, false])('opens only the matching pool order read-only arrangement: %s', matching => {
routing.route.query = { orderNo: truck.orderNo, fleetSource: 'inquiry', fleetOfferNo: 'FO-1', fleetBookingNo: matching ? arrangement.bookingNo : 'FB-OTHER' }
vi.mocked(listUsTruckOrders).mockResolvedValue({ records: [{ ...truck, fleetArrangement: arrangement }], page: 1, size: 20, total: 1, pages: 1 })