From f5f62af4a7dc3201fa96d92640490903ef0524f5 Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Fri, 6 Nov 2026 10:42:39 +0800 Subject: [PATCH 1/3] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/platformOverseasBusiness.test.ts | 6 +- .../api/platformOverseasBusiness.ts | 5 +- .../pages/LooseboardOrderDetailView.test.ts | 26 ++++++- .../pages/LooseboardOrderDetailView.vue | 8 +- .../pages/OverseasBusinessViews.test.ts | 2 +- src/modules/overseas-business/types.ts | 1 + .../outsourcedBusinessTypes.ts | 2 +- .../ownedBusinessTypes.ts | 2 +- .../pages/OutsourcedBusinessDetailView.vue | 4 +- .../pages/OutsourcedBusinessViews.test.ts | 13 ++++ .../pages/OwnedBusinessDetailView.vue | 4 +- .../pages/OwnedBusinessViews.test.ts | 13 ++++ .../components/DispatchReturnCargo.test.ts | 77 +++++++++++++++++++ .../components/DispatchReturnCargo.vue | 37 +++++---- .../DispatchWorkflowDialog.layout.test.ts | 12 +++ .../overseas-warehouse/dispatchContracts.ts | 2 +- 16 files changed, 184 insertions(+), 30 deletions(-) create mode 100644 src/modules/overseas-warehouse/components/DispatchReturnCargo.test.ts diff --git a/src/modules/overseas-business/api/platformOverseasBusiness.test.ts b/src/modules/overseas-business/api/platformOverseasBusiness.test.ts index 084ef4e6..78c7406a 100644 --- a/src/modules/overseas-business/api/platformOverseasBusiness.test.ts +++ b/src/modules/overseas-business/api/platformOverseasBusiness.test.ts @@ -152,10 +152,10 @@ describe('platform overseas business API adapter', () => { }) it('supports looseboard list/detail/create and PUT update', async () => { - const detail = { summary: looseboardSummary, originAddress: null, destinationAddress: null, remark: null, createdBy: '7', histories: [] } + const detail = { summary: looseboardSummary, originAddress: null, destinationAddress: null, remark: null, createdBy: '7', histories: [], dispatchNos: ['PC-1', 'PC-2'] } overseas.get.mockResolvedValueOnce(envelope({ records: [looseboardSummary], page: 1, size: 20, total: 1, pages: 1 })).mockResolvedValueOnce(envelope(detail)) expect((await listLocalLooseboardOrders({ status: 'PENDING_ACCEPTANCE' })).records[0]?.id).toBe('9007199254741005') - expect((await getLocalLooseboardOrder('LB-1', '10003')).status).toBe('PENDING_ACCEPTANCE') + expect(await getLocalLooseboardOrder('LB-1', '10003')).toMatchObject({ status: 'PENDING_ACCEPTANCE', dispatchNos: ['PC-1', 'PC-2'] }) const create = { productName: '家居用品', estimatedPalletCount: 2, originContactName: 'A', originContactPhone: '1', destinationContactName: 'B', destinationContactPhone: '2', pickupAt: '2026-10-20T09:30:00', deliveryAt: '2026-10-20T15:45:00', origin: 'LA', destination: 'ONT', packageCount: 8, totalWeightKg: 80.5, totalVolumeCbm: 3.2, idempotencyKey: 'HTTP-20260824-MARKER-looseboard' } overseas.post.mockResolvedValue(envelope(detail)); await createLocalLooseboardOrder('9007199254742001', create) expect(overseas.post).toHaveBeenCalledWith('/platform/v1/last-leg/local-looseboard-orders', create, { params: { customerId: '9007199254742001' } }) @@ -180,7 +180,7 @@ describe('platform overseas business API adapter', () => { it('preserves unknown pool pallet service and product name in list and detail responses', async () => { const summary = { ...looseboardSummary, palletRequired: null, productName: null } - overseas.get.mockResolvedValueOnce(envelope({ records: [summary], total: 1, page: 1, size: 20, pages: 1 })).mockResolvedValueOnce(envelope({ summary, histories: [] })) + overseas.get.mockResolvedValueOnce(envelope({ records: [summary], total: 1, page: 1, size: 20, pages: 1 })).mockResolvedValueOnce(envelope({ summary, histories: [], dispatchNos: [] })) expect((await listLocalLooseboardOrders({})).records[0]).toMatchObject({ palletRequired: null, productName: null }) expect(await getLocalLooseboardOrder('LB-1', '10003')).toMatchObject({ palletRequired: null, productName: null }) expect(overseas.get).toHaveBeenCalledTimes(2) diff --git a/src/modules/overseas-business/api/platformOverseasBusiness.ts b/src/modules/overseas-business/api/platformOverseasBusiness.ts index 831517d3..57465e39 100644 --- a/src/modules/overseas-business/api/platformOverseasBusiness.ts +++ b/src/modules/overseas-business/api/platformOverseasBusiness.ts @@ -27,7 +27,7 @@ interface TruckDetailPayload { interface LooseboardSummaryPayload extends Omit { id: Numeric; budgetAmount?: Numeric | null; totalWeightKg: Numeric; totalVolumeCbm: Numeric } interface LooseboardDetailPayload { summary: LooseboardSummaryPayload; originAddress?: string | null; destinationAddress?: string | null - remark?: string | null; createdBy?: string | null; histories?: OverseasBusinessHistory[] + remark?: string | null; createdBy?: string | null; histories?: OverseasBusinessHistory[]; dispatchNos: string[] } function normalizePage(payload: PagePayload, normalize: (value: TPayload) => TResult): OverseasBusinessPage { @@ -49,8 +49,9 @@ function truckDetail(value: TruckDetailPayload): LocalTransportDetail { function looseboardSummary(value: LooseboardSummaryPayload): LocalLooseboardOrder { return { ...value, id: String(value.id), totalWeightKg: String(value.totalWeightKg), totalVolumeCbm: String(value.totalVolumeCbm), budgetAmount: value.budgetAmount == null ? null : String(value.budgetAmount) } } +/** 详情保留按原单查询的全部排车,不依赖车队预订摘要。 */ function looseboardDetail(value: LooseboardDetailPayload): LocalLooseboardDetail { - return { ...looseboardSummary(value.summary), originAddress: value.originAddress || null, destinationAddress: value.destinationAddress || null, remark: value.remark || null, createdBy: value.createdBy || null, histories: value.histories || [] } + return { ...looseboardSummary(value.summary), originAddress: value.originAddress || null, destinationAddress: value.destinationAddress || null, remark: value.remark || null, createdBy: value.createdBy || null, histories: value.histories || [], dispatchNos: value.dispatchNos } } export async function listOverseasPurchaseCustomerOptions(keyword = '', limit = 200) { diff --git a/src/modules/overseas-business/pages/LooseboardOrderDetailView.test.ts b/src/modules/overseas-business/pages/LooseboardOrderDetailView.test.ts index 102c160c..df6bb631 100644 --- a/src/modules/overseas-business/pages/LooseboardOrderDetailView.test.ts +++ b/src/modules/overseas-business/pages/LooseboardOrderDetailView.test.ts @@ -9,6 +9,8 @@ import type { LocalLooseboardDetail } from '../types' import LooseboardOrderDetailView from './LooseboardOrderDetailView.vue' const routing = vi.hoisted(() => ({ push: vi.fn(), route: { params: { orderId: 'LB-TEST' }, query: { customerId: '9007199254741999' } as Record } })) +const permissions = vi.hoisted(() => ({ can: vi.fn(() => false) })) +vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => permissions })) vi.mock('vue-router', () => ({ useRoute: () => routing.route, useRouter: () => ({ push: routing.push }) })) vi.mock('../api/platformOverseasBusiness', () => ({ getLocalLooseboardOrder: vi.fn() })) @@ -21,7 +23,7 @@ function order(overrides: Partial = {}): LocalLooseboardD pickupAt: '2026-10-20T09:30:00', deliveryAt: '2026-10-20T15:45:00', packageCount: 20, totalWeightKg: '100', totalVolumeCbm: '2', budgetAmount: '500', currency: 'USD', status: 'PENDING_ACCEPTANCE', version: 1, remark: '运输备注', createdBy: '7', - createdAt: '2026-10-20T08:00:00', updatedAt: '2026-10-20T08:00:00', histories: [], ...overrides, + createdAt: '2026-10-20T08:00:00', updatedAt: '2026-10-20T08:00:00', histories: [], dispatchNos: [], ...overrides, } } function mountDetail() { @@ -31,11 +33,33 @@ function mountDetail() { enableAutoUnmount(afterEach) beforeEach(() => { vi.clearAllMocks() + permissions.can.mockReturnValue(false) routing.route = reactive({ params: { orderId: 'LB-TEST' }, query: { customerId: '9007199254741999' } }) vi.mocked(getLocalLooseboardOrder).mockResolvedValue(order()) }) describe('looseboard document fees', () => { + it('shows all actual dispatches without a fleet booking and links only with dispatch permission', async () => { + vi.mocked(getLocalLooseboardOrder).mockResolvedValue(order({ dispatchNos: ['PC-1', 'PC-2'] })) + const wrapper = mountDetail(); await flushPromises() + expect(wrapper.find('.looseboard-dispatches').text()).toContain('PC-1') + expect(wrapper.find('.looseboard-dispatches').text()).toContain('PC-2') + expect(wrapper.find('.looseboard-dispatches button').exists()).toBe(false) + permissions.can.mockReturnValue(true) + await wrapper.findAll('button').find(button => button.text() === '刷新订单')!.trigger('click') + await flushPromises() + const links = wrapper.findAll('.looseboard-dispatches button') + expect(links).toHaveLength(2) + await links[1]!.trigger('click') + expect(routing.push).toHaveBeenCalledWith({ name: 'overseas-dispatch-detail', params: { dispatchNo: 'PC-2' } }) + }) + + it('identifies a looseboard order that has not been dispatched', async () => { + const wrapper = mountDetail(); await flushPromises() + expect(wrapper.text()).toContain('排车单号') + expect(wrapper.text()).toContain('暂未排车') + }) + it('refreshes the current status when returning to a cached document tab', async () => { const visible = ref(true) vi.mocked(getLocalLooseboardOrder).mockResolvedValueOnce(order({ status: 'WAITING_INBOUND' })) diff --git a/src/modules/overseas-business/pages/LooseboardOrderDetailView.vue b/src/modules/overseas-business/pages/LooseboardOrderDetailView.vue index ed49aac2..497b8969 100644 --- a/src/modules/overseas-business/pages/LooseboardOrderDetailView.vue +++ b/src/modules/overseas-business/pages/LooseboardOrderDetailView.vue @@ -3,6 +3,7 @@ import '@/styles/adminWorkbench.css' import '@/styles/adminDensity.css' import { computed, onActivated, ref, watch } from 'vue' import { useRoute, useRouter } from 'vue-router' +import { useAccessPreviewStore } from '@/core/access/accessPreview' import { ArrowLeft } from '@lucide/vue' import AdminPage from '@/shared/components/AdminPage.vue' import DetailPageLayout from '@/shared/components/DetailPageLayout.vue' @@ -19,6 +20,7 @@ import { poolOrderStatusLabels } from '../fleetArrangement' import type { LocalLooseboardDetail, OverseasBusinessHistory } from '../types' const route = useRoute(), router = useRouter() +const access = useAccessPreviewStore() const orderNo = computed(() => typeof route.params.orderId === 'string' ? route.params.orderId : '') const customerId = computed(() => typeof route.query.customerId === 'string' ? route.query.customerId : '') const detail = ref(null), loading = ref(false) @@ -81,7 +83,10 @@ onActivated(() => { @@ -92,4 +97,5 @@ onActivated(() => { .looseboard-detail-header { flex-wrap: wrap; } .looseboard-detail-header .density-detail-heading { flex: 1; } .looseboard-detail-section { margin-bottom: var(--admin-space-6); } +.looseboard-dispatches { display: flex; flex-wrap: wrap; gap: var(--admin-space-2); overflow-wrap: anywhere; } diff --git a/src/modules/overseas-business/pages/OverseasBusinessViews.test.ts b/src/modules/overseas-business/pages/OverseasBusinessViews.test.ts index 085e32f2..40c31a36 100644 --- a/src/modules/overseas-business/pages/OverseasBusinessViews.test.ts +++ b/src/modules/overseas-business/pages/OverseasBusinessViews.test.ts @@ -61,7 +61,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', 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 looseboardDetail: LocalLooseboardDetail = { ...looseboard, originAddress: null, destinationAddress: null, remark: null, createdBy: '7', histories: [], dispatchNos: [] } 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: '池来源订单按实际运输履约推进' } function activatePermissions() { const pinia = createPinia(); setActivePinia(pinia); const auth = useAuthStore(); auth.token = 'test-token'; auth.user = { id: '7', name: '测试操作员', account: 'tester', roles: [] }; auth.permissions = ['platform:overseas-business:purchase:list', 'platform:overseas-business:purchase:edit', 'platform:overseas-business:purchase:review', 'platform:overseas-business:truck:list', 'platform:overseas-business:truck:create', 'platform:overseas-business:truck:edit', 'platform:overseas-business:looseboard:list', 'platform:overseas-business:looseboard:create', 'platform:overseas-business:looseboard:edit']; useAccessPreviewStore().initializeAfterLogin(); return pinia } diff --git a/src/modules/overseas-business/types.ts b/src/modules/overseas-business/types.ts index 1f4bdedf..fb259811 100644 --- a/src/modules/overseas-business/types.ts +++ b/src/modules/overseas-business/types.ts @@ -92,6 +92,7 @@ export interface LocalLooseboardOrder { export interface LocalLooseboardDetail extends LocalLooseboardOrder { originAddress: string | null; destinationAddress: string | null; remark: string | null; createdBy: string | null histories: OverseasBusinessHistory[] + dispatchNos: string[] } export interface CreateLocalLooseboardOrderRequest { deliveryMode?: 'DELIVERY_TO_WAREHOUSE' | 'WAREHOUSE_PICKUP' diff --git a/src/modules/overseas-master-data/outsourcedBusinessTypes.ts b/src/modules/overseas-master-data/outsourcedBusinessTypes.ts index 056b8d08..df51ec7e 100644 --- a/src/modules/overseas-master-data/outsourcedBusinessTypes.ts +++ b/src/modules/overseas-master-data/outsourcedBusinessTypes.ts @@ -19,7 +19,7 @@ export interface OutsourcedBusinessQuery { page?: number; size?: number; planned 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 OutsourcedBusinessDetail { row: OutsourcedBusinessRow; dispatch: DispatchDetail; cargo: { sourceKey: string; customerId: string; customerName: string | null; orderNo: string | null; inboundCode: string | null; palletNo: string | null; destinationKey: string; destinationKeys?: string[]; packages: string | null }[]; photos: OutsourcedBusinessPhoto[] } export interface OutsourcedBusinessSave { recordVersion: string; idempotencyKey: string; operations: OutsourcedBusinessOperations; reason: string | null } export interface OutsourcedBusinessBind { recordVersion: string; idempotencyKey: string; fileId: string; replaceFileId?: string } export type OutsourcedBusinessAction = 'SAVE_OPERATIONS' | 'UPLOAD_PHOTO' | 'BIND_PHOTO' diff --git a/src/modules/overseas-master-data/ownedBusinessTypes.ts b/src/modules/overseas-master-data/ownedBusinessTypes.ts index 803b7b44..782ff89f 100644 --- a/src/modules/overseas-master-data/ownedBusinessTypes.ts +++ b/src/modules/overseas-master-data/ownedBusinessTypes.ts @@ -20,7 +20,7 @@ export interface OwnedBusinessQuery { page?: number; size?: number; plannedDepar export interface OwnedBusinessSummary { dispatchCount: number; afterLoadingCount: number; deliveringCount: number; deliveredCount: number } export interface OwnedBusinessPage { records: T[]; page: number; size: number; total: number } export interface OwnedBusinessList extends OwnedBusinessPage { summary: OwnedBusinessSummary } -export interface OwnedBusinessDetail { row: OwnedBusinessRow; dispatch: DispatchDetail; cargo: { sourceKey: string; customerId: string; customerName: string | null; orderNo: string | null; inboundCode: string | null; palletNo: string; destinationKey: string; packages: string }[]; photos: OwnedBusinessPhoto[] } +export interface OwnedBusinessDetail { row: OwnedBusinessRow; dispatch: DispatchDetail; cargo: { sourceKey: string; customerId: string; customerName: string | null; orderNo: string | null; inboundCode: string | null; palletNo: string | null; destinationKey: string; destinationKeys?: string[]; packages: string | null }[]; photos: OwnedBusinessPhoto[] } export interface OwnedBusinessSave { recordVersion: string; idempotencyKey: string; operations: OwnedBusinessOperations; reason: string | null } export interface OwnedBusinessBind { recordVersion: string; idempotencyKey: string; fileId: string; replaceFileId?: string } export type OwnedBusinessAction = 'SAVE_OPERATIONS' | 'UPLOAD_PHOTO' | 'BIND_PHOTO' diff --git a/src/modules/overseas-master-data/pages/OutsourcedBusinessDetailView.vue b/src/modules/overseas-master-data/pages/OutsourcedBusinessDetailView.vue index 185674b5..21770bb9 100644 --- a/src/modules/overseas-master-data/pages/OutsourcedBusinessDetailView.vue +++ b/src/modules/overseas-master-data/pages/OutsourcedBusinessDetailView.vue @@ -23,7 +23,7 @@ const history = ref([]), historyPage = ref(1), hist let generation = 0, historyGeneration = 0, active = true, initialActivation = true const previewUrls = new Set() const historyRows = computed(() => history.value.map((event, index) => ({ ...event, eventKey: `${event.eventSource}:${event.recordVersion ?? event.dispatchRecordVersion ?? event.occurredAt}:${event.action}:${index}` }))) -const cargoColumns: DataTableColumn[] = [{ prop: 'customerName', label: '客户', minWidth: 150 }, { prop: 'orderNo', label: '订单号', minWidth: 220 }, { prop: 'inboundCode', label: '入仓码', minWidth: 180 }, { prop: 'palletNo', label: '实物托盘码', minWidth: 180 }, { key: 'destination', label: '目的地', minWidth: 180, slotName: 'destination' }, { prop: 'packages', label: '箱数', minWidth: 90 }] +const cargoColumns: DataTableColumn[] = [{ prop: 'customerName', label: '客户', minWidth: 150 }, { prop: 'orderNo', label: '订单号', minWidth: 220 }, { prop: 'inboundCode', label: '入仓码', minWidth: 180 }, { prop: 'palletNo', label: '关联托盘码', minWidth: 180 }, { key: 'destination', label: '目的地', minWidth: 180, slotName: 'destination' }, { prop: 'packages', label: '箱数', minWidth: 90 }] const historyColumns: DataTableColumn[] = [{ key: 'source', label: '事件来源', minWidth: 110, slotName: 'source' }, { key: 'revision', label: '运营版本', minWidth: 90, prop: 'recordVersion' }, { prop: 'dispatchRecordVersion', label: '排车版本', minWidth: 90 }, { key: 'action', label: '操作', minWidth: 150, slotName: 'action' }, { prop: 'operatorName', label: '操作人', minWidth: 140 }, { key: 'occurredAt', label: '操作时间', minWidth: 180, slotName: 'occurredAt' }, { prop: 'reason', label: '说明', minWidth: 160 }, { key: 'snapshot', label: '当时运营信息', minWidth: 250, slotName: 'snapshot' }, { key: 'photos', label: '当时照片', minWidth: 190, slotName: 'photos' }] const taskFields = computed(() => { const row = detail.value?.row @@ -68,7 +68,7 @@ onBeforeUnmount(() => { active = false; generation++; historyGeneration++; previ - + diff --git a/src/modules/overseas-master-data/pages/OutsourcedBusinessViews.test.ts b/src/modules/overseas-master-data/pages/OutsourcedBusinessViews.test.ts index 4510908f..fbb8b33f 100644 --- a/src/modules/overseas-master-data/pages/OutsourcedBusinessViews.test.ts +++ b/src/modules/overseas-master-data/pages/OutsourcedBusinessViews.test.ts @@ -30,6 +30,19 @@ beforeEach(() => { }) describe('outsourced business views', () => { + it('shows the complete destination set on a grouped looseboard order', async () => { + const detail = outsourcedBusinessFixture() + detail.row.destinations.push({ ...detail.row.destinations[0]!, destinationKey: 'destination-2', warehouseCode: 'FBA-2' }) + detail.cargo = [{ ...detail.cargo[0]!, orderNo: 'LB-1', palletNo: 'PAL-1、PAL-2', destinationKeys: ['destination-1', 'destination-2'], packages: '9' }] + calls.detail.mockResolvedValue(detail) + const wrapper = mount(OutsourcedBusinessDetailView, { global }); await flushPromises() + wrapper.findComponent(Tabs).vm.$emit('update:modelValue', 'route'); await flushPromises() + const table = wrapper.findAllComponents(Table).find(component => component.props('columns').some((column: { prop?: string }) => column.prop === 'orderNo'))! + expect(table.props('data')).toHaveLength(1) + expect(table.props('data')[0]).toMatchObject({ orderNo: 'LB-1', palletNo: 'PAL-1、PAL-2', packages: '9' }) + expect(table.text()).toContain('FBA-1、FBA-2') + wrapper.unmount() + }) it('keeps an owned vehicle with pooled cargo in the outsourced task list', async () => { const detail = outsourcedBusinessFixture() detail.row.vehicle.ownership = 'OWNED' diff --git a/src/modules/overseas-master-data/pages/OwnedBusinessDetailView.vue b/src/modules/overseas-master-data/pages/OwnedBusinessDetailView.vue index 51c67331..c777ba15 100644 --- a/src/modules/overseas-master-data/pages/OwnedBusinessDetailView.vue +++ b/src/modules/overseas-master-data/pages/OwnedBusinessDetailView.vue @@ -23,7 +23,7 @@ const history = ref([]), historyPage = ref(1), historySi let generation = 0, historyGeneration = 0, active = true, initialActivation = true const previewUrls = new Set() const historyRows = computed(() => history.value.map((event, index) => ({ ...event, eventKey: `${event.eventSource}:${event.recordVersion ?? event.dispatchRecordVersion ?? event.occurredAt}:${event.action}:${index}` }))) -const cargoColumns: DataTableColumn[] = [{ prop: 'customerName', label: '客户', minWidth: 150 }, { prop: 'orderNo', label: '订单号', minWidth: 220 }, { prop: 'inboundCode', label: '入仓码', minWidth: 180 }, { prop: 'palletNo', label: '实物托盘码', minWidth: 180 }, { key: 'destination', label: '目的地', minWidth: 180, slotName: 'destination' }, { prop: 'packages', label: '箱数', minWidth: 90 }] +const cargoColumns: DataTableColumn[] = [{ prop: 'customerName', label: '客户', minWidth: 150 }, { prop: 'orderNo', label: '订单号', minWidth: 220 }, { prop: 'inboundCode', label: '入仓码', minWidth: 180 }, { prop: 'palletNo', label: '关联托盘码', minWidth: 180 }, { key: 'destination', label: '目的地', minWidth: 180, slotName: 'destination' }, { prop: 'packages', label: '箱数', minWidth: 90 }] const historyColumns: DataTableColumn[] = [{ key: 'source', label: '事件来源', minWidth: 110, slotName: 'source' }, { key: 'revision', label: '运营版本', minWidth: 90, prop: 'recordVersion' }, { prop: 'dispatchRecordVersion', label: '排车版本', minWidth: 90 }, { key: 'action', label: '操作', minWidth: 150, slotName: 'action' }, { prop: 'operatorName', label: '操作人', minWidth: 140 }, { key: 'occurredAt', label: '操作时间', minWidth: 180, slotName: 'occurredAt' }, { prop: 'reason', label: '说明', minWidth: 160 }, { key: 'snapshot', label: '当时运营信息', minWidth: 250, slotName: 'snapshot' }, { key: 'photos', label: '当时照片', minWidth: 190, slotName: 'photos' }] const taskFields = computed(() => { const row = detail.value?.row @@ -68,7 +68,7 @@ onBeforeUnmount(() => { active = false; generation++; historyGeneration++; previ - + diff --git a/src/modules/overseas-master-data/pages/OwnedBusinessViews.test.ts b/src/modules/overseas-master-data/pages/OwnedBusinessViews.test.ts index 328e5e69..75fd3c35 100644 --- a/src/modules/overseas-master-data/pages/OwnedBusinessViews.test.ts +++ b/src/modules/overseas-master-data/pages/OwnedBusinessViews.test.ts @@ -30,6 +30,19 @@ beforeEach(() => { }) describe('owned business views', () => { + it('shows one looseboard order with all associated destinations and pallets', async () => { + const detail = ownedBusinessFixture() + detail.row.destinations.push({ ...detail.row.destinations[0]!, destinationKey: 'destination-2', warehouseCode: 'FBA-2' }) + detail.cargo = [{ ...detail.cargo[0]!, orderNo: 'LB-1', palletNo: 'PAL-1、PAL-2', destinationKeys: ['destination-1', 'destination-2'], packages: '9' }] + calls.detail.mockResolvedValue(detail) + const wrapper = mount(OwnedBusinessDetailView, { global }); await flushPromises() + wrapper.findComponent(Tabs).vm.$emit('update:modelValue', 'route'); await flushPromises() + const table = wrapper.findAllComponents(Table).find(component => component.props('columns').some((column: { prop?: string }) => column.prop === 'orderNo'))! + expect(table.props('data')).toHaveLength(1) + expect(table.props('data')[0]).toMatchObject({ orderNo: 'LB-1', palletNo: 'PAL-1、PAL-2', packages: '9' }) + expect(table.text()).toContain('FBA-1、FBA-2') + wrapper.unmount() + }) it('keeps split prices authoritative and arrival times associated with each destination', async () => { const detail = ownedBusinessFixture() detail.row.operations.money = { currency: 'USD', amount: '0.00' }; detail.row.unitPrice = '0.00' diff --git a/src/modules/overseas-warehouse/components/DispatchReturnCargo.test.ts b/src/modules/overseas-warehouse/components/DispatchReturnCargo.test.ts new file mode 100644 index 00000000..7e67c8b3 --- /dev/null +++ b/src/modules/overseas-warehouse/components/DispatchReturnCargo.test.ts @@ -0,0 +1,77 @@ +import ElementPlus from 'element-plus' +import { flushPromises, mount } from '@vue/test-utils' +import { beforeEach, expect, it, vi } from 'vitest' +import DispatchReturnCargo from './DispatchReturnCargo.vue' +import { getDispatchReturnOptions } from '../api/dispatches' +import type { DispatchReturnCandidate, DispatchReturnSelection } from '../dispatchContracts' + +vi.mock('../api/dispatches', () => ({ getDispatchReturnOptions: vi.fn() })) + +const candidates: DispatchReturnCandidate[] = [ + { customerId: '101', customerName: '客户甲', deliveryNo: 'DL-1', lastMileOrderId: '11', lastMileOrderNo: '同号订单', sources: [ + { outboundPalletItemId: '21', inventoryPositionId: '31', returnCartonId: null, palletNo: 'P-1', skuCode: 'SKU-A', cartonMark: 'BOX-A', maxReturnPackages: 3 }, + ] }, + { customerId: '102', customerName: '客户乙', deliveryNo: 'DL-2', lastMileOrderId: '12', lastMileOrderNo: '同号订单', sources: [ + { outboundPalletItemId: '22', inventoryPositionId: '32', returnCartonId: null, palletNo: 'P-2', skuCode: 'SKU-B', cartonMark: 'BOX-B', maxReturnPackages: 5 }, + ] }, +] + +beforeEach(() => { + vi.resetAllMocks() + vi.mocked(getDispatchReturnOptions).mockResolvedValue(structuredClone(candidates)) + vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} }) +}) + +it('selects multiple customer orders, preserves independent quantities and removes only the deselected order', async () => { + const wrapper = mount(DispatchReturnCargo, { props: { dispatchNo: 'PC-1', disabled: false }, global: { plugins: [ElementPlus] } }) + await flushPromises() + expect(getDispatchReturnOptions).toHaveBeenCalledExactlyOnceWith('PC-1') + const select = wrapper.getComponent({ name: 'ElSelect' }) + expect(select.props('multiple')).toBe(true) + const values = wrapper.findAllComponents({ name: 'ElOption' }).map(option => option.props('value')) + expect(new Set(values).size).toBe(2) + /** 模拟选择控件的真实双向绑定和 change,验证用户操作后的提交模型。 */ + async function choose(selected: unknown[]) { + select.vm.$emit('update:modelValue', selected) + select.vm.$emit('change', selected) + await flushPromises() + } + /** 数量控件先更新绑定,再触发提交模型重算。 */ + async function count(index: number, value: number) { + const input = wrapper.findAllComponents({ name: 'ElInputNumber' })[index]! + input.vm.$emit('update:modelValue', value) + input.vm.$emit('change', value) + await flushPromises() + } + /** 只核对公开的 v-model 输出,不依赖组件内部状态。 */ + function selected() { return wrapper.emitted('update:modelValue')!.at(-1)![0] as DispatchReturnSelection[] } + await choose([values[0]]) + await count(0, 1) + await choose(values) + expect(wrapper.findAllComponents({ name: 'ElInputNumber' }).map(input => input.props('modelValue'))).toEqual([1, undefined]) + await count(1, 2) + expect(selected()).toEqual([ + { deliveryNo: 'DL-1', lastMileOrderId: '11', items: [{ outboundPalletItemId: '21', inventoryPositionId: '31', returnCartonId: null, packageCount: 1 }] }, + { deliveryNo: 'DL-2', lastMileOrderId: '12', items: [{ outboundPalletItemId: '22', inventoryPositionId: '32', returnCartonId: null, packageCount: 2 }] }, + ]) + await choose([values[1]]) + expect(selected()).toEqual([{ deliveryNo: 'DL-2', lastMileOrderId: '12', items: [{ outboundPalletItemId: '22', inventoryPositionId: '32', returnCartonId: null, packageCount: 2 }] }]) + await choose(values) + expect(wrapper.findAllComponents({ name: 'ElInputNumber' }).map(input => input.props('modelValue'))).toEqual([undefined, 2]) + wrapper.unmount() +}) + +it('clears stale selections and exposes a retry when return sources cannot be loaded', async () => { + vi.mocked(getDispatchReturnOptions).mockRejectedValueOnce(new Error('来源读取失败')) + const wrapper = mount(DispatchReturnCargo, { props: { dispatchNo: 'PC-1', disabled: false, modelValue: [{ deliveryNo: 'OLD', lastMileOrderId: 'OLD', items: [] }] }, global: { plugins: [ElementPlus] } }) + await flushPromises() + expect(wrapper.get('[role="alert"]').text()).toContain('来源货物加载失败') + expect(wrapper.emitted('update:modelValue')!.at(-1)).toEqual([[]]) + expect(wrapper.findAllComponents({ name: 'ElOption' })).toHaveLength(0) + await wrapper.get('[role="alert"] button').trigger('click') + await flushPromises() + expect(wrapper.find('[role="alert"]').exists()).toBe(false) + expect(wrapper.findAllComponents({ name: 'ElOption' })).toHaveLength(2) + expect(wrapper.emitted('update:modelValue')!.at(-1)).toEqual([[]]) + wrapper.unmount() +}) diff --git a/src/modules/overseas-warehouse/components/DispatchReturnCargo.vue b/src/modules/overseas-warehouse/components/DispatchReturnCargo.vue index 9c3d58ab..6fb00d71 100644 --- a/src/modules/overseas-warehouse/components/DispatchReturnCargo.vue +++ b/src/modules/overseas-warehouse/components/DispatchReturnCargo.vue @@ -6,20 +6,24 @@ import { getApiErrorDetails } from '@/shared/api/http' const props = defineProps<{ dispatchNo: string; disabled: boolean }>() const selections = defineModel({ default: () => [] }) const candidates = ref([]), loading = ref(false), error = ref('') -const sourceId = ref(''), quantities = ref([]) -const source = computed(() => candidates.value.find(row => row.lastMileOrderId === sourceId.value)) -/** 原单切换时清空旧货物,避免把另一单数量提交到当前来源。 */ -function choose() { quantities.value = []; selections.value = [] } -/** 仅提交用户明确填写的原出库来源与退回箱数。 */ +const sourceIds = ref([]), quantities = ref>({}) +/** 客户、派送单和原订单共同标识选项,避免同号货物串单。 */ +function sourceKey(row: DispatchReturnCandidate) { return JSON.stringify([row.customerId, row.deliveryNo, row.lastMileOrderId]) } +const sources = computed(() => candidates.value.filter(row => sourceIds.value.includes(sourceKey(row)))) +/** 增选保留已填数量,取消选中的订单立即清除其退回数量与提交内容。 */ +function choose() { + quantities.value = Object.fromEntries(sourceIds.value.map(key => [key, quantities.value[key] || []])) + update() +} +/** 每单分别提交用户填写的原出库来源和退回箱数,不把其他订单数量带入本单。 */ function update() { - const row = source.value - selections.value = row ? [{ deliveryNo: row.deliveryNo, lastMileOrderId: row.lastMileOrderId, - items: row.sources.flatMap((item, index) => quantities.value[index]! > 0 ? [{ outboundPalletItemId: item.outboundPalletItemId, - inventoryPositionId: item.inventoryPositionId, returnCartonId: item.returnCartonId, packageCount: quantities.value[index]! }] : []) }] : [] + selections.value = sources.value.map(row => ({ deliveryNo: row.deliveryNo, lastMileOrderId: row.lastMileOrderId, + items: row.sources.flatMap((item, index) => quantities.value[sourceKey(row)]?.[index]! > 0 ? [{ outboundPalletItemId: item.outboundPalletItemId, + inventoryPositionId: item.inventoryPositionId, returnCartonId: item.returnCartonId, packageCount: quantities.value[sourceKey(row)]![index]! }] : []) })) } /** 每次打开按本车最新派送事实加载候选,失败明确阻断退回提交。 */ async function load() { - loading.value = true; error.value = ''; selections.value = [] + loading.value = true; error.value = ''; candidates.value = []; sourceIds.value = []; quantities.value = {}; selections.value = [] try { candidates.value = await getDispatchReturnOptions(props.dispatchNo) } catch (cause) { error.value = getApiErrorDetails(cause, '来源货物加载失败').message } finally { loading.value = false } @@ -29,11 +33,14 @@ onMounted(load) diff --git a/src/modules/overseas-warehouse/components/DispatchWorkflowDialog.layout.test.ts b/src/modules/overseas-warehouse/components/DispatchWorkflowDialog.layout.test.ts index 0c5bb461..dcad2f21 100644 --- a/src/modules/overseas-warehouse/components/DispatchWorkflowDialog.layout.test.ts +++ b/src/modules/overseas-warehouse/components/DispatchWorkflowDialog.layout.test.ts @@ -124,6 +124,18 @@ describe('dispatch workflow native layout', () => { expect(api.updateDispatch).toHaveBeenCalledWith('PC/1', expect.objectContaining({ maintenance: saved })) expect(vi.mocked(api.updateDispatch).mock.calls[0]![1]).not.toHaveProperty('departedAt') }) + it('shows the server grouped looseboard orders and their aggregated pallet and volume totals', async () => { + const base = detail().orderDetails[0]! + const orderDetails = [{ ...base, orderNo: 'LB202611050003', inboundCode: null, palletCount: 2, volumeCbm: 10, containerNo: null, sourceBusinessType: 'LOCAL_LOOSEBOARD', sourceBusinessNo: 'LB202611050003', sourceKeys: ['PLAN:11', 'PLAN:12'] }, + { ...base, sourceKey: 'PLAN:13', orderNo: 'LB202611050002', inboundCode: null, palletCount: 1, volumeCbm: 4, containerNo: null, sourceBusinessType: 'LOCAL_LOOSEBOARD', sourceBusinessNo: 'LB202611050002', sourceKeys: ['PLAN:13'] }] + const { dialog } = await render('EDIT', undefined, false, { orderDetails }) + expect(dialog.text()).toContain('关联订单 (2)') + await dialog.findAll('[role="tab"]').find(tab => tab.text().includes('关联订单'))!.trigger('click') + const rows = dialog.get('.maintenance-orders').findAll('tbody tr') + expect(rows).toHaveLength(2) + expect(rows[0]!.findAll('td').map(cell => cell.text())).toEqual(['客户甲', 'LB202611050003', '—', '—', '2', 'ONT8', '10', '—']) + expect(rows[1]!.text()).toContain('LB202611050002') + }) it('edits one number per destination and can save an incomplete second destination', async () => { const destinations = ['ONT8', 'LGB8'].map(code => ({ batchKey: null, batchNo: null, customerId: null, appointmentNo: null, destinationKey: `FBA:${code}`, destinationWarehouseCode: code, status: 'PENDING', confirmedAt: null, blockReason: null, shipmentNumbers: [] })) const initial = detail() diff --git a/src/modules/overseas-warehouse/dispatchContracts.ts b/src/modules/overseas-warehouse/dispatchContracts.ts index 88cddf3f..fd77f151 100644 --- a/src/modules/overseas-warehouse/dispatchContracts.ts +++ b/src/modules/overseas-warehouse/dispatchContracts.ts @@ -31,7 +31,7 @@ export interface DispatchPage { records: DispatchListRow[]; total: number; page: export interface OutboundReference { customerId: string; customerName: string; outboundNo: string; status: string; packageCount: number; palletCount: number } export interface DeliveryReference { groupKey: string; customerId: string; customerName: string; outboundNo: string; deliveryNo: string; destination: Destination; status: string; packageCount: number; palletCount: number } export interface FbaAppointment { batchKey: string | null; batchNo: string | null; customerId: string | null; appointmentNo: string | null; destinationKey: string; destinationWarehouseCode: string | null; status: string; confirmedAt: string | null; blockReason: string | null; shipmentNumbers: string[] } -export interface OrderDetail { customerId: string; customerName: string; orderNo: string; inboundCode: string | null; grossWeightKg: number | null; palletCount: number | null; destinationFbaWarehouse: string | null; volumeCbm: number | null; containerNo: string | null; sourceKey: string; sourceBusinessType: string | null; sourceBusinessNo: string | null } +export interface OrderDetail { customerId: string; customerName: string; orderNo: string; inboundCode: string | null; grossWeightKg: number | null; palletCount: number | null; destinationFbaWarehouse: string | null; volumeCbm: number | null; containerNo: string | null; sourceKey: string; sourceBusinessType: string | null; sourceBusinessNo: string | null; sourceKeys?: string[] } export type DispatchExceptionType = 'REFUSED' | 'PARTIAL_RECEIPT' | 'VEHICLE_EXCEPTION' export type DispatchReturnDirection = 'OVERSEAS_WAREHOUSE' | 'OTHER' export interface DispatchExceptionRecord { exceptionType: DispatchExceptionType; returnDirection: DispatchReturnDirection | null; description: string | null; imageFileIds: string[]; occurredAt: string } From 110d52fd5b13fe62d176d24f24073201a03e5c09 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Fri, 6 Nov 2026 12:52:05 +0800 Subject: [PATCH 2/3] fix(ci): bound Docker image build resources --- Dockerfile | 7 +-- Jenkinsfile | 8 ++- README.md | 2 + scripts/build-image.sh | 18 +++++++ scripts/build-resource-contract.test.mjs | 67 ++++++++++++++++++++++++ scripts/deploy-production.sh | 2 +- 6 files changed, 98 insertions(+), 6 deletions(-) create mode 100644 scripts/build-image.sh create mode 100644 scripts/build-resource-contract.test.mjs diff --git a/Dockerfile b/Dockerfile index 8c6e73e1..9219df38 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,6 +6,9 @@ FROM ${NODE_IMAGE} AS build WORKDIR /app +# 为类型检查和打包留出原生内存余量,容器总内存由构建入口限制为 3GiB。 +ENV NODE_OPTIONS=--max-old-space-size=2048 + # Corepack 与 pnpm 依赖均通过 npmmirror 下载,避免构建时访问 npm 官方源过慢。 ENV COREPACK_NPM_REGISTRY=https://registry.npmmirror.com ENV NPM_CONFIG_REGISTRY=https://registry.npmmirror.com @@ -21,9 +24,7 @@ RUN pnpm config set registry https://registry.npmmirror.com && \ COPY . . ARG VITE_CLIENT_WEB_URL ENV VITE_CLIENT_WEB_URL=${VITE_CLIENT_WEB_URL} -# Docker 构建时同机还会运行多项服务,限制 Vitest 并发以避免测试超时。 -# pnpm 会直接把脚本名后的参数传给 Vitest;额外的 `--` 会让 Vitest -# 把 maxWorkers 当作位置参数,导致并发限制不生效。 +# 类型检查通过后才生成静态产物。 RUN pnpm build # 运行阶段:仅保留构建产物和 Nginx,不包含源码或 node_modules。 diff --git a/Jenkinsfile b/Jenkinsfile index 0004b5cb..bb1bb658 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -3,6 +3,7 @@ pipeline { options { timestamps() disableConcurrentBuilds() + timeout(time: 20, unit: 'MINUTES') } stages { stage('检查测试发布参数') { @@ -27,8 +28,11 @@ pipeline { when { expression { !env.JOB_BASE_NAME?.endsWith('-prod') } } steps { sh '''set -eu - docker compose -f docker-compose.test.yml build --pull=false - docker compose -f docker-compose.test.yml up -d --wait --wait-timeout 120 + sh scripts/build-image.sh --pull=false \ + --build-arg NODE_IMAGE="${NODE_BASE_IMAGE:-node:22-alpine}" \ + --build-arg NGINX_IMAGE="${NGINX_BASE_IMAGE:-nginx:1.27-alpine}" \ + -t "admin-web-test:$IMAGE_TAG" . + docker compose -f docker-compose.test.yml up -d --no-build --pull never --wait --wait-timeout 120 docker compose -f docker-compose.test.yml exec -T admin-web wget -qO- http://127.0.0.1/healthz ''' } diff --git a/README.md b/README.md index ced2f53f..7bbb33e7 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,8 @@ VITE_CLIENT_WEB_URL=http://localhost:5174 容器启动通过 `CLIENT_WEB_URL` 生成 `/runtime-config.js`;本地 Vite 使用 `VITE_CLIENT_WEB_URL`。本地构建通过不代表容器或测试环境已发布。真实 HTTP 验收入口为 `pnpm test:phase4:test --environment test --gateway <统一入口>`,凭据通过环境变量传入,仅在取得对应测试写入授权后执行。 +Jenkins 的测试及灰度镜像构建均通过 `scripts/build-image.sh`,Compose 只部署已构建镜像。构建预算及节点要求见[部署指南](../../docs/测试环境Jenkins部署指南.md)。离线回归为 `node --test scripts/build-resource-contract.test.mjs`,本机仍经统一 `local-check.ps1` 入口运行。 + 认证请求使用 `/api/auth/**`,平台 IAM 请求使用 `/api/platform/v1/iam/**`。登录后只从 JWT 读取平台身份,菜单和操作权限通过当前用户授权接口实时加载,不从 JWT 携带或解析角色权限。企业审核调用 `/reviews`,打开审核详情后按租户调用 `/tenants/{tenantId}/qualifications`;营业执照通过 `/api/platform/v1/files/qualifications/{tenantId}/{fileName}` 携带平台 Token 临时读取,关闭预览后释放本地对象地址。客户端自主注册企业审核通过后继续使用注册账号作为管理员,不展示新凭据;Admin 代建企业并由后端创建管理员时,页面必须展示不可通过遮罩、Esc 或关闭按钮跳过的一次性凭据弹窗,运营人员确认保存后才清除账号密码。平台角色查询、新增、编辑、启停、删除和权限全量保存均已按 `RbacController` 接入真实 `/roles` 契约,角色返回的 `permissionIds` 用于授权回显;系统内置角色及仍被成员使用的角色继续由服务端保护。权限资源查询和新增调用 `/permissions`,尚未提供的权限编辑、删除和启停不会模拟成功。订单与客户真实权限资源只创建 `MENU` 与 `BUTTON`,不创建 `PAGE` 或 `API` 类型。其余业务页面需在对应服务契约确认后逐项替换,生产逻辑不得静默回退到模拟数据。 仓库管理已接入真实平台 Warehouse API,统一维护国内收货仓、FBA 仓和海外仓,支持按租户筛选、详情、新增、编辑和启停,并在更新时保留后端返回的地址、能力和截单时间等完整字段。停用仓库保留历史数据,但不再提供给新业务选择;订单详情使用下单时保存的仓库编码、名称和地址快照,仓库主数据后续修改不改变历史展示。库存和出入库作业由对应业务模块管理,不在仓库主数据页面中模拟。 diff --git a/scripts/build-image.sh b/scripts/build-image.sh new file mode 100644 index 00000000..a6174568 --- /dev/null +++ b/scripts/build-image.sh @@ -0,0 +1,18 @@ +#!/bin/sh +set -eu + +# 构建前至少保留 1GiB 给业务服务,避免在宿主机内存不足时继续启动构建。 +available_kib=$(awk '$1 == "MemAvailable:" { print $2 }' /proc/meminfo) +case "$available_kib" in + ''|*[!0-9]*) echo 'Cannot read host available memory; build refused.' >&2; exit 69 ;; +esac +if [ "$available_kib" -lt 4194304 ]; then + echo 'Docker build requires at least 4GiB available host memory; retry after resources recover.' >&2 + exit 75 +fi + +# 当前节点使用 Docker 26 的经典构建器。显式固定后端,确保资源参数实际生效。 +# 限制覆盖每个 RUN 容器,失败时也清理中间容器,超时由宿主机独立终止。 +exec env DOCKER_BUILDKIT=0 timeout --signal=TERM --kill-after=30s 15m \ + docker build --memory=3g --memory-swap=3g \ + --cpu-period=100000 --cpu-quota=200000 --force-rm "$@" diff --git a/scripts/build-resource-contract.test.mjs b/scripts/build-resource-contract.test.mjs new file mode 100644 index 00000000..12ec275d --- /dev/null +++ b/scripts/build-resource-contract.test.mjs @@ -0,0 +1,67 @@ +import assert from 'node:assert/strict' +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { basename, dirname, join, resolve } from 'node:path' +import { spawnSync } from 'node:child_process' +import { fileURLToPath } from 'node:url' +import test from 'node:test' + +const root = fileURLToPath(new URL('../', import.meta.url)) +const wrapper = join(root, 'scripts/build-image.sh') +const bash = process.platform === 'win32' ? 'C:/Program Files/Git/bin/bash.exe' : 'bash' +const shellAvailable = process.platform !== 'win32' || existsSync(bash) + +// 使用替身验证参数、内存预检和退出码,不启动 Docker 或访问部署环境。 +function runBuild(availableMemory, childStatus = '0') { + const directory = mkdtempSync(join(tmpdir(), 'frontend-build-resource-')) + try { + writeFileSync(join(directory, 'awk'), '#!/bin/sh\nprintf "%s\\n" "$TEST_AVAILABLE_MEMORY"\n', { mode: 0o755 }) + writeFileSync(join(directory, 'timeout'), '#!/bin/sh\nprintf "%s\\n" watchdog "$@"\nshift 3\nexec "$@"\n', { mode: 0o755 }) + writeFileSync(join(directory, 'docker'), '#!/bin/sh\nprintf "%s\\n" "builder=$DOCKER_BUILDKIT" "$@"\nexit "$TEST_CHILD_STATUS"\n', { mode: 0o755 }) + return spawnSync(bash, ['-c', + 'testbin=$(cd "$1" && pwd); shift; PATH="$testbin:$PATH"; export PATH; exec sh "$@"', + 'fixture', directory.replaceAll('\\', '/'), wrapper.replaceAll('\\', '/'), + '--build-arg', 'VALUE=with space', '-t', 'web:test', '.'], { + encoding: 'utf8', timeout: 10000, + env: { ...process.env, DOCKER_BUILDKIT: '1', TEST_AVAILABLE_MEMORY: availableMemory, TEST_CHILD_STATUS: childStatus } + }) + } finally { + assert.equal(dirname(resolve(directory)), resolve(tmpdir())) + rmSync(directory, { recursive: true }) + } +} + +test('TEST and gray builds share the resource gate and deployment cannot rebuild implicitly', () => { + const pipeline = readFileSync(join(root, 'Jenkinsfile'), 'utf8') + const production = readFileSync(join(root, 'scripts/deploy-production.sh'), 'utf8') + const dockerfile = readFileSync(join(root, 'Dockerfile'), 'utf8') + for (const source of [pipeline, production]) { + assert.match(source, /sh scripts\/build-image\.sh/) + assert.doesNotMatch(source, /docker (?:compose[^\n]* build|build\s)/) + } + assert.match(pipeline, /timeout\(time: 20, unit: 'MINUTES'\)/) + assert.match(pipeline, /up -d --no-build --pull never --wait/) + assert.ok(pipeline.includes(`-t "${basename(root)}-test:$IMAGE_TAG"`)) + assert.match(dockerfile, /^ENV NODE_OPTIONS=--max-old-space-size=2048$/m) +}) + +test('build forwards quoted arguments, enforces container budgets and propagates failure', { skip: !shellAvailable }, () => { + const result = runBuild('4194304', '23') + assert.equal(result.status, 23, result.stderr) + assert.match(result.stdout, /watchdog\r?\n--signal=TERM\r?\n--kill-after=30s\r?\n15m\r?\ndocker\r?\nbuild/) + assert.match(result.stdout, /builder=0\r?\nbuild/) + for (const argument of ['--memory=3g', '--memory-swap=3g', '--cpu-period=100000', '--cpu-quota=200000', '--force-rm']) { + assert.ok(result.stdout.split(/\r?\n/).includes(argument), argument) + } + assert.match(result.stdout, /--build-arg\r?\nVALUE=with space\r?\n-t\r?\nweb:test\r?\n\./) + assert.equal(runBuild('8388608').status, 0) + assert.equal(runBuild('8388608', '124').status, 124) +}) + +test('low or unreadable host memory refuses to start Docker', { skip: !shellAvailable }, () => { + for (const [memory, status] of [['4194303', 75], ['', 69], ['unknown', 69]]) { + const result = runBuild(memory) + assert.equal(result.status, status, result.stderr) + assert.equal(result.stdout, '') + } +}) diff --git a/scripts/deploy-production.sh b/scripts/deploy-production.sh index c1169e7e..496b0fbe 100644 --- a/scripts/deploy-production.sh +++ b/scripts/deploy-production.sh @@ -35,7 +35,7 @@ DOCKER_CONFIG=$(mktemp -d) cleanup() { rm -f "$DOCKER_CONFIG/config.json"; rmdir "$DOCKER_CONFIG" 2>/dev/null || true; } trap cleanup EXIT HUP INT TERM printf '%s' "$BUILD_REGISTRY_PASSWORD" | docker login -u "$BUILD_REGISTRY_USER" --password-stdin "$registry" -docker build --build-arg NODE_IMAGE="${NODE_BASE_IMAGE:-node:22-alpine}" \ +sh scripts/build-image.sh --build-arg NODE_IMAGE="${NODE_BASE_IMAGE:-node:22-alpine}" \ --build-arg NGINX_IMAGE="${NGINX_BASE_IMAGE:-nginx:1.27-alpine}" \ --build-arg VITE_CLIENT_WEB_URL="$PUBLIC_WEB_ORIGIN" -t "$image" . docker push "$image" From e10e0795652f186f27c71957f492c4a9259bd656 Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Fri, 6 Nov 2026 13:33:14 +0800 Subject: [PATCH 3/3] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/InboundTaskList.test.ts | 3 +- .../inbounds/components/InboundTaskList.vue | 10 ++++--- .../pages/LooseboardOrderDetailView.vue | 2 +- .../pages/TransportOrdersView.vue | 2 +- .../overseas-business/transportDemandForm.ts | 3 +- src/modules/overseas-business/types.ts | 2 +- .../pages/UnifiedOrdersView.requests.test.ts | 28 ++++++++++++++++++- .../pages/UnifiedOrdersView.vue | 23 +++++++++++++-- 8 files changed, 61 insertions(+), 12 deletions(-) diff --git a/src/modules/inbounds/components/InboundTaskList.test.ts b/src/modules/inbounds/components/InboundTaskList.test.ts index c57ed062..9017b8b7 100644 --- a/src/modules/inbounds/components/InboundTaskList.test.ts +++ b/src/modules/inbounds/components/InboundTaskList.test.ts @@ -25,7 +25,8 @@ describe('InboundTaskList measurement scope', () => { expect(toolbar).toContain('') expect(filters).toContain('') + expect(source).toContain(' -