diff --git a/src/core/navigation/adminNavigation.test.ts b/src/core/navigation/adminNavigation.test.ts index 41411781..c9874ae8 100644 --- a/src/core/navigation/adminNavigation.test.ts +++ b/src/core/navigation/adminNavigation.test.ts @@ -8,6 +8,13 @@ import { } from './adminNavigation' describe('adminNavigation', () => { + it('keeps outsourced read access separate from owned access and write-only permissions', () => { + const read = filterAdminNavigation(adminNavigation, new Set(['platform:fleet:outsourced-business:list'])) + const vehicle = read.find(node => node.key === 'vehicle-management') + expect(vehicle?.children?.map(node => node.path)).toEqual(['/overseas-master-data/outsourced-business']) + const write = filterAdminNavigation(adminNavigation, new Set(['platform:fleet:outsourced-business:edit', 'platform:fleet:outsourced-business:upload'])) + expect(getNavigationLeaves(write).some(node => node.path === '/overseas-master-data/outsourced-business')).toBe(false) + }) it('places owned-business under vehicle management and keeps edit/upload from granting page access', () => { const read = filterAdminNavigation(adminNavigation, new Set(['platform:fleet:owned-business:list'])) const vehicle = read.find(node => node.key === 'vehicle-management') @@ -40,7 +47,7 @@ describe('adminNavigation', () => { { title: '货柜管理', children: ['订舱列表', '拖车列表', '货柜列表', '装柜清单', '预装清单'] }, { title: '海外尾程', children: ['尾程订单', '入库单', '废弃单', '出库单', '换标单', '派送单', '托盘管理', '库位管理', '库存管理', '退货库存管理', '退货箱号管理'] }, { title: '预约管理', children: ['预约列表'] }, - { title: '车辆管理', children: ['排车列表', '车辆列表', '司机管理', '自有业务', '出车记录'] }, + { title: '车辆管理', children: ['排车列表', '车辆列表', '司机管理', '自有业务', '外配业务', '出车记录'] }, { title: '海外业务', children: ['采购订单', '本土卡车', '本土散板', '散板需求'] }, { title: '客户服务', children: ['异常订单'] }, { title: '财务管理', children: ['客户账户', '客户应收应付', '应收款统计表', '客户对账单', '结算单应收', '结算单应付'] }, diff --git a/src/core/navigation/adminNavigation.ts b/src/core/navigation/adminNavigation.ts index 203141f4..6dfc76ba 100644 --- a/src/core/navigation/adminNavigation.ts +++ b/src/core/navigation/adminNavigation.ts @@ -135,6 +135,7 @@ export const adminNavigation: AdminNavigationNode[] = [ { key: 'vehicle-list', title: '车辆列表', icon: Truck, path: '/overseas-master-data/vehicles', permission: 'platform:overseas-master-data:vehicle:list' }, { key: 'driver-management', title: '司机管理', icon: ContactRound, path: '/overseas-master-data/drivers', permission: 'platform:overseas-master-data:driver:list' }, { key: 'fleet-owned-business', title: '自有业务', icon: Truck, path: '/overseas-master-data/owned-business', permission: 'platform:fleet:owned-business:list' }, + { key: 'fleet-outsourced-business', title: '外配业务', icon: Truck, path: '/overseas-master-data/outsourced-business', permission: 'platform:fleet:outsourced-business:list' }, { key: 'fleet-trips', title: '车次管理', icon: Truck, path: '/overseas-master-data/trips', permission: 'platform:fleet:trip:list', hidden: true }, { key: 'fleet-operations', title: '出车记录', icon: Truck, path: '/overseas-master-data/trip-operations', permission: 'platform:fleet:operations:list' }, ], diff --git a/src/modules/overseas-master-data/api/outsourcedBusiness.test.ts b/src/modules/overseas-master-data/api/outsourcedBusiness.test.ts new file mode 100644 index 00000000..0b63bb41 --- /dev/null +++ b/src/modules/overseas-master-data/api/outsourcedBusiness.test.ts @@ -0,0 +1,87 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { http } from '@/shared/api/http' +import { getOutsourcedBusiness, listOutsourcedBusiness, listOutsourcedBusinessHistory, saveOutsourcedBusinessOperations, uploadOutsourcedBusinessPhoto, bindOutsourcedBusinessPhoto, getOutsourcedBusinessPhotoContent } from './outsourcedBusiness' +import { outsourcedBusinessFixture } from '../components/OutsourcedBusinessFixtures' +import type { OutsourcedBusinessPhoto, OutsourcedBusinessPhotoSlot, OutsourcedBusinessSave } from '../outsourcedBusinessTypes' + +vi.mock('@/shared/api/http', async importOriginal => { + const actual = await importOriginal() + return { ...actual, http: { get: vi.fn(), put: vi.fn(), post: vi.fn() }, unwrapApiResponse: (value: { data: unknown }) => value.data } +}) +beforeEach(() => vi.clearAllMocks()) +const base = '/platform/v1/last-leg/fleet/outsourced-business' +const photo: OutsourcedBusinessPhoto = { slot: 'LOADING_SCENE', category: 'LOADING', fileId: '9007199254740993', fileName: 'loading.png', contentType: 'image/png', sizeBytes: '10', uploadedAt: '2026-10-20T10:00:00Z', savedAt: null } +describe('outsourced business HTTP contract', () => { + it('uses one list response for matched statistics and keeps physical count independent of cargo rows', async () => { + const detail = outsourcedBusinessFixture() + vi.mocked(http.get).mockResolvedValue({ data: { records: [detail.row], page: '1', size: '20', total: '41', summary: { dispatchCount: '41', afterLoadingCount: '40', deliveringCount: '1', deliveredCount: '0' } } }) + const query = { page: 1, size: 20, plannedDepartureFrom: '2026-10-20T00:00:00Z', plannedDepartureTo: '2026-10-21T00:00:00Z', keyword: 'FBA-1' } + const value = await listOutsourcedBusiness(query) + expect(http.get).toHaveBeenCalledExactlyOnceWith(base, { params: query }) + expect(value.summary.dispatchCount).toBe(41); expect(value.records).toHaveLength(1); expect(value.records[0]!.actualPalletCount).toBe(1) + }) + it('encodes the dispatch business number and preserves auxiliary/main versions independently', async () => { + const detail = outsourcedBusinessFixture(); detail.row.recordVersion = '0'; detail.row.dispatchRecordVersion = '9007199254740993' + detail.row.dispatchNo = 'DS/1'; detail.dispatch.dispatchNo = 'DS/1' + vi.mocked(http.get).mockResolvedValue({ data: detail }) + expect((await getOutsourcedBusiness('DS/1')).row.dispatchRecordVersion).toBe('9007199254740993') + expect(http.get).toHaveBeenCalledWith(`${base}/DS%2F1`, { params: undefined }) + detail.dispatch.dispatchNo = 'another'; await expect(getOutsourcedBusiness('DS/1')).rejects.toThrow('不一致') + }) + it('does not convert money text, calculate a client unit price or submit a dispatch version', async () => { + const body: OutsourcedBusinessSave = { recordVersion: '0', idempotencyKey: 'original-key', operations: { ...outsourcedBusinessFixture().row.operations, money: { currency: 'USD', amount: '9999999999999999.99' } }, reason: null } + const detail = outsourcedBusinessFixture(); detail.row.operations.money = body.operations.money + vi.mocked(http.put).mockResolvedValue({ data: detail }) + const saved = await saveOutsourcedBusinessOperations('DS-OUT-1', body) + expect(http.put).toHaveBeenCalledExactlyOnceWith(`${base}/DS-OUT-1/operations`, body) + expect(body.operations.money!.amount).toBe('9999999999999999.99'); expect('dispatchRecordVersion' in body).toBe(false) + expect(saved.row.operations.money!.amount).toBe('9999999999999999.99') + }) + it('keeps upload and versioned file binding separate with the same prepared file identity', async () => { + vi.mocked(http.post).mockResolvedValue({ data: photo }) + const file = new File(['image'], 'loading.png', { type: 'image/png' }) + await uploadOutsourcedBusinessPhoto('DS/1', 'LOADING_SCENE', file, 'upload-key') + const [url, form] = vi.mocked(http.post).mock.calls[0]! + expect(url).toBe(`${base}/DS%2F1/photos/LOADING_SCENE/uploads`); expect((form as FormData).get('file')).toBe(file); expect((form as FormData).get('idempotencyKey')).toBe('upload-key') + const detail = outsourcedBusinessFixture(); detail.row.dispatchNo = 'DS/1'; detail.dispatch.dispatchNo = 'DS/1' + vi.mocked(http.put).mockResolvedValue({ data: detail }) + await bindOutsourcedBusinessPhoto('DS/1', 'LOADING_SCENE', { recordVersion: '0', idempotencyKey: 'bind-key', fileId: photo.fileId }) + expect(http.put).toHaveBeenCalledExactlyOnceWith(`${base}/DS%2F1/photos/LOADING_SCENE`, { recordVersion: '0', idempotencyKey: 'bind-key', fileId: photo.fileId }) + }) + it('retains the two history streams and reads historical files only at their explicit revision', async () => { + vi.mocked(http.get).mockResolvedValue({ data: { records: [{ eventSource: 'DISPATCH', recordVersion: null, dispatchRecordVersion: '9', operations: null, photos: [] }, { eventSource: 'OPERATIONS', recordVersion: '1', dispatchRecordVersion: null, operations: outsourcedBusinessFixture().row.operations, photos: [] }], page: '1', size: '20', total: '2' } }) + const value = await listOutsourcedBusinessHistory('DS/1', 1, 20) + expect(value.records.map(record => [record.eventSource, record.recordVersion, record.dispatchRecordVersion])).toEqual([['DISPATCH',null,'9'],['OPERATIONS','1',null]]) + vi.mocked(http.get).mockResolvedValue(new Blob(['image'], { type: 'image/png' })) + await getOutsourcedBusinessPhotoContent('DS/1', photo, '1') + expect(http.get).toHaveBeenLastCalledWith(`${base}/DS%2F1/photos/LOADING_SCENE/files/${photo.fileId}/content`, { responseType: 'blob', params: { revisionVersion: '1' } }) + }) + it('never renders JSON errors or empty bytes as a photo', async () => { + vi.mocked(http.get).mockResolvedValue(new Blob([JSON.stringify({ code: 'A0403', message: '拒绝读取', requestId: 'request', traceId: 'trace' })], { type: 'application/json' })) + await expect(getOutsourcedBusinessPhotoContent('DS-OUT-1', photo)).rejects.toMatchObject({ code: 'A0403', requestId: 'request', traceId: 'trace' }) + vi.mocked(http.get).mockResolvedValue(new Blob([], { type: 'image/png' })); await expect(getOutsourcedBusinessPhotoContent('DS-OUT-1', photo)).rejects.toThrow('为空') + await expect(uploadOutsourcedBusinessPhoto('DS-OUT-1','LOADING_SCENE',new File(['text'],'bad.txt',{type:'text/plain'}),'key')).rejects.toThrow('JPG') + }) + it.each(['2', null])('accepts authoritative external vehicle identity %s without looking up the current master', async vehicleId => { + const detail = outsourcedBusinessFixture(); detail.row.vehicle.vehicleId = vehicleId; detail.row.vehicle.carrierName = null + vi.mocked(http.get).mockResolvedValue({ data: detail }) + const value = await getOutsourcedBusiness('DS-OUT-1') + expect(value.row.vehicle).toMatchObject({ vehicleId, ownership: 'DISPATCHED', carrierName: null }) + expect(http.get).toHaveBeenCalledTimes(1) + }) + it('rejects owned vehicle facts and unsupported fuel or mileage writes before any request', async () => { + const detail = outsourcedBusinessFixture() + vi.mocked(http.get).mockResolvedValue({ data: { ...detail, row: { ...detail.row, vehicle: { ...detail.row.vehicle, ownership: 'OWNED' } } } }) + await expect(getOutsourcedBusiness('DS-OUT-1')).rejects.toThrow('来源或阶段') + for (const field of ['actualDistanceMiles','fuelConsumptionLitresPer100Km','actualFuelLitres']) { + const body = { recordVersion:'0', idempotencyKey:'original-key', operations:{ ...detail.row.operations, [field]:'1' }, reason:null } + await expect(saveOutsourcedBusinessOperations('DS-OUT-1', body)).rejects.toThrow('不支持里程或油耗') + } + expect(http.put).not.toHaveBeenCalled() + for (const slot of ['FUEL_START','FUEL_END']) { + await expect(uploadOutsourcedBusinessPhoto('DS-OUT-1',slot as OutsourcedBusinessPhotoSlot,new File(['image'],'loading.png',{type:'image/png'}),'upload-key')).rejects.toThrow('不支持该照片分类') + await expect(bindOutsourcedBusinessPhoto('DS-OUT-1',slot as OutsourcedBusinessPhotoSlot,{recordVersion:'0',idempotencyKey:'bind-key',fileId:photo.fileId})).rejects.toThrow('不支持该照片分类') + } + expect(http.post).not.toHaveBeenCalled(); expect(http.put).not.toHaveBeenCalled() + }) +}) diff --git a/src/modules/overseas-master-data/api/outsourcedBusiness.ts b/src/modules/overseas-master-data/api/outsourcedBusiness.ts new file mode 100644 index 00000000..52162d8f --- /dev/null +++ b/src/modules/overseas-master-data/api/outsourcedBusiness.ts @@ -0,0 +1,74 @@ +import axios from 'axios' +import { http, unwrapApiResponse, ApiResponseError, type ApiEnvelope } from '@/shared/api/http' +import { outsourcedBusinessDecimal, outsourcedBusinessVersion, outsourcedBusinessPhotoRoles, type OutsourcedBusinessOperations, type OutsourcedBusinessRow, type OutsourcedBusinessDetail, type OutsourcedBusinessList, type OutsourcedBusinessQuery, type OutsourcedBusinessSave, type OutsourcedBusinessBind, type OutsourcedBusinessPhoto, type OutsourcedBusinessPhotoSlot, type OutsourcedBusinessCommand, type OutsourcedBusinessHistory, type OutsourcedBusinessPage } from '../outsourcedBusinessTypes' + +const base = '/platform/v1/last-leg/fleet/outsourced-business' +const path = (no: string) => `${base}/${encodeURIComponent(no)}` +async function get(url: string, params?: object): Promise { return unwrapApiResponse(await http.get, ApiEnvelope>(url, { params })) } +function count(value: unknown): number { const text = outsourcedBusinessVersion(value); const result = Number(text); if (!Number.isSafeInteger(result)) throw new Error('运营记录数量超出精确范围'); return result } +function decimal(value: string | null, scale: number, precision: number): string | null { + if (value == null) return null + if (typeof value !== 'string' || outsourcedBusinessDecimal(value, scale, precision) !== value) throw new Error('运营金额或测量必须使用精确十进制字符串') + return value +} +function operations(value: OutsourcedBusinessOperations): OutsourcedBusinessOperations { + if ('actualDistanceMiles' in value || 'fuelConsumptionLitresPer100Km' in value || 'actualFuelLitres' in value) throw new Error('外配运营信息不支持里程或油耗') + if (value.money && !['CNY','USD'].includes(value.money.currency)) throw new Error('运营金额币种无效') + const amount = value.money ? decimal(value.money.amount, 2, 18) : null + if (value.money && amount == null) throw new Error('承运金额与币种必须同时登记') + return { money: value.money ? { currency: value.money.currency, amount: amount! } : null, arrivals: value.arrivals } +} +function requireSlot(slot: OutsourcedBusinessPhotoSlot) { if (!outsourcedBusinessPhotoRoles.some(role => role.slot === slot)) throw new Error('外配业务不支持该照片分类') } +function row(value: OutsourcedBusinessRow): OutsourcedBusinessRow { + if (!value.dispatchNo || !['AFTER_LOADING','DELIVERING','DELIVERED'].includes(value.stage) || value.vehicle?.ownership !== 'DISPATCHED') throw new Error('外配排车来源或阶段不完整,请重新读取') + return { ...value, operations: operations(value.operations), unitPrice: decimal(value.unitPrice,2,18), recordVersion: outsourcedBusinessVersion(value.recordVersion), dispatchRecordVersion: outsourcedBusinessVersion(value.dispatchRecordVersion), actualPalletCount: value.actualPalletCount == null ? null : count(value.actualPalletCount), photoSummary: { loadingCount: count(value.photoSummary.loadingCount) } } +} +function detail(value: OutsourcedBusinessDetail): OutsourcedBusinessDetail { if (value.row.dispatchNo !== value.dispatch.dispatchNo) throw new Error('运营资料与原排车单不一致'); return { ...value, row: row(value.row) } } +function command(value: OutsourcedBusinessCommand): OutsourcedBusinessCommand { return { ...value, recordVersion: value.recordVersion == null ? null : outsourcedBusinessVersion(value.recordVersion) } } +export async function listOutsourcedBusiness(query: OutsourcedBusinessQuery): Promise { + const value = await get(base, query) + return { ...value, records: value.records.map(row), page: count(value.page), size: count(value.size), total: count(value.total), summary: { dispatchCount: count(value.summary.dispatchCount), afterLoadingCount: count(value.summary.afterLoadingCount), deliveringCount: count(value.summary.deliveringCount), deliveredCount: count(value.summary.deliveredCount) } } +} +export async function getOutsourcedBusiness(no: string) { const value = detail(await get(path(no))); if (value.row.dispatchNo !== no) throw new Error('运营资料与当前排车业务号不一致'); return value } +export async function listOutsourcedBusinessHistory(no: string, page: number, size: number): Promise> { + const value = await get>(`${path(no)}/history`, { page, size }) + return { ...value, records: value.records.map(record => ({ ...record, operations: record.operations ? operations(record.operations) : null, recordVersion: record.recordVersion == null ? null : outsourcedBusinessVersion(record.recordVersion), dispatchRecordVersion: record.dispatchRecordVersion == null ? null : outsourcedBusinessVersion(record.dispatchRecordVersion) })), page: count(value.page), size: count(value.size), total: count(value.total) } +} +export const getOutsourcedBusinessCommand = (no: string, key: string) => get(`${path(no)}/commands/${encodeURIComponent(key)}`).then(command) +export async function saveOutsourcedBusinessOperations(no: string, body: OutsourcedBusinessSave) { + outsourcedBusinessVersion(body.recordVersion) + operations(body.operations) + const value = detail(unwrapApiResponse(await http.put, ApiEnvelope>(`${path(no)}/operations`, body))) + if (value.row.dispatchNo !== no) throw new Error('运营保存结果与原排车单不一致') + return value +} +export async function uploadOutsourcedBusinessPhoto(no: string, slot: OutsourcedBusinessPhotoSlot, file: File, key: string) { + requireSlot(slot) + if (!['image/jpeg','image/png'].includes(file.type) || file.size <= 0) throw new Error('请选择有效的JPG或PNG图片') + const form = new FormData(); form.append('file', file); form.append('idempotencyKey', key) + const value = unwrapApiResponse(await http.post, ApiEnvelope>(`${path(no)}/photos/${slot}/uploads`, form)) + if (value.slot !== slot || !value.fileId) throw new Error('上传文件与原照片分类不一致') + return value +} +export async function bindOutsourcedBusinessPhoto(no: string, slot: OutsourcedBusinessPhotoSlot, body: OutsourcedBusinessBind) { + requireSlot(slot) + outsourcedBusinessVersion(body.recordVersion) + const value = detail(unwrapApiResponse(await http.put, ApiEnvelope>(`${path(no)}/photos/${slot}`, body))) + if (value.row.dispatchNo !== no) throw new Error('照片绑定结果与原排车单不一致') + return value +} +async function downloadError(payload: unknown, status: number) { + let value = payload + if (payload instanceof Blob && payload.type.includes('json')) { try { value = JSON.parse(await payload.text()) as unknown } catch { throw new Error('图片接口返回了无法识别的错误') } } + if (value && typeof value === 'object' && 'code' in value) { const error = value as ApiEnvelope; throw new ApiResponseError(error.message || '图片读取失败', error.code, status, error.data, error.requestId, error.traceId) } +} +export async function getOutsourcedBusinessPhotoContent(no: string, photo: OutsourcedBusinessPhoto, revisionVersion?: string): Promise { + requireSlot(photo.slot) + if (revisionVersion != null && BigInt(outsourcedBusinessVersion(revisionVersion)) < 1n) throw new Error('历史图片版本必须大于零') + try { + const blob = await http.get(`${path(no)}/photos/${photo.slot}/files/${encodeURIComponent(photo.fileId)}/content`, { responseType: 'blob', params: revisionVersion == null ? undefined : { revisionVersion: outsourcedBusinessVersion(revisionVersion) } }) + await downloadError(blob, 200) + if (!(blob instanceof Blob) || !['image/jpeg','image/png'].includes(blob.type) || !blob.size) throw new Error('图片内容为空或格式无效') + return blob + } catch (cause) { if (axios.isAxiosError(cause)) await downloadError(cause.response?.data, cause.response?.status || 0); throw cause } +} diff --git a/src/modules/overseas-master-data/components/OutsourcedBusinessCommandRecovery.test.ts b/src/modules/overseas-master-data/components/OutsourcedBusinessCommandRecovery.test.ts new file mode 100644 index 00000000..0f43adab --- /dev/null +++ b/src/modules/overseas-master-data/components/OutsourcedBusinessCommandRecovery.test.ts @@ -0,0 +1,72 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ApiResponseError } from '@/shared/api/http' +import * as api from '../api/outsourcedBusiness' +import { outsourcedBusinessFixture, outsourcedBusinessCommandFixture } from './OutsourcedBusinessFixtures' +import { acceptOutsourcedBusinessCommand, newOutsourcedBusinessPending, persistOutsourcedBusinessPending, restoreOutsourcedBusinessPending, recoverOutsourcedBusinessCommand, outsourcedBusinessPendingKey } from './OutsourcedBusinessCommandRecovery' +import type { OutsourcedBusinessPhoto, OutsourcedBusinessSave } from '../outsourcedBusinessTypes' + +beforeEach(() => sessionStorage.clear()) +const no = 'DS-OUT-1' +function operation() : OutsourcedBusinessSave { return { recordVersion: '0', idempotencyKey: 'original-key', operations: { ...outsourcedBusinessFixture().row.operations, money: { currency: 'USD', amount: '9999999999999999.99' } }, reason: null } } +function client() { + const current = outsourcedBusinessFixture(); current.row.recordVersion = '1' + const photo: OutsourcedBusinessPhoto = { slot:'LOADING_SCENE',category:'LOADING',fileId:'9007199254740993',fileName:'loading.png',contentType:'image/png',sizeBytes:'5',uploadedAt:'2026-10-20T10:00:00Z',savedAt:null } + return { ...api, getOutsourcedBusiness: vi.fn(async () => current), getOutsourcedBusinessCommand: vi.fn(async () => outsourcedBusinessCommandFixture()), saveOutsourcedBusinessOperations: vi.fn(async () => current), bindOutsourcedBusinessPhoto: vi.fn(async () => current), uploadOutsourcedBusinessPhoto: vi.fn(async () => photo) } +} +describe('outsourced business exact command recovery', () => { + it('persists the complete original payload, actor and zero auxiliary version', () => { + const pending = newOutsourcedBusinessPending('actor', no, 'SAVE_OPERATIONS', operation()) + persistOutsourcedBusinessPending('actor', no, 'operations', pending) + expect(restoreOutsourcedBusinessPending('actor', no, 'operations')).toEqual(pending) + expect(restoreOutsourcedBusinessPending('another', no, 'operations')).toBeNull() + const raw = JSON.parse(sessionStorage.getItem(outsourcedBusinessPendingKey('actor',no,'operations'))!) + raw.actor = 'another'; sessionStorage.setItem(outsourcedBusinessPendingKey('actor',no,'operations'),JSON.stringify(raw)) + expect(() => restoreOutsourcedBusinessPending('actor',no,'operations')).toThrow('损坏') + }) + it('looks up an unknown write first and retries only the unchanged original body on explicit not-found', async () => { + const pending = newOutsourcedBusinessPending('actor', no, 'SAVE_OPERATIONS', operation()); pending.attempted = true + const service = client(), persist = vi.fn() + service.getOutsourcedBusinessCommand.mockRejectedValue(new ApiResponseError('not found','A0404',404)) + await recoverOutsourcedBusinessCommand(pending,persist,() => {},service) + expect(service.getOutsourcedBusinessCommand).toHaveBeenCalledWith(no,'original-key') + expect(service.saveOutsourcedBusinessOperations).toHaveBeenCalledExactlyOnceWith(no,operation()) + expect(pending.body).toEqual(operation()); expect(pending.body?.recordVersion).toBe('0') + }) + it('retains a proven success through failed fresh reads without writing it a second time', async () => { + const pending = newOutsourcedBusinessPending('actor', no, 'SAVE_OPERATIONS', operation()), service = client() + service.getOutsourcedBusiness.mockRejectedValueOnce(new Error('read offline')) + await expect(recoverOutsourcedBusinessCommand(pending,() => {},() => {},service)).rejects.toThrow('read offline') + expect(pending.confirmed?.recordVersion).toBe('1') + await recoverOutsourcedBusinessCommand(pending,() => {},() => {},service) + expect(service.saveOutsourcedBusinessOperations).toHaveBeenCalledTimes(1); expect(service.getOutsourcedBusinessCommand).not.toHaveBeenCalled() + }) + it('never retransmits a file for an unknown upload, including command not-found', async () => { + const pending = newOutsourcedBusinessPending('actor',no,'UPLOAD_PHOTO',null,'LOADING_SCENE'); pending.attempted = true + const service = client(); service.getOutsourcedBusinessCommand.mockRejectedValue(new ApiResponseError('not found','A0404',404)) + await expect(recoverOutsourcedBusinessCommand(pending,() => {},() => {},service)).rejects.toThrow('禁止重复上传') + expect(service.uploadOutsourcedBusinessPhoto).not.toHaveBeenCalled(); expect(service.bindOutsourcedBusinessPhoto).not.toHaveBeenCalled(); expect(pending.key).toBeTruthy() + }) + it('keeps the prepared file and exact binding key through unknown binding and later replacement', async () => { + const photo: OutsourcedBusinessPhoto = { slot:'LOADING_SCENE',category:'LOADING',fileId:'9007199254740993',fileName:'loading.png',contentType:'image/png',sizeBytes:'5',uploadedAt:'2026-10-20T10:00:00Z',savedAt:'2026-10-20T10:01:00Z' } + const pending = newOutsourcedBusinessPending('actor',no,'BIND_PHOTO',{recordVersion:'0',idempotencyKey:'bind-key',fileId:photo.fileId},photo.slot) + pending.attempted = true + const service = client(); const bound = outsourcedBusinessFixture(); bound.row.recordVersion='1'; bound.photos=[photo] + const original = outsourcedBusinessCommandFixture({action:'BIND_PHOTO',idempotencyKey:'bind-key',result:bound}) + service.getOutsourcedBusinessCommand.mockResolvedValue(original) + const current = outsourcedBusinessFixture();current.row.recordVersion='2';current.photos=[{...photo,fileId:'9007199254740994'}] + service.getOutsourcedBusiness.mockResolvedValue(current) + const result = await recoverOutsourcedBusinessCommand(pending,() => {},() => {},service) + expect(result.detail?.photos[0]?.fileId).toBe('9007199254740994') + expect(pending.body).toEqual({recordVersion:'0',idempotencyKey:'bind-key',fileId:photo.fileId}) + expect(service.uploadOutsourcedBusinessPhoto).not.toHaveBeenCalled();expect(service.bindOutsourcedBusinessPhoto).not.toHaveBeenCalled() + }) + it('refuses wrong command identities, stale reads, definite rejection retries and scope changes', async () => { + const pending = newOutsourcedBusinessPending('actor',no,'SAVE_OPERATIONS',operation()), service=client() + expect(() => acceptOutsourcedBusinessCommand(pending,outsourcedBusinessCommandFixture({dispatchNo:'another'}))).toThrow('不一致') + pending.command=outsourcedBusinessCommandFixture({recordVersion:'9007199254740993'}) + await expect(recoverOutsourcedBusinessCommand(pending,() => {},() => {},service)).rejects.toThrow('早于') + pending.rejected=true;await expect(recoverOutsourcedBusinessCommand(pending,() => {},() => {},service)).rejects.toThrow('明确拒绝') + pending.rejected=false;await expect(recoverOutsourcedBusinessCommand(pending,() => {},() => {throw new Error('scope changed')},service)).rejects.toThrow('scope changed') + expect(service.saveOutsourcedBusinessOperations).not.toHaveBeenCalled() + }) +}) diff --git a/src/modules/overseas-master-data/components/OutsourcedBusinessCommandRecovery.ts b/src/modules/overseas-master-data/components/OutsourcedBusinessCommandRecovery.ts new file mode 100644 index 00000000..a9097a53 --- /dev/null +++ b/src/modules/overseas-master-data/components/OutsourcedBusinessCommandRecovery.ts @@ -0,0 +1,89 @@ +import { ApiResponseError } from '@/shared/api/http' +import * as api from '../api/outsourcedBusiness' +import { outsourcedBusinessPhotoRoles, outsourcedBusinessVersion, type OutsourcedBusinessAction, type OutsourcedBusinessBind, type OutsourcedBusinessCommand, type OutsourcedBusinessDetail, type OutsourcedBusinessPhoto, type OutsourcedBusinessPhotoSlot, type OutsourcedBusinessSave } from '../outsourcedBusinessTypes' + +export interface OutsourcedBusinessPending { + actor: string; dispatchNo: string; action: OutsourcedBusinessAction; key: string; slot?: OutsourcedBusinessPhotoSlot + body: OutsourcedBusinessSave | OutsourcedBusinessBind | null; attempted: boolean; command: OutsourcedBusinessCommand | null; confirmed: { recordVersion: string | null; result: OutsourcedBusinessDetail | OutsourcedBusinessPhoto } | null; rejected: boolean +} +export const outsourcedBusinessPendingKey = (actor: string, no: string, area: string) => `outsourced-business:${encodeURIComponent(actor)}:${encodeURIComponent(no)}:${area}` +export function restoreOutsourcedBusinessPending(actor: string, no: string, area: string): OutsourcedBusinessPending | null { + const raw = sessionStorage.getItem(outsourcedBusinessPendingKey(actor, no, area)) + if (!raw) return null + const pending = JSON.parse(raw) as OutsourcedBusinessPending + if (pending.actor !== actor || pending.dispatchNo !== no || !pending.key || !['SAVE_OPERATIONS','UPLOAD_PHOTO','BIND_PHOTO'].includes(pending.action)) throw new Error('原运营请求记录损坏,请保留现场核对') + if (pending.action !== 'SAVE_OPERATIONS' && !outsourcedBusinessPhotoRoles.some(role => role.slot === pending.slot) || pending.action !== 'UPLOAD_PHOTO' && (!pending.body || pending.body.idempotencyKey !== pending.key)) throw new Error('原运营请求内容不完整,请保留现场核对') + return pending +} +export function persistOutsourcedBusinessPending(actor: string, no: string, area: string, pending: OutsourcedBusinessPending | null) { + if (!actor || !no || pending && (pending.actor !== actor || pending.dispatchNo !== no)) throw new Error('运营请求身份已变化') + const key = outsourcedBusinessPendingKey(actor, no, area) + if (pending) sessionStorage.setItem(key, JSON.stringify(pending)); else sessionStorage.removeItem(key) +} +export function newOutsourcedBusinessPending(actor: string, no: string, action: OutsourcedBusinessAction, body: OutsourcedBusinessPending['body'], slot?: OutsourcedBusinessPhotoSlot): OutsourcedBusinessPending { + if (!actor || !no) throw new Error('当前运营作业身份缺失') + const key = body?.idempotencyKey || crypto.randomUUID() + return { actor, dispatchNo: no, action, key, slot, body: body ? structuredClone(body) : null, attempted: false, command: null, confirmed: null, rejected: false } +} +export function acceptOutsourcedBusinessResult(pending: OutsourcedBusinessPending, result: OutsourcedBusinessDetail | OutsourcedBusinessPhoto) { + if (pending.action === 'UPLOAD_PHOTO') { + if (!('fileId' in result) || result.slot !== pending.slot || result.category !== 'LOADING' || typeof result.fileId !== 'string' || !/^[1-9]\d*$/.test(result.fileId) || !['image/jpeg','image/png'].includes(result.contentType)) throw new Error('原上传尚未返回本分类的真实文件') + pending.confirmed = { recordVersion: null, result } + } else { + if (!('row' in result) || result.row.dispatchNo !== pending.dispatchNo) throw new Error('运营保存结果与原排车单不一致') + pending.confirmed = { recordVersion: outsourcedBusinessVersion(result.row.recordVersion), result } + } +} +export function acceptOutsourcedBusinessCommand(pending: OutsourcedBusinessPending, command: OutsourcedBusinessCommand) { + if (command.dispatchNo !== pending.dispatchNo || command.action !== pending.action || command.idempotencyKey !== pending.key) throw new Error('返回结果与原运营请求不一致,请保留原请求核对') + pending.command = command + if (command.state === 'REJECTED') pending.rejected = true + if (command.state !== 'SUCCEEDED') throw new Error(command.state === 'REJECTED' ? '原请求已明确拒绝,请核对原因后重新登记' : '原请求结果尚未确定,请保留原请求继续核对') + if (!command.result) throw new Error('原运营命令的成功结果不完整,请继续核对') + acceptOutsourcedBusinessResult(pending, command.result) + if (pending.action !== 'UPLOAD_PHOTO' && (command.recordVersion == null || command.recordVersion !== pending.confirmed!.recordVersion)) throw new Error('原运营命令版本与结果不一致') +} +export async function recoverOutsourcedBusinessCommand(pending: OutsourcedBusinessPending, persist: () => void, ensureScope: () => void, client = api): Promise<{ detail?: OutsourcedBusinessDetail; photo?: OutsourcedBusinessPhoto }> { + ensureScope() + if (pending.rejected) throw new Error('原请求已明确拒绝,请先按明确拒绝结果重新核对') + if (pending.command?.state === 'SUCCEEDED') acceptOutsourcedBusinessCommand(pending, pending.command) + const previous = pending.attempted + if (!pending.confirmed) { + let original: OutsourcedBusinessCommand | null = null + if (previous) { + try { original = await client.getOutsourcedBusinessCommand(pending.dispatchNo, pending.key) } + catch (cause) { if (!(cause instanceof ApiResponseError) || !['404','A0404','HTTP_404'].includes(cause.code)) throw cause } + ensureScope() + } + if (original) { try { acceptOutsourcedBusinessCommand(pending, original) } finally { persist() } } + else { + if (pending.action === 'UPLOAD_PHOTO') throw new Error('图片上传结果未知,禁止重复上传;请继续核对原上传请求') + pending.attempted = true; persist() + try { + const result = pending.action === 'SAVE_OPERATIONS' + ? await client.saveOutsourcedBusinessOperations(pending.dispatchNo, pending.body as OutsourcedBusinessSave) + : await client.bindOutsourcedBusinessPhoto(pending.dispatchNo, pending.slot!, pending.body as OutsourcedBusinessBind) + ensureScope(); acceptOutsourcedBusinessResult(pending, result); persist() + } catch (cause) { + ensureScope() + if (!previous && cause instanceof ApiResponseError && ['A0400','A0403','A0404','A0409','400','403','404','409','422'].includes(cause.code)) { pending.rejected = true; persist() } + throw cause + } + } + } + const confirmed = pending.confirmed! + if (pending.action === 'UPLOAD_PHOTO') { + const photo = confirmed.result as OutsourcedBusinessPhoto | null + if (!photo || photo.slot !== pending.slot || photo.category !== 'LOADING' || typeof photo.fileId !== 'string' || !/^[1-9]\d*$/.test(photo.fileId) || !['image/jpeg','image/png'].includes(photo.contentType)) throw new Error('原上传尚未返回本分类的真实文件') + return { photo } + } + const current = await client.getOutsourcedBusiness(pending.dispatchNo); ensureScope() + if (current.row.dispatchNo !== pending.dispatchNo || confirmed.recordVersion == null || BigInt(outsourcedBusinessVersion(current.row.recordVersion)) < BigInt(outsourcedBusinessVersion(confirmed.recordVersion))) throw new Error('当前运营版本早于原操作结果,请继续核对') + if (pending.action === 'BIND_PHOTO') { + const matches = (photo: OutsourcedBusinessPhoto) => photo.slot === pending.slot && photo.fileId === (pending.body as OutsourcedBusinessBind).fileId && Boolean(photo.savedAt) + const original = confirmed.result + const originalPhotos = original && 'row' in original ? original.photos : original && 'fileId' in original ? [original] : [] + if (!current.photos.some(matches) && !originalPhotos.some(matches)) throw new Error('图片绑定结果尚未确认,请保留原文件和请求') + } + return { detail: current } +} diff --git a/src/modules/overseas-master-data/components/OutsourcedBusinessDetailLayout.vue b/src/modules/overseas-master-data/components/OutsourcedBusinessDetailLayout.vue new file mode 100644 index 00000000..0245aa3f --- /dev/null +++ b/src/modules/overseas-master-data/components/OutsourcedBusinessDetailLayout.vue @@ -0,0 +1,36 @@ + + + + + diff --git a/src/modules/overseas-master-data/components/OutsourcedBusinessFixtures.ts b/src/modules/overseas-master-data/components/OutsourcedBusinessFixtures.ts new file mode 100644 index 00000000..db3e4a3a --- /dev/null +++ b/src/modules/overseas-master-data/components/OutsourcedBusinessFixtures.ts @@ -0,0 +1,20 @@ +import type { OutsourcedBusinessCommand, OutsourcedBusinessDetail } from '../outsourcedBusinessTypes' + +export function outsourcedBusinessFixture(): OutsourcedBusinessDetail { + const review = { status: 'APPROVED' as const, reason: null, reviewerId: null, reviewedAt: '2026-10-20T10:00:00Z', reviewRound: '1', basisHash: null, invalidatedReason: null } + return { + row: { + dispatchNo: 'DS-OUT-1', stage: 'AFTER_LOADING', plannedDepartureAt: '2026-10-20T10:00:00Z', loadingCompletedAt: '2026-10-20T09:00:00Z', departedAt: null, deliveryCompletedAt: null, + origin: { warehouseId: '1', warehouseCode: 'WH-1', warehouseName: '起运仓' }, vehicle: { vehicleId: '2', plate: 'OUT-1', ownership: 'DISPATCHED', carrierName: '测试外配承运商' }, driver: { name: null, phone: null }, + destinations: [{ destinationKey: 'destination-1', warehouseCode: 'FBA-1', warehouseName: '目的仓', address: null, appointments: [{ appointmentNo: 'AP-1', confirmationNo: null, scheduledAt: null, allocatedPackages: '2' }], actualArrivedAt: null }], + actualPalletCount: 1, operations: { money: null, arrivals: [{ destinationKey: 'destination-1', actualArrivedAt: null }] }, unitPrice: null, recordVersion: '0', dispatchRecordVersion: '8', + photoSummary: { loadingCount: 0 }, actions: { edit: { allowed: true, blockReason: null }, upload: { allowed: true, blockReason: null } }, sourceIssues: [], + }, + dispatch: { dispatchNo: 'DS-OUT-1', stage: 'AFTER_LOADING', status: 'ACTIVE', recordVersion: '8', reviewState: 'APPROVED', reviews: { dispatchReview: { ...review }, loadingReview: { ...review } }, counts: { itemCount: 2, palletCount: 1, packageCount: 2, pickedPackageCount: 2, shippedPackageCount: 0 }, pickingReconciled: true, actions: {}, vehicleInfo: { vehicle: null, driver: null }, dispatchInfo: { route: null, plannedDepartureAt: '2026-10-20T10:00:00Z', departedAt: null, estimatedArrivalAt: null, fbaAppointments: [], appointmentBatches: [] }, documents: { dispatchNo: 'DS-OUT-1', outbounds: [], deliveries: [], createdAt: '2026-10-20T08:00:00Z', updatedAt: '2026-10-20T09:00:00Z', remark: null }, orderDetails: [] }, + cargo: ['A','B'].map(customer => ({ sourceKey: `source-${customer}`, customerId: customer, customerName: `客户${customer}`, orderNo: `ORDER-${customer}`, inboundCode: `IN-${customer}`, palletNo: 'ONE-PHYSICAL-PALLET', destinationKey: 'destination-1', packages: '1' })), photos: [], + } +} +export function outsourcedBusinessCommandFixture(overrides: Partial = {}): OutsourcedBusinessCommand { + const detail = outsourcedBusinessFixture(); detail.row.recordVersion = overrides.recordVersion ?? '1' + return { dispatchNo: 'DS-OUT-1', action: 'SAVE_OPERATIONS', idempotencyKey: 'original-key', state: 'SUCCEEDED', recordVersion: '1', replayed: false, result: detail, errorCode: null, retryAllowed: false, ...overrides } +} diff --git a/src/modules/overseas-master-data/components/OutsourcedBusinessLayouts.test.ts b/src/modules/overseas-master-data/components/OutsourcedBusinessLayouts.test.ts new file mode 100644 index 00000000..be6008fd --- /dev/null +++ b/src/modules/overseas-master-data/components/OutsourcedBusinessLayouts.test.ts @@ -0,0 +1,48 @@ +import { defineComponent } from 'vue' +import { mount } from '@vue/test-utils' +import { describe, expect, it, vi } from 'vitest' +import OutsourcedBusinessDetailLayout from './OutsourcedBusinessDetailLayout.vue' +import OutsourcedBusinessPhotosDialog from './OutsourcedBusinessPhotosDialog.vue' +import OutsourcedBusinessOperationsDialog from './OutsourcedBusinessOperationsDialog.vue' + +const Surface = defineComponent({ template: '
' }) +const Tabs = defineComponent({ props: ['modelValue'], emits: ['update:modelValue'], template: '
' }) +const Pane = defineComponent({ props: ['name', 'label'], template: '
' }) +const Dialog = defineComponent({ props: ['width', 'showConfirm', 'beforeClose'], template: '
' }) +const global = { stubs: { AdminPage: Surface, DetailPageLayout: Surface, AppDialog: Dialog, ElTabs: Tabs, ElTabPane: Pane, ElButton: Surface }, directives: { loading: () => {} } } + +describe('OutsourcedBusiness presentation boundaries', () => { + it('mounts only the selected one of the three independent detail regions', async () => { + const wrapper = mount(OutsourcedBusinessDetailLayout, { global, props: { dispatchNo: 'DS-OUT-1' }, slots: { task: '

task-facts

', route: '

route-facts

', history: '

history-facts

' } }) + expect(wrapper.findAllComponents(Pane).map(pane => pane.props('label'))).toEqual(['任务与车辆信息', '路线货物与凭证', '操作记录']) + expect(wrapper.text()).toContain('task-facts'); expect(wrapper.text()).not.toContain('history-facts') + wrapper.findComponent(Tabs).vm.$emit('update:modelValue', 'history'); await wrapper.vm.$nextTick() + expect(wrapper.text()).toContain('history-facts'); expect(wrapper.text()).not.toContain('task-facts') + }) + + it('preserves only five approved photo roles without demo images or uploaded claims', () => { + const wrapper = mount(OutsourcedBusinessPhotosDialog, { global, props: { modelValue: true } }) + expect(wrapper.findComponent(Dialog).props('width')).toBe('760px') + expect(wrapper.findComponent(Dialog).props('showConfirm')).toBe(false) + expect(wrapper.findAll('section[aria-label]').map(section => section.attributes('aria-label'))).toEqual(['装车现场', '装车清单', '出车前检查', '驾驶证附件', '签收凭证']) + expect(wrapper.findAll('img')).toHaveLength(0); expect(wrapper.text()).not.toContain('已上传') + }) + + it('blocks a close while busy and delegates unsaved-result decisions to the owner', async () => { + const guard = vi.fn(), done = vi.fn() + const wrapper = mount(OutsourcedBusinessPhotosDialog, { global, props: { modelValue: true, busy: true, beforeClose: guard } }) + const close = wrapper.findComponent(Dialog).props('beforeClose') as (done: () => void) => void + close(done); expect(guard).not.toHaveBeenCalled(); expect(done).not.toHaveBeenCalled() + await wrapper.setProps({ busy: false }); close(done) + expect(guard).toHaveBeenCalledWith(done); expect(done).not.toHaveBeenCalled() + }) + + it('keeps an unknown operations request until its owner explicitly handles closing', async () => { + const done = vi.fn(), guard = vi.fn() + const wrapper = mount(OutsourcedBusinessOperationsDialog, { global, props: { modelValue: true, pending: true } }) + const close = wrapper.findComponent(Dialog).props('beforeClose') as (done: () => void) => void + close(done); expect(done).not.toHaveBeenCalled() + await wrapper.setProps({ beforeClose: guard }); close(done) + expect(guard).toHaveBeenCalledWith(done); expect(done).not.toHaveBeenCalled() + }) +}) diff --git a/src/modules/overseas-master-data/components/OutsourcedBusinessListLayout.vue b/src/modules/overseas-master-data/components/OutsourcedBusinessListLayout.vue new file mode 100644 index 00000000..c176cda2 --- /dev/null +++ b/src/modules/overseas-master-data/components/OutsourcedBusinessListLayout.vue @@ -0,0 +1,16 @@ + + + diff --git a/src/modules/overseas-master-data/components/OutsourcedBusinessOperationsDialog.vue b/src/modules/overseas-master-data/components/OutsourcedBusinessOperationsDialog.vue new file mode 100644 index 00000000..b34728f2 --- /dev/null +++ b/src/modules/overseas-master-data/components/OutsourcedBusinessOperationsDialog.vue @@ -0,0 +1,34 @@ + + + + + diff --git a/src/modules/overseas-master-data/components/OutsourcedBusinessOperationsEditor.test.ts b/src/modules/overseas-master-data/components/OutsourcedBusinessOperationsEditor.test.ts new file mode 100644 index 00000000..6e9cd12a --- /dev/null +++ b/src/modules/overseas-master-data/components/OutsourcedBusinessOperationsEditor.test.ts @@ -0,0 +1,59 @@ +import { defineComponent, h } from 'vue' +import { flushPromises, mount } from '@vue/test-utils' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ApiResponseError } from '@/shared/api/http' +import OutsourcedBusinessOperationsEditor from './OutsourcedBusinessOperationsEditor.vue' +import { outsourcedBusinessFixture } from './OutsourcedBusinessFixtures' + +const calls = vi.hoisted(() => ({ save: vi.fn(), detail: vi.fn(), command: vi.fn(), canEdit: true })) +vi.mock('../api/outsourcedBusiness', () => ({ saveOutsourcedBusinessOperations: calls.save, getOutsourcedBusiness: calls.detail, getOutsourcedBusinessCommand: calls.command })) +vi.mock('@/core/auth/auth', () => ({ useAuthStore: () => ({ user: { id: 'actor' } }) })) +vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => calls.canEdit }) })) +vi.mock('@/core/unsaved-work/useUnsavedWorkRegistration', () => ({ useUnsavedWorkRegistration: () => ({ captureBaseline: vi.fn() }) })) +vi.mock('element-plus', () => ({ ElMessage: { success: vi.fn() }, ElMessageBox: { confirm: vi.fn() } })) +const Frame = defineComponent({ props: ['modelValue','busy','saveDisabled','pending'], emits: ['save','update:modelValue'], setup(props,{ slots,emit }) { return () => h('div', [...Object.values(slots).map(slot => slot?.()), h('button',{ 'data-save':'',disabled: props.busy || props.saveDisabled,onClick: () => emit('save') },'save')]) } }) +const Control = defineComponent({ props: ['modelValue'], emits: ['update:modelValue'], template: '' }) +const Field = defineComponent({ props: ['label'], template: '' }) +const global = { stubs: { OutsourcedBusinessOperationsDialog: Frame, ElForm: { template:'
' }, ElFormItem: Field, ElInput: Control, ElSelect: Control, ElOption: true, ElDatePicker: Control, LoadErrorState: { props:['message'],template:'
{{message}}
' }, ApiErrorMeta: true } } + +beforeEach(() => { + vi.clearAllMocks(); sessionStorage.clear(); calls.canEdit = true + const current = outsourcedBusinessFixture(); current.row.recordVersion = '1' + calls.save.mockResolvedValue(current); calls.detail.mockResolvedValue(current) + calls.command.mockRejectedValue(new ApiResponseError('not found','A0404',404)) +}) + +describe('outsourced operations registration', () => { + it('sends explicit zero and destination arrival with auxiliary zero, without fuel or mileage fields', async () => { + const wrapper = mount(OutsourcedBusinessOperationsEditor,{ global,props:{modelValue:true,detail:outsourcedBusinessFixture()} }); await flushPromises() + const currency = wrapper.findAll('label').find(field => field.text() === '币种')!.find('input') + const amount = wrapper.findAll('label').find(field => field.text() === '整车承运金额')!.find('input') + await currency.setValue('USD'); await amount.setValue('0.00') + await wrapper.findAll('input').at(-1)!.setValue('2026-10-20T12:00:00+08:00') + await wrapper.find('[data-save]').trigger('click'); await flushPromises() + const body = calls.save.mock.calls[0]![1] + expect(body).toEqual({ recordVersion:'0',idempotencyKey:expect.any(String),operations:{money:{currency:'USD',amount:'0.00'},arrivals:[{destinationKey:'destination-1',actualArrivedAt:'2026-10-20T12:00:00+08:00'}]},reason:null }) + expect(calls.save).toHaveBeenCalledTimes(1); expect(wrapper.emitted('saved')).toHaveLength(1) + expect(wrapper.text()).not.toContain('油耗'); expect(wrapper.text()).not.toContain('里程'); wrapper.unmount() + }) + it('clears money as null and retains exactly the original request after an unknown save', async () => { + const detail = outsourcedBusinessFixture(); detail.row.operations.money = {currency:'CNY',amount:'123.45'} + calls.save.mockRejectedValueOnce(new TypeError('network interrupted')) + const wrapper = mount(OutsourcedBusinessOperationsEditor,{global,props:{modelValue:true,detail}}); await flushPromises() + await wrapper.findAll('label').find(field => field.text() === '币种')!.find('input').setValue('') + await wrapper.findAll('label').find(field => field.text() === '整车承运金额')!.find('input').setValue('') + await wrapper.find('[data-save]').trigger('click'); await flushPromises() + const original = JSON.parse(JSON.stringify(calls.save.mock.calls[0]![1])) + expect(original.operations.money).toBeNull(); expect(wrapper.emitted('saved')).toBeUndefined() + await wrapper.find('[data-save]').trigger('click'); await flushPromises() + expect(calls.command).toHaveBeenCalledExactlyOnceWith('DS-OUT-1',original.idempotencyKey) + expect(calls.save).toHaveBeenNthCalledWith(2,'DS-OUT-1',original); expect(wrapper.emitted('saved')).toHaveLength(1); wrapper.unmount() + }) + it('does not write for an unchanged form or a user without edit permission', async () => { + const first = mount(OutsourcedBusinessOperationsEditor,{global,props:{modelValue:true,detail:outsourcedBusinessFixture()}}); await flushPromises() + await first.find('[data-save]').trigger('click'); await flushPromises(); expect(calls.save).not.toHaveBeenCalled(); first.unmount() + calls.canEdit = false + const readonly = mount(OutsourcedBusinessOperationsEditor,{global,props:{modelValue:true,detail:outsourcedBusinessFixture()}}); await flushPromises() + expect(readonly.find('[data-save]').attributes('disabled')).toBeDefined(); expect(calls.save).not.toHaveBeenCalled(); readonly.unmount() + }) +}) diff --git a/src/modules/overseas-master-data/components/OutsourcedBusinessOperationsEditor.vue b/src/modules/overseas-master-data/components/OutsourcedBusinessOperationsEditor.vue new file mode 100644 index 00000000..9a98a011 --- /dev/null +++ b/src/modules/overseas-master-data/components/OutsourcedBusinessOperationsEditor.vue @@ -0,0 +1,91 @@ + + + + + diff --git a/src/modules/overseas-master-data/components/OutsourcedBusinessPhotosDialog.vue b/src/modules/overseas-master-data/components/OutsourcedBusinessPhotosDialog.vue new file mode 100644 index 00000000..f8179fc1 --- /dev/null +++ b/src/modules/overseas-master-data/components/OutsourcedBusinessPhotosDialog.vue @@ -0,0 +1,36 @@ + + + + + diff --git a/src/modules/overseas-master-data/components/OutsourcedBusinessPhotosEditor.test.ts b/src/modules/overseas-master-data/components/OutsourcedBusinessPhotosEditor.test.ts new file mode 100644 index 00000000..21f9903d --- /dev/null +++ b/src/modules/overseas-master-data/components/OutsourcedBusinessPhotosEditor.test.ts @@ -0,0 +1,64 @@ +import { defineComponent, h } from 'vue' +import { flushPromises, mount } from '@vue/test-utils' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { ApiResponseError } from '@/shared/api/http' +import OutsourcedBusinessPhotosEditor from './OutsourcedBusinessPhotosEditor.vue' +import { outsourcedBusinessFixture } from './OutsourcedBusinessFixtures' +import type { OutsourcedBusinessPhoto } from '../outsourcedBusinessTypes' + +const calls=vi.hoisted(()=>({upload:vi.fn(),bind:vi.fn(),command:vi.fn(),detail:vi.fn(),content:vi.fn(),actor:{user:{id:'actor'}},canUpload:true})) +vi.mock('../api/outsourcedBusiness',()=>({uploadOutsourcedBusinessPhoto:calls.upload,bindOutsourcedBusinessPhoto:calls.bind,getOutsourcedBusinessCommand:calls.command,getOutsourcedBusiness:calls.detail,getOutsourcedBusinessPhotoContent:calls.content})) +vi.mock('@/core/auth/auth',()=>({useAuthStore:()=>calls.actor})) +vi.mock('@/core/access/accessPreview',()=>({useAccessPreviewStore:()=>({can:()=>calls.canUpload})})) +vi.mock('@/core/unsaved-work/useUnsavedWorkRegistration',()=>({useUnsavedWorkRegistration:()=>({captureBaseline:vi.fn()})})) +vi.mock('element-plus',()=>({ElMessage:{success:vi.fn()},ElMessageBox:{confirm:vi.fn()}})) +const Frame=defineComponent({props:['modelValue','busy','beforeClose'],setup(_props,{slots}){return()=>h('div',Object.values(slots).map(slot=>slot?.()))}}) +const Button=defineComponent({props:['disabled'],template:''}) +const global={stubs:{OutsourcedBusinessPhotosDialog:Frame,ElButton:Button,LoadErrorState:{props:['message'],template:'
{{message}}
'},ApiErrorMeta:true}} +const photo:OutsourcedBusinessPhoto={slot:'LOADING_SCENE',category:'LOADING',fileId:'9007199254740993',fileName:'loading.png',contentType:'image/png',sizeBytes:'5',uploadedAt:'2026-10-20T10:00:00Z',savedAt:null} +async function choose(wrapper:ReturnType){const input=wrapper.find('input[type="file"]');Object.defineProperty(input.element,'files',{configurable:true,value:[new File(['image'],'loading.png',{type:'image/png'})]});await input.trigger('change');await flushPromises()} +beforeEach(()=>{ + vi.clearAllMocks();sessionStorage.clear();calls.actor.user.id='actor';calls.canUpload=true + calls.upload.mockResolvedValue(photo);calls.command.mockRejectedValue(new ApiResponseError('not found','A0404',404)) + const current=outsourcedBusinessFixture();current.row.recordVersion='1';current.photos=[{...photo,savedAt:'2026-10-20T10:01:00Z'}] + calls.bind.mockResolvedValue(current);calls.detail.mockResolvedValue(current) +}) +afterEach(() => vi.unstubAllGlobals()) +describe('outsourced business per-slot photo save',()=>{ + it('retains prepared file and original binding request through a failed bind without retransmitting the image',async()=>{ + calls.bind.mockRejectedValueOnce(new TypeError('network interrupted')) + const wrapper=mount(OutsourcedBusinessPhotosEditor,{global,props:{modelValue:true,detail:outsourcedBusinessFixture()}});await choose(wrapper) + expect(calls.upload).toHaveBeenCalledTimes(1);expect(calls.bind).toHaveBeenCalledTimes(1) + expect(wrapper.text()).toContain('尚未确认绑定保存');expect(wrapper.emitted('saved')).toBeUndefined() + const original={...calls.bind.mock.calls[0]![2]} + await wrapper.findAll('button').find(button=>button.text()==='核对原结果并继续绑定')!.trigger('click');await flushPromises() + expect(calls.upload).toHaveBeenCalledTimes(1);expect(calls.bind).toHaveBeenCalledTimes(2) + expect(calls.bind.mock.calls[1]![2]).toEqual(original);expect(original.fileId).toBe(photo.fileId);expect(original.recordVersion).toBe('0') + expect(wrapper.emitted('saved')).toHaveLength(1);wrapper.unmount() + }) + it('does not offer a new upload after unknown upload or fabricate a saved photo',async()=>{ + calls.upload.mockRejectedValue(new TypeError('network interrupted')) + const wrapper=mount(OutsourcedBusinessPhotosEditor,{global,props:{modelValue:true,detail:outsourcedBusinessFixture()}});await choose(wrapper) + await wrapper.findAll('button').find(button=>button.text()==='核对原结果并继续绑定')!.trigger('click');await flushPromises() + expect(calls.upload).toHaveBeenCalledTimes(1);expect(calls.bind).not.toHaveBeenCalled();expect(wrapper.emitted('saved')).toBeUndefined() + expect(wrapper.find('input[type="file"]').attributes('disabled')).toBeDefined();expect(wrapper.text()).toContain('禁止重复上传');wrapper.unmount() + }) + it('keeps saved metadata readable without upload permission and fetches image bytes only on explicit preview',async()=>{ + calls.canUpload=false + const detail=outsourcedBusinessFixture();detail.photos=[{...photo,savedAt:'2026-10-20T10:01:00Z'}] + const wrapper=mount(OutsourcedBusinessPhotosEditor,{global,props:{modelValue:true,detail}});await flushPromises() + expect(wrapper.find('input[type="file"]').exists()).toBe(false);expect(wrapper.text()).toContain('已保存');expect(calls.content).not.toHaveBeenCalled() + wrapper.unmount() + }) + it('reads a saved loading photo only on click and releases its URL on closing',async()=>{ + const create=vi.fn(()=> 'blob:outsourced-loading'),revoke=vi.fn() + vi.stubGlobal('URL',class extends URL { static createObjectURL=create; static revokeObjectURL=revoke }) + calls.canUpload=false;calls.content.mockResolvedValue(new Blob(['image'],{type:'image/png'})) + const detail=outsourcedBusinessFixture();detail.photos=[{...photo,savedAt:'2026-10-20T10:01:00Z'}] + const wrapper=mount(OutsourcedBusinessPhotosEditor,{global,props:{modelValue:true,detail}});await flushPromises() + expect(calls.content).not.toHaveBeenCalled() + await wrapper.findAll('button').find(button=>button.text()==='查看图片')!.trigger('click');await flushPromises() + expect(calls.content).toHaveBeenCalledExactlyOnceWith('DS-OUT-1',detail.photos[0]);expect(wrapper.find('img').attributes('src')).toBe('blob:outsourced-loading') + await wrapper.setProps({modelValue:false});expect(revoke).toHaveBeenCalledWith('blob:outsourced-loading');wrapper.unmount() + }) +}) diff --git a/src/modules/overseas-master-data/components/OutsourcedBusinessPhotosEditor.vue b/src/modules/overseas-master-data/components/OutsourcedBusinessPhotosEditor.vue new file mode 100644 index 00000000..de1ded89 --- /dev/null +++ b/src/modules/overseas-master-data/components/OutsourcedBusinessPhotosEditor.vue @@ -0,0 +1,127 @@ + + + + + diff --git a/src/modules/overseas-master-data/outsourcedBusinessTypes.test.ts b/src/modules/overseas-master-data/outsourcedBusinessTypes.test.ts new file mode 100644 index 00000000..a46c10b7 --- /dev/null +++ b/src/modules/overseas-master-data/outsourcedBusinessTypes.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest' +import { outsourcedBusinessDecimal, outsourcedBusinessVersion, outsourcedBusinessText, outsourcedBusinessPhotoRoles } from './outsourcedBusinessTypes' + +describe('outsourced business decimal and version boundary', () => { + it('supports exactly the five approved loading photo slots', () => { + expect(outsourcedBusinessPhotoRoles.map(role => role.slot)).toEqual(['LOADING_SCENE','LOADING_LIST','PRE_DEPARTURE_CHECK','DRIVER_LICENSE','RECEIPT']) + expect(outsourcedBusinessPhotoRoles.every(role => role.category === 'LOADING')).toBe(true) + }) + it('preserves the maximum decimal money text and distinguishes empty from explicit zero', () => { + expect(outsourcedBusinessDecimal('9999999999999999.99', 2, 18)).toBe('9999999999999999.99') + expect(outsourcedBusinessDecimal('0.00', 2, 18)).toBe('0.00'); expect(outsourcedBusinessDecimal(' ', 2, 18)).toBeNull() + expect(() => outsourcedBusinessDecimal('10000000000000000.00', 2, 18)).toThrow() + for (const value of ['-1','1e3','1.001','01','NaN','Infinity']) expect(() => outsourcedBusinessDecimal(value, 2, 18)).toThrow() + expect(outsourcedBusinessDecimal('123.456', 3, 12)).toBe('123.456') + }) + it('keeps version zero and large string identities without accepting unsafe numeric versions', () => { + expect(outsourcedBusinessVersion(0)).toBe('0'); expect(outsourcedBusinessVersion('9007199254740993')).toBe('9007199254740993') + for (const value of [undefined,null,-1,0.5,Number.MAX_SAFE_INTEGER + 1,'','1e3']) expect(() => outsourcedBusinessVersion(value)).toThrow() + expect(outsourcedBusinessText(null)).toBe('—'); expect(outsourcedBusinessText(0)).toBe('0') + }) +}) diff --git a/src/modules/overseas-master-data/outsourcedBusinessTypes.ts b/src/modules/overseas-master-data/outsourcedBusinessTypes.ts new file mode 100644 index 00000000..480953a7 --- /dev/null +++ b/src/modules/overseas-master-data/outsourcedBusinessTypes.ts @@ -0,0 +1,45 @@ +import type { DispatchDetail } from '@/modules/overseas-warehouse' + +export type OutsourcedBusinessStage = 'AFTER_LOADING' | 'DELIVERING' | 'DELIVERED' +export type OutsourcedBusinessPhotoSlot = 'LOADING_SCENE' | 'LOADING_LIST' | 'PRE_DEPARTURE_CHECK' | 'DRIVER_LICENSE' | 'RECEIPT' +export interface OutsourcedBusinessMoney { currency: 'CNY' | 'USD'; amount: string } +export interface OutsourcedBusinessArrival { destinationKey: string; actualArrivedAt: string | null } +export interface OutsourcedBusinessOperations { money: OutsourcedBusinessMoney | null; arrivals: OutsourcedBusinessArrival[] } +export interface OutsourcedBusinessPhoto { slot: OutsourcedBusinessPhotoSlot; category: 'LOADING'; fileId: string; fileName: string; contentType: 'image/jpeg' | 'image/png'; sizeBytes: string; uploadedAt: string; savedAt: string | null } +export interface OutsourcedBusinessDestination { destinationKey: string; warehouseCode: string | null; warehouseName: string | null; address: string | null; appointments: { appointmentNo: string; confirmationNo: string | null; scheduledAt: string | null; allocatedPackages: string }[]; actualArrivedAt: string | null } +export interface OutsourcedBusinessRow { + dispatchNo: string; stage: OutsourcedBusinessStage; plannedDepartureAt: string | null; loadingCompletedAt: string; departedAt: string | null; deliveryCompletedAt: string | null + origin: { warehouseId: string; warehouseCode: string; warehouseName: string } + vehicle: { vehicleId: string | null; plate: string | null; ownership: 'DISPATCHED'; carrierName: string | null }; driver: { name: string | null; phone: string | null } + destinations: OutsourcedBusinessDestination[]; actualPalletCount: number | null; operations: OutsourcedBusinessOperations; unitPrice: string | null; recordVersion: string; dispatchRecordVersion: string + photoSummary: { loadingCount: number }; actions: { edit: OutsourcedBusinessGate; upload: OutsourcedBusinessGate }; sourceIssues: string[] +} +export interface OutsourcedBusinessGate { allowed: boolean; blockReason: string | null } +export interface OutsourcedBusinessQuery { page?: number; size?: number; plannedDepartureFrom?: string; plannedDepartureTo?: string; keyword?: string; stage?: OutsourcedBusinessStage; warehouseId?: string } +export interface OutsourcedBusinessSummary { dispatchCount: number; afterLoadingCount: number; deliveringCount: number; deliveredCount: number } +export interface OutsourcedBusinessPage { records: T[]; page: number; size: number; total: number } +export interface OutsourcedBusinessList extends OutsourcedBusinessPage { summary: OutsourcedBusinessSummary } +export interface OutsourcedBusinessDetail { row: OutsourcedBusinessRow; dispatch: DispatchDetail; cargo: { sourceKey: string; customerId: string; customerName: string | null; orderNo: string | null; inboundCode: string | null; palletNo: string; destinationKey: string; packages: string }[]; photos: OutsourcedBusinessPhoto[] } +export interface OutsourcedBusinessSave { recordVersion: string; idempotencyKey: string; operations: OutsourcedBusinessOperations; reason: string | null } +export interface OutsourcedBusinessBind { recordVersion: string; idempotencyKey: string; fileId: string } +export type OutsourcedBusinessAction = 'SAVE_OPERATIONS' | 'UPLOAD_PHOTO' | 'BIND_PHOTO' +export interface OutsourcedBusinessCommand { dispatchNo: string; action: OutsourcedBusinessAction; idempotencyKey: string; state: 'SUCCEEDED' | 'PENDING' | 'UNKNOWN_RESULT' | 'REJECTED'; recordVersion: string | null; replayed: boolean; result: OutsourcedBusinessDetail | OutsourcedBusinessPhoto | null; errorCode: string | null; retryAllowed: boolean } +export interface OutsourcedBusinessHistory { eventSource: 'DISPATCH' | 'OPERATIONS'; recordVersion: string | null; dispatchRecordVersion: string | null; action: string; operatorName: string | null; occurredAt: string; reason: string | null; operations: OutsourcedBusinessOperations | null; photos: OutsourcedBusinessPhoto[] } +export const outsourcedBusinessStages: Record = { AFTER_LOADING: '装车完成', DELIVERING: '派送中', DELIVERED: '派送完成' } +export const outsourcedBusinessActionLabels: Record = { CREATE: '新建排车', EDIT: '维护车辆信息', ADD_CARGO: '添加货物', REMOVE_CARGO: '移除货物', SUBMIT_REVIEW: '提交调度审核', DISPATCH_REVIEW: '调度审核', LOADING_REVIEW: '理货员审核', RETURN_TO_REVIEW: '退回重审', PICK: '真实拣货', LOADING_COMPLETE: '确认装车完成', ARRANGE_DELIVERY: '安排派送', DEPART: '实际发车', CANCEL: '取消排车', SAVE_OPERATIONS: '登记运营信息', BIND_PHOTO: '保存分类照片' } +export const outsourcedBusinessPhotoRoles: { slot: OutsourcedBusinessPhotoSlot; viewSlot: string; label: string; category: 'LOADING' }[] = [ + { slot: 'LOADING_SCENE', viewSlot: 'loading-scene', label: '装车现场', category: 'LOADING' }, { slot: 'LOADING_LIST', viewSlot: 'loading-list', label: '装车清单', category: 'LOADING' }, + { slot: 'PRE_DEPARTURE_CHECK', viewSlot: 'pre-departure-check', label: '出车前检查', category: 'LOADING' }, { slot: 'DRIVER_LICENSE', viewSlot: 'driver-license', label: '驾驶证附件', category: 'LOADING' }, + { slot: 'RECEIPT', viewSlot: 'delivery-receipt', label: '签收凭证', category: 'LOADING' }, +] +export function outsourcedBusinessVersion(value: unknown): string { + if ((typeof value !== 'string' && typeof value !== 'number') || !/^(0|[1-9]\d*)$/.test(String(value)) || typeof value === 'number' && !Number.isSafeInteger(value)) throw new Error('运营记录版本无效,请重新读取') + return String(value) +} +export function outsourcedBusinessDecimal(value: string, scale: number, precision: number): string | null { + const text = value.trim() + if (!text) return null + if (!new RegExp(`^(?:0|[1-9]\\d*)(?:\\.\\d{1,${scale}})?$`).test(text) || text.split('.')[0]!.length > precision - scale) throw new Error(`请输入不超过${scale}位小数的有效非负数`) + return text +} +export function outsourcedBusinessText(value: unknown): string { return value == null || value === '' ? '—' : String(value) } diff --git a/src/modules/overseas-master-data/pages/OutsourcedBusinessDetailView.vue b/src/modules/overseas-master-data/pages/OutsourcedBusinessDetailView.vue new file mode 100644 index 00000000..de8b033a --- /dev/null +++ b/src/modules/overseas-master-data/pages/OutsourcedBusinessDetailView.vue @@ -0,0 +1,82 @@ + + + + + diff --git a/src/modules/overseas-master-data/pages/OutsourcedBusinessView.vue b/src/modules/overseas-master-data/pages/OutsourcedBusinessView.vue new file mode 100644 index 00000000..d2f26245 --- /dev/null +++ b/src/modules/overseas-master-data/pages/OutsourcedBusinessView.vue @@ -0,0 +1,128 @@ + + + + + diff --git a/src/modules/overseas-master-data/pages/OutsourcedBusinessViews.test.ts b/src/modules/overseas-master-data/pages/OutsourcedBusinessViews.test.ts new file mode 100644 index 00000000..058dd30c --- /dev/null +++ b/src/modules/overseas-master-data/pages/OutsourcedBusinessViews.test.ts @@ -0,0 +1,96 @@ +import { defineComponent, h } from 'vue' +import { flushPromises, mount } from '@vue/test-utils' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import dayjs from 'dayjs' +import OutsourcedBusinessView from './OutsourcedBusinessView.vue' +import OutsourcedBusinessDetailView from './OutsourcedBusinessDetailView.vue' +import { outsourcedBusinessFixture } from '../components/OutsourcedBusinessFixtures' +import { overseasMasterDataPermissions as permission } from '../permissions' + +const calls = vi.hoisted(() => ({ list: vi.fn(), detail: vi.fn(), history: vi.fn(), content: vi.fn(), warehouses: vi.fn(), push: vi.fn(), allowed: new Set() })) +vi.mock('../api/outsourcedBusiness', () => ({ listOutsourcedBusiness: calls.list, getOutsourcedBusiness: calls.detail, listOutsourcedBusinessHistory: calls.history, getOutsourcedBusinessPhotoContent: calls.content })) +vi.mock('@/modules/warehouses', () => ({ listAllPlatformWarehouses: calls.warehouses })) +vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: (code: string) => calls.allowed.has(code) }) })) +vi.mock('vue-router', () => ({ useRouter: () => ({ push: calls.push }), useRoute: () => ({ params: { dispatchNo: 'DS-OUT-1' } }) })) +const Surface = defineComponent({ template: '
' }) +const Button = defineComponent({ props: ['disabled'], template: '' }) +const Tabs = defineComponent({ props: ['modelValue'], emits: ['update:modelValue'], template: '
' }) +const Pane = defineComponent({ props: ['name','label'], template: '
' }) +const Query = defineComponent({ props: ['modelValue','fields'], emits: ['update:modelValue','search'], template: '
' }) +const Table = defineComponent({ props: ['data','columns','total','page','pageSize'], emits: ['update:page','update:pageSize'], setup(props,{slots}) { return () => h('div', [slots['toolbar-start']?.(), slots.filters?.(), slots.notices?.(), ...(props.data || []).map((row: object) => h('div', Object.entries(slots).filter(([name]) => !['toolbar-start','filters','notices'].includes(name)).map(([,slot]) => slot?.({row}))))]) } }) +const OperationsEditor = defineComponent({ name: 'OutsourcedBusinessOperationsEditorStub', props: ['modelValue','detail'], emits: ['update:modelValue','saved','busy'], template: '
' }) +const PhotosEditor = defineComponent({ name: 'OutsourcedBusinessPhotosEditorStub', props: ['modelValue','detail'], emits: ['update:modelValue','saved','busy'], template: '
' }) +const global = { stubs: { AdminPage: Surface, DetailPageLayout: Surface, DataTable: Table, QueryToolbar: Query, ElTabs: Tabs, ElTabPane: Pane, ElButton: Button, OutsourcedBusinessOperationsEditor: OperationsEditor, OutsourcedBusinessPhotosEditor: PhotosEditor, LoadErrorState: { props:['message'], template:'
{{message}}
' }, ApiErrorMeta: true }, directives: { loading: () => {} } } + +beforeEach(() => { + vi.clearAllMocks(); calls.allowed = new Set(Object.values(permission)); calls.allowed.add('platform:warehouse:list') + const detail = outsourcedBusinessFixture() + calls.list.mockResolvedValue({ records:[detail.row],page:1,size:20,total:41,summary:{dispatchCount:41,afterLoadingCount:30,deliveringCount:10,deliveredCount:1} }) + calls.detail.mockResolvedValue(detail); calls.history.mockResolvedValue({records:[],page:1,size:20,total:0}); calls.warehouses.mockResolvedValue([]) +}) + +describe('outsourced business views', () => { + it('uses one list request for all statistics and never hydrates each row', async () => { + const wrapper = mount(OutsourcedBusinessView,{global}); await flushPromises() + expect(calls.list).toHaveBeenCalledTimes(1); expect(calls.detail).not.toHaveBeenCalled(); expect(calls.content).not.toHaveBeenCalled() + expect(wrapper.find('[aria-label="完整筛选范围外配业务统计"]').text()).toContain('排车数41') + expect(wrapper.text()).not.toContain('今日排车') + expect(wrapper.findComponent(Table).props('columns')[0].key).toBe('actions') + expect(wrapper.findComponent(Table).props('total')).toBe(41) + expect(wrapper.text()).toContain('测试外配承运商'); expect(wrapper.text()).not.toContain('油耗'); expect(wrapper.text()).not.toContain('自有成本') + wrapper.unmount() + }) + it('applies planned date half-open boundaries only on query and keeps all stage counts from the response', async () => { + const wrapper=mount(OutsourcedBusinessView,{global});await flushPromises();const query=wrapper.findComponent(Query) + query.vm.$emit('update:modelValue',{plannedRange:['2026-10-20','2026-10-21'],keyword:' FBA-1 ',stage:'AFTER_LOADING',warehouseId:'1'});await wrapper.vm.$nextTick() + expect(calls.list).toHaveBeenCalledTimes(1) + query.vm.$emit('search');await flushPromises() + expect(calls.list).toHaveBeenLastCalledWith({page:1,size:20,keyword:'FBA-1',stage:'AFTER_LOADING',warehouseId:'1',plannedDepartureFrom:dayjs('2026-10-20').startOf('day').toISOString(),plannedDepartureTo:dayjs('2026-10-21').add(1,'day').startOf('day').toISOString()}) + expect(wrapper.find('[aria-label="完整筛选范围外配业务统计"]').text()).toContain('装车完成30') + wrapper.unmount() + }) + it('opens the operations modal on demand with auxiliary zero version instead of the dispatch version', async () => { + const wrapper=mount(OutsourcedBusinessView,{global});await flushPromises() + expect(calls.detail).not.toHaveBeenCalled();expect(wrapper.findComponent(OperationsEditor).props('modelValue')).toBe(false) + await wrapper.findAll('button').find(button=>button.text()==='登记运营信息')!.trigger('click');await flushPromises() + expect(calls.detail).toHaveBeenCalledExactlyOnceWith('DS-OUT-1') + const editor=wrapper.findComponent(OperationsEditor) + expect(editor.props('modelValue')).toBe(true);expect(wrapper.findComponent(PhotosEditor).props('modelValue')).toBe(false) + expect(editor.props('detail').row.recordVersion).toBe('0');expect(editor.props('detail').dispatch.recordVersion).toBe('8') + wrapper.unmount() + }) + it('keeps a list-only user read-only while retaining detail and authorized photo reads', async () => { + calls.allowed=new Set([permission.outsourcedBusinessList]) + const wrapper=mount(OutsourcedBusinessView,{global});await flushPromises() + expect(wrapper.text()).not.toContain('登记运营信息');expect(wrapper.text()).toContain('查看照片');expect(calls.warehouses).not.toHaveBeenCalled() + wrapper.unmount() + }) + it('shows a temporary external vehicle and a missing carrier without requiring a master ID', async () => { + const detail = outsourcedBusinessFixture(); detail.row.vehicle = { vehicleId:null,plate:'TEMP-1',ownership:'DISPATCHED',carrierName:null } + calls.detail.mockResolvedValue(detail) + const wrapper = mount(OutsourcedBusinessDetailView,{global}); await flushPromises() + expect(wrapper.text()).toContain('TEMP-1'); expect(wrapper.text()).toContain('承运商'); expect(wrapper.text()).toContain('—') + expect(calls.detail).toHaveBeenCalledExactlyOnceWith('DS-OUT-1'); expect(calls.content).not.toHaveBeenCalled(); wrapper.unmount() + }) + it('loads aggregated history only on its tab, preserving null operations on dispatch events', async () => { + calls.history.mockResolvedValue({records:[{eventSource:'DISPATCH',recordVersion:null,dispatchRecordVersion:'8',action:'LOADING_COMPLETE',operatorName:null,occurredAt:'2026-10-20T09:00:00Z',reason:null,operations:null,photos:[]},{eventSource:'OPERATIONS',recordVersion:'1',dispatchRecordVersion:null,action:'SAVE_OPERATIONS',operatorName:'登记员',occurredAt:'2026-10-20T10:00:00Z',reason:null,operations:outsourcedBusinessFixture().row.operations,photos:[]}],page:1,size:20,total:2}) + const wrapper=mount(OutsourcedBusinessDetailView,{global});await flushPromises() + expect(calls.detail).toHaveBeenCalledTimes(1);expect(calls.history).not.toHaveBeenCalled() + wrapper.findComponent(Tabs).vm.$emit('update:modelValue','history');await flushPromises() + expect(calls.history).toHaveBeenCalledExactlyOnceWith('DS-OUT-1',1,20) + expect(wrapper.text()).toContain('原排车');expect(wrapper.text()).toContain('运营登记') + const table=wrapper.findComponent(Table), records=table.props('data') + expect(records[0].eventKey).not.toBe(records[1].eventKey);expect(records[0].operations).toBeNull() + table.vm.$emit('update:page',2);await flushPromises();expect(calls.history).toHaveBeenLastCalledWith('DS-OUT-1',2,20) + expect(calls.detail).toHaveBeenCalledTimes(1);expect(calls.content).not.toHaveBeenCalled() + wrapper.unmount() + }) + it('shows authoritative large money text and unit price without rounding or recomputing from two cargo rows', async () => { + const detail=outsourcedBusinessFixture();detail.row.operations.money={currency:'USD',amount:'9999999999999999.99'};detail.row.unitPrice='9999999999999999.99' + calls.detail.mockResolvedValue(detail) + const wrapper=mount(OutsourcedBusinessDetailView,{global});await flushPromises() + expect(wrapper.text()).toContain('USD 9999999999999999.99');expect(wrapper.text()).not.toContain('10000000000000000') + expect(wrapper.text()).not.toContain('4999999999999999') + wrapper.unmount() + }) +}) diff --git a/src/modules/overseas-master-data/permissions.ts b/src/modules/overseas-master-data/permissions.ts index f6e89c98..eb42f31c 100644 --- a/src/modules/overseas-master-data/permissions.ts +++ b/src/modules/overseas-master-data/permissions.ts @@ -17,6 +17,9 @@ export const overseasMasterDataPermissions = { ownedBusinessList: 'platform:fleet:owned-business:list', ownedBusinessEdit: 'platform:fleet:owned-business:edit', ownedBusinessUpload: 'platform:fleet:owned-business:upload', + outsourcedBusinessList: 'platform:fleet:outsourced-business:list', + outsourcedBusinessEdit: 'platform:fleet:outsourced-business:edit', + outsourcedBusinessUpload: 'platform:fleet:outsourced-business:upload', tripBookingCreate: 'platform:fleet:trip:booking-create', tripBookingEdit: 'platform:fleet:trip:booking-edit', tripBookingConfirm: 'platform:fleet:trip:booking-confirm', diff --git a/src/modules/overseas-master-data/routes.test.ts b/src/modules/overseas-master-data/routes.test.ts index d279afaa..a7b7e1c6 100644 --- a/src/modules/overseas-master-data/routes.test.ts +++ b/src/modules/overseas-master-data/routes.test.ts @@ -13,6 +13,8 @@ describe('overseas master data routes', () => { ['overseas-master-data/trip-operations', overseasMasterDataPermissions.operationsList], ['overseas-master-data/owned-business', overseasMasterDataPermissions.ownedBusinessList], ['overseas-master-data/owned-business/:dispatchNo', overseasMasterDataPermissions.ownedBusinessList], + ['overseas-master-data/outsourced-business', overseasMasterDataPermissions.outsourcedBusinessList], + ['overseas-master-data/outsourced-business/:dispatchNo', overseasMasterDataPermissions.outsourcedBusinessList], ['overseas-master-data/trips', overseasMasterDataPermissions.tripList], ['overseas-master-data/trips/:tripNo', overseasMasterDataPermissions.tripList], ['overseas-master-data/drivers', overseasMasterDataPermissions.driverList], @@ -45,6 +47,17 @@ describe('overseas master data routes', () => { } }) + it('uses external list permission for both routes independently of owned and write permissions', () => { + const external = overseasMasterDataRoutes.filter(route => route.path.includes('/outsourced-business')) + expect(external).toHaveLength(2) + for (const route of external) { + expect(route.meta?.permission).toBe(overseasMasterDataPermissions.outsourcedBusinessList) + expect(route.meta?.permissionAnyOf).toBeUndefined() + expect(route.meta?.permission).not.toBe(overseasMasterDataPermissions.ownedBusinessList) + expect(route.meta?.permission).not.toBe(overseasMasterDataPermissions.outsourcedBusinessEdit) + } + }) + it('keeps vehicle detail as a route page and does not add a duplicate partner warehouse route', () => { expect(overseasMasterDataRoutes.at(-1)?.meta?.showPageHeading).toBe(false) expect(overseasMasterDataRoutes.some((route) => route.path.includes('partner-warehouse'))).toBe(false) diff --git a/src/modules/overseas-master-data/routes.ts b/src/modules/overseas-master-data/routes.ts index 029b1e16..5a445c69 100644 --- a/src/modules/overseas-master-data/routes.ts +++ b/src/modules/overseas-master-data/routes.ts @@ -38,6 +38,18 @@ export const overseasMasterDataRoutes = [ component: () => import('./pages/OwnedBusinessDetailView.vue'), meta: { title: '自有业务详情', description: '查看原排车事实与附加运营信息', permission: permission.ownedBusinessList, showPageHeading: false }, }, + { + path: 'overseas-master-data/outsourced-business', + name: 'overseas-master-data-outsourced-business', + component: () => import('./pages/OutsourcedBusinessView.vue'), + meta: { title: '外配业务', description: '按原排车查询外配车辆装车后的履约与承运信息', permission: permission.outsourcedBusinessList, showPageHeading: false }, + }, + { + path: 'overseas-master-data/outsourced-business/:dispatchNo', + name: 'overseas-master-data-outsourced-business-detail', + component: () => import('./pages/OutsourcedBusinessDetailView.vue'), + meta: { title: '外配业务详情', description: '查看原排车及外配承运登记', permission: permission.outsourcedBusinessList, showPageHeading: false }, + }, { path: 'overseas-master-data/trips', name: 'overseas-master-data-trips',