From 8c0daaa5db449e114c19221e3b92c58ec5d9f2fe Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 28 Sep 2026 17:05:47 +0100 Subject: [PATCH 01/17] fix(admin): reuse master data in sailing forms --- .../pages/SailingCalendarView.vue | 128 ++++++++++++------ 1 file changed, 84 insertions(+), 44 deletions(-) diff --git a/src/modules/sailing-calendar/pages/SailingCalendarView.vue b/src/modules/sailing-calendar/pages/SailingCalendarView.vue index 759793e6..40d28e32 100644 --- a/src/modules/sailing-calendar/pages/SailingCalendarView.vue +++ b/src/modules/sailing-calendar/pages/SailingCalendarView.vue @@ -7,12 +7,11 @@ import AdminPage from '@/shared/components/AdminPage.vue' import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue' import LoadErrorState from '@/shared/components/LoadErrorState.vue' import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http' -import { getPlatformPriceBook, listPlatformPriceBooks } from '@/modules/fba-pricing' +import { listAllPlatformWarehouses, type PlatformWarehouseSummaryResource } from '@/modules/warehouses' import { useAccessPreviewStore } from '@/core/access/accessPreview' import { cancelSailing, copySailing, - createSailingChannel, createSailingTemplate, createSailing, generateSailingTemplate, @@ -23,12 +22,10 @@ import { listSailingCalendar, listSailingTemplates, previewSailingTemplateGeneration, - updateSailingChannel, updateSailingTemplate, updateSailing, } from '../api/platformSailingCalendar' -import type { PlatformPriceBookParsedChannel } from '@/modules/fba-pricing' -import type { SailingBookingAdvice, SailingCalendarEntry, SailingCalendarFilterOptions, SailingCalendarResponse, SailingChannelResource, SailingChannelSaveRequest, SailingChannelSourceType, SailingHistory, SailingSaveRequest, SailingStatus, SailingTemplateResource, SailingTemplateSaveRequest, SailingVolumeDetail } from '../types' +import type { SailingBookingAdvice, SailingCalendarEntry, SailingCalendarFilterOptions, SailingCalendarResponse, SailingChannelResource, SailingHistory, SailingSaveRequest, SailingStatus, SailingTemplateResource, SailingTemplateSaveRequest, SailingVolumeDetail } from '../types' interface CalendarCell { date: string; day: number; currentMonth: boolean; records: SailingCalendarEntry[] } @@ -62,11 +59,12 @@ const managementTab = ref<'channels' | 'templates'>('channels') const managementLoading = ref(false) const managementError = ref(null) const channels = ref([]) -const priceBookChannels = ref([]) -const channelDialogOpen = ref(false) -const channelSaving = ref(false) -const editingChannelId = ref('') -const channelForm = reactive({ channelCode: '', channelName: '', sourceType: 'SCHEDULE_ONLY', mappedChannelCode: '', defaultDeparturePortCode: '', defaultDestinationPortCode: '', defaultCutoffDays: 3, remark: '' }) +const warehouseOptions = ref([]) +const warehouseLoading = ref(false) +let warehouseRequest: Promise | null = null +const warehousesLoaded = ref(false) +const formOptionsLoading = ref(false) +const routeExpanded = ref([]) const templates = ref([]) const templateDialogOpen = ref(false) const templateSaving = ref(false) @@ -195,37 +193,53 @@ async function openManagement(tab: 'channels' | 'templates' = 'channels') { managementError.value = null managementLoading.value = true try { - const [channelRows, templateRows] = await Promise.all([listSailingChannels(), listSailingTemplates()]) + const [channelRows, templateRows] = await Promise.all([listSailingChannels(), listSailingTemplates(), ensureWarehouseOptions()]) channels.value = channelRows templates.value = templateRows } catch (cause) { managementError.value = getApiErrorDetails(cause, '渠道与周模板加载失败') } finally { managementLoading.value = false } } -async function toggleChannel(row: unknown) { - const channelRow = row as SailingChannelResource - try { - const updated = await updateSailingChannel(channelRow.id, { channelCode: channelRow.channelCode, channelName: channelRow.channelName, sourceType: channelRow.sourceType, mappedChannelCode: channelRow.mappedChannelCode, defaultDeparturePortCode: channelRow.defaultDeparturePortCode, defaultDestinationPortCode: channelRow.defaultDestinationPortCode, defaultCutoffDays: channelRow.defaultCutoffDays, remark: channelRow.remark, expectedVersion: channelRow.version, status: channelRow.status === 'ACTIVE' ? 'INACTIVE' : 'ACTIVE' } as SailingChannelSaveRequest & { status: string }) - channelRow.status = updated.status - ElMessage.success(channelRow.status === 'ACTIVE' ? '渠道已启用' : '渠道已停用') - } catch (cause) { ElMessage.error(getApiErrorDetails(cause, '渠道状态更新失败').message) } +async function ensureWarehouseOptions() { + if (warehousesLoaded.value) return + if (warehouseRequest) return warehouseRequest + warehouseLoading.value = true + warehouseRequest = listAllPlatformWarehouses({ warehouseType: 'DOMESTIC', status: 'ACTIVE' }) + .then((rows) => { warehouseOptions.value = rows; warehousesLoaded.value = true }) + .finally(() => { warehouseLoading.value = false; warehouseRequest = null }) + return warehouseRequest } -function openChannelCreate() { editingChannelId.value = ''; Object.assign(channelForm, { channelCode: '', channelName: '', sourceType: 'SCHEDULE_ONLY' as SailingChannelSourceType, mappedChannelCode: '', defaultDeparturePortCode: '', defaultDestinationPortCode: '', defaultCutoffDays: 3, remark: '', expectedVersion: undefined, status: undefined }); channelDialogOpen.value = true } -function openChannelEdit(row: unknown) { const channelRow = row as SailingChannelResource; editingChannelId.value = channelRow.id; Object.assign(channelForm, { channelCode: channelRow.channelCode, channelName: channelRow.channelName, sourceType: channelRow.sourceType, mappedChannelCode: channelRow.mappedChannelCode || '', defaultDeparturePortCode: channelRow.defaultDeparturePortCode || '', defaultDestinationPortCode: channelRow.defaultDestinationPortCode || '', defaultCutoffDays: channelRow.defaultCutoffDays, remark: channelRow.remark || '', expectedVersion: channelRow.version, status: channelRow.status }); channelDialogOpen.value = true } -async function saveChannel() { - if (!channelForm.channelCode.trim() || !channelForm.channelName.trim() || channelForm.defaultCutoffDays < 0) { ElMessage.error('请填写渠道编码、名称和有效的提前截止天数'); return } - channelSaving.value = true - try { - const response = editingChannelId.value ? await updateSailingChannel(editingChannelId.value, channelForm) : await createSailingChannel(channelForm) - if (editingChannelId.value) channels.value = channels.value.map((item) => item.id === response.id ? response : item) - else channels.value = [...channels.value, response] - channelDialogOpen.value = false - ElMessage.success(editingChannelId.value ? '渠道已更新' : '渠道已创建') - editingChannelId.value = '' - } catch (cause) { ElMessage.error(getApiErrorDetails(cause, editingChannelId.value ? '渠道更新失败' : '渠道创建失败').message) } finally { channelSaving.value = false } +function warehouseLabel(id: string) { + const warehouse = warehouseOptions.value.find((item) => String(item.id) === String(id)) + return warehouse ? `${warehouse.warehouseName}(${warehouse.warehouseCode})` : '未关联收货仓' } -function openTemplateCreate() { +function applyWarehouse(id: string, target: { warehouseId: string; warehouseName?: string | null }) { + target.warehouseId = String(id || '') + const warehouse = warehouseOptions.value.find((item) => String(item.id) === String(id)) + target.warehouseName = warehouse?.warehouseName || target.warehouseName || '' +} +function syncTemplateDerivedFields() { + const channel = channels.value.find((item) => item.id === String(templateForm.channelId)) + if (channel) { + templateForm.departurePortCode = templateForm.departurePortCode || channel.defaultDeparturePortCode || '' + templateForm.destinationPortCode = templateForm.destinationPortCode || channel.defaultDestinationPortCode || '' + templateForm.vesselName = templateForm.vesselName || '待订舱确认' + } + const warehouse = warehouseOptions.value.find((item) => String(item.id) === String(templateForm.warehouseId)) + if (warehouse) templateForm.warehouseName = warehouse.warehouseName +} +function onTemplateChannelChanged(id: string) { + const channel = channels.value.find((item) => item.id === String(id)) + if (!channel) return + templateForm.cutoffDays = channel.defaultCutoffDays + templateForm.departurePortCode = channel.defaultDeparturePortCode || '' + templateForm.destinationPortCode = channel.defaultDestinationPortCode || '' + templateForm.vesselName = '待订舱确认' +} +function onTemplateWarehouseChanged(id: string) { applyWarehouse(id, templateForm) } +async function openTemplateCreate() { + await ensureWarehouseOptions() editingTemplateId.value = '' const activeChannel = channels.value.find((item) => item.status === 'ACTIVE') - Object.assign(templateForm, { templateName: '', channelId: activeChannel?.id || '', warehouseId: '', warehouseName: '', weekdays: [1], effectiveFrom: formatDate(today), effectiveTo: '', departureTime: '10:00:00', cutoffDays: activeChannel?.defaultCutoffDays ?? 3, cutoffLocalTime: '18:00:00', voyageRule: '', vesselName: '', departurePortCode: '', destinationPortCode: '', remark: '', expectedVersion: undefined }) + Object.assign(templateForm, { templateName: '', channelId: activeChannel?.id || '', warehouseId: '', warehouseName: '', weekdays: [1], effectiveFrom: formatDate(today), effectiveTo: '', departureTime: '10:00:00', cutoffDays: activeChannel?.defaultCutoffDays ?? 3, cutoffLocalTime: '18:00:00', voyageRule: '', vesselName: '待订舱确认', departurePortCode: activeChannel?.defaultDeparturePortCode || '', destinationPortCode: activeChannel?.defaultDestinationPortCode || '', remark: '', expectedVersion: undefined }) templatePreview.value = [] resetGenerationRange(templateForm.effectiveFrom) templateDialogOpen.value = true @@ -238,8 +252,8 @@ function openTemplateEdit(row: unknown, options: { preserveGenerationRange?: boo if (!options.preserveGenerationRange) resetGenerationRange(templateForm.effectiveFrom) templateDialogOpen.value = true } -function templatePayload() { return { ...templateForm, effectiveTo: templateForm.effectiveTo || null, weekdays: [...templateForm.weekdays].sort((a, b) => a - b) } } -function validateTemplate() { if (!templateForm.channelId || !templateForm.warehouseId || !templateForm.weekdays.length || !templateForm.effectiveFrom || !templateForm.departureTime || templateForm.cutoffDays < 0 || !templateForm.vesselName?.trim() || !templateForm.departurePortCode?.trim() || !templateForm.destinationPortCode?.trim()) { ElMessage.error('请补全渠道、收货仓、周几、默认船名、起运港、目的港、有效期和截止天数'); return false } if (templateForm.cutoffDays === 0 && templateForm.cutoffLocalTime >= templateForm.departureTime) { ElMessage.error('同日收货截止时刻必须早于开船时间'); return false } return true } +function templatePayload() { syncTemplateDerivedFields(); return { ...templateForm, effectiveTo: templateForm.effectiveTo || null, weekdays: [...templateForm.weekdays].sort((a, b) => a - b), voyageRule: templateForm.voyageRule || null, vesselName: templateForm.vesselName || '待订舱确认', remark: templateForm.remark || null } } +function validateTemplate() { syncTemplateDerivedFields(); if (!templateForm.channelId || !templateForm.warehouseId || !templateForm.weekdays.length || !templateForm.effectiveFrom || !templateForm.departureTime || templateForm.cutoffDays < 0 || !templateForm.departurePortCode?.trim() || !templateForm.destinationPortCode?.trim()) { ElMessage.error('请补全渠道、收货仓、周几、有效期、开船时间、起运港和目的港'); return false } if (templateForm.cutoffDays === 0 && templateForm.cutoffLocalTime >= templateForm.departureTime) { ElMessage.error('同日收货截止时刻必须早于开船时间'); return false } return true } async function saveTemplate() { if (!validateTemplate()) return templateSaving.value = true @@ -283,11 +297,38 @@ async function confirmTemplateGeneration() { } finally { templateGenerateLoading.value = false } } function formatWeekdays(value: unknown) { return Array.isArray(value) ? value.map((day: unknown) => ['一', '二', '三', '四', '五', '六', '日'][Number(day) - 1]).join('、') : '—' } -async function onChannelChanged(value: string) { const channel = priceBookChannels.value.find((item) => item.channelCode === value); form.channelName = channel?.channelName || ''; form.channelId = null } -async function openCreate() { dialogMode.value = 'create'; cutoffMode.value = 'rule'; cutoffDays.value = 3; cutoffLocalTime.value = '18:00:00'; Object.assign(form, blankForm()); try { if (!priceBookChannels.value.length) { const versions = await listPlatformPriceBooks({ customerId: '0', status: 'ACTIVE' }); const latest = versions.sort((a, b) => (b.publishedAt || b.createdAt || '').localeCompare(a.publishedAt || a.createdAt || '') || b.versionNo - a.versionNo)[0]; if (latest) priceBookChannels.value = (await getPlatformPriceBook(latest.id)).channels } } catch (cause) { ElMessage.error(getApiErrorDetails(cause, '价格表渠道加载失败').message) } dialogOpen.value = true } -function openEdit(record: SailingCalendarEntry) { dialogMode.value = 'edit'; cutoffMode.value = 'custom'; cutoffDays.value = 3; cutoffLocalTime.value = record.cutoffAt.slice(11, 19) || '18:00:00'; Object.assign(form, { templateId: record.templateId ?? null, channelId: record.channelId ?? null, warehouseId: record.warehouseId, warehouseName: record.warehouseName, channelCode: record.channelCode, channelName: record.channelName, vesselName: record.vesselName, voyageNo: record.voyageNo, departurePortCode: record.departurePortCode, departurePortName: record.departurePortName || '', destinationPortCode: record.destinationPortCode, destinationPortName: record.destinationPortName || '', etdAt: record.etdAt.slice(0, 19), etaAt: record.etaAt ?? null, cutoffAt: record.cutoffAt.slice(0, 19), sourceType: record.sourceType ?? 'MANUAL', plannedCapacityCbm: record.plannedCapacityCbm ?? null, remark: record.remark || '', expectedVersion: record.version }); dialogOpen.value = true } +function onChannelChanged(value: string) { + const channel = channels.value.find((item) => item.channelCode === value) + form.channelName = channel?.channelName || '' + form.channelId = channel?.id || null + form.departurePortCode = channel?.defaultDeparturePortCode || '' + form.destinationPortCode = channel?.defaultDestinationPortCode || '' + form.departurePortName = '' + form.destinationPortName = '' + form.vesselName = null + form.voyageNo = null + cutoffDays.value = channel?.defaultCutoffDays ?? 3 + syncCutoffFromRule() + routeExpanded.value = form.departurePortCode && form.destinationPortCode ? [] : ['route'] +} +async function openCreate() { + dialogMode.value = 'create' + cutoffMode.value = 'rule' + cutoffDays.value = 3 + cutoffLocalTime.value = '18:00:00' + Object.assign(form, blankForm(), { expectedVersion: undefined }) + routeExpanded.value = [] + dialogOpen.value = true + formOptionsLoading.value = true + try { + const [rows] = await Promise.all([listSailingChannels(), ensureWarehouseOptions()]) + channels.value = rows + } catch (cause) { ElMessage.error(getApiErrorDetails(cause, '排船资料加载失败,请关闭后重试').message) } + finally { formOptionsLoading.value = false } +} +async function openEdit(record: SailingCalendarEntry) { try { const [rows] = await Promise.all([listSailingChannels(), ensureWarehouseOptions()]); channels.value = rows } catch (cause) { ElMessage.error(getApiErrorDetails(cause, '排船资料加载失败').message); return } routeExpanded.value = []; dialogMode.value = 'edit'; cutoffMode.value = 'custom'; cutoffDays.value = 3; cutoffLocalTime.value = record.cutoffAt.slice(11, 19) || '18:00:00'; Object.assign(form, { templateId: record.templateId ?? null, channelId: record.channelId ?? null, warehouseId: record.warehouseId, warehouseName: record.warehouseName, channelCode: record.channelCode, channelName: record.channelName, vesselName: record.vesselName, voyageNo: record.voyageNo, departurePortCode: record.departurePortCode, departurePortName: record.departurePortName || '', destinationPortCode: record.destinationPortCode, destinationPortName: record.destinationPortName || '', etdAt: record.etdAt.slice(0, 19), etaAt: record.etaAt ?? null, cutoffAt: record.cutoffAt.slice(0, 19), sourceType: record.sourceType ?? 'MANUAL', plannedCapacityCbm: record.plannedCapacityCbm ?? null, remark: record.remark || '', expectedVersion: record.version }); dialogOpen.value = true } function onEtdChanged() { if (dialogMode.value === 'create' || cutoffMode.value === 'rule') syncCutoffFromRule() } -function validateForm() { const required: Array<[keyof SailingSaveRequest, string]> = [['warehouseId', '收货仓'], ['channelCode', '价格表渠道'], ['departurePortCode', '起运港'], ['destinationPortCode', '目的港'], ['etdAt', '开船时间'], ['cutoffAt', '截止时间']]; const missing = required.find(([key]) => !String(form[key] || '').trim()); if (missing) { ElMessage.error(`请填写${missing[1]}`); return false } if (new Date(form.cutoffAt).getTime() >= new Date(form.etdAt).getTime()) { ElMessage.error('收货截止时间必须早于预计开船时间'); return false } return true } +function validateForm() { const required: Array<[keyof SailingSaveRequest, string]> = [['warehouseId', '收货仓'], ['channelCode', '渠道'], ['departurePortCode', '起运港'], ['destinationPortCode', '目的港'], ['etdAt', '开船时间'], ['cutoffAt', '截止时间']]; const missing = required.find(([key]) => !String(form[key] || '').trim()); if (missing) { ElMessage.error(`请填写${missing[1]}`); return false } if (new Date(form.cutoffAt).getTime() >= new Date(form.etdAt).getTime()) { ElMessage.error('收货截止时间必须早于预计开船时间'); return false } return true } async function save() { if (!validateForm()) return saving.value = true @@ -380,15 +421,14 @@ onMounted(() => void load()) - 渠道名称和编码来自已解析的船期价格表。
按开船前 N 天自定义时间
提前天数2 天3 天4 天
截止时刻
建议截止{{ dateTime(form.cutoffAt) }}按开船本地日期倒推自然日
+ 选择已有渠道,自动带入排船默认资料。
{{ form.vesselName || '待订舱确认' }} / {{ form.voyageNo || '待订舱确认' }}
未确认船名、航次时可先排船,显示为待订舱确认。
按开船前 N 天自定义时间
提前天数2 天3 天4 天
截止时刻
建议截止{{ dateTime(form.cutoffAt) }}按开船本地日期倒推自然日
-
停用渠道仍保留历史船期,但不能新建模板或生成未来船期。新增渠道
按周几生成前先预览,生成操作使用服务端幂等键。新增周模板
- - 周{{ ['一', '二', '三', '四', '五', '六', '日'][day - 1] }}
2 天3 天4 天
快捷生成范围:未来 4 周未来 8 周未来 12 周

生成预览({{ templatePreview.length }} 条)

+
查看已有船期渠道及默认收货规则。
设置每周开船日和收货截止,预览后批量生成;重复日期自动跳过。新增周模板
+ 周{{ ['一', '二', '三', '四', '五', '六', '日'][day - 1] }}
2 天3 天4 天
快捷生成范围:未来 4 周未来 8 周未来 12 周

生成预览({{ templatePreview.length }} 条)

From 0798143bad3756a16c750a97f2cfe3e0d29bb869 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 28 Sep 2026 17:09:46 +0100 Subject: [PATCH 02/17] fix(admin): reuse master data in sailing forms --- .../pages/SailingCalendarView.vue | 128 ++++++++++++------ 1 file changed, 84 insertions(+), 44 deletions(-) diff --git a/src/modules/sailing-calendar/pages/SailingCalendarView.vue b/src/modules/sailing-calendar/pages/SailingCalendarView.vue index 759793e6..40d28e32 100644 --- a/src/modules/sailing-calendar/pages/SailingCalendarView.vue +++ b/src/modules/sailing-calendar/pages/SailingCalendarView.vue @@ -7,12 +7,11 @@ import AdminPage from '@/shared/components/AdminPage.vue' import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue' import LoadErrorState from '@/shared/components/LoadErrorState.vue' import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http' -import { getPlatformPriceBook, listPlatformPriceBooks } from '@/modules/fba-pricing' +import { listAllPlatformWarehouses, type PlatformWarehouseSummaryResource } from '@/modules/warehouses' import { useAccessPreviewStore } from '@/core/access/accessPreview' import { cancelSailing, copySailing, - createSailingChannel, createSailingTemplate, createSailing, generateSailingTemplate, @@ -23,12 +22,10 @@ import { listSailingCalendar, listSailingTemplates, previewSailingTemplateGeneration, - updateSailingChannel, updateSailingTemplate, updateSailing, } from '../api/platformSailingCalendar' -import type { PlatformPriceBookParsedChannel } from '@/modules/fba-pricing' -import type { SailingBookingAdvice, SailingCalendarEntry, SailingCalendarFilterOptions, SailingCalendarResponse, SailingChannelResource, SailingChannelSaveRequest, SailingChannelSourceType, SailingHistory, SailingSaveRequest, SailingStatus, SailingTemplateResource, SailingTemplateSaveRequest, SailingVolumeDetail } from '../types' +import type { SailingBookingAdvice, SailingCalendarEntry, SailingCalendarFilterOptions, SailingCalendarResponse, SailingChannelResource, SailingHistory, SailingSaveRequest, SailingStatus, SailingTemplateResource, SailingTemplateSaveRequest, SailingVolumeDetail } from '../types' interface CalendarCell { date: string; day: number; currentMonth: boolean; records: SailingCalendarEntry[] } @@ -62,11 +59,12 @@ const managementTab = ref<'channels' | 'templates'>('channels') const managementLoading = ref(false) const managementError = ref(null) const channels = ref([]) -const priceBookChannels = ref([]) -const channelDialogOpen = ref(false) -const channelSaving = ref(false) -const editingChannelId = ref('') -const channelForm = reactive({ channelCode: '', channelName: '', sourceType: 'SCHEDULE_ONLY', mappedChannelCode: '', defaultDeparturePortCode: '', defaultDestinationPortCode: '', defaultCutoffDays: 3, remark: '' }) +const warehouseOptions = ref([]) +const warehouseLoading = ref(false) +let warehouseRequest: Promise | null = null +const warehousesLoaded = ref(false) +const formOptionsLoading = ref(false) +const routeExpanded = ref([]) const templates = ref([]) const templateDialogOpen = ref(false) const templateSaving = ref(false) @@ -195,37 +193,53 @@ async function openManagement(tab: 'channels' | 'templates' = 'channels') { managementError.value = null managementLoading.value = true try { - const [channelRows, templateRows] = await Promise.all([listSailingChannels(), listSailingTemplates()]) + const [channelRows, templateRows] = await Promise.all([listSailingChannels(), listSailingTemplates(), ensureWarehouseOptions()]) channels.value = channelRows templates.value = templateRows } catch (cause) { managementError.value = getApiErrorDetails(cause, '渠道与周模板加载失败') } finally { managementLoading.value = false } } -async function toggleChannel(row: unknown) { - const channelRow = row as SailingChannelResource - try { - const updated = await updateSailingChannel(channelRow.id, { channelCode: channelRow.channelCode, channelName: channelRow.channelName, sourceType: channelRow.sourceType, mappedChannelCode: channelRow.mappedChannelCode, defaultDeparturePortCode: channelRow.defaultDeparturePortCode, defaultDestinationPortCode: channelRow.defaultDestinationPortCode, defaultCutoffDays: channelRow.defaultCutoffDays, remark: channelRow.remark, expectedVersion: channelRow.version, status: channelRow.status === 'ACTIVE' ? 'INACTIVE' : 'ACTIVE' } as SailingChannelSaveRequest & { status: string }) - channelRow.status = updated.status - ElMessage.success(channelRow.status === 'ACTIVE' ? '渠道已启用' : '渠道已停用') - } catch (cause) { ElMessage.error(getApiErrorDetails(cause, '渠道状态更新失败').message) } +async function ensureWarehouseOptions() { + if (warehousesLoaded.value) return + if (warehouseRequest) return warehouseRequest + warehouseLoading.value = true + warehouseRequest = listAllPlatformWarehouses({ warehouseType: 'DOMESTIC', status: 'ACTIVE' }) + .then((rows) => { warehouseOptions.value = rows; warehousesLoaded.value = true }) + .finally(() => { warehouseLoading.value = false; warehouseRequest = null }) + return warehouseRequest } -function openChannelCreate() { editingChannelId.value = ''; Object.assign(channelForm, { channelCode: '', channelName: '', sourceType: 'SCHEDULE_ONLY' as SailingChannelSourceType, mappedChannelCode: '', defaultDeparturePortCode: '', defaultDestinationPortCode: '', defaultCutoffDays: 3, remark: '', expectedVersion: undefined, status: undefined }); channelDialogOpen.value = true } -function openChannelEdit(row: unknown) { const channelRow = row as SailingChannelResource; editingChannelId.value = channelRow.id; Object.assign(channelForm, { channelCode: channelRow.channelCode, channelName: channelRow.channelName, sourceType: channelRow.sourceType, mappedChannelCode: channelRow.mappedChannelCode || '', defaultDeparturePortCode: channelRow.defaultDeparturePortCode || '', defaultDestinationPortCode: channelRow.defaultDestinationPortCode || '', defaultCutoffDays: channelRow.defaultCutoffDays, remark: channelRow.remark || '', expectedVersion: channelRow.version, status: channelRow.status }); channelDialogOpen.value = true } -async function saveChannel() { - if (!channelForm.channelCode.trim() || !channelForm.channelName.trim() || channelForm.defaultCutoffDays < 0) { ElMessage.error('请填写渠道编码、名称和有效的提前截止天数'); return } - channelSaving.value = true - try { - const response = editingChannelId.value ? await updateSailingChannel(editingChannelId.value, channelForm) : await createSailingChannel(channelForm) - if (editingChannelId.value) channels.value = channels.value.map((item) => item.id === response.id ? response : item) - else channels.value = [...channels.value, response] - channelDialogOpen.value = false - ElMessage.success(editingChannelId.value ? '渠道已更新' : '渠道已创建') - editingChannelId.value = '' - } catch (cause) { ElMessage.error(getApiErrorDetails(cause, editingChannelId.value ? '渠道更新失败' : '渠道创建失败').message) } finally { channelSaving.value = false } +function warehouseLabel(id: string) { + const warehouse = warehouseOptions.value.find((item) => String(item.id) === String(id)) + return warehouse ? `${warehouse.warehouseName}(${warehouse.warehouseCode})` : '未关联收货仓' } -function openTemplateCreate() { +function applyWarehouse(id: string, target: { warehouseId: string; warehouseName?: string | null }) { + target.warehouseId = String(id || '') + const warehouse = warehouseOptions.value.find((item) => String(item.id) === String(id)) + target.warehouseName = warehouse?.warehouseName || target.warehouseName || '' +} +function syncTemplateDerivedFields() { + const channel = channels.value.find((item) => item.id === String(templateForm.channelId)) + if (channel) { + templateForm.departurePortCode = templateForm.departurePortCode || channel.defaultDeparturePortCode || '' + templateForm.destinationPortCode = templateForm.destinationPortCode || channel.defaultDestinationPortCode || '' + templateForm.vesselName = templateForm.vesselName || '待订舱确认' + } + const warehouse = warehouseOptions.value.find((item) => String(item.id) === String(templateForm.warehouseId)) + if (warehouse) templateForm.warehouseName = warehouse.warehouseName +} +function onTemplateChannelChanged(id: string) { + const channel = channels.value.find((item) => item.id === String(id)) + if (!channel) return + templateForm.cutoffDays = channel.defaultCutoffDays + templateForm.departurePortCode = channel.defaultDeparturePortCode || '' + templateForm.destinationPortCode = channel.defaultDestinationPortCode || '' + templateForm.vesselName = '待订舱确认' +} +function onTemplateWarehouseChanged(id: string) { applyWarehouse(id, templateForm) } +async function openTemplateCreate() { + await ensureWarehouseOptions() editingTemplateId.value = '' const activeChannel = channels.value.find((item) => item.status === 'ACTIVE') - Object.assign(templateForm, { templateName: '', channelId: activeChannel?.id || '', warehouseId: '', warehouseName: '', weekdays: [1], effectiveFrom: formatDate(today), effectiveTo: '', departureTime: '10:00:00', cutoffDays: activeChannel?.defaultCutoffDays ?? 3, cutoffLocalTime: '18:00:00', voyageRule: '', vesselName: '', departurePortCode: '', destinationPortCode: '', remark: '', expectedVersion: undefined }) + Object.assign(templateForm, { templateName: '', channelId: activeChannel?.id || '', warehouseId: '', warehouseName: '', weekdays: [1], effectiveFrom: formatDate(today), effectiveTo: '', departureTime: '10:00:00', cutoffDays: activeChannel?.defaultCutoffDays ?? 3, cutoffLocalTime: '18:00:00', voyageRule: '', vesselName: '待订舱确认', departurePortCode: activeChannel?.defaultDeparturePortCode || '', destinationPortCode: activeChannel?.defaultDestinationPortCode || '', remark: '', expectedVersion: undefined }) templatePreview.value = [] resetGenerationRange(templateForm.effectiveFrom) templateDialogOpen.value = true @@ -238,8 +252,8 @@ function openTemplateEdit(row: unknown, options: { preserveGenerationRange?: boo if (!options.preserveGenerationRange) resetGenerationRange(templateForm.effectiveFrom) templateDialogOpen.value = true } -function templatePayload() { return { ...templateForm, effectiveTo: templateForm.effectiveTo || null, weekdays: [...templateForm.weekdays].sort((a, b) => a - b) } } -function validateTemplate() { if (!templateForm.channelId || !templateForm.warehouseId || !templateForm.weekdays.length || !templateForm.effectiveFrom || !templateForm.departureTime || templateForm.cutoffDays < 0 || !templateForm.vesselName?.trim() || !templateForm.departurePortCode?.trim() || !templateForm.destinationPortCode?.trim()) { ElMessage.error('请补全渠道、收货仓、周几、默认船名、起运港、目的港、有效期和截止天数'); return false } if (templateForm.cutoffDays === 0 && templateForm.cutoffLocalTime >= templateForm.departureTime) { ElMessage.error('同日收货截止时刻必须早于开船时间'); return false } return true } +function templatePayload() { syncTemplateDerivedFields(); return { ...templateForm, effectiveTo: templateForm.effectiveTo || null, weekdays: [...templateForm.weekdays].sort((a, b) => a - b), voyageRule: templateForm.voyageRule || null, vesselName: templateForm.vesselName || '待订舱确认', remark: templateForm.remark || null } } +function validateTemplate() { syncTemplateDerivedFields(); if (!templateForm.channelId || !templateForm.warehouseId || !templateForm.weekdays.length || !templateForm.effectiveFrom || !templateForm.departureTime || templateForm.cutoffDays < 0 || !templateForm.departurePortCode?.trim() || !templateForm.destinationPortCode?.trim()) { ElMessage.error('请补全渠道、收货仓、周几、有效期、开船时间、起运港和目的港'); return false } if (templateForm.cutoffDays === 0 && templateForm.cutoffLocalTime >= templateForm.departureTime) { ElMessage.error('同日收货截止时刻必须早于开船时间'); return false } return true } async function saveTemplate() { if (!validateTemplate()) return templateSaving.value = true @@ -283,11 +297,38 @@ async function confirmTemplateGeneration() { } finally { templateGenerateLoading.value = false } } function formatWeekdays(value: unknown) { return Array.isArray(value) ? value.map((day: unknown) => ['一', '二', '三', '四', '五', '六', '日'][Number(day) - 1]).join('、') : '—' } -async function onChannelChanged(value: string) { const channel = priceBookChannels.value.find((item) => item.channelCode === value); form.channelName = channel?.channelName || ''; form.channelId = null } -async function openCreate() { dialogMode.value = 'create'; cutoffMode.value = 'rule'; cutoffDays.value = 3; cutoffLocalTime.value = '18:00:00'; Object.assign(form, blankForm()); try { if (!priceBookChannels.value.length) { const versions = await listPlatformPriceBooks({ customerId: '0', status: 'ACTIVE' }); const latest = versions.sort((a, b) => (b.publishedAt || b.createdAt || '').localeCompare(a.publishedAt || a.createdAt || '') || b.versionNo - a.versionNo)[0]; if (latest) priceBookChannels.value = (await getPlatformPriceBook(latest.id)).channels } } catch (cause) { ElMessage.error(getApiErrorDetails(cause, '价格表渠道加载失败').message) } dialogOpen.value = true } -function openEdit(record: SailingCalendarEntry) { dialogMode.value = 'edit'; cutoffMode.value = 'custom'; cutoffDays.value = 3; cutoffLocalTime.value = record.cutoffAt.slice(11, 19) || '18:00:00'; Object.assign(form, { templateId: record.templateId ?? null, channelId: record.channelId ?? null, warehouseId: record.warehouseId, warehouseName: record.warehouseName, channelCode: record.channelCode, channelName: record.channelName, vesselName: record.vesselName, voyageNo: record.voyageNo, departurePortCode: record.departurePortCode, departurePortName: record.departurePortName || '', destinationPortCode: record.destinationPortCode, destinationPortName: record.destinationPortName || '', etdAt: record.etdAt.slice(0, 19), etaAt: record.etaAt ?? null, cutoffAt: record.cutoffAt.slice(0, 19), sourceType: record.sourceType ?? 'MANUAL', plannedCapacityCbm: record.plannedCapacityCbm ?? null, remark: record.remark || '', expectedVersion: record.version }); dialogOpen.value = true } +function onChannelChanged(value: string) { + const channel = channels.value.find((item) => item.channelCode === value) + form.channelName = channel?.channelName || '' + form.channelId = channel?.id || null + form.departurePortCode = channel?.defaultDeparturePortCode || '' + form.destinationPortCode = channel?.defaultDestinationPortCode || '' + form.departurePortName = '' + form.destinationPortName = '' + form.vesselName = null + form.voyageNo = null + cutoffDays.value = channel?.defaultCutoffDays ?? 3 + syncCutoffFromRule() + routeExpanded.value = form.departurePortCode && form.destinationPortCode ? [] : ['route'] +} +async function openCreate() { + dialogMode.value = 'create' + cutoffMode.value = 'rule' + cutoffDays.value = 3 + cutoffLocalTime.value = '18:00:00' + Object.assign(form, blankForm(), { expectedVersion: undefined }) + routeExpanded.value = [] + dialogOpen.value = true + formOptionsLoading.value = true + try { + const [rows] = await Promise.all([listSailingChannels(), ensureWarehouseOptions()]) + channels.value = rows + } catch (cause) { ElMessage.error(getApiErrorDetails(cause, '排船资料加载失败,请关闭后重试').message) } + finally { formOptionsLoading.value = false } +} +async function openEdit(record: SailingCalendarEntry) { try { const [rows] = await Promise.all([listSailingChannels(), ensureWarehouseOptions()]); channels.value = rows } catch (cause) { ElMessage.error(getApiErrorDetails(cause, '排船资料加载失败').message); return } routeExpanded.value = []; dialogMode.value = 'edit'; cutoffMode.value = 'custom'; cutoffDays.value = 3; cutoffLocalTime.value = record.cutoffAt.slice(11, 19) || '18:00:00'; Object.assign(form, { templateId: record.templateId ?? null, channelId: record.channelId ?? null, warehouseId: record.warehouseId, warehouseName: record.warehouseName, channelCode: record.channelCode, channelName: record.channelName, vesselName: record.vesselName, voyageNo: record.voyageNo, departurePortCode: record.departurePortCode, departurePortName: record.departurePortName || '', destinationPortCode: record.destinationPortCode, destinationPortName: record.destinationPortName || '', etdAt: record.etdAt.slice(0, 19), etaAt: record.etaAt ?? null, cutoffAt: record.cutoffAt.slice(0, 19), sourceType: record.sourceType ?? 'MANUAL', plannedCapacityCbm: record.plannedCapacityCbm ?? null, remark: record.remark || '', expectedVersion: record.version }); dialogOpen.value = true } function onEtdChanged() { if (dialogMode.value === 'create' || cutoffMode.value === 'rule') syncCutoffFromRule() } -function validateForm() { const required: Array<[keyof SailingSaveRequest, string]> = [['warehouseId', '收货仓'], ['channelCode', '价格表渠道'], ['departurePortCode', '起运港'], ['destinationPortCode', '目的港'], ['etdAt', '开船时间'], ['cutoffAt', '截止时间']]; const missing = required.find(([key]) => !String(form[key] || '').trim()); if (missing) { ElMessage.error(`请填写${missing[1]}`); return false } if (new Date(form.cutoffAt).getTime() >= new Date(form.etdAt).getTime()) { ElMessage.error('收货截止时间必须早于预计开船时间'); return false } return true } +function validateForm() { const required: Array<[keyof SailingSaveRequest, string]> = [['warehouseId', '收货仓'], ['channelCode', '渠道'], ['departurePortCode', '起运港'], ['destinationPortCode', '目的港'], ['etdAt', '开船时间'], ['cutoffAt', '截止时间']]; const missing = required.find(([key]) => !String(form[key] || '').trim()); if (missing) { ElMessage.error(`请填写${missing[1]}`); return false } if (new Date(form.cutoffAt).getTime() >= new Date(form.etdAt).getTime()) { ElMessage.error('收货截止时间必须早于预计开船时间'); return false } return true } async function save() { if (!validateForm()) return saving.value = true @@ -380,15 +421,14 @@ onMounted(() => void load()) - 渠道名称和编码来自已解析的船期价格表。
按开船前 N 天自定义时间
提前天数2 天3 天4 天
截止时刻
建议截止{{ dateTime(form.cutoffAt) }}按开船本地日期倒推自然日
+ 选择已有渠道,自动带入排船默认资料。
{{ form.vesselName || '待订舱确认' }} / {{ form.voyageNo || '待订舱确认' }}
未确认船名、航次时可先排船,显示为待订舱确认。
按开船前 N 天自定义时间
提前天数2 天3 天4 天
截止时刻
建议截止{{ dateTime(form.cutoffAt) }}按开船本地日期倒推自然日
-
停用渠道仍保留历史船期,但不能新建模板或生成未来船期。新增渠道
按周几生成前先预览,生成操作使用服务端幂等键。新增周模板
- - 周{{ ['一', '二', '三', '四', '五', '六', '日'][day - 1] }}
2 天3 天4 天
快捷生成范围:未来 4 周未来 8 周未来 12 周

生成预览({{ templatePreview.length }} 条)

+
查看已有船期渠道及默认收货规则。
设置每周开船日和收货截止,预览后批量生成;重复日期自动跳过。新增周模板
+ 周{{ ['一', '二', '三', '四', '五', '六', '日'][day - 1] }}
2 天3 天4 天
快捷生成范围:未来 4 周未来 8 周未来 12 周

生成预览({{ templatePreview.length }} 条)

From 365a7f7bc44544580fe35ec89d33bb67f438fbe1 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 28 Sep 2026 17:11:10 +0100 Subject: [PATCH 03/17] fix(admin): type sailing warehouse change handler --- src/modules/sailing-calendar/pages/SailingCalendarView.vue | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/modules/sailing-calendar/pages/SailingCalendarView.vue b/src/modules/sailing-calendar/pages/SailingCalendarView.vue index 40d28e32..5385c88d 100644 --- a/src/modules/sailing-calendar/pages/SailingCalendarView.vue +++ b/src/modules/sailing-calendar/pages/SailingCalendarView.vue @@ -235,6 +235,7 @@ function onTemplateChannelChanged(id: string) { templateForm.vesselName = '待订舱确认' } function onTemplateWarehouseChanged(id: string) { applyWarehouse(id, templateForm) } +function onFormWarehouseChanged(id: string) { applyWarehouse(id, form) } async function openTemplateCreate() { await ensureWarehouseOptions() editingTemplateId.value = '' @@ -421,7 +422,7 @@ onMounted(() => void load()) - 选择已有渠道,自动带入排船默认资料。
{{ form.vesselName || '待订舱确认' }} / {{ form.voyageNo || '待订舱确认' }}
未确认船名、航次时可先排船,显示为待订舱确认。
按开船前 N 天自定义时间
提前天数2 天3 天4 天
截止时刻
建议截止{{ dateTime(form.cutoffAt) }}按开船本地日期倒推自然日
+ 选择已有渠道,自动带入排船默认资料。
{{ form.vesselName || '待订舱确认' }} / {{ form.voyageNo || '待订舱确认' }}
未确认船名、航次时可先排船,显示为待订舱确认。
按开船前 N 天自定义时间
提前天数2 天3 天4 天
截止时刻
建议截止{{ dateTime(form.cutoffAt) }}按开船本地日期倒推自然日
查看已有船期渠道及默认收货规则。
设置每周开船日和收货截止,预览后批量生成;重复日期自动跳过。新增周模板
From d7489a20be7ca00f6c9de4417316cd1395e724c2 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 28 Sep 2026 17:11:47 +0100 Subject: [PATCH 04/17] fix(admin): type sailing warehouse change handler --- src/modules/sailing-calendar/pages/SailingCalendarView.vue | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/modules/sailing-calendar/pages/SailingCalendarView.vue b/src/modules/sailing-calendar/pages/SailingCalendarView.vue index 40d28e32..5385c88d 100644 --- a/src/modules/sailing-calendar/pages/SailingCalendarView.vue +++ b/src/modules/sailing-calendar/pages/SailingCalendarView.vue @@ -235,6 +235,7 @@ function onTemplateChannelChanged(id: string) { templateForm.vesselName = '待订舱确认' } function onTemplateWarehouseChanged(id: string) { applyWarehouse(id, templateForm) } +function onFormWarehouseChanged(id: string) { applyWarehouse(id, form) } async function openTemplateCreate() { await ensureWarehouseOptions() editingTemplateId.value = '' @@ -421,7 +422,7 @@ onMounted(() => void load()) - 选择已有渠道,自动带入排船默认资料。
{{ form.vesselName || '待订舱确认' }} / {{ form.voyageNo || '待订舱确认' }}
未确认船名、航次时可先排船,显示为待订舱确认。
按开船前 N 天自定义时间
提前天数2 天3 天4 天
截止时刻
建议截止{{ dateTime(form.cutoffAt) }}按开船本地日期倒推自然日
+ 选择已有渠道,自动带入排船默认资料。
{{ form.vesselName || '待订舱确认' }} / {{ form.voyageNo || '待订舱确认' }}
未确认船名、航次时可先排船,显示为待订舱确认。
按开船前 N 天自定义时间
提前天数2 天3 天4 天
截止时刻
建议截止{{ dateTime(form.cutoffAt) }}按开船本地日期倒推自然日
查看已有船期渠道及默认收货规则。
设置每周开船日和收货截止,预览后批量生成;重复日期自动跳过。新增周模板
From a903020610dc8f86252f9ac8b2bc2ee79a11795e Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 29 Sep 2026 03:48:49 +0100 Subject: [PATCH 05/17] fix(admin): compact sailing calendar toolbar --- .../sailing-calendar/pages/SailingCalendarView.vue | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/modules/sailing-calendar/pages/SailingCalendarView.vue b/src/modules/sailing-calendar/pages/SailingCalendarView.vue index 5385c88d..1eb84953 100644 --- a/src/modules/sailing-calendar/pages/SailingCalendarView.vue +++ b/src/modules/sailing-calendar/pages/SailingCalendarView.vue @@ -396,19 +396,19 @@ onMounted(() => void load())
+
+ 重置 + 渠道与周模板 + 按周批量生成 + 新增单个船次 +
-
-
- 重置 - 渠道与周模板 - 按周批量生成 - 新增单个船次
@@ -431,5 +431,5 @@ onMounted(() => void load()) From ab5a64c6f733726c62c10f812f3b696fe6b250e5 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 29 Sep 2026 03:49:09 +0100 Subject: [PATCH 06/17] fix(admin): compact sailing calendar toolbar --- .../sailing-calendar/pages/SailingCalendarView.vue | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/modules/sailing-calendar/pages/SailingCalendarView.vue b/src/modules/sailing-calendar/pages/SailingCalendarView.vue index 5385c88d..1eb84953 100644 --- a/src/modules/sailing-calendar/pages/SailingCalendarView.vue +++ b/src/modules/sailing-calendar/pages/SailingCalendarView.vue @@ -396,19 +396,19 @@ onMounted(() => void load())
+
+ 重置 + 渠道与周模板 + 按周批量生成 + 新增单个船次 +
-
-
- 重置 - 渠道与周模板 - 按周批量生成 - 新增单个船次
@@ -431,5 +431,5 @@ onMounted(() => void load()) From 5ad8c5c462544b88811ac1ca43cc2cee32337241 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 29 Sep 2026 03:52:40 +0100 Subject: [PATCH 07/17] fix(admin): improve calendar toolbar breakpoint --- src/modules/sailing-calendar/pages/SailingCalendarView.vue | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/modules/sailing-calendar/pages/SailingCalendarView.vue b/src/modules/sailing-calendar/pages/SailingCalendarView.vue index 1eb84953..3e955347 100644 --- a/src/modules/sailing-calendar/pages/SailingCalendarView.vue +++ b/src/modules/sailing-calendar/pages/SailingCalendarView.vue @@ -431,5 +431,7 @@ onMounted(() => void load()) + +\n \ No newline at end of file From f89a04ad2f0277f8631b621b24c7a6adaf9ea8de Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 29 Sep 2026 03:52:50 +0100 Subject: [PATCH 08/17] fix(admin): improve calendar toolbar breakpoint --- src/modules/sailing-calendar/pages/SailingCalendarView.vue | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/modules/sailing-calendar/pages/SailingCalendarView.vue b/src/modules/sailing-calendar/pages/SailingCalendarView.vue index 1eb84953..3e955347 100644 --- a/src/modules/sailing-calendar/pages/SailingCalendarView.vue +++ b/src/modules/sailing-calendar/pages/SailingCalendarView.vue @@ -431,5 +431,7 @@ onMounted(() => void load()) + +\n \ No newline at end of file From ea35a12f8b6e9dc68381ceba98ce644c83cbf7c1 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 29 Sep 2026 03:58:14 +0100 Subject: [PATCH 09/17] fix(admin): separate sailing search and actions --- .../pages/SailingCalendarView.vue | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/src/modules/sailing-calendar/pages/SailingCalendarView.vue b/src/modules/sailing-calendar/pages/SailingCalendarView.vue index 3e955347..0babdce1 100644 --- a/src/modules/sailing-calendar/pages/SailingCalendarView.vue +++ b/src/modules/sailing-calendar/pages/SailingCalendarView.vue @@ -396,12 +396,6 @@ onMounted(() => void load())
-
- 重置 - 渠道与周模板 - 按周批量生成 - 新增单个船次 -
@@ -410,6 +404,12 @@ onMounted(() => void load())
+
+ 重置 + 渠道与周模板 + 按周批量生成 + 新增单个船次 +
@@ -431,7 +431,5 @@ onMounted(() => void load()) - -\n \ No newline at end of file From 8fe256372ce87c77d7dda7d02334ec324427b496 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 29 Sep 2026 03:58:24 +0100 Subject: [PATCH 10/17] fix(admin): separate sailing search and actions --- .../pages/SailingCalendarView.vue | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/src/modules/sailing-calendar/pages/SailingCalendarView.vue b/src/modules/sailing-calendar/pages/SailingCalendarView.vue index 3e955347..0babdce1 100644 --- a/src/modules/sailing-calendar/pages/SailingCalendarView.vue +++ b/src/modules/sailing-calendar/pages/SailingCalendarView.vue @@ -396,12 +396,6 @@ onMounted(() => void load())
-
- 重置 - 渠道与周模板 - 按周批量生成 - 新增单个船次 -
@@ -410,6 +404,12 @@ onMounted(() => void load())
+
+ 重置 + 渠道与周模板 + 按周批量生成 + 新增单个船次 +
@@ -431,7 +431,5 @@ onMounted(() => void load()) - -\n \ No newline at end of file From 5279fc774a8355b0c0c980bdb0cf785726818d69 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 29 Sep 2026 04:17:43 +0100 Subject: [PATCH 11/17] fix(admin): load sailing channels from active price book --- .../api/platformSailingCalendar.ts | 4 +- .../pages/SailingCalendarView.vue | 39 +++++++++++++------ 2 files changed, 29 insertions(+), 14 deletions(-) diff --git a/src/modules/sailing-calendar/api/platformSailingCalendar.ts b/src/modules/sailing-calendar/api/platformSailingCalendar.ts index c120a55f..f0d3d1c5 100644 --- a/src/modules/sailing-calendar/api/platformSailingCalendar.ts +++ b/src/modules/sailing-calendar/api/platformSailingCalendar.ts @@ -145,8 +145,8 @@ const exceptionPath = '/platform/v1/sailing-assignments/exceptions' function channel(raw: SailingChannelResource): SailingChannelResource { return { ...raw, id: String(raw.id), defaultCutoffDays: number(raw.defaultCutoffDays), version: number(raw.version) } } function template(raw: SailingTemplateResource): SailingTemplateResource { return { ...raw, id: String(raw.id), channelId: String(raw.channelId), warehouseId: String(raw.warehouseId), weekdays: (raw.weekdays || []).map((value) => number(value)), cutoffDays: number(raw.cutoffDays), version: number(raw.version) } } -export async function listSailingChannels() { - const response = await http.get, ApiEnvelope>(channelPath) +export async function listSailingChannels(warehouseId?: string) { + const response = await http.get, ApiEnvelope>(channelPath, { params: warehouseId ? { warehouseId } : undefined }) const result = unwrapApiResponse(response) as SailingChannelResource[] | { records?: SailingChannelResource[] } | null return (Array.isArray(result) ? result : result?.records || []).map(channel) } diff --git a/src/modules/sailing-calendar/pages/SailingCalendarView.vue b/src/modules/sailing-calendar/pages/SailingCalendarView.vue index 0babdce1..a9bfb8db 100644 --- a/src/modules/sailing-calendar/pages/SailingCalendarView.vue +++ b/src/modules/sailing-calendar/pages/SailingCalendarView.vue @@ -234,23 +234,38 @@ function onTemplateChannelChanged(id: string) { templateForm.destinationPortCode = channel.defaultDestinationPortCode || '' templateForm.vesselName = '待订舱确认' } -function onTemplateWarehouseChanged(id: string) { applyWarehouse(id, templateForm) } -function onFormWarehouseChanged(id: string) { applyWarehouse(id, form) } +async function loadChannelsForWarehouse(warehouseId?: string) { + channels.value = warehouseId ? await listSailingChannels(warehouseId) : [] +} +async function onTemplateWarehouseChanged(id: string) { + applyWarehouse(id, templateForm) + templateForm.channelId = '' + templateForm.departurePortCode = '' + templateForm.destinationPortCode = '' + await loadChannelsForWarehouse(id) +} +async function onFormWarehouseChanged(id: string) { + applyWarehouse(id, form) + form.channelCode = '' + form.channelId = null + await loadChannelsForWarehouse(id) +} async function openTemplateCreate() { await ensureWarehouseOptions() editingTemplateId.value = '' - const activeChannel = channels.value.find((item) => item.status === 'ACTIVE') - Object.assign(templateForm, { templateName: '', channelId: activeChannel?.id || '', warehouseId: '', warehouseName: '', weekdays: [1], effectiveFrom: formatDate(today), effectiveTo: '', departureTime: '10:00:00', cutoffDays: activeChannel?.defaultCutoffDays ?? 3, cutoffLocalTime: '18:00:00', voyageRule: '', vesselName: '待订舱确认', departurePortCode: activeChannel?.defaultDeparturePortCode || '', destinationPortCode: activeChannel?.defaultDestinationPortCode || '', remark: '', expectedVersion: undefined }) + Object.assign(templateForm, { templateName: '', channelId: '', warehouseId: '', warehouseName: '', weekdays: [1], effectiveFrom: formatDate(today), effectiveTo: '', departureTime: '10:00:00', cutoffDays: 3, cutoffLocalTime: '18:00:00', voyageRule: '', vesselName: '待订舱确认', departurePortCode: '', destinationPortCode: '', remark: '', expectedVersion: undefined }) + channels.value = [] templatePreview.value = [] resetGenerationRange(templateForm.effectiveFrom) templateDialogOpen.value = true } -function openTemplateEdit(row: unknown, options: { preserveGenerationRange?: boolean } = {}) { +async function openTemplateEdit(row: unknown, options: { preserveGenerationRange?: boolean } = {}) { const templateRow = row as SailingTemplateResource editingTemplateId.value = templateRow.id Object.assign(templateForm, { templateName: templateRow.templateName || '', channelId: templateRow.channelId, warehouseId: templateRow.warehouseId, warehouseName: templateRow.warehouseName || '', weekdays: [...templateRow.weekdays], effectiveFrom: templateRow.effectiveFrom, effectiveTo: templateRow.effectiveTo || '', departureTime: templateRow.departureTime, cutoffDays: templateRow.cutoffDays, cutoffLocalTime: templateRow.cutoffLocalTime, voyageRule: templateRow.voyageRule || '', vesselName: templateRow.vesselName || '', departurePortCode: templateRow.departurePortCode || '', destinationPortCode: templateRow.destinationPortCode || '', remark: templateRow.remark || '', expectedVersion: templateRow.version }) templatePreview.value = [] if (!options.preserveGenerationRange) resetGenerationRange(templateForm.effectiveFrom) + try { await loadChannelsForWarehouse(templateForm.warehouseId) } catch (cause) { ElMessage.error(getApiErrorDetails(cause, '渠道加载失败').message); return } templateDialogOpen.value = true } function templatePayload() { syncTemplateDerivedFields(); return { ...templateForm, effectiveTo: templateForm.effectiveTo || null, weekdays: [...templateForm.weekdays].sort((a, b) => a - b), voyageRule: templateForm.voyageRule || null, vesselName: templateForm.vesselName || '待订舱确认', remark: templateForm.remark || null } } @@ -318,16 +333,16 @@ async function openCreate() { cutoffDays.value = 3 cutoffLocalTime.value = '18:00:00' Object.assign(form, blankForm(), { expectedVersion: undefined }) + channels.value = [] routeExpanded.value = [] dialogOpen.value = true formOptionsLoading.value = true try { - const [rows] = await Promise.all([listSailingChannels(), ensureWarehouseOptions()]) - channels.value = rows + await ensureWarehouseOptions() } catch (cause) { ElMessage.error(getApiErrorDetails(cause, '排船资料加载失败,请关闭后重试').message) } finally { formOptionsLoading.value = false } } -async function openEdit(record: SailingCalendarEntry) { try { const [rows] = await Promise.all([listSailingChannels(), ensureWarehouseOptions()]); channels.value = rows } catch (cause) { ElMessage.error(getApiErrorDetails(cause, '排船资料加载失败').message); return } routeExpanded.value = []; dialogMode.value = 'edit'; cutoffMode.value = 'custom'; cutoffDays.value = 3; cutoffLocalTime.value = record.cutoffAt.slice(11, 19) || '18:00:00'; Object.assign(form, { templateId: record.templateId ?? null, channelId: record.channelId ?? null, warehouseId: record.warehouseId, warehouseName: record.warehouseName, channelCode: record.channelCode, channelName: record.channelName, vesselName: record.vesselName, voyageNo: record.voyageNo, departurePortCode: record.departurePortCode, departurePortName: record.departurePortName || '', destinationPortCode: record.destinationPortCode, destinationPortName: record.destinationPortName || '', etdAt: record.etdAt.slice(0, 19), etaAt: record.etaAt ?? null, cutoffAt: record.cutoffAt.slice(0, 19), sourceType: record.sourceType ?? 'MANUAL', plannedCapacityCbm: record.plannedCapacityCbm ?? null, remark: record.remark || '', expectedVersion: record.version }); dialogOpen.value = true } +async function openEdit(record: SailingCalendarEntry) { try { await ensureWarehouseOptions(); await loadChannelsForWarehouse(record.warehouseId) } catch (cause) { ElMessage.error(getApiErrorDetails(cause, '排船资料加载失败').message); return } routeExpanded.value = []; dialogMode.value = 'edit'; cutoffMode.value = 'custom'; cutoffDays.value = 3; cutoffLocalTime.value = record.cutoffAt.slice(11, 19) || '18:00:00'; Object.assign(form, { templateId: record.templateId ?? null, channelId: record.channelId ?? null, warehouseId: record.warehouseId, warehouseName: record.warehouseName, channelCode: record.channelCode, channelName: record.channelName, vesselName: record.vesselName, voyageNo: record.voyageNo, departurePortCode: record.departurePortCode, departurePortName: record.departurePortName || '', destinationPortCode: record.destinationPortCode, destinationPortName: record.destinationPortName || '', etdAt: record.etdAt.slice(0, 19), etaAt: record.etaAt ?? null, cutoffAt: record.cutoffAt.slice(0, 19), sourceType: record.sourceType ?? 'MANUAL', plannedCapacityCbm: record.plannedCapacityCbm ?? null, remark: record.remark || '', expectedVersion: record.version }); dialogOpen.value = true } function onEtdChanged() { if (dialogMode.value === 'create' || cutoffMode.value === 'rule') syncCutoffFromRule() } function validateForm() { const required: Array<[keyof SailingSaveRequest, string]> = [['warehouseId', '收货仓'], ['channelCode', '渠道'], ['departurePortCode', '起运港'], ['destinationPortCode', '目的港'], ['etdAt', '开船时间'], ['cutoffAt', '截止时间']]; const missing = required.find(([key]) => !String(form[key] || '').trim()); if (missing) { ElMessage.error(`请填写${missing[1]}`); return false } if (new Date(form.cutoffAt).getTime() >= new Date(form.etdAt).getTime()) { ElMessage.error('收货截止时间必须早于预计开船时间'); return false } return true } async function save() { @@ -422,14 +437,14 @@ onMounted(() => void load()) - 选择已有渠道,自动带入排船默认资料。
{{ form.vesselName || '待订舱确认' }} / {{ form.voyageNo || '待订舱确认' }}
未确认船名、航次时可先排船,显示为待订舱确认。
按开船前 N 天自定义时间
提前天数2 天3 天4 天
截止时刻
建议截止{{ dateTime(form.cutoffAt) }}按开船本地日期倒推自然日
+ 选择已有渠道,自动带入排船默认资料。
{{ form.vesselName || '待订舱确认' }} / {{ form.voyageNo || '待订舱确认' }}
未确认船名、航次时可先排船,显示为待订舱确认。
按开船前 N 天自定义时间
提前天数2 天3 天4 天
截止时刻
建议截止{{ dateTime(form.cutoffAt) }}按开船本地日期倒推自然日
-
查看已有船期渠道及默认收货规则。
设置每周开船日和收货截止,预览后批量生成;重复日期自动跳过。新增周模板
- 周{{ ['一', '二', '三', '四', '五', '六', '日'][day - 1] }}
2 天3 天4 天
快捷生成范围:未来 4 周未来 8 周未来 12 周

生成预览({{ templatePreview.length }} 条)

+
渠道只读展示当前生效的通用船期价格表;周模板按收货仓筛选可用渠道。
设置每周开船日和收货截止,预览后批量生成;重复日期自动跳过。新增周模板
+ 渠道来自当前生效的通用船期价格表,并按收货仓的有效报价筛选。周{{ ['一', '二', '三', '四', '五', '六', '日'][day - 1] }}
2 天3 天4 天
快捷生成范围:未来 4 周未来 8 周未来 12 周

生成预览({{ templatePreview.length }} 条)

From 584b74e5e16f777be4c03818c8f1592a99c08895 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 29 Sep 2026 04:18:46 +0100 Subject: [PATCH 12/17] test(admin): active price book sailing channels --- .../overseas-warehouse/api/lastMileOrders.ts | 9 ----- .../overseas-warehouse/api/relabelOrders.ts | 9 ----- .../api/warehouseTransfers.ts | 16 -------- .../pages/LastMileOrdersView.behavior.test.ts | 15 ------- .../pages/LastMileOrdersView.vue | 35 ++--------------- .../pages/RelabelOrdersView.vue | 15 ++----- .../pages/prototypeContract.test.ts | 2 +- src/modules/overseas-warehouse/permissions.ts | 1 - src/modules/overseas-warehouse/types.ts | 3 -- .../api/platformSailingCalendar.ts | 4 +- .../pages/SailingCalendarView.vue | 39 +++++++++++++------ 11 files changed, 36 insertions(+), 112 deletions(-) diff --git a/src/modules/overseas-warehouse/api/lastMileOrders.ts b/src/modules/overseas-warehouse/api/lastMileOrders.ts index 269cc51d..d5136bee 100644 --- a/src/modules/overseas-warehouse/api/lastMileOrders.ts +++ b/src/modules/overseas-warehouse/api/lastMileOrders.ts @@ -383,15 +383,6 @@ export async function reviewDirectRelabelOrder(orderId: string, customerId: stri return unwrapApiResponse(response) } -export async function reviewCargoDiscardOrder(discardNo: string, customerId: string, decision: 'APPROVE' | 'REJECT', remark?: string) { - const response = await overseasHttp.post, ApiEnvelope>( - `/platform/v1/last-leg/cargo-discards/${encodeURIComponent(discardNo)}/review`, - { decision, remark: remark?.trim() || undefined }, - { params: { customerId } }, - ) - return unwrapApiResponse(response) -} - export async function downloadLastMileOrders(query: Partial) { const { page: _page, size: _size, ...filters } = query const params = Object.fromEntries( diff --git a/src/modules/overseas-warehouse/api/relabelOrders.ts b/src/modules/overseas-warehouse/api/relabelOrders.ts index bafbfd2b..a79aa178 100644 --- a/src/modules/overseas-warehouse/api/relabelOrders.ts +++ b/src/modules/overseas-warehouse/api/relabelOrders.ts @@ -9,12 +9,6 @@ export interface RelabelOrderQuery { relabelNo?: string skuCode?: string status?: string - reviewedFrom?: string - reviewedTo?: string - measuredFrom?: string - measuredTo?: string - outboundFrom?: string - outboundTo?: string } export interface CreateRelabelItemPayload { @@ -88,9 +82,6 @@ export interface RelabelSummaryDto { customerConfirmedAt?: string | null reviewChannel?: 'TAIL_ORDER' | 'RELABEL_ORDER' | null reviewRemark?: string | null - reviewedAt?: string | null - measuredAt?: string | null - outboundAt?: string | null createdAt?: string | null } diff --git a/src/modules/overseas-warehouse/api/warehouseTransfers.ts b/src/modules/overseas-warehouse/api/warehouseTransfers.ts index 57b72704..10407f11 100644 --- a/src/modules/overseas-warehouse/api/warehouseTransfers.ts +++ b/src/modules/overseas-warehouse/api/warehouseTransfers.ts @@ -10,12 +10,6 @@ export interface WarehouseTransferQuery { fbaWarehouseCode?: string status?: string needRelabel?: boolean - reviewedFrom?: string - reviewedTo?: string - measuredFrom?: string - measuredTo?: string - outboundFrom?: string - outboundTo?: string } export interface WarehouseTransferSummaryDto { @@ -32,12 +26,6 @@ export interface WarehouseTransferSummaryDto { relabelNo?: string | null status: string estimatedArrivalAt?: string | null - estimatedShipAt?: string | null - actualShipAt?: string | null - reviewedAt?: string | null - measuredAt?: string | null - outboundAt?: string | null - relabelCompletedAt?: string | null createdAt?: string | null } @@ -96,10 +84,6 @@ export interface WarehouseTransferDetailDto extends WarehouseTransferSummaryDto relabelBindings: WarehouseTransferRelabelBindingDto[] customerConfirmedAt?: string | null actualShipAt?: string | null - reviewedAt?: string | null - measuredAt?: string | null - outboundAt?: string | null - relabelCompletedAt?: string | null } export async function listWarehouseTransfers(customerId: string, query: WarehouseTransferQuery) { diff --git a/src/modules/overseas-warehouse/pages/LastMileOrdersView.behavior.test.ts b/src/modules/overseas-warehouse/pages/LastMileOrdersView.behavior.test.ts index ed74e72a..ec295788 100644 --- a/src/modules/overseas-warehouse/pages/LastMileOrdersView.behavior.test.ts +++ b/src/modules/overseas-warehouse/pages/LastMileOrdersView.behavior.test.ts @@ -257,21 +257,6 @@ describe('LastMileOrdersView pending order and container conversion', () => { expect((button(wrapper, '生成入库计划')!.element as HTMLButtonElement).disabled).toBe(true) }) - it.each(['WAREHOUSE_TRANSFER', 'RELABEL_APPLICATION', 'RETURN_RELABEL'])( - 'does not offer an inbound plan for %s orders', - async (orderType) => { - canAccess.mockReturnValue(true) - vi.mocked(listLastMileOrders).mockResolvedValue({ records: [{ - id: `order-${orderType}`, customerId, orderNo: `LM-${orderType}`, currentStatus: 'PENDING_REVIEW', orderType, - }], page: 1, size: 10, total: 1, pages: 1 }) - - const wrapper = await mountView() - const row = (wrapper.findComponent(DataTable).props('data') as object[])[0] - expect(wrapper.get('.el-table__body-wrapper .row-actions').text()).not.toContain('生成入库计划') - expect((wrapper.vm as unknown as { canGenerateInboundPlan: (source: object) => boolean }).canGenerateInboundPlan(row)).toBe(false) - }, - ) - it('keeps the original pending order table and does not render a view switch or container table', async () => { const wrapper = await openPending() expect(wrapper.text()).toContain('尾程单号') diff --git a/src/modules/overseas-warehouse/pages/LastMileOrdersView.vue b/src/modules/overseas-warehouse/pages/LastMileOrdersView.vue index 18be6021..a44ce948 100644 --- a/src/modules/overseas-warehouse/pages/LastMileOrdersView.vue +++ b/src/modules/overseas-warehouse/pages/LastMileOrdersView.vue @@ -15,7 +15,7 @@ import LoadErrorState from '@/shared/components/LoadErrorState.vue' import OperationErrorToast from '@/shared/components/OperationErrorToast.vue' import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue' import RowActions, { type RowActionItem } from '@/shared/components/RowActions.vue' -import { changeContainerReceivingWarehouse, convertLastMileContainerToInboundPlans, createManualInboundPlan, downloadLastMileOrders, getContainerInboundPlanPreview, getContainerPlanProgress, getLastMileOrderManualNodes, listContainerPlanProgress, listLastMileOrders, reviewCargoDiscardOrder, reviewDirectRelabelOrder, reviewReturnOrder, transferLastMileOrders, updateLastMileOrderHold, updateLastMileOrderManualNodes, type ContainerInboundPlanPreviewDto, type ContainerInboundPlanPreviewGroupDto, type ContainerInboundPlanPreviewOrderDto, type ContainerPlanProgressDetailDto, type ContainerPlanProgressSummaryDto, type ManualNodeCode } from '../api/lastMileOrders' +import { changeContainerReceivingWarehouse, convertLastMileContainerToInboundPlans, createManualInboundPlan, downloadLastMileOrders, getContainerInboundPlanPreview, getContainerPlanProgress, getLastMileOrderManualNodes, listContainerPlanProgress, listLastMileOrders, reviewDirectRelabelOrder, reviewReturnOrder, transferLastMileOrders, updateLastMileOrderHold, updateLastMileOrderManualNodes, type ContainerInboundPlanPreviewDto, type ContainerInboundPlanPreviewGroupDto, type ContainerInboundPlanPreviewOrderDto, type ContainerPlanProgressDetailDto, type ContainerPlanProgressSummaryDto, type ManualNodeCode } from '../api/lastMileOrders' import OrderReferenceText from '../components/OrderReferenceText.vue' import WarehouseTransferReviewDialog from '../components/WarehouseTransferReviewDialog.vue' import { confirmRelabelCompletion, getRelabelReviewTask, returnRelabelForRework } from '../api/relabelOrders' @@ -446,12 +446,6 @@ function isRejectedReturn(source: object) { } function hasSelectedRejectedReturn() { return selectedRows.value.some(isRejectedReturn) } function hasSelectedInboundPlan() { return selectedRows.value.some(hasInboundPlan) } -const inboundPlanExcludedOrderTypes = new Set(['WAREHOUSE_TRANSFER', 'RELABEL_APPLICATION', 'RETURN_RELABEL']) -function canGenerateInboundPlan(row: LastMileOrderListItem) { - // Older list responses expose the same value as orderType; keep the action - // gate correct while the API rollout is mixed across environments. - return !inboundPlanExcludedOrderTypes.has(String(row.orderTypeCode || row.orderType || '')) -} async function loadTransferWarehouseOptions() { transferWarehouseLoading.value = true transferWarehouseError.value = null @@ -506,7 +500,6 @@ function idempotencyKey(prefix: string) { function openInboundPlan(source?: object) { const target = source ? source as LastMileOrderListItem : (selectedRows.value.length === 1 ? selectedRows.value[0] : null) if (!target) { ElMessage.warning('生成入库计划时请选择 1 条尾程订单'); return } - if (!canGenerateInboundPlan(target)) { ElMessage.warning('当前业务单不需要生成入库计划'); return } if (isRejectedReturn(target)) { ElMessage.warning('已驳回的退货预报只能查看详情'); return } if (hasInboundPlan(target)) { ElMessage.warning('当前订单已提交入库计划,不能重复生成'); return } if (target.detained) { ElMessage.warning('订单处于暂扣状态,解除暂扣后才能生成入库计划'); return } @@ -832,26 +825,6 @@ async function reviewDirectRelabel(source: object) { } else if (error !== 'close') ElMessage.error(getApiErrorDetails(error, '贴标单审核失败').message) } } -async function reviewCargoDiscard(source: object) { - const row = source as LastMileOrderListItem - const discardNo = row.orderNo - if (!discardNo || discardNo === '—' || !row.customerId) return - try { - await ElMessageBox.confirm('审核通过后,货物废弃单将推送到 PDA,仓库按托盘填写实际废弃数量。', '货物废弃审核', { confirmButtonText: '审核通过', cancelButtonText: '驳回', distinguishCancelAndClose: true }) - await reviewCargoDiscardOrder(discardNo, row.customerId, 'APPROVE') - ElMessage.success('货物废弃单已审核通过,已推送 PDA 废弃出库作业') - await load() - } catch (error) { - if (error === 'cancel') { - try { - const { value } = await ElMessageBox.prompt('请填写驳回原因', '驳回货物废弃单', { inputType: 'textarea', inputValidator: (input) => Boolean(input?.trim()) || '请填写驳回原因', confirmButtonText: '确认驳回', cancelButtonText: '取消' }) - await reviewCargoDiscardOrder(discardNo, row.customerId, 'REJECT', value) - ElMessage.success('货物废弃单已驳回') - await load() - } catch (rejectError) { if (rejectError !== 'cancel' && rejectError !== 'close') ElMessage.error(getApiErrorDetails(rejectError, '货物废弃审核失败').message) } - } else if (error !== 'close') ElMessage.error(getApiErrorDetails(error, '货物废弃审核失败').message) - } -} function canConfirmRelabel(row: { relabelStatusCode?: string }) { return row.relabelStatusCode === 'CUSTOMER_CONFIRM_PENDING' } function canReworkRelabel(row: { relabelStatusCode?: string }) { return ['CUSTOMER_CONFIRM_PENDING', 'PROCESSING'].includes(row.relabelStatusCode ?? '') } function hasRelabelActions(row: LastMileOrderListItem) { @@ -878,9 +851,8 @@ function lastMileRowActions(row: LastMileOrderListItem): RowActionItem[] { { key: 'detail', label: '查看', priority: 500 }, { key: 'review-direct-relabel', label: '审核', priority: 470, tone: 'warning', visible: access.can(permission.lastMileOrderTransfer) && row.orderTypeCode === 'RELABEL_APPLICATION' && row.currentStatusCode === 'PENDING_REVIEW' }, { key: 'review-return', label: '审核', priority: 460, tone: 'warning', visible: access.can(permission.returnOrderReview) && row.orderTypeCode === 'RETURN_RELABEL' && row.currentStatusCode === 'PENDING_REVIEW' }, - { key: 'review-cargo-discard', label: '审核', priority: 455, tone: 'warning', visible: (access.can(permission.cargoDiscardApprove) || access.can(permission.warehouseTransferApprove)) && row.orderTypeCode === 'CARGO_DISCARD' && row.currentStatusCode === 'PENDING_REVIEW' }, { key: 'review', label: '审核', priority: 450, tone: 'warning', visible: access.can(permission.warehouseTransferApprove) && row.orderTypeCode === 'WAREHOUSE_TRANSFER' && row.currentStatusCode === 'PENDING_REVIEW' }, - { key: 'inbound-plan', label: '生成入库计划', priority: 400, visible: access.can(permission.inboundOrderCreate) && canGenerateInboundPlan(row) && !inboundPlanCreated }, + { key: 'inbound-plan', label: '生成入库计划', priority: 400, visible: access.can(permission.inboundOrderCreate) && !inboundPlanCreated }, { key: 'hold', label: row.detained ? '解除暂扣' : '暂扣订单', priority: 300, tone: 'warning', visible: access.can(permission.lastMileOrderTransfer) && (row.detained || row.currentStatusCode === 'INBOUND_COMPLETED') }, { key: 'manual-nodes', label: '人工修改节点', priority: 200, visible: access.can(permission.lastMileOrderTransfer) }, { key: 'confirm-relabel', label: '换标完成确认', priority: 150, visible: access.can(permission.relabelOrderStatus) && Boolean(row.relabelOrderNo) && canConfirmRelabel(row) }, @@ -891,7 +863,6 @@ function handleLastMileRowAction(command: string, row: LastMileOrderListItem) { if (command === 'detail') openDetail(row) else if (command === 'review-direct-relabel') void reviewDirectRelabel(row) else if (command === 'review-return') void reviewReturn(row) - else if (command === 'review-cargo-discard') void reviewCargoDiscard(row) else if (command === 'review') openTransferReview(row) else if (command === 'inbound-plan') openInboundPlan(row) else if (command === 'hold') openHold(row) @@ -991,7 +962,7 @@ onMounted(() => { void load() })
- + diff --git a/src/modules/overseas-warehouse/pages/RelabelOrdersView.vue b/src/modules/overseas-warehouse/pages/RelabelOrdersView.vue index 1b52d7bf..a0c2748c 100644 --- a/src/modules/overseas-warehouse/pages/RelabelOrdersView.vue +++ b/src/modules/overseas-warehouse/pages/RelabelOrdersView.vue @@ -31,14 +31,11 @@ const warehousesLoading = ref(false) const page = ref(1) const pageSize = ref(10) const total = ref(0) -const filters = ref({ relabelNo: '', sku: '', status: '', reviewedDates: [], measuredDates: [], outboundDates: [] }) +const filters = ref({ relabelNo: '', sku: '', status: '' }) const fields = computed(() => [ { key: 'relabelNo', type: 'input', label: '换标单号', placeholder: '请输入换标单号', width: 220 }, { key: 'sku', type: 'input', label: '商品SKU', placeholder: '请输入商品SKU', width: 180 }, { key: 'status', type: 'select', label: '状态', placeholder: '全部状态', width: 160, options: [{ label: '待审核', value: 'PENDING_REVIEW' }, { label: '待处理', value: 'PENDING_PROCESSING' }, { label: '处理中', value: 'PROCESSING' }, { label: '换标完成', value: 'COMPLETED' }] }, - { key: 'reviewedDates', type: 'daterange', label: '审核时间', width: 240 }, - { key: 'measuredDates', type: 'daterange', label: '测量时间', width: 240 }, - { key: 'outboundDates', type: 'daterange', label: '出库时间', width: 240 }, ]) const columns: DataTableColumn[] = [ { key: 'actions', label: '操作', width: 180, fixed: 'left', slotName: 'actions' }, @@ -52,9 +49,6 @@ const columns: DataTableColumn[] = [ { key: 'labelRequirement', label: '贴标要求', minWidth: 150 }, { key: 'fileName', label: '上传文件', minWidth: 160 }, { key: 'status', label: '状态', width: 110 }, - { key: 'reviewedAt', label: '审核时间', minWidth: 170 }, - { key: 'measuredAt', label: '测量时间', minWidth: 170 }, - { key: 'outboundAt', label: '出库时间', minWidth: 170 }, { key: 'createdAt', label: '创建时间', minWidth: 170 }, ] const dialogOpen = ref(false) @@ -177,13 +171,10 @@ async function load(model: QueryModel = filters.value) { Object.assign(filters.value, model) loading.value = true; loadError.value = null try { - const reviewedDates = Array.isArray(filters.value.reviewedDates) ? filters.value.reviewedDates : [] - const measuredDates = Array.isArray(filters.value.measuredDates) ? filters.value.measuredDates : [] - const outboundDates = Array.isArray(filters.value.outboundDates) ? filters.value.outboundDates : [] - const result = await listRelabelOrders({ page: page.value, size: pageSize.value, relabelNo: String(filters.value.relabelNo || '').trim() || undefined, skuCode: String(filters.value.sku || '').trim() || undefined, status: String(filters.value.status || '').trim() || undefined, reviewedFrom: String(reviewedDates[0] || '') || undefined, reviewedTo: String(reviewedDates[1] || '') || undefined, measuredFrom: String(measuredDates[0] || '') || undefined, measuredTo: String(measuredDates[1] || '') || undefined, outboundFrom: String(outboundDates[0] || '') || undefined, outboundTo: String(outboundDates[1] || '') || undefined }) + const result = await listRelabelOrders({ page: page.value, size: pageSize.value, relabelNo: String(filters.value.relabelNo || '').trim() || undefined, skuCode: String(filters.value.sku || '').trim() || undefined, status: String(filters.value.status || '').trim() || undefined }) rows.value = result.records.map((item) => ({ customerId: '', orderNo: String(item.relabelNo), relatedOrderNo: String(item.lastlegOrderNo || item.customerOrderNo || '—'), sku: String(item.skuCodes || '—'), - productName: '—', cartons: Number(item.totalPackageCount || 0), budgetTotal: null, relabelCost: Number(item.chargeAmount || 0), walletStatus: walletStatus(item.walletStatus), walletErrorMessage: item.walletErrorMessage || '', labelRequirement: '—', fileName: '—', status: relabelStatus(item.status), rawStatus: String(item.status || ''), remark: '', reviewedAt: item.reviewedAt, measuredAt: item.measuredAt, outboundAt: item.outboundAt, + productName: '—', cartons: Number(item.totalPackageCount || 0), budgetTotal: null, relabelCost: Number(item.chargeAmount || 0), walletStatus: walletStatus(item.walletStatus), walletErrorMessage: item.walletErrorMessage || '', labelRequirement: '—', fileName: '—', status: relabelStatus(item.status), rawStatus: String(item.status || ''), remark: '', reviewChannel: item.reviewChannel || undefined, createdAt: formatOverseasDateTime(item.createdAt), })) diff --git a/src/modules/overseas-warehouse/pages/prototypeContract.test.ts b/src/modules/overseas-warehouse/pages/prototypeContract.test.ts index 06729649..66f1bfc5 100644 --- a/src/modules/overseas-warehouse/pages/prototypeContract.test.ts +++ b/src/modules/overseas-warehouse/pages/prototypeContract.test.ts @@ -409,7 +409,7 @@ describe('overseas warehouse first-batch prototype contract', () => { 'inTransit', 'updatedAt', ]) expect(columnKeys('RelabelOrdersView.vue')).toEqual([ - 'actions', 'orderNo', 'sku', 'productName', 'cartons', 'budgetTotal', 'relabelCost', 'walletStatus', 'labelRequirement', 'fileName', 'status', 'reviewedAt', 'measuredAt', 'outboundAt', 'createdAt', + 'actions', 'orderNo', 'sku', 'productName', 'cartons', 'budgetTotal', 'relabelCost', 'walletStatus', 'labelRequirement', 'fileName', 'status', 'createdAt', ]) expect(columnKeys('PalletsView.vue')).toEqual([ 'actions', 'code', 'type', 'warehouse', 'volume', 'purchasePrice', 'fbaWarehouse', 'status', 'remark', diff --git a/src/modules/overseas-warehouse/permissions.ts b/src/modules/overseas-warehouse/permissions.ts index 1ca5f879..e73a8a7c 100644 --- a/src/modules/overseas-warehouse/permissions.ts +++ b/src/modules/overseas-warehouse/permissions.ts @@ -3,7 +3,6 @@ export const overseasWarehousePermissions = { lastMileOrderExport: 'platform:overseas-warehouse:last-mile-order:export', lastMileOrderTransfer: 'platform:overseas-warehouse:last-mile-order:edit', warehouseTransferApprove: 'platform:overseas-warehouse:warehouse-transfer:approve', - cargoDiscardApprove: 'platform:overseas-warehouse:discard:approve', inboundOrderList: 'platform:overseas-warehouse:inbound-order:list', inboundOrderCreate: 'platform:overseas-warehouse:inbound-order:create', inboundOrderEdit: 'platform:overseas-warehouse:inbound-order:edit', diff --git a/src/modules/overseas-warehouse/types.ts b/src/modules/overseas-warehouse/types.ts index e919bb4c..3dedbc66 100644 --- a/src/modules/overseas-warehouse/types.ts +++ b/src/modules/overseas-warehouse/types.ts @@ -153,9 +153,6 @@ export interface RelabelOrderListItem { status: string rawStatus?: string reviewChannel?: 'TAIL_ORDER' | 'RELABEL_ORDER' - reviewedAt?: string | null - measuredAt?: string | null - outboundAt?: string | null remark: string createdAt: string } diff --git a/src/modules/sailing-calendar/api/platformSailingCalendar.ts b/src/modules/sailing-calendar/api/platformSailingCalendar.ts index c120a55f..f0d3d1c5 100644 --- a/src/modules/sailing-calendar/api/platformSailingCalendar.ts +++ b/src/modules/sailing-calendar/api/platformSailingCalendar.ts @@ -145,8 +145,8 @@ const exceptionPath = '/platform/v1/sailing-assignments/exceptions' function channel(raw: SailingChannelResource): SailingChannelResource { return { ...raw, id: String(raw.id), defaultCutoffDays: number(raw.defaultCutoffDays), version: number(raw.version) } } function template(raw: SailingTemplateResource): SailingTemplateResource { return { ...raw, id: String(raw.id), channelId: String(raw.channelId), warehouseId: String(raw.warehouseId), weekdays: (raw.weekdays || []).map((value) => number(value)), cutoffDays: number(raw.cutoffDays), version: number(raw.version) } } -export async function listSailingChannels() { - const response = await http.get, ApiEnvelope>(channelPath) +export async function listSailingChannels(warehouseId?: string) { + const response = await http.get, ApiEnvelope>(channelPath, { params: warehouseId ? { warehouseId } : undefined }) const result = unwrapApiResponse(response) as SailingChannelResource[] | { records?: SailingChannelResource[] } | null return (Array.isArray(result) ? result : result?.records || []).map(channel) } diff --git a/src/modules/sailing-calendar/pages/SailingCalendarView.vue b/src/modules/sailing-calendar/pages/SailingCalendarView.vue index 0babdce1..a9bfb8db 100644 --- a/src/modules/sailing-calendar/pages/SailingCalendarView.vue +++ b/src/modules/sailing-calendar/pages/SailingCalendarView.vue @@ -234,23 +234,38 @@ function onTemplateChannelChanged(id: string) { templateForm.destinationPortCode = channel.defaultDestinationPortCode || '' templateForm.vesselName = '待订舱确认' } -function onTemplateWarehouseChanged(id: string) { applyWarehouse(id, templateForm) } -function onFormWarehouseChanged(id: string) { applyWarehouse(id, form) } +async function loadChannelsForWarehouse(warehouseId?: string) { + channels.value = warehouseId ? await listSailingChannels(warehouseId) : [] +} +async function onTemplateWarehouseChanged(id: string) { + applyWarehouse(id, templateForm) + templateForm.channelId = '' + templateForm.departurePortCode = '' + templateForm.destinationPortCode = '' + await loadChannelsForWarehouse(id) +} +async function onFormWarehouseChanged(id: string) { + applyWarehouse(id, form) + form.channelCode = '' + form.channelId = null + await loadChannelsForWarehouse(id) +} async function openTemplateCreate() { await ensureWarehouseOptions() editingTemplateId.value = '' - const activeChannel = channels.value.find((item) => item.status === 'ACTIVE') - Object.assign(templateForm, { templateName: '', channelId: activeChannel?.id || '', warehouseId: '', warehouseName: '', weekdays: [1], effectiveFrom: formatDate(today), effectiveTo: '', departureTime: '10:00:00', cutoffDays: activeChannel?.defaultCutoffDays ?? 3, cutoffLocalTime: '18:00:00', voyageRule: '', vesselName: '待订舱确认', departurePortCode: activeChannel?.defaultDeparturePortCode || '', destinationPortCode: activeChannel?.defaultDestinationPortCode || '', remark: '', expectedVersion: undefined }) + Object.assign(templateForm, { templateName: '', channelId: '', warehouseId: '', warehouseName: '', weekdays: [1], effectiveFrom: formatDate(today), effectiveTo: '', departureTime: '10:00:00', cutoffDays: 3, cutoffLocalTime: '18:00:00', voyageRule: '', vesselName: '待订舱确认', departurePortCode: '', destinationPortCode: '', remark: '', expectedVersion: undefined }) + channels.value = [] templatePreview.value = [] resetGenerationRange(templateForm.effectiveFrom) templateDialogOpen.value = true } -function openTemplateEdit(row: unknown, options: { preserveGenerationRange?: boolean } = {}) { +async function openTemplateEdit(row: unknown, options: { preserveGenerationRange?: boolean } = {}) { const templateRow = row as SailingTemplateResource editingTemplateId.value = templateRow.id Object.assign(templateForm, { templateName: templateRow.templateName || '', channelId: templateRow.channelId, warehouseId: templateRow.warehouseId, warehouseName: templateRow.warehouseName || '', weekdays: [...templateRow.weekdays], effectiveFrom: templateRow.effectiveFrom, effectiveTo: templateRow.effectiveTo || '', departureTime: templateRow.departureTime, cutoffDays: templateRow.cutoffDays, cutoffLocalTime: templateRow.cutoffLocalTime, voyageRule: templateRow.voyageRule || '', vesselName: templateRow.vesselName || '', departurePortCode: templateRow.departurePortCode || '', destinationPortCode: templateRow.destinationPortCode || '', remark: templateRow.remark || '', expectedVersion: templateRow.version }) templatePreview.value = [] if (!options.preserveGenerationRange) resetGenerationRange(templateForm.effectiveFrom) + try { await loadChannelsForWarehouse(templateForm.warehouseId) } catch (cause) { ElMessage.error(getApiErrorDetails(cause, '渠道加载失败').message); return } templateDialogOpen.value = true } function templatePayload() { syncTemplateDerivedFields(); return { ...templateForm, effectiveTo: templateForm.effectiveTo || null, weekdays: [...templateForm.weekdays].sort((a, b) => a - b), voyageRule: templateForm.voyageRule || null, vesselName: templateForm.vesselName || '待订舱确认', remark: templateForm.remark || null } } @@ -318,16 +333,16 @@ async function openCreate() { cutoffDays.value = 3 cutoffLocalTime.value = '18:00:00' Object.assign(form, blankForm(), { expectedVersion: undefined }) + channels.value = [] routeExpanded.value = [] dialogOpen.value = true formOptionsLoading.value = true try { - const [rows] = await Promise.all([listSailingChannels(), ensureWarehouseOptions()]) - channels.value = rows + await ensureWarehouseOptions() } catch (cause) { ElMessage.error(getApiErrorDetails(cause, '排船资料加载失败,请关闭后重试').message) } finally { formOptionsLoading.value = false } } -async function openEdit(record: SailingCalendarEntry) { try { const [rows] = await Promise.all([listSailingChannels(), ensureWarehouseOptions()]); channels.value = rows } catch (cause) { ElMessage.error(getApiErrorDetails(cause, '排船资料加载失败').message); return } routeExpanded.value = []; dialogMode.value = 'edit'; cutoffMode.value = 'custom'; cutoffDays.value = 3; cutoffLocalTime.value = record.cutoffAt.slice(11, 19) || '18:00:00'; Object.assign(form, { templateId: record.templateId ?? null, channelId: record.channelId ?? null, warehouseId: record.warehouseId, warehouseName: record.warehouseName, channelCode: record.channelCode, channelName: record.channelName, vesselName: record.vesselName, voyageNo: record.voyageNo, departurePortCode: record.departurePortCode, departurePortName: record.departurePortName || '', destinationPortCode: record.destinationPortCode, destinationPortName: record.destinationPortName || '', etdAt: record.etdAt.slice(0, 19), etaAt: record.etaAt ?? null, cutoffAt: record.cutoffAt.slice(0, 19), sourceType: record.sourceType ?? 'MANUAL', plannedCapacityCbm: record.plannedCapacityCbm ?? null, remark: record.remark || '', expectedVersion: record.version }); dialogOpen.value = true } +async function openEdit(record: SailingCalendarEntry) { try { await ensureWarehouseOptions(); await loadChannelsForWarehouse(record.warehouseId) } catch (cause) { ElMessage.error(getApiErrorDetails(cause, '排船资料加载失败').message); return } routeExpanded.value = []; dialogMode.value = 'edit'; cutoffMode.value = 'custom'; cutoffDays.value = 3; cutoffLocalTime.value = record.cutoffAt.slice(11, 19) || '18:00:00'; Object.assign(form, { templateId: record.templateId ?? null, channelId: record.channelId ?? null, warehouseId: record.warehouseId, warehouseName: record.warehouseName, channelCode: record.channelCode, channelName: record.channelName, vesselName: record.vesselName, voyageNo: record.voyageNo, departurePortCode: record.departurePortCode, departurePortName: record.departurePortName || '', destinationPortCode: record.destinationPortCode, destinationPortName: record.destinationPortName || '', etdAt: record.etdAt.slice(0, 19), etaAt: record.etaAt ?? null, cutoffAt: record.cutoffAt.slice(0, 19), sourceType: record.sourceType ?? 'MANUAL', plannedCapacityCbm: record.plannedCapacityCbm ?? null, remark: record.remark || '', expectedVersion: record.version }); dialogOpen.value = true } function onEtdChanged() { if (dialogMode.value === 'create' || cutoffMode.value === 'rule') syncCutoffFromRule() } function validateForm() { const required: Array<[keyof SailingSaveRequest, string]> = [['warehouseId', '收货仓'], ['channelCode', '渠道'], ['departurePortCode', '起运港'], ['destinationPortCode', '目的港'], ['etdAt', '开船时间'], ['cutoffAt', '截止时间']]; const missing = required.find(([key]) => !String(form[key] || '').trim()); if (missing) { ElMessage.error(`请填写${missing[1]}`); return false } if (new Date(form.cutoffAt).getTime() >= new Date(form.etdAt).getTime()) { ElMessage.error('收货截止时间必须早于预计开船时间'); return false } return true } async function save() { @@ -422,14 +437,14 @@ onMounted(() => void load()) - 选择已有渠道,自动带入排船默认资料。
{{ form.vesselName || '待订舱确认' }} / {{ form.voyageNo || '待订舱确认' }}
未确认船名、航次时可先排船,显示为待订舱确认。
按开船前 N 天自定义时间
提前天数2 天3 天4 天
截止时刻
建议截止{{ dateTime(form.cutoffAt) }}按开船本地日期倒推自然日
+ 选择已有渠道,自动带入排船默认资料。
{{ form.vesselName || '待订舱确认' }} / {{ form.voyageNo || '待订舱确认' }}
未确认船名、航次时可先排船,显示为待订舱确认。
按开船前 N 天自定义时间
提前天数2 天3 天4 天
截止时刻
建议截止{{ dateTime(form.cutoffAt) }}按开船本地日期倒推自然日
-
查看已有船期渠道及默认收货规则。
设置每周开船日和收货截止,预览后批量生成;重复日期自动跳过。新增周模板
- 周{{ ['一', '二', '三', '四', '五', '六', '日'][day - 1] }}
2 天3 天4 天
快捷生成范围:未来 4 周未来 8 周未来 12 周

生成预览({{ templatePreview.length }} 条)

+
渠道只读展示当前生效的通用船期价格表;周模板按收货仓筛选可用渠道。
设置每周开船日和收货截止,预览后批量生成;重复日期自动跳过。新增周模板
+ 渠道来自当前生效的通用船期价格表,并按收货仓的有效报价筛选。周{{ ['一', '二', '三', '四', '五', '六', '日'][day - 1] }}
2 天3 天4 天
快捷生成范围:未来 4 周未来 8 周未来 12 周

生成预览({{ templatePreview.length }} 条)

From 1324cf16ce2d240dfdb95b5969f1112ad2c8791b Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 29 Sep 2026 04:20:58 +0100 Subject: [PATCH 13/17] feat(admin): support manual customs exception reasons --- .../components/ContainerCustomsPanel.vue | 58 +++++++++++++++---- 1 file changed, 47 insertions(+), 11 deletions(-) diff --git a/src/modules/container-loading/components/ContainerCustomsPanel.vue b/src/modules/container-loading/components/ContainerCustomsPanel.vue index ed95c2d7..30cb8a4c 100644 --- a/src/modules/container-loading/components/ContainerCustomsPanel.vue +++ b/src/modules/container-loading/components/ContainerCustomsPanel.vue @@ -26,6 +26,7 @@ import { type ContainerCustomsDocument, type ContainerCustomsStatus, type ContainerCustomsView, + type ClearanceResultType, type DeclarationBatch, } from '../api/platformContainerCustoms' @@ -46,6 +47,17 @@ const actionError = ref(null) const downloadingKey = ref('') const receiptFiles = reactive>({}) const finalFiles = reactive>({}) +const clearanceDialogVisible = ref(false) +const clearanceBatch = ref(null) +const clearanceResultType = ref('INSPECTION') +const clearanceRemark = ref('') +const clearanceResultOptions: Array<{ value: ClearanceResultType; label: string }> = [ + { value: 'INSPECTION', label: '查验' }, + { value: 'SUPPLEMENT_REQUIRED', label: '补资料' }, + { value: 'DETAINED', label: '扣货' }, + { value: 'RELEASED', label: '放行' }, + { value: 'OTHER', label: '其他' }, +] const missingInbounds = computed(() => customs.value?.inbounds.filter((item) => item.taxMode === 'TAX_REFUND' && !item.covered) || []) const missingCoverageCount = computed(() => Math.max(0, (customs.value?.taxRefundCount || 0) - (customs.value?.coveredTaxRefundCount || 0))) @@ -122,16 +134,27 @@ async function submitFormalDeclaration(batch: DeclarationBatch) { } finally { actionLoading.value = false } } -async function recordFormalResult(batch: DeclarationBatch) { +function openFormalResultDialog(batch: DeclarationBatch) { + clearanceBatch.value = batch + clearanceResultType.value = 'INSPECTION' + clearanceRemark.value = '' + clearanceDialogVisible.value = true +} + +async function recordFormalResult() { + const batch = clearanceBatch.value + if (!batch) return + if (clearanceResultType.value === 'OTHER' && !clearanceRemark.value.trim()) { + ElMessage.warning('选择“其他”时请填写备注') + return + } + actionLoading.value = true; actionError.value = null try { - const answer = await ElMessageBox.prompt('可填写查验处理说明,没有说明可直接确认', '录入海关查验结果', { customClass: 'admin-density-confirm', inputPlaceholder: '处理说明(可选)', confirmButtonText: '确认录入' }) - actionLoading.value = true; actionError.value = null - await recordClearanceResult(batch, 'INSPECTION', answer.value) - await load(); ElMessage.success('海关结果已录入') - } catch (error) { - if (error === 'cancel' || error === 'close') return - actionError.value = getApiErrorDetails(error, '海关结果录入失败') - } finally { actionLoading.value = false } + await recordClearanceResult(batch, clearanceResultType.value, clearanceRemark.value.trim() || undefined) + clearanceDialogVisible.value = false + await load(); ElMessage.success('海关异常节点已标记') + } catch (error) { actionError.value = getApiErrorDetails(error, '海关异常节点标记失败') } + finally { actionLoading.value = false } } async function resolveFormalResult(batch: DeclarationBatch, result: DeclarationBatch['results'][number]) { @@ -305,7 +328,7 @@ onMounted(load)
{{ batch.batchNo }}报关单号:{{ batch.customsEntryNo || '—' }} · 版本 {{ batch.version }}
{{ declarationStatusLabel(batch.status) }} -
提交正式申报录入查验结果
+
提交正式申报标记海关异常/结果
{{ result.resultType === 'INSPECTION' ? '查验' : result.resultType === 'SUPPLEMENT_REQUIRED' ? '补充资料' : result.resultType === 'RELEASED' ? '放行' : result.resultType }}{{ result.resultStatus === 'RESOLVED' ? '已处理' : result.resultStatus === 'OPEN' ? '待处理' : '已取消' }}{{ result.resolution || '—' }}处理
@@ -314,9 +337,22 @@ onMounted(load)
国内离仓状态:{{ departureState === 'DEPARTED' ? '已自动离仓' : departureState === 'UNKNOWN' ? '离仓状态尚未读取' : '报关已完成,待其他离仓条件' }}
+ + + + + + + + + + + + +
From 6bada2aca9b30c1afdef23c41fc742adf511009c Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 29 Sep 2026 13:50:05 +0800 Subject: [PATCH 14/17] fix(admin): clarify customs results and apply dialog density --- src/deployment/nginxConfig.test.ts | 2 +- .../components/ContainerCustomsPanel.test.ts | 38 ++++++++++++++++++- .../components/ContainerCustomsPanel.vue | 12 ++++-- .../fee-items/pages/FeeItemsView.test.ts | 2 +- 4 files changed, 47 insertions(+), 7 deletions(-) diff --git a/src/deployment/nginxConfig.test.ts b/src/deployment/nginxConfig.test.ts index 58d0630d..6a8f0f47 100644 --- a/src/deployment/nginxConfig.test.ts +++ b/src/deployment/nginxConfig.test.ts @@ -2,7 +2,7 @@ import { readFileSync } from 'node:fs' import { resolve } from 'node:path' import { describe, expect, it } from 'vitest' -const config = readFileSync(resolve(process.cwd(), 'nginx/default.conf.template'), 'utf8') +const config = readFileSync(resolve(process.cwd(), 'nginx/default.conf.template'), 'utf8').replace(/\r\n/g, '\n') const testCompose = readFileSync(resolve(process.cwd(), 'docker-compose.test.yml'), 'utf8') const developmentCompose = readFileSync(resolve(process.cwd(), 'docker-compose.development.yml'), 'utf8') const productionCompose = readFileSync(resolve(process.cwd(), 'docker-compose.production.yml'), 'utf8') diff --git a/src/modules/container-loading/components/ContainerCustomsPanel.test.ts b/src/modules/container-loading/components/ContainerCustomsPanel.test.ts index f5fd91b7..e6f4e900 100644 --- a/src/modules/container-loading/components/ContainerCustomsPanel.test.ts +++ b/src/modules/container-loading/components/ContainerCustomsPanel.test.ts @@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { flushPromises, mount } from '@vue/test-utils' import ElementPlus from 'element-plus' import { ApiResponseError } from '@/shared/api/http' -import type { ContainerCustomsStatus, ContainerCustomsView } from '../api/platformContainerCustoms' +import type { ContainerCustomsStatus, ContainerCustomsView, DeclarationBatch, ClearanceResultType } from '../api/platformContainerCustoms' const api = vi.hoisted(() => ({ get: vi.fn(), @@ -77,6 +77,42 @@ describe('ContainerCustomsPanel', () => { vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => undefined) }) + it('renders all customs result reasons in Chinese with the recorded remarks', async () => { + const reasons: Array<[ClearanceResultType, string]> = [ + ['INSPECTION', '查验'], ['SUPPLEMENT_REQUIRED', '补资料'], + ['DETAINED', '扣货'], ['RELEASED', '放行'], ['OTHER', '其他'], + ] + const batch: DeclarationBatch = { + id: '101', customsContainerId: '102', loadingPlanId: '2092438843630391402', + batchNo: 'DEC-TEST-001', status: 'RESULT_PENDING', sourceVersion: 1, + customsEntryNo: 'ENTRY-TEST-001', version: 1, submittedAt: null, + submittedByName: null, acknowledgedAt: null, acknowledgedByName: null, remark: null, + results: reasons.map(([resultType], index) => ({ + id: String(200 + index), declarationBatchId: '101', customsContainerId: '102', + resultType, resultStatus: 'OPEN', customsEntryNo: 'ENTRY-TEST-001', + occurredAt: '2026-09-29T10:00:00', resolution: `人工备注${index}`, + evidenceFileId: null, recordedByName: '操作员', recordedAt: '2026-09-29T10:00:00', + previousResultId: null, + })), + } + api.getDeclarationBatches.mockResolvedValue([batch]) + api.get.mockResolvedValue({ ...customs('COMPLETED', true), brokerSubmittedAt: '2026-09-29T09:00:00' }) + const wrapper = mount(ContainerCustomsPanel, { + props: { loadingPlanId: batch.loadingPlanId, canList: true, canManage: false, departureState: 'WAITING_OTHER_GATES' }, + global: { plugins: [ElementPlus] }, + }) + await flushPromises() + const rows = wrapper.findAll('.declaration-result-row') + expect(rows).toHaveLength(reasons.length) + for (const [index, [code, label]] of reasons.entries()) { + expect(rows[index]!.text()).toContain(label) + expect(rows[index]!.text()).toContain(`人工备注${index}`) + expect(rows[index]!.text()).not.toContain(code) + expect(rows[index]!.find('button').exists()).toBe(false) + } + wrapper.unmount() + }) + it('keeps READY_TO_START read-only while exposing source file name and download', async () => { const wrapper = await render('READY_TO_START') expect(wrapper.text()).toContain('开始报关') diff --git a/src/modules/container-loading/components/ContainerCustomsPanel.vue b/src/modules/container-loading/components/ContainerCustomsPanel.vue index 30cb8a4c..041c9db4 100644 --- a/src/modules/container-loading/components/ContainerCustomsPanel.vue +++ b/src/modules/container-loading/components/ContainerCustomsPanel.vue @@ -59,6 +59,10 @@ const clearanceResultOptions: Array<{ value: ClearanceResultType; label: string { value: 'OTHER', label: '其他' }, ] +function clearanceResultLabel(value: ClearanceResultType) { + return clearanceResultOptions.find((option) => option.value === value)?.label || '未知结果' +} + const missingInbounds = computed(() => customs.value?.inbounds.filter((item) => item.taxMode === 'TAX_REFUND' && !item.covered) || []) const missingCoverageCount = computed(() => Math.max(0, (customs.value?.taxRefundCount || 0) - (customs.value?.coveredTaxRefundCount || 0))) const allBuy = computed(() => customs.value?.hasActualCargo === true && customs.value.taxRefundCount === 0) @@ -152,8 +156,8 @@ async function recordFormalResult() { try { await recordClearanceResult(batch, clearanceResultType.value, clearanceRemark.value.trim() || undefined) clearanceDialogVisible.value = false - await load(); ElMessage.success('海关异常节点已标记') - } catch (error) { actionError.value = getApiErrorDetails(error, '海关异常节点标记失败') } + await load(); ElMessage.success('海关结果已记录') + } catch (error) { actionError.value = getApiErrorDetails(error, '海关结果记录失败') } finally { actionLoading.value = false } } @@ -329,7 +333,7 @@ onMounted(load)
{{ batch.batchNo }}报关单号:{{ batch.customsEntryNo || '—' }} · 版本 {{ batch.version }}
{{ declarationStatusLabel(batch.status) }}
提交正式申报标记海关异常/结果
-
{{ result.resultType === 'INSPECTION' ? '查验' : result.resultType === 'SUPPLEMENT_REQUIRED' ? '补充资料' : result.resultType === 'RELEASED' ? '放行' : result.resultType }}{{ result.resultStatus === 'RESOLVED' ? '已处理' : result.resultStatus === 'OPEN' ? '待处理' : '已取消' }}{{ result.resolution || '—' }}处理
+
{{ clearanceResultLabel(result.resultType) }}{{ result.resultStatus === 'RESOLVED' ? '已处理' : result.resultStatus === 'OPEN' ? '待处理' : '已取消' }}{{ result.resolution || '—' }}处理
@@ -337,7 +341,7 @@ onMounted(load)
国内离仓状态:{{ departureState === 'DEPARTED' ? '已自动离仓' : departureState === 'UNKNOWN' ? '离仓状态尚未读取' : '报关已完成,待其他离仓条件' }}
- + diff --git a/src/modules/fee-items/pages/FeeItemsView.test.ts b/src/modules/fee-items/pages/FeeItemsView.test.ts index 5742b7af..039fd831 100644 --- a/src/modules/fee-items/pages/FeeItemsView.test.ts +++ b/src/modules/fee-items/pages/FeeItemsView.test.ts @@ -9,7 +9,7 @@ import { useAuthStore } from '@/core/auth/auth' import { listPlatformFeeItems } from '@/modules/fee-items/api/platformFeeItems' import FeeItemsView from './FeeItemsView.vue' -const source = readFileSync(join(process.cwd(), 'src/modules/fee-items/pages/FeeItemsView.vue'), 'utf8') +const source = readFileSync(join(process.cwd(), 'src/modules/fee-items/pages/FeeItemsView.vue'), 'utf8').replace(/\r\n/g, '\n') vi.mock('@/modules/fee-items/api/platformFeeItems', () => ({ listPlatformFeeItems: vi.fn(), From 4ead51a5feedf65be9443ddfe3dd45da7ca49e64 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 29 Sep 2026 15:45:37 +0800 Subject: [PATCH 15/17] fix: record customs results in Shanghai business time --- .../api/platformContainerCustoms.test.ts | 15 +++++++++++++++ .../api/platformContainerCustoms.ts | 8 +++++++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/src/modules/container-loading/api/platformContainerCustoms.test.ts b/src/modules/container-loading/api/platformContainerCustoms.test.ts index 3aae0473..70397885 100644 --- a/src/modules/container-loading/api/platformContainerCustoms.test.ts +++ b/src/modules/container-loading/api/platformContainerCustoms.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { http } from '@/shared/api/http' +import { recordClearanceResult, type DeclarationBatch } from './platformContainerCustoms' import { completeAllBuyContainerCustoms, downloadContainerCustomsReceipt, downloadContainerCustomsDocument, getContainerCustoms, sendContainerCustomsToBroker, startContainerCustoms, uploadContainerCustomsReceipt } from './platformContainerCustoms' const customs = { @@ -14,6 +15,20 @@ const customs = { describe('platform container customs API', () => { beforeEach(() => vi.restoreAllMocks()) + it('records domestic customs time in Shanghai even when UTC falls on the previous day', async () => { + vi.useFakeTimers() + try { + vi.setSystemTime(new Date('2026-09-29T16:30:00Z')) + const post = vi.spyOn(http, 'post').mockResolvedValue({ code: '200', data: { id: '1' } }) + await recordClearanceResult({ id: '123', version: 1 } as DeclarationBatch, 'OTHER', 'test') + expect(post).toHaveBeenCalledWith('/platform/v1/customs/loading-plans/declaration-batches/123/results', + expect.objectContaining({ occurredAt: '2026-09-30T00:30:00', resultType: 'OTHER', resolution: 'test' }), + expect.objectContaining({ headers: { 'Idempotency-Key': expect.any(String) } })) + } finally { + vi.useRealTimers() + } + }) + it('uses the frozen customs-v1 paths and preserves long ids as strings', async () => { const get = vi.spyOn(http, 'get').mockResolvedValue({ code: '200', message: 'ok', data: customs }) const result = await getContainerCustoms('2092438843630391402') diff --git a/src/modules/container-loading/api/platformContainerCustoms.ts b/src/modules/container-loading/api/platformContainerCustoms.ts index a8bd6067..db2c3cd0 100644 --- a/src/modules/container-loading/api/platformContainerCustoms.ts +++ b/src/modules/container-loading/api/platformContainerCustoms.ts @@ -189,7 +189,13 @@ export async function submitDeclarationBatch(batch: DeclarationBatch, customsEnt } export async function recordClearanceResult(batch: DeclarationBatch, resultType: ClearanceResultType, resolution?: string) { - const response = await http.post, ApiEnvelope>(`${basePath}/declaration-batches/${encodeURIComponent(batch.id)}/results`, { expectedVersion: batch.version, resultType, occurredAt: new Date().toISOString(), resolution: resolution?.trim() || null, evidenceFileId: null, resolved: false }, { headers: { 'Idempotency-Key': operationId('customs-clearance-result-record') } }) + // The domestic API stores LocalDateTime; send Shanghai wall time, not UTC with a discarded offset. + const parts = Object.fromEntries(new Intl.DateTimeFormat('en-CA', { + timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit', + hour: '2-digit', minute: '2-digit', second: '2-digit', hourCycle: 'h23', + }).formatToParts(new Date()).map(({ type, value }) => [type, value])) + const occurredAt = `${parts.year}-${parts.month}-${parts.day}T${parts.hour}:${parts.minute}:${parts.second}` + const response = await http.post, ApiEnvelope>(`${basePath}/declaration-batches/${encodeURIComponent(batch.id)}/results`, { expectedVersion: batch.version, resultType, occurredAt, resolution: resolution?.trim() || null, evidenceFileId: null, resolved: false }, { headers: { 'Idempotency-Key': operationId('customs-clearance-result-record') } }) return normalizeClearanceResult(unwrapApiResponse(response)) } From 2bf4353b1b4b4b1d954ec9ba63941c9b2f32573a Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 29 Sep 2026 17:56:00 +0800 Subject: [PATCH 16/17] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 3 - src/core/auth/auth.test.ts | 10 +- src/core/auth/auth.ts | 9 -- .../api/containerArrivalTimes.test.ts | 12 +-- .../api/containerArrivalTimes.ts | 8 +- .../api/overseasContainerFees.test.ts | 6 +- .../api/overseasContainerFees.ts | 18 ++-- .../api/platformOverseasBusiness.test.ts | 2 +- .../api/platformOverseasBusiness.ts | 36 +++---- .../api/overseasMasterData.test.ts | 58 +++++------ .../api/overseasMasterData.ts | 36 +++---- .../api/customerVisits.test.ts | 32 +++--- .../overseas-warehouse/api/customerVisits.ts | 14 +-- .../api/deliveryDocuments.test.ts | 18 ++-- .../api/deliveryDocuments.ts | 12 +-- .../api/deliveryOrders.test.ts | 28 +++--- .../overseas-warehouse/api/deliveryOrders.ts | 12 +-- .../overseas-warehouse/api/dispatches.test.ts | 70 ++++++------- .../overseas-warehouse/api/dispatches.ts | 34 +++---- .../api/exceptionOrders.test.ts | 18 ++-- .../overseas-warehouse/api/exceptionOrders.ts | 10 +- .../api/inboundOrders.test.ts | 26 ++--- .../overseas-warehouse/api/inboundOrders.ts | 14 +-- .../api/inboundPlans.test.ts | 14 +-- .../overseas-warehouse/api/inboundPlans.ts | 10 +- .../overseas-warehouse/api/inventory.test.ts | 26 ++--- .../overseas-warehouse/api/inventory.ts | 16 +-- .../api/lastMileOrders.test.ts | 70 ++++++------- .../overseas-warehouse/api/lastMileOrders.ts | 36 +++---- .../overseas-warehouse/api/locations.ts | 16 +-- .../overseas-warehouse/api/outbounds.test.ts | 24 ++--- .../overseas-warehouse/api/outbounds.ts | 22 ++--- .../palletWarehouseRouting.contract.test.ts | 6 +- .../overseas-warehouse/api/pallets.test.ts | 28 +++--- src/modules/overseas-warehouse/api/pallets.ts | 28 +++--- .../api/relabelOrders.test.ts | 28 +++--- .../overseas-warehouse/api/relabelOrders.ts | 24 ++--- .../overseas-warehouse/api/returnInventory.ts | 14 +-- .../api/warehouseTransfers.test.ts | 16 +-- .../api/warehouseTransfers.ts | 10 +- src/shared/api/inboundCodeRelations.test.ts | 6 +- src/shared/api/inboundCodeRelations.ts | 3 +- src/shared/api/overseasHttp.test.ts | 99 ------------------- src/shared/api/overseasHttp.ts | 77 --------------- src/shared/api/unifiedHttp.test.ts | 49 +++++++++ vite.config.ts | 11 --- 46 files changed, 481 insertions(+), 638 deletions(-) delete mode 100644 src/shared/api/overseasHttp.test.ts delete mode 100644 src/shared/api/overseasHttp.ts create mode 100644 src/shared/api/unifiedHttp.test.ts diff --git a/.env.example b/.env.example index ffaf65f9..5086bd91 100644 --- a/.env.example +++ b/.env.example @@ -5,9 +5,6 @@ API_PROXY_TARGET=http://localhost:9000 # AUTH_API_PROXY_TARGET=http://localhost:9001 # IAM_API_PROXY_TARGET=http://localhost:9002 # WAREHOUSE_API_PROXY_TARGET=http://localhost:9003 -# 海外业务浏览器请求使用独立代理,代理会将 /overseas-api 重写为海外 Gateway 的 /api。 -VITE_OVERSEAS_API_BASE_URL=/overseas-api # 按本机联调环境填写海外 Gateway origin,不提交真实环境地址。 -OVERSEAS_API_PROXY_TARGET=http://localhost:9300 # Local Vite fallback. Container deployments should use CLIENT_WEB_URL instead. VITE_CLIENT_WEB_URL=http://localhost:5174 diff --git a/src/core/auth/auth.test.ts b/src/core/auth/auth.test.ts index a1ae3425..95e75cc5 100644 --- a/src/core/auth/auth.test.ts +++ b/src/core/auth/auth.test.ts @@ -60,13 +60,11 @@ function loginResponse(accessToken = token(), refreshToken = 'refresh-next') { expiresIn: 1800, refreshToken, refreshExpiresIn: 604800, - overseasAccessToken: 'overseas-token', - overseasExpiresIn: 900, }, } } -function domesticOnlyLoginResponse() { +function unifiedLoginResponse() { return { code: '200', message: 'success', @@ -91,8 +89,8 @@ describe('admin auth session', () => { afterEach(() => vi.unstubAllGlobals()) - it('accepts a domestic-only token response when the test environment has no overseas services', async () => { - mocks.post.mockResolvedValue(domesticOnlyLoginResponse()) + it('accepts the unified access and refresh token response', async () => { + mocks.post.mockResolvedValue(unifiedLoginResponse()) mocks.listCurrentPlatformPermissionCodes.mockResolvedValue(['platform:warehouse:list']) const auth = useAuthStore() @@ -116,8 +114,6 @@ describe('admin auth session', () => { expiresIn: '7200', refreshToken: 'refresh-serialized', refreshExpiresIn: '604800', - overseasAccessToken: null, - overseasExpiresIn: '0', }, }) mocks.listCurrentPlatformPermissionCodes.mockResolvedValue(['platform:warehouse:list']) diff --git a/src/core/auth/auth.ts b/src/core/auth/auth.ts index 56510cd7..24099027 100644 --- a/src/core/auth/auth.ts +++ b/src/core/auth/auth.ts @@ -8,7 +8,6 @@ import { type ApiEnvelope, } from '@/shared/api/http' import type { PlatformPermissionResource } from '@/shared/types/platformPermission' -import { clearOverseasAccessToken, replaceOverseasAccessToken } from '@/shared/api/overseasHttp' import { decodeJwtClaims } from './jwt' import { listCurrentPlatformPermissionCodes } from './platformAuthorization' import { coordinateCrossTabRefresh } from './crossTabRefresh' @@ -24,8 +23,6 @@ interface LoginResponse { expiresIn: number | string refreshToken: string refreshExpiresIn: number | string - overseasAccessToken?: string | null - overseasExpiresIn?: number | string } class ExpiredAccessTokenError extends Error {} @@ -100,10 +97,6 @@ export const useAuthStore = defineStore('admin-auth', () => { async function applySession(result: LoginResponse, expectedGeneration: number) { const session = sessionFromToken(result.accessToken) ensureCurrentGeneration(expectedGeneration) - replaceOverseasAccessToken( - result.overseasAccessToken ?? null, - parseLifetimeSeconds(result.overseasExpiresIn ?? 0, 'overseasExpiresIn'), - ) token.value = result.accessToken refreshToken.value = result.refreshToken user.value = session.user @@ -123,7 +116,6 @@ export const useAuthStore = defineStore('admin-auth', () => { function clearSession() { sessionGeneration += 1 - clearOverseasAccessToken() token.value = '' refreshToken.value = '' user.value = null @@ -151,7 +143,6 @@ export const useAuthStore = defineStore('admin-auth', () => { try { const session = sessionFromToken(storedToken) const cachedPermissions = JSON.parse(localStorage.getItem(PERMISSION_CODES_KEY) || '[]') as unknown - if (token.value !== storedToken) clearOverseasAccessToken() token.value = storedToken refreshToken.value = storedRefreshToken user.value = session.user diff --git a/src/modules/container-loading/api/containerArrivalTimes.test.ts b/src/modules/container-loading/api/containerArrivalTimes.test.ts index c2fe6c21..e379e18e 100644 --- a/src/modules/container-loading/api/containerArrivalTimes.test.ts +++ b/src/modules/container-loading/api/containerArrivalTimes.test.ts @@ -1,20 +1,20 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { overseasHttp } from '@/shared/api/overseasHttp' +import { http } from '@/shared/api/http' import { listContainerArrivalTimes, updateContainerExpectedArrival } from './containerArrivalTimes' -vi.mock('@/shared/api/overseasHttp', () => ({ overseasHttp: { post: vi.fn(), put: vi.fn() } })) +vi.mock('@/shared/api/http', async (importOriginal) => ({ ...(await importOriginal()), http: { post: vi.fn(), put: vi.fn() } })) describe('container arrival-time overseas API', () => { beforeEach(() => vi.clearAllMocks()) it('queries visible containers and updates the whole-container expected arrival', async () => { - vi.mocked(overseasHttp.post).mockResolvedValue({ code: '200', data: [{ sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null, version: 0 }] }) - vi.mocked(overseasHttp.put).mockResolvedValue({ code: '200', data: { sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: '2026-09-25T15:31:00', actualWarehouseArrivalAt: null, version: 1 } } as never) + vi.mocked(http.post).mockResolvedValue({ code: '200', data: [{ sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null, version: 0 }] }) + vi.mocked(http.put).mockResolvedValue({ code: '200', data: { sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: '2026-09-25T15:31:00', actualWarehouseArrivalAt: null, version: 1 } } as never) await listContainerArrivalTimes(['9001']) await updateContainerExpectedArrival('9001', { estimatedWarehouseArrivalAt: '2026-09-25T15:31:00', expectedVersion: 0 }) - expect(overseasHttp.post).toHaveBeenCalledWith('/platform/v1/last-leg/orders/container-arrival-times/query', { sourceContainerBookingIds: ['9001'] }) - expect(overseasHttp.put).toHaveBeenCalledWith('/platform/v1/last-leg/orders/container-summaries/9001/expected-arrival', { estimatedWarehouseArrivalAt: '2026-09-25T15:31:00', expectedVersion: 0 }, { headers: { 'Idempotency-Key': expect.stringContaining('container-expected-arrival-9001-') } }) + expect(http.post).toHaveBeenCalledWith('/platform/v1/last-leg/orders/container-arrival-times/query', { sourceContainerBookingIds: ['9001'] }) + expect(http.put).toHaveBeenCalledWith('/platform/v1/last-leg/orders/container-summaries/9001/expected-arrival', { estimatedWarehouseArrivalAt: '2026-09-25T15:31:00', expectedVersion: 0 }, { headers: { 'Idempotency-Key': expect.stringContaining('container-expected-arrival-9001-') } }) }) }) diff --git a/src/modules/container-loading/api/containerArrivalTimes.ts b/src/modules/container-loading/api/containerArrivalTimes.ts index be5a29c1..aedc9f82 100644 --- a/src/modules/container-loading/api/containerArrivalTimes.ts +++ b/src/modules/container-loading/api/containerArrivalTimes.ts @@ -1,5 +1,5 @@ -import { overseasHttp } from '@/shared/api/overseasHttp' -import { unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' +import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' + export interface ContainerArrivalTime { sourceContainerBookingId: string @@ -16,7 +16,7 @@ function operationId(containerBookingId: string) { export async function listContainerArrivalTimes(sourceContainerBookingIds: string[]) { if (!sourceContainerBookingIds.length) return [] - const response = await overseasHttp.post, ApiEnvelope>( + const response = await http.post, ApiEnvelope>( queryPath, { sourceContainerBookingIds }, ) @@ -27,7 +27,7 @@ export async function updateContainerExpectedArrival( sourceContainerBookingId: string, body: { estimatedWarehouseArrivalAt: string; expectedVersion: number; remark?: string }, ) { - const response = await overseasHttp.put, ApiEnvelope>( + const response = await http.put, ApiEnvelope>( `/platform/v1/last-leg/orders/container-summaries/${encodeURIComponent(sourceContainerBookingId)}/expected-arrival`, body, { headers: { 'Idempotency-Key': operationId(sourceContainerBookingId) } }, diff --git a/src/modules/container-loading/api/overseasContainerFees.test.ts b/src/modules/container-loading/api/overseasContainerFees.test.ts index 6920c5a7..91e7d955 100644 --- a/src/modules/container-loading/api/overseasContainerFees.test.ts +++ b/src/modules/container-loading/api/overseasContainerFees.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { overseasHttp } from '@/shared/api/overseasHttp' +import { http } from '@/shared/api/http' import { adjustOverseasContainerFee, createOverseasContainerFee, listOverseasContainerCustomers, listOverseasContainerFees, reviewOverseasContainerFee, submitOverseasContainerFee, voidOverseasContainerFee, type OverseasContainerFeeSaveRequest } from './overseasContainerFees' const raw = { id: 91, sourceFeeId: 81, sourceVersion: 2, customerId: 11, customerName: '客户A', sourceType: 'OVERSEAS_CONTAINER_FEE', businessType: 'OVERSEAS_CONTAINER', businessId: 99, businessNo: 'CONT-99', containerBookingId: 99, containerNo: 'CONT-99', feeItemCode: 'PORT_FEE', feeItemName: '港口费', direction: 'PAYABLE', amount: 300, currency: 'CNY', reviewStatus: 'DRAFT', sourceStatus: 'ACTIVE', businessDate: '2026-09-08' } @@ -7,14 +7,14 @@ const fee: OverseasContainerFeeSaveRequest = { customerId: '11', customerName: ' describe('overseas container fees API', () => { beforeEach(() => vi.restoreAllMocks()) it('loads all fees and customers with two container aggregate requests', async () => { - const get = vi.spyOn(overseasHttp, 'get').mockResolvedValueOnce({ code: '200', data: [raw] }).mockResolvedValueOnce({ code: '200', data: [{ customerId: 11, customerName: '客户A' }] }) + const get = vi.spyOn(http, 'get').mockResolvedValueOnce({ code: '200', data: [raw] }).mockResolvedValueOnce({ code: '200', data: [{ customerId: 11, customerName: '客户A' }] }) await expect(listOverseasContainerFees('99')).resolves.toMatchObject([{ sourceFeeId: '81', containerBookingId: '99', feeItemCode: 'PORT_FEE' }]) await expect(listOverseasContainerCustomers('99')).resolves.toEqual([{ customerId: '11', customerName: '客户A' }]) expect(get).toHaveBeenNthCalledWith(1, '/platform/v1/last-leg/finance-fee-sources', { params: { containerBookingId: '99', businessType: 'OVERSEAS_CONTAINER' } }) expect(get).toHaveBeenNthCalledWith(2, '/platform/v1/last-leg/finance-fee-sources/container-customers', { params: { containerBookingId: '99' } }) }) it('uses the exact create and lifecycle action contracts', async () => { - const post = vi.spyOn(overseasHttp, 'post').mockResolvedValue({ code: '200', data: raw }) + const post = vi.spyOn(http, 'post').mockResolvedValue({ code: '200', data: raw }) await createOverseasContainerFee(fee, 'create-1'); await submitOverseasContainerFee('81', 'submit-1'); await reviewOverseasContainerFee('81', 'APPROVE', null, 'review-1'); await adjustOverseasContainerFee('81', 2, fee, 'adjust-1'); await voidOverseasContainerFee('81', 2, '重复费用', 'void-1') expect(post).toHaveBeenCalledWith('/platform/v1/last-leg/finance-fee-sources', fee, { headers: { 'Idempotency-Key': 'create-1' } }) expect(post).toHaveBeenCalledWith('/platform/v1/last-leg/finance-fee-sources/81/submit', { idempotencyKey: 'submit-1' }) diff --git a/src/modules/container-loading/api/overseasContainerFees.ts b/src/modules/container-loading/api/overseasContainerFees.ts index 57f5641d..35e305c3 100644 --- a/src/modules/container-loading/api/overseasContainerFees.ts +++ b/src/modules/container-loading/api/overseasContainerFees.ts @@ -1,5 +1,5 @@ -import { overseasHttp } from '@/shared/api/overseasHttp' -import { unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' +import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' + const basePath = '/platform/v1/last-leg/finance-fee-sources' type RawResource = Record @@ -42,25 +42,25 @@ function normalizeFee(raw: RawResource): OverseasContainerFeeSource { } } export async function listOverseasContainerFees(containerBookingId: string) { - const response = await overseasHttp.get, ApiEnvelope>(basePath, { params: { containerBookingId, businessType: 'OVERSEAS_CONTAINER' } }) + const response = await http.get, ApiEnvelope>(basePath, { params: { containerBookingId, businessType: 'OVERSEAS_CONTAINER' } }) return unwrapApiResponse(response).map(normalizeFee) } export async function listOverseasContainerCustomers(containerBookingId: string) { - const response = await overseasHttp.get>, ApiEnvelope>>(`${basePath}/container-customers`, { params: { containerBookingId } }) + const response = await http.get>, ApiEnvelope>>(`${basePath}/container-customers`, { params: { containerBookingId } }) return unwrapApiResponse(response).map((item) => ({ customerId: requiredText(item.customerId), customerName: text(item.customerName) })) } export async function createOverseasContainerFee(payload: OverseasContainerFeeSaveRequest, idempotencyKey: string) { - return normalizeFee(unwrapApiResponse(await overseasHttp.post, ApiEnvelope>(basePath, payload, { headers: { 'Idempotency-Key': idempotencyKey } }))) + return normalizeFee(unwrapApiResponse(await http.post, ApiEnvelope>(basePath, payload, { headers: { 'Idempotency-Key': idempotencyKey } }))) } export async function submitOverseasContainerFee(sourceFeeId: string, idempotencyKey: string) { - return normalizeFee(unwrapApiResponse(await overseasHttp.post, ApiEnvelope>(`${basePath}/${encodeURIComponent(sourceFeeId)}/submit`, { idempotencyKey }))) + return normalizeFee(unwrapApiResponse(await http.post, ApiEnvelope>(`${basePath}/${encodeURIComponent(sourceFeeId)}/submit`, { idempotencyKey }))) } export async function reviewOverseasContainerFee(sourceFeeId: string, decision: 'APPROVE' | 'REJECT', reason: string | null, idempotencyKey: string) { - return normalizeFee(unwrapApiResponse(await overseasHttp.post, ApiEnvelope>(`${basePath}/${encodeURIComponent(sourceFeeId)}/review`, { decision, reason, idempotencyKey }))) + return normalizeFee(unwrapApiResponse(await http.post, ApiEnvelope>(`${basePath}/${encodeURIComponent(sourceFeeId)}/review`, { decision, reason, idempotencyKey }))) } export async function adjustOverseasContainerFee(sourceFeeId: string, expectedVersion: number, fee: OverseasContainerFeeSaveRequest, idempotencyKey: string) { - return normalizeFee(unwrapApiResponse(await overseasHttp.post, ApiEnvelope>(`${basePath}/${encodeURIComponent(sourceFeeId)}/adjust`, { expectedVersion, fee, idempotencyKey }))) + return normalizeFee(unwrapApiResponse(await http.post, ApiEnvelope>(`${basePath}/${encodeURIComponent(sourceFeeId)}/adjust`, { expectedVersion, fee, idempotencyKey }))) } export async function voidOverseasContainerFee(sourceFeeId: string, expectedVersion: number, reason: string, idempotencyKey: string) { - return normalizeFee(unwrapApiResponse(await overseasHttp.post, ApiEnvelope>(`${basePath}/${encodeURIComponent(sourceFeeId)}/void`, { expectedVersion, reason, idempotencyKey }))) + return normalizeFee(unwrapApiResponse(await http.post, ApiEnvelope>(`${basePath}/${encodeURIComponent(sourceFeeId)}/void`, { expectedVersion, reason, idempotencyKey }))) } diff --git a/src/modules/overseas-business/api/platformOverseasBusiness.test.ts b/src/modules/overseas-business/api/platformOverseasBusiness.test.ts index 1a683b83..41892075 100644 --- a/src/modules/overseas-business/api/platformOverseasBusiness.test.ts +++ b/src/modules/overseas-business/api/platformOverseasBusiness.test.ts @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const overseas = vi.hoisted(() => ({ get: vi.fn(), patch: vi.fn(), post: vi.fn(), put: vi.fn() })) -vi.mock('@/shared/api/overseasHttp', () => ({ overseasHttp: overseas })) +vi.mock('@/shared/api/http', async (importOriginal) => ({ ...(await importOriginal()), http: overseas })) import { createLocalLooseboardOrder, createUsTruckOrder, getLocalLooseboardOrder, getOverseasPurchase, getUsTruckOrder, diff --git a/src/modules/overseas-business/api/platformOverseasBusiness.ts b/src/modules/overseas-business/api/platformOverseasBusiness.ts index e5df5822..d76f6b76 100644 --- a/src/modules/overseas-business/api/platformOverseasBusiness.ts +++ b/src/modules/overseas-business/api/platformOverseasBusiness.ts @@ -1,5 +1,5 @@ -import { overseasHttp } from '@/shared/api/overseasHttp' -import { unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' +import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' + import type { CreateLocalLooseboardOrderRequest, CreateLocalTransportOrderRequest, LocalLooseboardDetail, LocalLooseboardOrder, LocalLooseboardQuery, LocalTransportDetail, LocalTransportOrder, LocalTransportPallet, LocalTransportQuery, @@ -57,67 +57,67 @@ function looseboardDetail(value: LooseboardDetailPayload): LocalLooseboardDetail } export async function listOverseasPurchaseCustomerOptions(keyword = '', limit = 200) { - const response = await overseasHttp.get, ApiEnvelope>(`${procurementBasePath}/customer-options`, { params: { keyword: keyword.trim() || undefined, limit } }) + const response = await http.get, ApiEnvelope>(`${procurementBasePath}/customer-options`, { params: { keyword: keyword.trim() || undefined, limit } }) return unwrapApiResponse(response) } export async function listOverseasPurchaseWarehouseOptions() { - const response = await overseasHttp.get, ApiEnvelope>(`${procurementBasePath}/warehouse-options`) + const response = await http.get, ApiEnvelope>(`${procurementBasePath}/warehouse-options`) return unwrapApiResponse(response).map((item) => ({ ...item, warehouseId: String(item.warehouseId) })) } export async function listOverseasPurchases(query: OverseasPurchaseQuery = {}) { - const response = await overseasHttp.get>, ApiEnvelope>>(procurementBasePath, { params: { ...query, customerId: query.customerId || undefined, orderNo: query.orderNo?.trim() || undefined, customerKeyword: query.customerKeyword?.trim() || undefined, purchaseCategory: query.purchaseCategory?.trim() || undefined, productName: query.productName?.trim() || undefined, status: query.status || undefined, purchaseWarehouseId: query.purchaseWarehouseId || undefined } }) + const response = await http.get>, ApiEnvelope>>(procurementBasePath, { params: { ...query, customerId: query.customerId || undefined, orderNo: query.orderNo?.trim() || undefined, customerKeyword: query.customerKeyword?.trim() || undefined, purchaseCategory: query.purchaseCategory?.trim() || undefined, productName: query.productName?.trim() || undefined, status: query.status || undefined, purchaseWarehouseId: query.purchaseWarehouseId || undefined } }) return normalizePage(unwrapApiResponse(response), procurementSummary) } export async function getOverseasPurchase(orderNo: string, customerId: string) { - const response = await overseasHttp.get, ApiEnvelope>(`${procurementBasePath}/${encodeURIComponent(orderNo)}`, { params: { customerId } }) + const response = await http.get, ApiEnvelope>(`${procurementBasePath}/${encodeURIComponent(orderNo)}`, { params: { customerId } }) return procurementDetail(unwrapApiResponse(response)) } export async function updateOverseasPurchase(orderNo: string, customerId: string, payload: UpdateOverseasPurchaseRequest) { - const response = await overseasHttp.patch, ApiEnvelope>(`${procurementBasePath}/${encodeURIComponent(orderNo)}`, payload, { params: { customerId } }) + const response = await http.patch, ApiEnvelope>(`${procurementBasePath}/${encodeURIComponent(orderNo)}`, payload, { params: { customerId } }) return procurementDetail(unwrapApiResponse(response)) } export async function reviewOverseasPurchase(orderNo: string, customerId: string, payload: ReviewOverseasPurchaseRequest) { - const response = await overseasHttp.post, ApiEnvelope>(`${procurementBasePath}/${encodeURIComponent(orderNo)}/review`, payload, { params: { customerId } }) + const response = await http.post, ApiEnvelope>(`${procurementBasePath}/${encodeURIComponent(orderNo)}/review`, payload, { params: { customerId } }) return procurementDetail(unwrapApiResponse(response)) } export async function uploadOverseasPurchaseFile(orderNo: string, customerId: string, category: 'RECEIPT' | 'VOUCHER', file: File) { const body = new FormData(); body.append('file', file) - const response = await overseasHttp.post, ApiEnvelope>(`${procurementBasePath}/${encodeURIComponent(orderNo)}/files/${category}`, body, { params: { customerId } }) + const response = await http.post, ApiEnvelope>(`${procurementBasePath}/${encodeURIComponent(orderNo)}/files/${category}`, body, { params: { customerId } }) return unwrapApiResponse(response) } export async function createOverseasPurchaseFee(orderNo: string, customerId: string, payload: { feeItemCode: string; feeItemName: string; amount: number; voucherFileId: string; remark?: string }) { - const response = await overseasHttp.post, ApiEnvelope>(`${procurementBasePath}/${encodeURIComponent(orderNo)}/fees`, payload, { params: { customerId } }) + const response = await http.post, ApiEnvelope>(`${procurementBasePath}/${encodeURIComponent(orderNo)}/fees`, payload, { params: { customerId } }) return unwrapApiResponse(response) } export async function listUsTruckOrders(query: LocalTransportQuery = {}) { - const response = await overseasHttp.get>, ApiEnvelope>>(truckBasePath, { params: { ...query, orderNo: query.orderNo?.trim() || undefined, routeKeyword: query.routeKeyword?.trim() || undefined, status: query.status || undefined, transportType: query.transportType || undefined, customerId: query.customerId || undefined } }) + const response = await http.get>, ApiEnvelope>>(truckBasePath, { params: { ...query, orderNo: query.orderNo?.trim() || undefined, routeKeyword: query.routeKeyword?.trim() || undefined, status: query.status || undefined, transportType: query.transportType || undefined, customerId: query.customerId || undefined } }) return normalizePage(unwrapApiResponse(response), truckSummary) } export async function getUsTruckOrder(orderNo: string, customerId: string) { - const response = await overseasHttp.get, ApiEnvelope>(`${truckBasePath}/${encodeURIComponent(orderNo)}`, { params: { customerId } }) + const response = await http.get, ApiEnvelope>(`${truckBasePath}/${encodeURIComponent(orderNo)}`, { params: { customerId } }) return truckDetail(unwrapApiResponse(response)) } export async function createUsTruckOrder(customerId: string, payload: CreateLocalTransportOrderRequest) { - const response = await overseasHttp.post, ApiEnvelope>(truckBasePath, payload, { params: { customerId } }) + const response = await http.post, ApiEnvelope>(truckBasePath, payload, { params: { customerId } }) return truckDetail(unwrapApiResponse(response)) } export async function updateUsTruckOrder(orderNo: string, customerId: string, payload: UpdateLocalTransportOrderRequest) { - const response = await overseasHttp.patch, ApiEnvelope>(`${truckBasePath}/${encodeURIComponent(orderNo)}`, payload, { params: { customerId } }) + const response = await http.patch, ApiEnvelope>(`${truckBasePath}/${encodeURIComponent(orderNo)}`, payload, { params: { customerId } }) return truckDetail(unwrapApiResponse(response)) } export async function listLocalLooseboardOrders(query: LocalLooseboardQuery = {}) { - const response = await overseasHttp.get>, ApiEnvelope>>(looseboardBasePath, { params: { ...query, customerId: query.customerId || undefined, orderNo: query.orderNo?.trim() || undefined, routeKeyword: query.routeKeyword?.trim() || undefined, status: query.status || undefined, transportMode: query.transportMode || undefined } }) + const response = await http.get>, ApiEnvelope>>(looseboardBasePath, { params: { ...query, customerId: query.customerId || undefined, orderNo: query.orderNo?.trim() || undefined, routeKeyword: query.routeKeyword?.trim() || undefined, status: query.status || undefined, transportMode: query.transportMode || undefined } }) return normalizePage(unwrapApiResponse(response), looseboardSummary) } export async function getLocalLooseboardOrder(orderNo: string, customerId: string) { - const response = await overseasHttp.get, ApiEnvelope>(`${looseboardBasePath}/${encodeURIComponent(orderNo)}`, { params: { customerId } }) + const response = await http.get, ApiEnvelope>(`${looseboardBasePath}/${encodeURIComponent(orderNo)}`, { params: { customerId } }) return looseboardDetail(unwrapApiResponse(response)) } export async function createLocalLooseboardOrder(customerId: string, payload: CreateLocalLooseboardOrderRequest) { - const response = await overseasHttp.post, ApiEnvelope>(looseboardBasePath, payload, { params: { customerId } }) + const response = await http.post, ApiEnvelope>(looseboardBasePath, payload, { params: { customerId } }) return looseboardDetail(unwrapApiResponse(response)) } export async function updateLocalLooseboardOrder(orderNo: string, customerId: string, payload: UpdateLocalLooseboardOrderRequest) { - const response = await overseasHttp.put, ApiEnvelope>(`${looseboardBasePath}/${encodeURIComponent(orderNo)}`, payload, { params: { customerId } }) + const response = await http.put, ApiEnvelope>(`${looseboardBasePath}/${encodeURIComponent(orderNo)}`, payload, { params: { customerId } }) return looseboardDetail(unwrapApiResponse(response)) } diff --git a/src/modules/overseas-master-data/api/overseasMasterData.test.ts b/src/modules/overseas-master-data/api/overseasMasterData.test.ts index d192ff54..c69b9cb8 100644 --- a/src/modules/overseas-master-data/api/overseasMasterData.test.ts +++ b/src/modules/overseas-master-data/api/overseasMasterData.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { ApiResponseError } from '@/shared/api/http' -import { overseasHttp } from '@/shared/api/overseasHttp' +import { ApiResponseError, http } from '@/shared/api/http' + import { changeExchangeRateStatus, changeQuoteStatus, changeVehicleStatus, createExchangeRate, createQuote, createVehicle, exportExchangeRates, exportQuotes, exportVehicles, getExchangeRate, getExchangeRateSummary, getQuote, getVehicle, @@ -8,7 +8,7 @@ import { type QuoteCreate, type QuoteUpdate, } from './overseasMasterData' -vi.mock('@/shared/api/overseasHttp', () => ({ overseasHttp: { get: vi.fn(), post: vi.fn(), put: vi.fn(), patch: vi.fn() } })) +vi.mock('@/shared/api/http', async (importOriginal) => ({ ...(await importOriginal()), http: { get: vi.fn(), post: vi.fn(), put: vi.fn(), patch: vi.fn() } })) const ok = (data: unknown) => ({ code: '200', message: 'success', data }) const longId = '9223372036854775807' const quoteSummary = { @@ -29,7 +29,7 @@ beforeEach(() => vi.clearAllMocks()) describe('overseas master data API', () => { it('normalizes quote page/detail decimals and preserves long string IDs', async () => { - vi.mocked(overseasHttp.get) + vi.mocked(http.get) .mockResolvedValueOnce(ok({ records: [quoteSummary], page: '1', size: '20', total: '1', pages: '1' })) .mockResolvedValueOnce(ok(quoteDetail)) .mockResolvedValueOnce(ok([{ warehouseId: quoteSummary.warehouseId, warehouseCode: 'US-LAX', warehouseName: '洛杉矶合作仓', countryCode: 'US' }])) @@ -44,9 +44,9 @@ describe('overseas master data API', () => { expect(detail.transitPrices[0]).toMatchObject({ estimatedMiles: 12.5, price: 18.75, minimumCharge: null }) await listQuoteWarehouseOptions() - expect(overseasHttp.get).toHaveBeenNthCalledWith(1, '/platform/v1/last-leg/master-data/overseas-quotes', { params: { page: 1, size: 20, keyword: 'V1' } }) - expect(overseasHttp.get).toHaveBeenNthCalledWith(2, `/platform/v1/last-leg/master-data/overseas-quotes/${longId}`) - expect(overseasHttp.get).toHaveBeenNthCalledWith(3, '/platform/v1/last-leg/master-data/overseas-quotes/warehouse-options') + expect(http.get).toHaveBeenNthCalledWith(1, '/platform/v1/last-leg/master-data/overseas-quotes', { params: { page: 1, size: 20, keyword: 'V1' } }) + expect(http.get).toHaveBeenNthCalledWith(2, `/platform/v1/last-leg/master-data/overseas-quotes/${longId}`) + expect(http.get).toHaveBeenNthCalledWith(3, '/platform/v1/last-leg/master-data/overseas-quotes/warehouse-options') }) it('keeps quote create/update DTOs distinct and sends recordVersion on writes', async () => { @@ -60,24 +60,24 @@ describe('overseas master data API', () => { name: '报价-编辑', providerName: '服务商', currency: 'USD', effectiveStart: '2026-08-01', effectiveEnd: '2026-12-31', warehouseFees: create.warehouseFees, transitPrices: [], remark: null, recordVersion: 7, } - vi.mocked(overseasHttp.post).mockResolvedValue(ok(quoteDetail)) - vi.mocked(overseasHttp.put).mockResolvedValue(ok({ ...quoteDetail, summary: { ...quoteSummary, recordVersion: '8' } })) - vi.mocked(overseasHttp.patch).mockResolvedValue(ok({ ...quoteDetail, summary: { ...quoteSummary, status: 'ACTIVE', recordVersion: '9' } })) + vi.mocked(http.post).mockResolvedValue(ok(quoteDetail)) + vi.mocked(http.put).mockResolvedValue(ok({ ...quoteDetail, summary: { ...quoteSummary, recordVersion: '8' } })) + vi.mocked(http.patch).mockResolvedValue(ok({ ...quoteDetail, summary: { ...quoteSummary, status: 'ACTIVE', recordVersion: '9' } })) await createQuote(create) const updated = await updateQuote(longId, update) await changeQuoteStatus(longId, 'ACTIVE', 8) expect(updated.summary.recordVersion).toBe(8) - expect(overseasHttp.post).toHaveBeenCalledWith('/platform/v1/last-leg/master-data/overseas-quotes', create) - expect(overseasHttp.put).toHaveBeenCalledWith(`/platform/v1/last-leg/master-data/overseas-quotes/${longId}`, update) - expect(overseasHttp.patch).toHaveBeenCalledWith(`/platform/v1/last-leg/master-data/overseas-quotes/${longId}/status`, { status: 'ACTIVE', recordVersion: 8 }) + expect(http.post).toHaveBeenCalledWith('/platform/v1/last-leg/master-data/overseas-quotes', create) + expect(http.put).toHaveBeenCalledWith(`/platform/v1/last-leg/master-data/overseas-quotes/${longId}`, update) + expect(http.patch).toHaveBeenCalledWith(`/platform/v1/last-leg/master-data/overseas-quotes/${longId}/status`, { status: 'ACTIVE', recordVersion: 8 }) expect(update).not.toHaveProperty('warehouseId') expect(update).not.toHaveProperty('versionCode') }) it('normalizes exchange-rate decimal/version and uses month as the stable key', async () => { - vi.mocked(overseasHttp.get) + vi.mocked(http.get) .mockResolvedValueOnce(ok({ records: [exchangeRate], page: '1', size: '20', total: '1', pages: '1' })) .mockResolvedValueOnce(ok(exchangeRate)) .mockResolvedValueOnce(ok({ latestMonth: '2026-08', latestUsdToCnyAverage: '7.12345678', updatedBy: 'admin', updatedAt: '2026-08-24T10:00:00' })) @@ -88,17 +88,17 @@ describe('overseas master data API', () => { expect(detail).toMatchObject({ month: '2026-08', usdToCnyAverage: 7.12345678, recordVersion: 3 }) expect(summary.latestUsdToCnyAverage).toBe(7.12345678) - vi.mocked(overseasHttp.post).mockResolvedValue(ok(exchangeRate)) - vi.mocked(overseasHttp.put).mockResolvedValue(ok({ ...exchangeRate, recordVersion: '4' })) - vi.mocked(overseasHttp.patch).mockResolvedValue(ok({ ...exchangeRate, status: 'INACTIVE', recordVersion: '5' })) + vi.mocked(http.post).mockResolvedValue(ok(exchangeRate)) + vi.mocked(http.put).mockResolvedValue(ok({ ...exchangeRate, recordVersion: '4' })) + vi.mocked(http.patch).mockResolvedValue(ok({ ...exchangeRate, status: 'INACTIVE', recordVersion: '5' })) await createExchangeRate({ month: '2026-08', usdToCnyAverage: 7.12345678, remark: null }) await updateExchangeRate('2026-08', { recordVersion: 3, usdToCnyAverage: 7.2, remark: null }) await changeExchangeRateStatus('2026-08', 'INACTIVE', 4, '停用') - expect(overseasHttp.patch).toHaveBeenLastCalledWith('/platform/v1/last-leg/master-data/exchange-rates/2026-08/status', { status: 'INACTIVE', recordVersion: 4, remark: '停用' }) + expect(http.patch).toHaveBeenLastCalledWith('/platform/v1/last-leg/master-data/exchange-rates/2026-08/status', { status: 'INACTIVE', recordVersion: 4, remark: '停用' }) }) it('normalizes vehicle decimals/version while preserving long string IDs', async () => { - vi.mocked(overseasHttp.get) + vi.mocked(http.get) .mockResolvedValueOnce(ok({ records: [vehicle], page: '1', size: '20', total: '1', pages: '1' })) .mockResolvedValueOnce(ok(vehicle)) const result = await listVehicles({ page: 1, size: 20, ownership: 'OWNED' }) @@ -107,18 +107,18 @@ describe('overseas master data API', () => { expect(detail.id).toBe(longId) const editable = { plateNumber: 'CA-01', model: 'Truck', fuelConsumptionPer100Km: 12.5, mileageKm: 101, driverOrDispatchCompany: null, phone: null, region: 'OVERSEAS' as const, ownership: 'OWNED' as const, remark: null } - vi.mocked(overseasHttp.post).mockResolvedValue(ok(vehicle)) - vi.mocked(overseasHttp.put).mockResolvedValue(ok({ ...vehicle, recordVersion: '5' })) - vi.mocked(overseasHttp.patch).mockResolvedValue(ok({ ...vehicle, status: 'INACTIVE', recordVersion: '6' })) + vi.mocked(http.post).mockResolvedValue(ok(vehicle)) + vi.mocked(http.put).mockResolvedValue(ok({ ...vehicle, recordVersion: '5' })) + vi.mocked(http.patch).mockResolvedValue(ok({ ...vehicle, status: 'INACTIVE', recordVersion: '6' })) await createVehicle({ ...editable, status: 'IN_SERVICE' }) await updateVehicle(longId, { ...editable, recordVersion: 4 }) await changeVehicleStatus(longId, 'INACTIVE', 5) - expect(overseasHttp.put).toHaveBeenCalledWith(`/platform/v1/last-leg/master-data/vehicles/${longId}`, { ...editable, recordVersion: 4 }) - expect(overseasHttp.patch).toHaveBeenCalledWith(`/platform/v1/last-leg/master-data/vehicles/${longId}/status`, { status: 'INACTIVE', recordVersion: 5 }) + expect(http.put).toHaveBeenCalledWith(`/platform/v1/last-leg/master-data/vehicles/${longId}`, { ...editable, recordVersion: 4 }) + expect(http.patch).toHaveBeenCalledWith(`/platform/v1/last-leg/master-data/vehicles/${longId}/status`, { status: 'INACTIVE', recordVersion: 5 }) }) it('preserves A0409 metadata instead of translating it into success', async () => { - vi.mocked(overseasHttp.put).mockResolvedValue({ code: 'A0409', message: '记录版本已变化', data: null, requestId: 'request-conflict', traceId: 'trace-conflict' }) + vi.mocked(http.put).mockResolvedValue({ code: 'A0409', message: '记录版本已变化', data: null, requestId: 'request-conflict', traceId: 'trace-conflict' }) const error = await updateExchangeRate('2026-08', { recordVersion: 1, usdToCnyAverage: 7.2, remark: null }).catch((reason: unknown) => reason) expect(error).toBeInstanceOf(ApiResponseError) expect(error).toMatchObject({ code: 'A0409', message: '记录版本已变化', requestId: 'request-conflict', traceId: 'trace-conflict' }) @@ -126,7 +126,7 @@ describe('overseas master data API', () => { it('downloads real CSV Blobs and rejects JSON error Blobs with trace metadata', async () => { const csv = new Blob(['id,status\n1,ACTIVE\n'], { type: 'text/csv;charset=UTF-8' }) - vi.mocked(overseasHttp.get) + vi.mocked(http.get) .mockResolvedValueOnce(csv) .mockResolvedValueOnce(csv) .mockResolvedValueOnce(csv) @@ -138,8 +138,8 @@ describe('overseas master data API', () => { const error = await exportVehicles({}).catch((reason: unknown) => reason) expect(error).toBeInstanceOf(ApiResponseError) expect(error).toMatchObject({ code: 'A0409', message: '导出条件已变化', requestId: 'request-export' }) - expect(overseasHttp.get).toHaveBeenNthCalledWith(1, '/platform/v1/last-leg/master-data/overseas-quotes/export', { params: { status: 'ACTIVE' }, responseType: 'arraybuffer' }) - expect(overseasHttp.get).toHaveBeenNthCalledWith(2, '/platform/v1/last-leg/master-data/exchange-rates/export', { params: {}, responseType: 'arraybuffer' }) - expect(overseasHttp.get).toHaveBeenNthCalledWith(3, '/platform/v1/last-leg/master-data/vehicles/export', { params: { ownership: 'OWNED' }, responseType: 'arraybuffer' }) + expect(http.get).toHaveBeenNthCalledWith(1, '/platform/v1/last-leg/master-data/overseas-quotes/export', { params: { status: 'ACTIVE' }, responseType: 'arraybuffer' }) + expect(http.get).toHaveBeenNthCalledWith(2, '/platform/v1/last-leg/master-data/exchange-rates/export', { params: {}, responseType: 'arraybuffer' }) + expect(http.get).toHaveBeenNthCalledWith(3, '/platform/v1/last-leg/master-data/vehicles/export', { params: { ownership: 'OWNED' }, responseType: 'arraybuffer' }) }) }) diff --git a/src/modules/overseas-master-data/api/overseasMasterData.ts b/src/modules/overseas-master-data/api/overseasMasterData.ts index 53901045..739a8bba 100644 --- a/src/modules/overseas-master-data/api/overseasMasterData.ts +++ b/src/modules/overseas-master-data/api/overseasMasterData.ts @@ -1,6 +1,6 @@ import axios from 'axios' -import { overseasHttp } from '@/shared/api/overseasHttp' -import { ApiResponseError, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' +import { http, ApiResponseError, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' + import type { MasterDataStatus, MonthlyExchangeRate, OverseasTransitPrice, OverseasWarehouseFee, OverseasWarehouseQuote, OverseasWarehouseQuoteDetail, PartnerWarehouseOption, VehicleDetail, VehicleOwnership, VehicleRegion, VehicleStatus, @@ -23,7 +23,7 @@ function page(value: PagePayload, normalize: (record: W) => T): PageRes return { ...value, records: value.records.map(normalize), page: Number(value.page), size: Number(value.size), total: Number(value.total), pages: Number(value.pages) } } async function getPage(path: string, params: object, normalize: (record: W) => T) { - return page(unwrapApiResponse(await overseasHttp.get>, ApiEnvelope>>(path, { params })), normalize) + return page(unwrapApiResponse(await http.get>, ApiEnvelope>>(path, { params })), normalize) } const numeric = (value: Numeric) => Number(value) const nullableNumeric = (value: Numeric | null) => value == null ? null : Number(value) @@ -71,7 +71,7 @@ function csvBlob(payload: DownloadPayload) { } async function download(path: string, params: object) { try { - const payload = await overseasHttp.get(path, { params, responseType: 'arraybuffer' }) + const payload = await http.get(path, { params, responseType: 'arraybuffer' }) const error = await csvError(payload, 200) if (error) throw error return csvBlob(payload) @@ -93,23 +93,23 @@ type QuoteEditableFields = Pick export type QuoteUpdate = QuoteEditableFields & Pick export const listQuotes = (query: QuoteQuery) => getPage(`${base}/overseas-quotes`, query, normalizeQuote) -export async function getQuote(id: string) { return normalizeQuoteDetail(unwrapApiResponse(await overseasHttp.get, ApiEnvelope>(`${base}/overseas-quotes/${encodeURIComponent(id)}`))) } -export async function listQuoteWarehouseOptions() { return unwrapApiResponse(await overseasHttp.get, ApiEnvelope>(`${base}/overseas-quotes/warehouse-options`)) } -export async function createQuote(payload: QuoteCreate) { return normalizeQuoteDetail(unwrapApiResponse(await overseasHttp.post, ApiEnvelope>(`${base}/overseas-quotes`, payload))) } -export async function updateQuote(id: string, payload: QuoteUpdate) { return normalizeQuoteDetail(unwrapApiResponse(await overseasHttp.put, ApiEnvelope>(`${base}/overseas-quotes/${encodeURIComponent(id)}`, payload))) } -export async function changeQuoteStatus(id: string, status: MasterDataStatus, recordVersion: number) { return normalizeQuoteDetail(unwrapApiResponse(await overseasHttp.patch, ApiEnvelope>(`${base}/overseas-quotes/${encodeURIComponent(id)}/status`, { status, recordVersion }))) } +export async function getQuote(id: string) { return normalizeQuoteDetail(unwrapApiResponse(await http.get, ApiEnvelope>(`${base}/overseas-quotes/${encodeURIComponent(id)}`))) } +export async function listQuoteWarehouseOptions() { return unwrapApiResponse(await http.get, ApiEnvelope>(`${base}/overseas-quotes/warehouse-options`)) } +export async function createQuote(payload: QuoteCreate) { return normalizeQuoteDetail(unwrapApiResponse(await http.post, ApiEnvelope>(`${base}/overseas-quotes`, payload))) } +export async function updateQuote(id: string, payload: QuoteUpdate) { return normalizeQuoteDetail(unwrapApiResponse(await http.put, ApiEnvelope>(`${base}/overseas-quotes/${encodeURIComponent(id)}`, payload))) } +export async function changeQuoteStatus(id: string, status: MasterDataStatus, recordVersion: number) { return normalizeQuoteDetail(unwrapApiResponse(await http.patch, ApiEnvelope>(`${base}/overseas-quotes/${encodeURIComponent(id)}/status`, { status, recordVersion }))) } export const exportQuotes = (query: Omit) => download(`${base}/overseas-quotes/export`, query) export interface ExchangeRateQuery { page: number; size: number; monthFrom?: string; monthTo?: string; status?: MasterDataStatus } export const listExchangeRates = (query: ExchangeRateQuery) => getPage(`${base}/exchange-rates`, query, normalizeExchangeRate) -export async function getExchangeRate(month: string) { return normalizeExchangeRate(unwrapApiResponse(await overseasHttp.get, ApiEnvelope>(`${base}/exchange-rates/${encodeURIComponent(month)}`))) } +export async function getExchangeRate(month: string) { return normalizeExchangeRate(unwrapApiResponse(await http.get, ApiEnvelope>(`${base}/exchange-rates/${encodeURIComponent(month)}`))) } export async function getExchangeRateSummary(): Promise { - const value = unwrapApiResponse(await overseasHttp.get, ApiEnvelope>(`${base}/exchange-rates/summary`)) + const value = unwrapApiResponse(await http.get, ApiEnvelope>(`${base}/exchange-rates/summary`)) return { ...value, latestUsdToCnyAverage: nullableNumeric(value.latestUsdToCnyAverage) } } -export async function createExchangeRate(payload: { month: string; usdToCnyAverage: number; remark: string | null }) { return normalizeExchangeRate(unwrapApiResponse(await overseasHttp.post, ApiEnvelope>(`${base}/exchange-rates`, payload))) } -export async function updateExchangeRate(month: string, payload: { recordVersion: number; usdToCnyAverage: number; remark: string | null }) { return normalizeExchangeRate(unwrapApiResponse(await overseasHttp.put, ApiEnvelope>(`${base}/exchange-rates/${encodeURIComponent(month)}`, payload))) } -export async function changeExchangeRateStatus(month: string, status: MasterDataStatus, recordVersion: number, remark: string | null) { return normalizeExchangeRate(unwrapApiResponse(await overseasHttp.patch, ApiEnvelope>(`${base}/exchange-rates/${encodeURIComponent(month)}/status`, { status, recordVersion, remark }))) } +export async function createExchangeRate(payload: { month: string; usdToCnyAverage: number; remark: string | null }) { return normalizeExchangeRate(unwrapApiResponse(await http.post, ApiEnvelope>(`${base}/exchange-rates`, payload))) } +export async function updateExchangeRate(month: string, payload: { recordVersion: number; usdToCnyAverage: number; remark: string | null }) { return normalizeExchangeRate(unwrapApiResponse(await http.put, ApiEnvelope>(`${base}/exchange-rates/${encodeURIComponent(month)}`, payload))) } +export async function changeExchangeRateStatus(month: string, status: MasterDataStatus, recordVersion: number, remark: string | null) { return normalizeExchangeRate(unwrapApiResponse(await http.patch, ApiEnvelope>(`${base}/exchange-rates/${encodeURIComponent(month)}/status`, { status, recordVersion, remark }))) } export const exportExchangeRates = (query: Omit) => download(`${base}/exchange-rates/export`, query) export interface VehicleQuery { page: number; size: number; keyword?: string; ownership?: VehicleOwnership; region?: VehicleRegion; status?: VehicleStatus } @@ -117,8 +117,8 @@ type VehicleEditableFields = Omit export type VehicleUpdate = VehicleEditableFields & Pick export const listVehicles = (query: VehicleQuery) => getPage(`${base}/vehicles`, query, normalizeVehicle) -export async function getVehicle(id: string) { return normalizeVehicle(unwrapApiResponse(await overseasHttp.get, ApiEnvelope>(`${base}/vehicles/${encodeURIComponent(id)}`))) } -export async function createVehicle(payload: VehicleCreate) { return normalizeVehicle(unwrapApiResponse(await overseasHttp.post, ApiEnvelope>(`${base}/vehicles`, payload))) } -export async function updateVehicle(id: string, payload: VehicleUpdate) { return normalizeVehicle(unwrapApiResponse(await overseasHttp.put, ApiEnvelope>(`${base}/vehicles/${encodeURIComponent(id)}`, payload))) } -export async function changeVehicleStatus(id: string, status: VehicleStatus, recordVersion: number) { return normalizeVehicle(unwrapApiResponse(await overseasHttp.patch, ApiEnvelope>(`${base}/vehicles/${encodeURIComponent(id)}/status`, { status, recordVersion }))) } +export async function getVehicle(id: string) { return normalizeVehicle(unwrapApiResponse(await http.get, ApiEnvelope>(`${base}/vehicles/${encodeURIComponent(id)}`))) } +export async function createVehicle(payload: VehicleCreate) { return normalizeVehicle(unwrapApiResponse(await http.post, ApiEnvelope>(`${base}/vehicles`, payload))) } +export async function updateVehicle(id: string, payload: VehicleUpdate) { return normalizeVehicle(unwrapApiResponse(await http.put, ApiEnvelope>(`${base}/vehicles/${encodeURIComponent(id)}`, payload))) } +export async function changeVehicleStatus(id: string, status: VehicleStatus, recordVersion: number) { return normalizeVehicle(unwrapApiResponse(await http.patch, ApiEnvelope>(`${base}/vehicles/${encodeURIComponent(id)}/status`, { status, recordVersion }))) } export const exportVehicles = (query: Omit) => download(`${base}/vehicles/export`, query) diff --git a/src/modules/overseas-warehouse/api/customerVisits.test.ts b/src/modules/overseas-warehouse/api/customerVisits.test.ts index dd45827e..043d898a 100644 --- a/src/modules/overseas-warehouse/api/customerVisits.test.ts +++ b/src/modules/overseas-warehouse/api/customerVisits.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { overseasHttp } from '@/shared/api/overseasHttp' +import { http } from '@/shared/api/http' import { getCustomerVisit, listCustomerVisits, @@ -8,45 +8,45 @@ import { uploadCustomerVisitAttachment, } from './customerVisits' -vi.mock('@/shared/api/overseasHttp', () => ({ overseasHttp: { get: vi.fn(), post: vi.fn(), put: vi.fn() } })) +vi.mock('@/shared/api/http', async (importOriginal) => ({ ...(await importOriginal()), http: { get: vi.fn(), post: vi.fn(), put: vi.fn() } })) describe('customer visit API', () => { beforeEach(() => { - vi.mocked(overseasHttp.get).mockReset() - vi.mocked(overseasHttp.post).mockReset() - vi.mocked(overseasHttp.put).mockReset() + vi.mocked(http.get).mockReset() + vi.mocked(http.post).mockReset() + vi.mocked(http.put).mockReset() }) it('loads the customer visit page and detail in customer scope', async () => { - vi.mocked(overseasHttp.get).mockResolvedValueOnce({ code: '200', data: { records: [], current: 1, size: 10, total: 0, pages: 0, visitable: 3, pending: 2, completed: 1 } }) + vi.mocked(http.get).mockResolvedValueOnce({ code: '200', data: { records: [], current: 1, size: 10, total: 0, pages: 0, visitable: 3, pending: 2, completed: 1 } }) await listCustomerVisits({ page: 1, size: 10, keyword: 'DEL' }) - expect(overseasHttp.get).toHaveBeenNthCalledWith(1, '/platform/v1/last-leg/customer-visits', { params: { page: 1, size: 10, keyword: 'DEL' } }) + expect(http.get).toHaveBeenNthCalledWith(1, '/platform/v1/last-leg/customer-visits', { params: { page: 1, size: 10, keyword: 'DEL' } }) - vi.mocked(overseasHttp.get).mockResolvedValueOnce({ code: '200', data: { visit: { id: '9' }, inboundOrders: [] } }) + vi.mocked(http.get).mockResolvedValueOnce({ code: '200', data: { visit: { id: '9' }, inboundOrders: [] } }) await getCustomerVisit('9', '100') - expect(overseasHttp.get).toHaveBeenNthCalledWith(2, '/platform/v1/last-leg/customer-visits/9', { params: { customerId: '100' } }) + expect(http.get).toHaveBeenNthCalledWith(2, '/platform/v1/last-leg/customer-visits/9', { params: { customerId: '100' } }) }) it('updates a delivery to an operator-managed nonterminal status', async () => { - vi.mocked(overseasHttp.put).mockResolvedValue({ code: '200', data: { deliveryStatus: 'DELIVERING' } }) + vi.mocked(http.put).mockResolvedValue({ code: '200', data: { deliveryStatus: 'DELIVERING' } }) await updateDeliveryOrderStatus('DEL/1', '100', { status: 'DELIVERING', expectedVersion: 7, remark: '开始派送' }) - expect(overseasHttp.put).toHaveBeenCalledWith('/platform/v1/last-leg/delivery-orders/DEL%2F1/status', { status: 'DELIVERING', expectedVersion: 7, remark: '开始派送' }, { params: { customerId: '100' } }) + expect(http.put).toHaveBeenCalledWith('/platform/v1/last-leg/delivery-orders/DEL%2F1/status', { status: 'DELIVERING', expectedVersion: 7, remark: '开始派送' }, { params: { customerId: '100' } }) }) it('uploads and saves repeatable visit material', async () => { const file = new File(['data'], 'visit.pdf', { type: 'application/pdf' }) - vi.mocked(overseasHttp.post).mockResolvedValue({ code: '200', data: { fileId: '8', fileName: 'visit.pdf', sizeBytes: 4 } }) + vi.mocked(http.post).mockResolvedValue({ code: '200', data: { fileId: '8', fileName: 'visit.pdf', sizeBytes: 4 } }) await uploadCustomerVisitAttachment('9', '100', file) - const body = vi.mocked(overseasHttp.post).mock.calls[0]?.[1] as FormData + const body = vi.mocked(http.post).mock.calls[0]?.[1] as FormData expect(body.get('file')).toBe(file) - expect(overseasHttp.post).toHaveBeenCalledWith( + expect(http.post).toHaveBeenCalledWith( '/platform/v1/last-leg/customer-visits/9/attachments', body, { params: { customerId: '100' }, headers: { 'Content-Type': 'multipart/form-data' } }, ) - vi.mocked(overseasHttp.put).mockResolvedValue({ code: '200', data: { visit: { id: '9' }, inboundOrders: [] } }) + vi.mocked(http.put).mockResolvedValue({ code: '200', data: { visit: { id: '9' }, inboundOrders: [] } }) await saveCustomerVisit('9', '100', { selectedInboundOrderId: '7', visitResult: 'NORMAL', attachments: [] }) - expect(overseasHttp.put).toHaveBeenCalledWith('/platform/v1/last-leg/customer-visits/9', expect.objectContaining({ selectedInboundOrderId: '7' }), { params: { customerId: '100' } }) + expect(http.put).toHaveBeenCalledWith('/platform/v1/last-leg/customer-visits/9', expect.objectContaining({ selectedInboundOrderId: '7' }), { params: { customerId: '100' } }) }) }) diff --git a/src/modules/overseas-warehouse/api/customerVisits.ts b/src/modules/overseas-warehouse/api/customerVisits.ts index df84a332..28de8b50 100644 --- a/src/modules/overseas-warehouse/api/customerVisits.ts +++ b/src/modules/overseas-warehouse/api/customerVisits.ts @@ -1,5 +1,5 @@ -import { overseasHttp } from '@/shared/api/overseasHttp' -import { unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' +import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' + export interface CustomerVisitAttachment { fileId: string @@ -63,14 +63,14 @@ export async function listCustomerVisits(params: { completedFrom?: string completedTo?: string }) { - const response = await overseasHttp.get, ApiEnvelope>( + const response = await http.get, ApiEnvelope>( '/platform/v1/last-leg/customer-visits', { params }, ) return unwrapApiResponse(response) } export async function getCustomerVisit(visitId: string, customerId: string) { - const response = await overseasHttp.get, ApiEnvelope>( + const response = await http.get, ApiEnvelope>( `/platform/v1/last-leg/customer-visits/${encodeURIComponent(visitId)}`, { params: { customerId } }, ) return unwrapApiResponse(response) @@ -83,7 +83,7 @@ export async function saveCustomerVisit(visitId: string, customerId: string, pay remark?: string attachments: CustomerVisitAttachment[] }) { - const response = await overseasHttp.put, ApiEnvelope>( + const response = await http.put, ApiEnvelope>( `/platform/v1/last-leg/customer-visits/${encodeURIComponent(visitId)}`, payload, { params: { customerId } }, ) return unwrapApiResponse(response) @@ -92,7 +92,7 @@ export async function saveCustomerVisit(visitId: string, customerId: string, pay export async function uploadCustomerVisitAttachment(visitId: string, customerId: string, file: File) { const body = new FormData() body.append('file', file) - const response = await overseasHttp.post, ApiEnvelope>( + const response = await http.post, ApiEnvelope>( `/platform/v1/last-leg/customer-visits/${encodeURIComponent(visitId)}/attachments`, body, { params: { customerId }, headers: { 'Content-Type': 'multipart/form-data' } }, ) @@ -100,7 +100,7 @@ export async function uploadCustomerVisitAttachment(visitId: string, customerId: } export async function updateDeliveryOrderStatus(deliveryNo: string, customerId: string, payload: { status: string; expectedVersion: number; remark?: string }) { - const response = await overseasHttp.put, ApiEnvelope<{ visitId?: string | null; deliveryNo: string; deliveryStatus: string; visitStatus?: string | null; replayed: boolean }>>( + const response = await http.put, ApiEnvelope<{ visitId?: string | null; deliveryNo: string; deliveryStatus: string; visitStatus?: string | null; replayed: boolean }>>( `/platform/v1/last-leg/delivery-orders/${encodeURIComponent(deliveryNo)}/status`, payload, { params: { customerId } }, ) diff --git a/src/modules/overseas-warehouse/api/deliveryDocuments.test.ts b/src/modules/overseas-warehouse/api/deliveryDocuments.test.ts index bc07ffb8..ecb1b423 100644 --- a/src/modules/overseas-warehouse/api/deliveryDocuments.test.ts +++ b/src/modules/overseas-warehouse/api/deliveryDocuments.test.ts @@ -1,28 +1,28 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { overseasHttp } from '@/shared/api/overseasHttp' +import { http } from '@/shared/api/http' import { deliveryDocumentCategories, getDeliveryDocuments, saveDeliveryDocuments, uploadDeliveryDocument, downloadDeliveryDocument } from './deliveryDocuments' -vi.mock('@/shared/api/overseasHttp', () => ({ overseasHttp: { get: vi.fn(), post: vi.fn(), put: vi.fn() } })) +vi.mock('@/shared/api/http', async (importOriginal) => ({ ...(await importOriginal()), http: { get: vi.fn(), post: vi.fn(), put: vi.fn() } })) describe('delivery document API', () => { beforeEach(() => vi.clearAllMocks()) it('scopes reads and full three-category snapshots by customer and delivery number', async () => { - vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', data: {} }) - vi.mocked(overseasHttp.put).mockResolvedValue({ code: '200', data: {} }) + vi.mocked(http.get).mockResolvedValue({ code: '200', data: {} }) + vi.mocked(http.put).mockResolvedValue({ code: '200', data: {} }) await getDeliveryDocuments('DL/1', '9007199254740993') - expect(overseasHttp.get).toHaveBeenCalledWith('/platform/v1/last-leg/delivery-orders/DL%2F1/documents', { params: { customerId: '9007199254740993' } }) + expect(http.get).toHaveBeenCalledWith('/platform/v1/last-leg/delivery-orders/DL%2F1/documents', { params: { customerId: '9007199254740993' } }) const body = { expectedVersion: 8, idempotencyKey: 'save-1', bolNo: null, documents: deliveryDocumentCategories.map(item => ({ category: item.category, fileIds: [] })) } await saveDeliveryDocuments('DL/1', '9007199254740993', body) - expect(overseasHttp.put).toHaveBeenCalledWith('/platform/v1/last-leg/delivery-orders/DL%2F1/documents', body, { params: { customerId: '9007199254740993' } }) + expect(http.put).toHaveBeenCalledWith('/platform/v1/last-leg/delivery-orders/DL%2F1/documents', body, { params: { customerId: '9007199254740993' } }) }) it('uploads only the file and downloads through the authenticated category endpoint', async () => { - vi.mocked(overseasHttp.post).mockResolvedValue({ code: '200', data: { fileId: '9007199254740995' } }) + vi.mocked(http.post).mockResolvedValue({ code: '200', data: { fileId: '9007199254740995' } }) const file = new File(['data'], 'proof.bin') await uploadDeliveryDocument('DL/1', '100', 'RECEIPT', file) - const [url, body, config] = vi.mocked(overseasHttp.post).mock.calls[0]! + const [url, body, config] = vi.mocked(http.post).mock.calls[0]! expect(url).toBe('/platform/v1/last-leg/delivery-orders/DL%2F1/documents/RECEIPT/files') expect(Array.from((body as FormData).keys())).toEqual(['file']) expect((body as FormData).get('file')).toBe(file) expect(config).toEqual({ params: { customerId: '100' } }) await downloadDeliveryDocument('DL/1', '100', 'RECEIPT', '9007199254740995') - expect(overseasHttp.get).toHaveBeenCalledWith('/platform/v1/last-leg/delivery-orders/DL%2F1/documents/RECEIPT/files/9007199254740995/download', { params: { customerId: '100' }, responseType: 'blob' }) + expect(http.get).toHaveBeenCalledWith('/platform/v1/last-leg/delivery-orders/DL%2F1/documents/RECEIPT/files/9007199254740995/download', { params: { customerId: '100' }, responseType: 'blob' }) }) }) diff --git a/src/modules/overseas-warehouse/api/deliveryDocuments.ts b/src/modules/overseas-warehouse/api/deliveryDocuments.ts index 13eaf4e0..a13a21ad 100644 --- a/src/modules/overseas-warehouse/api/deliveryDocuments.ts +++ b/src/modules/overseas-warehouse/api/deliveryDocuments.ts @@ -1,5 +1,5 @@ -import { overseasHttp } from '@/shared/api/overseasHttp' -import { unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' +import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' + export const deliveryDocumentCategories = [ { category: 'LOADING_PHOTO', label: '装车照片' }, @@ -18,16 +18,16 @@ export interface DeliveryDocuments extends DeliveryDocumentSummary { export interface SaveDeliveryDocuments { expectedVersion: number; idempotencyKey: string; bolNo: string | null; documents: Array<{ category: DeliveryDocumentCategory; fileIds: string[] }> } const path = (deliveryNo: string) => `/platform/v1/last-leg/delivery-orders/${encodeURIComponent(deliveryNo)}/documents` export async function getDeliveryDocuments(deliveryNo: string, customerId: string) { - return unwrapApiResponse(await overseasHttp.get, ApiEnvelope>(path(deliveryNo), { params: { customerId } })) + return unwrapApiResponse(await http.get, ApiEnvelope>(path(deliveryNo), { params: { customerId } })) } export async function saveDeliveryDocuments(deliveryNo: string, customerId: string, body: SaveDeliveryDocuments) { - return unwrapApiResponse(await overseasHttp.put, ApiEnvelope>(path(deliveryNo), body, { params: { customerId } })) + return unwrapApiResponse(await http.put, ApiEnvelope>(path(deliveryNo), body, { params: { customerId } })) } export async function uploadDeliveryDocument(deliveryNo: string, customerId: string, category: DeliveryDocumentCategory, file: File) { const body = new FormData() body.append('file', file) - return unwrapApiResponse(await overseasHttp.post, ApiEnvelope>(`${path(deliveryNo)}/${category}/files`, body, { params: { customerId } })) + return unwrapApiResponse(await http.post, ApiEnvelope>(`${path(deliveryNo)}/${category}/files`, body, { params: { customerId } })) } export async function downloadDeliveryDocument(deliveryNo: string, customerId: string, category: DeliveryDocumentCategory, fileId: string) { - return overseasHttp.get(`${path(deliveryNo)}/${category}/files/${encodeURIComponent(fileId)}/download`, { params: { customerId }, responseType: 'blob' }) + return http.get(`${path(deliveryNo)}/${category}/files/${encodeURIComponent(fileId)}/download`, { params: { customerId }, responseType: 'blob' }) } diff --git a/src/modules/overseas-warehouse/api/deliveryOrders.test.ts b/src/modules/overseas-warehouse/api/deliveryOrders.test.ts index 2aa59f70..015bdd33 100644 --- a/src/modules/overseas-warehouse/api/deliveryOrders.test.ts +++ b/src/modules/overseas-warehouse/api/deliveryOrders.test.ts @@ -1,48 +1,48 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { overseasHttp } from '@/shared/api/overseasHttp' +import { http } from '@/shared/api/http' import { getDeliveryOrder, listDeliveryOrders, listDeliveryReturnInboundOptions, markDeliveryReturn } from './deliveryOrders' -vi.mock('@/shared/api/overseasHttp', () => ({ overseasHttp: { get: vi.fn(), post: vi.fn() } })) +vi.mock('@/shared/api/http', async (importOriginal) => ({ ...(await importOriginal()), http: { get: vi.fn(), post: vi.fn() } })) describe('delivery order API', () => { beforeEach(() => { - vi.mocked(overseasHttp.get).mockReset() - vi.mocked(overseasHttp.post).mockReset() + vi.mocked(http.get).mockReset() + vi.mocked(http.post).mockReset() }) it('passes paging and expanded filters and normalizes page numbers', async () => { - vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', data: { records: [], page: '2', size: '20', total: '41', pages: '3' } }) + vi.mocked(http.get).mockResolvedValue({ code: '200', data: { records: [], page: '2', size: '20', total: '41', pages: '3' } }) const result = await listDeliveryOrders({ page: 2, size: 20, documentNo: 'DEL-1', vehiclePlate: 'CA-1' }) - expect(overseasHttp.get).toHaveBeenCalledWith('/platform/v1/last-leg/delivery-orders', { params: { page: 2, size: 20, documentNo: 'DEL-1', vehiclePlate: 'CA-1' } }) + expect(http.get).toHaveBeenCalledWith('/platform/v1/last-leg/delivery-orders', { params: { page: 2, size: 20, documentNo: 'DEL-1', vehiclePlate: 'CA-1' } }) expect(result).toMatchObject({ page: 2, size: 20, total: 41, pages: 3 }) }) it('loads an encoded detail in its explicit customer scope', async () => { - vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', data: { order: { deliveryNo: 'DEL/1', version: '0' }, skus: [], fees: [], operations: [] } }) + vi.mocked(http.get).mockResolvedValue({ code: '200', data: { order: { deliveryNo: 'DEL/1', version: '0' }, skus: [], fees: [], operations: [] } }) await getDeliveryOrder('DEL/1', '1001') - expect(overseasHttp.get).toHaveBeenCalledWith('/platform/v1/last-leg/delivery-orders/DEL%2F1', { params: { customerId: '1001', page: 1, size: 200 } }) + expect(http.get).toHaveBeenCalledWith('/platform/v1/last-leg/delivery-orders/DEL%2F1', { params: { customerId: '1001', page: 1, size: 200 } }) }) it.each(['0', '7', '9007199254740991'])('reads Java Long version %s for status editing', async version => { - vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', data: { order: { version, id: '2097515131537313794' } } }) + vi.mocked(http.get).mockResolvedValue({ code: '200', data: { order: { version, id: '2097515131537313794' } } }) const detail = await getDeliveryOrder('DEL/1', '1001') expect(detail.order.version).toBe(Number(version)) expect(detail.order.id).toBe('2097515131537313794') }) it.each([null, undefined, '', ' ', '-1', '1.5', '9007199254740992', -1])('rejects invalid version %s', async version => { - vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', data: { order: { version } } }) + vi.mocked(http.get).mockResolvedValue({ code: '200', data: { order: { version } } }) await expect(getDeliveryOrder('DEL/1', '1001')).rejects.toThrow('派送单缺少有效版本') }) it('loads return options and marks a return in the explicit customer scope', async () => { - vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', data: [{ inboundOrderId: '9', inboundCode: 'IN-1' }] }) + vi.mocked(http.get).mockResolvedValue({ code: '200', data: [{ inboundOrderId: '9', inboundCode: 'IN-1' }] }) await listDeliveryReturnInboundOptions('DEL/1', '1001') - expect(overseasHttp.get).toHaveBeenCalledWith('/platform/v1/last-leg/delivery-orders/DEL%2F1/return-inbound-options', { params: { customerId: '1001' } }) + expect(http.get).toHaveBeenCalledWith('/platform/v1/last-leg/delivery-orders/DEL%2F1/return-inbound-options', { params: { customerId: '1001' } }) const payload = { inboundOrderId: '9', reason: '客户拒收', returnDirection: 'OVERSEAS_WAREHOUSE' as const, items: [{ outboundPalletItemId: '9007199254740995', inventoryPositionId: '9007199254740996', packageCount: 2 }], idempotencyKey: 'return-1' } - vi.mocked(overseasHttp.post).mockResolvedValue({ code: '200', data: { id: '19', exceptionNo: 'EXC-1', status: 'PENDING' } }) + vi.mocked(http.post).mockResolvedValue({ code: '200', data: { id: '19', exceptionNo: 'EXC-1', status: 'PENDING' } }) await markDeliveryReturn('DEL/1', '1001', payload) - expect(overseasHttp.post).toHaveBeenCalledWith('/platform/v1/last-leg/delivery-orders/DEL%2F1/mark-return', payload, { params: { customerId: '1001' } }) + expect(http.post).toHaveBeenCalledWith('/platform/v1/last-leg/delivery-orders/DEL%2F1/mark-return', payload, { params: { customerId: '1001' } }) }) }) diff --git a/src/modules/overseas-warehouse/api/deliveryOrders.ts b/src/modules/overseas-warehouse/api/deliveryOrders.ts index 339f62ae..266bbe40 100644 --- a/src/modules/overseas-warehouse/api/deliveryOrders.ts +++ b/src/modules/overseas-warehouse/api/deliveryOrders.ts @@ -1,5 +1,5 @@ -import { overseasHttp } from '@/shared/api/overseasHttp' -import { unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' +import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' + import { normalizeOverseasPage, type OverseasPagePayload } from './lastMileOrders' import type { DeliveryDocumentSummary } from './deliveryDocuments' @@ -94,14 +94,14 @@ export interface MarkDeliveryReturnPayload { export async function listDeliveryOrders(query: DeliveryOrderQuery) { const { customerId, ...params } = query - const response = await overseasHttp.get>, ApiEnvelope>>( + const response = await http.get>, ApiEnvelope>>( '/platform/v1/last-leg/delivery-orders', { params: { ...params, ...(customerId ? { customerId } : {}) } }, ) return normalizeOverseasPage(unwrapApiResponse(response)) } export async function getDeliveryOrder(deliveryNo: string, customerId: string) { - const response = await overseasHttp.get, ApiEnvelope>( + const response = await http.get, ApiEnvelope>( `/platform/v1/last-leg/delivery-orders/${encodeURIComponent(deliveryNo)}`, { params: { customerId, page: 1, size: 200 } }, ) const detail = unwrapApiResponse(response) @@ -116,14 +116,14 @@ export async function getDeliveryOrder(deliveryNo: string, customerId: string) { } export async function listDeliveryReturnInboundOptions(deliveryNo: string, customerId: string) { - const response = await overseasHttp.get, ApiEnvelope>( + const response = await http.get, ApiEnvelope>( `/platform/v1/last-leg/delivery-orders/${encodeURIComponent(deliveryNo)}/return-inbound-options`, { params: { customerId } }, ) return unwrapApiResponse(response) } export async function markDeliveryReturn(deliveryNo: string, customerId: string, payload: MarkDeliveryReturnPayload) { - const response = await overseasHttp.post, ApiEnvelope<{ id: string; exceptionNo: string; status: string }>>( + const response = await http.post, ApiEnvelope<{ id: string; exceptionNo: string; status: string }>>( `/platform/v1/last-leg/delivery-orders/${encodeURIComponent(deliveryNo)}/mark-return`, payload, { params: { customerId } }, ) return unwrapApiResponse(response) diff --git a/src/modules/overseas-warehouse/api/dispatches.test.ts b/src/modules/overseas-warehouse/api/dispatches.test.ts index 4468258d..3a3feee8 100644 --- a/src/modules/overseas-warehouse/api/dispatches.test.ts +++ b/src/modules/overseas-warehouse/api/dispatches.test.ts @@ -1,19 +1,19 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { overseasHttp } from '@/shared/api/overseasHttp' +import { http } from '@/shared/api/http' import { cancelDispatch, createDispatchDraftContainer, deleteDispatchDraftContainer, getDispatch, getDispatchBolPdf, listDispatchCandidates, listDispatchDrafts, listDispatches, lockDispatchDraftPallet, preArrangeDispatch, releaseDispatchDraft, transferDispatchToDelivery, unlockDispatchDraftPallet, updateDispatch } from './dispatches' -vi.mock('@/shared/api/overseasHttp', () => ({ overseasHttp: { get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn() } })) +vi.mock('@/shared/api/http', async (importOriginal) => ({ ...(await importOriginal()), http: { get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn() } })) describe('dispatch overseas API', () => { beforeEach(() => { - vi.mocked(overseasHttp.get).mockReset() - vi.mocked(overseasHttp.post).mockReset() - vi.mocked(overseasHttp.put).mockReset() - vi.mocked(overseasHttp.delete).mockReset() + vi.mocked(http.get).mockReset() + vi.mocked(http.post).mockReset() + vi.mocked(http.put).mockReset() + vi.mocked(http.delete).mockReset() }) it('uses the platform dispatch page without customer scope', async () => { - vi.mocked(overseasHttp.get).mockResolvedValue({ + vi.mocked(http.get).mockResolvedValue({ code: '200', message: 'success', data: { records: [], page: 2, size: 50, total: 0, pages: 0 }, }) @@ -22,7 +22,7 @@ describe('dispatch overseas API', () => { vehiclePlate: 'CA-100', status: 'PENDING_DEPARTURE', fbaAppointmentNo: 'FBA-1', }) - expect(overseasHttp.get).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches', { + expect(http.get).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches', { params: { page: 2, size: 50, dispatchNo: 'PC-1', vehiclePlate: 'CA-100', status: 'PENDING_DEPARTURE', fbaAppointmentNo: 'FBA-1', @@ -31,78 +31,78 @@ describe('dispatch overseas API', () => { }) it('loads a platform dispatch detail by encoded dispatch number', async () => { - vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', message: 'success', data: { id: '1', dispatchNo: 'PC/1' } }) + vi.mocked(http.get).mockResolvedValue({ code: '200', message: 'success', data: { id: '1', dispatchNo: 'PC/1' } }) await getDispatch('PC/1') - expect(overseasHttp.get).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/PC%2F1') + expect(http.get).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/PC%2F1') }) it('requests the generated BOL as a PDF blob', async () => { - vi.mocked(overseasHttp.get).mockResolvedValue(new Blob(['%PDF-test'])) + vi.mocked(http.get).mockResolvedValue(new Blob(['%PDF-test'])) await getDispatchBolPdf('PC/1') - expect(overseasHttp.get).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/PC%2F1/prints/bol', { responseType: 'blob' }) + expect(http.get).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/PC%2F1/prints/bol', { responseType: 'blob' }) }) it('loads candidates and creates a pre-arrange dispatch in an explicit customer scope', async () => { - vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', message: 'success', data: [] }) - vi.mocked(overseasHttp.post).mockResolvedValue({ code: '200', message: 'success', data: { id: '1', dispatchNo: 'PC-1' } }) + vi.mocked(http.get).mockResolvedValue({ code: '200', message: 'success', data: [] }) + vi.mocked(http.post).mockResolvedValue({ code: '200', message: 'success', data: { id: '1', dispatchNo: 'PC-1' } }) await listDispatchCandidates({ planIds: '101,102', fbaWarehouseCode: 'ONT8', sourceStage: 'PLAN' }) await preArrangeDispatch({ planPalletIds: ['190000000000000099'] }) - expect(overseasHttp.get).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/candidates', { params: { planIds: '101,102', fbaWarehouseCode: 'ONT8', sourceStage: 'PLAN' } }) - expect(overseasHttp.post).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/pre-arrange', { planPalletIds: ['190000000000000099'] }) + expect(http.get).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/candidates', { params: { planIds: '101,102', fbaWarehouseCode: 'ONT8', sourceStage: 'PLAN' } }) + expect(http.post).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/pre-arrange', { planPalletIds: ['190000000000000099'] }) }) it('loads platform candidates without selected inbound plans', async () => { - vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', message: 'success', data: [] }) + vi.mocked(http.get).mockResolvedValue({ code: '200', message: 'success', data: [] }) await listDispatchCandidates({ sourceStage: 'RECEIVED' }) - expect(overseasHttp.get).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/candidates', { + expect(http.get).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/candidates', { params: { sourceStage: 'RECEIVED' }, }) }) it('locks, unlocks, and releases intelligent dispatch drafts through dedicated endpoints', async () => { - vi.mocked(overseasHttp.post).mockResolvedValue({ code: '200', message: 'success', data: null }) + vi.mocked(http.post).mockResolvedValue({ code: '200', message: 'success', data: null }) await lockDispatchDraftPallet('99', 'draft-1') await unlockDispatchDraftPallet('99', 'draft-1') await releaseDispatchDraft('draft-1') - expect(overseasHttp.post).toHaveBeenNthCalledWith(1, '/platform/v1/last-leg/dispatches/draft-locks/lock', { planPalletId: '99', draftToken: 'draft-1' }) - expect(overseasHttp.post).toHaveBeenNthCalledWith(2, '/platform/v1/last-leg/dispatches/draft-locks/unlock', { planPalletId: '99', draftToken: 'draft-1' }) - expect(overseasHttp.post).toHaveBeenNthCalledWith(3, '/platform/v1/last-leg/dispatches/draft-locks/release', { draftToken: 'draft-1' }) + expect(http.post).toHaveBeenNthCalledWith(1, '/platform/v1/last-leg/dispatches/draft-locks/lock', { planPalletId: '99', draftToken: 'draft-1' }) + expect(http.post).toHaveBeenNthCalledWith(2, '/platform/v1/last-leg/dispatches/draft-locks/unlock', { planPalletId: '99', draftToken: 'draft-1' }) + expect(http.post).toHaveBeenNthCalledWith(3, '/platform/v1/last-leg/dispatches/draft-locks/release', { draftToken: 'draft-1' }) }) it('edits and cancels only by dispatchNo without customer scope or status mutation', async () => { - vi.mocked(overseasHttp.put).mockResolvedValue({ code: '200', message: 'success', data: { id: '1', dispatchNo: 'PC/1' } }) - vi.mocked(overseasHttp.delete).mockResolvedValue({ code: '200', message: 'success', data: null }) + vi.mocked(http.put).mockResolvedValue({ code: '200', message: 'success', data: { id: '1', dispatchNo: 'PC/1' } }) + vi.mocked(http.delete).mockResolvedValue({ code: '200', message: 'success', data: null }) await updateDispatch('PC/1', { vehiclePlate: 'CA-100' }) await cancelDispatch('PC/1') - expect(overseasHttp.put).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/PC%2F1', { vehiclePlate: 'CA-100' }) - expect(overseasHttp.put).not.toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ status: expect.anything() })) - expect(overseasHttp.delete).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/PC%2F1') + expect(http.put).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/PC%2F1', { vehiclePlate: 'CA-100' }) + expect(http.put).not.toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ status: expect.anything() })) + expect(http.delete).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/PC%2F1') }) it('transfers to delivery only by dispatchNo without customerId', async () => { - vi.mocked(overseasHttp.post).mockResolvedValue({ code: '200', message: 'success', data: { dispatchNo: 'PC/1', outboundNo: 'OUT-1', outboundStatus: 'PICKED', palletCount: 2, packageCount: 20, deliveries: [{ deliveryNo: 'DEL-1', deliveryStatus: 'WAIT_DELIVERY', destinationWarehouseCode: 'ACY2', palletCount: 2, packageCount: 20 }], replayed: false } }) + vi.mocked(http.post).mockResolvedValue({ code: '200', message: 'success', data: { dispatchNo: 'PC/1', outboundNo: 'OUT-1', outboundStatus: 'PICKED', palletCount: 2, packageCount: 20, deliveries: [{ deliveryNo: 'DEL-1', deliveryStatus: 'WAIT_DELIVERY', destinationWarehouseCode: 'ACY2', palletCount: 2, packageCount: 20 }], replayed: false } }) const payload = { idempotencyKey: 'transfer-1', plannedDeliveryAt: '2026-09-01T10:00:00', deliveryMethod: 'TRUCK_PALLET', pickupMethod: 'WAREHOUSE_PICKUP', loadingType: 'PALLET', fees: [{ feeType: 'DELIVERY_FEE', feeName: '派送费用', direction: 'RECEIVABLE' as const, currency: 'USD' as const, amount: 10 }] } await transferDispatchToDelivery('PC/1', payload) - expect(overseasHttp.post).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/PC%2F1/transfer-delivery', payload) + expect(http.post).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/PC%2F1/transfer-delivery', payload) }) it('loads drafts and preserves long container ids in create and delete paths', async () => { - vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', message: 'success', data: [] }) - vi.mocked(overseasHttp.post).mockResolvedValue({ code: '200', message: 'success', data: { id: '190000000000000099', capacity: 30, palletIds: [] } }) - vi.mocked(overseasHttp.delete).mockResolvedValue({ code: '200', message: 'success', data: null }) + vi.mocked(http.get).mockResolvedValue({ code: '200', message: 'success', data: [] }) + vi.mocked(http.post).mockResolvedValue({ code: '200', message: 'success', data: { id: '190000000000000099', capacity: 30, palletIds: [] } }) + vi.mocked(http.delete).mockResolvedValue({ code: '200', message: 'success', data: null }) await listDispatchDrafts() await createDispatchDraftContainer('draft/1', 30) await deleteDispatchDraftContainer('draft/1', '190000000000000099') - expect(overseasHttp.get).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/drafts') - expect(overseasHttp.post).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/drafts/containers', { draftToken: 'draft/1', capacity: 30 }) - expect(overseasHttp.delete).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/drafts/draft%2F1/containers/190000000000000099') + expect(http.get).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/drafts') + expect(http.post).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/drafts/containers', { draftToken: 'draft/1', capacity: 30 }) + expect(http.delete).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/drafts/draft%2F1/containers/190000000000000099') }) }) diff --git a/src/modules/overseas-warehouse/api/dispatches.ts b/src/modules/overseas-warehouse/api/dispatches.ts index 26b7410e..29c254d5 100644 --- a/src/modules/overseas-warehouse/api/dispatches.ts +++ b/src/modules/overseas-warehouse/api/dispatches.ts @@ -1,5 +1,5 @@ -import { overseasHttp } from '@/shared/api/overseasHttp' -import { unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' +import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' + import { normalizeOverseasPage, type OverseasPagePayload } from './lastMileOrders' export interface DispatchQuery { @@ -140,7 +140,7 @@ export interface DispatchItemDto { } export async function listDispatches(query: DispatchQuery) { - const response = await overseasHttp.get>, ApiEnvelope>>( + const response = await http.get>, ApiEnvelope>>( '/platform/v1/last-leg/dispatches', { params: query }, ) @@ -148,42 +148,42 @@ export async function listDispatches(query: DispatchQuery) { } export async function getDispatch(dispatchNo: string) { - const response = await overseasHttp.get, ApiEnvelope>( + const response = await http.get, ApiEnvelope>( `/platform/v1/last-leg/dispatches/${encodeURIComponent(dispatchNo)}`, ) return unwrapApiResponse(response) } export async function getDispatchBolPdf(dispatchNo: string) { - return overseasHttp.get( + return http.get( `/platform/v1/last-leg/dispatches/${encodeURIComponent(dispatchNo)}/prints/bol`, { responseType: 'blob' }, ) } export async function updateDispatch(dispatchNo: string, payload: UpdateDispatchPayload) { - const response = await overseasHttp.put, ApiEnvelope>( + const response = await http.put, ApiEnvelope>( `/platform/v1/last-leg/dispatches/${encodeURIComponent(dispatchNo)}`, payload, ) return unwrapApiResponse(response) } export async function cancelDispatch(dispatchNo: string) { - const response = await overseasHttp.delete, ApiEnvelope>( + const response = await http.delete, ApiEnvelope>( `/platform/v1/last-leg/dispatches/${encodeURIComponent(dispatchNo)}`, ) return unwrapApiResponse(response) } export async function removeDispatchItem(dispatchNo: string, itemId: string) { - const response = await overseasHttp.delete, ApiEnvelope>( + const response = await http.delete, ApiEnvelope>( `/platform/v1/last-leg/dispatches/${encodeURIComponent(dispatchNo)}/items/${encodeURIComponent(itemId)}`, ) return unwrapApiResponse(response) } export async function listDispatchCandidates(query: DispatchCandidateQuery) { - const response = await overseasHttp.get, ApiEnvelope>( + const response = await http.get, ApiEnvelope>( '/platform/v1/last-leg/dispatches/candidates', { params: query }, ) @@ -191,7 +191,7 @@ export async function listDispatchCandidates(query: DispatchCandidateQuery) { } export async function preArrangeDispatch(payload: PreArrangeDispatchPayload) { - const response = await overseasHttp.post, ApiEnvelope>( + const response = await http.post, ApiEnvelope>( '/platform/v1/last-leg/dispatches/pre-arrange', payload, ) @@ -199,34 +199,34 @@ export async function preArrangeDispatch(payload: PreArrangeDispatchPayload) { } export async function lockDispatchDraftPallet(planPalletId: string, draftToken: string, draftContainerId?: string) { - const response = await overseasHttp.post, ApiEnvelope>( + const response = await http.post, ApiEnvelope>( '/platform/v1/last-leg/dispatches/draft-locks/lock', { planPalletId, draftToken, ...(draftContainerId ? { draftContainerId } : {}) }, ) return unwrapApiResponse(response) } export async function listDispatchDrafts() { - const response = await overseasHttp.get, ApiEnvelope>('/platform/v1/last-leg/dispatches/drafts') + const response = await http.get, ApiEnvelope>('/platform/v1/last-leg/dispatches/drafts') return unwrapApiResponse(response) } export async function createDispatchDraftContainer(draftToken: string, capacity: number) { - const response = await overseasHttp.post, ApiEnvelope>('/platform/v1/last-leg/dispatches/drafts/containers', { draftToken, capacity }) + const response = await http.post, ApiEnvelope>('/platform/v1/last-leg/dispatches/drafts/containers', { draftToken, capacity }) return unwrapApiResponse(response) } export async function deleteDispatchDraftContainer(draftToken: string, containerId: string) { - const response = await overseasHttp.delete, ApiEnvelope>(`/platform/v1/last-leg/dispatches/drafts/${encodeURIComponent(draftToken)}/containers/${encodeURIComponent(containerId)}`) + const response = await http.delete, ApiEnvelope>(`/platform/v1/last-leg/dispatches/drafts/${encodeURIComponent(draftToken)}/containers/${encodeURIComponent(containerId)}`) return unwrapApiResponse(response) } export async function unlockDispatchDraftPallet(planPalletId: string, draftToken: string) { - const response = await overseasHttp.post, ApiEnvelope>( + const response = await http.post, ApiEnvelope>( '/platform/v1/last-leg/dispatches/draft-locks/unlock', { planPalletId, draftToken }, ) return unwrapApiResponse(response) } export async function releaseDispatchDraft(draftToken: string) { - const response = await overseasHttp.post, ApiEnvelope>( + const response = await http.post, ApiEnvelope>( '/platform/v1/last-leg/dispatches/draft-locks/release', { draftToken }, ) return unwrapApiResponse(response) @@ -257,7 +257,7 @@ export interface TransferDeliveryPayload { periodStart?: string; periodEnd?: string; remark?: string }> } export async function transferDispatchToDelivery(dispatchNo: string, payload: TransferDeliveryPayload) { - const response = await overseasHttp.post, ApiEnvelope>( + const response = await http.post, ApiEnvelope>( `/platform/v1/last-leg/dispatches/${encodeURIComponent(dispatchNo)}/transfer-delivery`, payload, ) return unwrapApiResponse(response) diff --git a/src/modules/overseas-warehouse/api/exceptionOrders.test.ts b/src/modules/overseas-warehouse/api/exceptionOrders.test.ts index c56716e5..cdc6dead 100644 --- a/src/modules/overseas-warehouse/api/exceptionOrders.test.ts +++ b/src/modules/overseas-warehouse/api/exceptionOrders.test.ts @@ -1,26 +1,26 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { overseasHttp } from '@/shared/api/overseasHttp' +import { http } from '@/shared/api/http' import { createExceptionOrder, listExceptionOrders, resolveExceptionRelation } from './exceptionOrders' -vi.mock('@/shared/api/overseasHttp', () => ({ overseasHttp: { get: vi.fn(), post: vi.fn() } })) +vi.mock('@/shared/api/http', async (importOriginal) => ({ ...(await importOriginal()), http: { get: vi.fn(), post: vi.fn() } })) describe('exception order API', () => { - beforeEach(() => { vi.mocked(overseasHttp.get).mockReset(); vi.mocked(overseasHttp.post).mockReset() }) + beforeEach(() => { vi.mocked(http.get).mockReset(); vi.mocked(http.post).mockReset() }) it('queries the real paginated exception endpoint', async () => { - vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', data: { records: [], page: '1', size: '20', total: '2', pages: '1' } }) + vi.mocked(http.get).mockResolvedValue({ code: '200', data: { records: [], page: '1', size: '20', total: '2', pages: '1' } }) const result = await listExceptionOrders({ page: 1, size: 20, keyword: 'EXC' }) - expect(overseasHttp.get).toHaveBeenCalledWith('/platform/v1/last-leg/exception-orders', { params: { page: 1, size: 20, keyword: 'EXC' } }) + expect(http.get).toHaveBeenCalledWith('/platform/v1/last-leg/exception-orders', { params: { page: 1, size: 20, keyword: 'EXC' } }) expect(result.total).toBe(2) }) it('resolves relation before creating an exception document', async () => { - vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', data: { inboundOrderId: '11', inboundCode: 'IN-1', lastMileOrderId: '12', lastMileOrderNo: 'LM-1', customerId: '1' } }) + vi.mocked(http.get).mockResolvedValue({ code: '200', data: { inboundOrderId: '11', inboundCode: 'IN-1', lastMileOrderId: '12', lastMileOrderNo: 'LM-1', customerId: '1' } }) await resolveExceptionRelation({ inboundCode: 'IN-1', lastMileOrderNo: 'LM-1' }) - expect(overseasHttp.get).toHaveBeenCalledWith('/platform/v1/last-leg/exception-orders/resolve', { params: { inboundCode: 'IN-1', lastMileOrderNo: 'LM-1' } }) + expect(http.get).toHaveBeenCalledWith('/platform/v1/last-leg/exception-orders/resolve', { params: { inboundCode: 'IN-1', lastMileOrderNo: 'LM-1' } }) - vi.mocked(overseasHttp.post).mockResolvedValue({ code: '200', data: { id: '99', exceptionNo: 'EXC-1', status: 'PENDING' } }) + vi.mocked(http.post).mockResolvedValue({ code: '200', data: { id: '99', exceptionNo: 'EXC-1', status: 'PENDING' } }) await createExceptionOrder({ inboundCode: 'IN-1', lastMileOrderNo: 'LM-1', exceptionType: 'RETURN', reason: '拒收' }) - expect(overseasHttp.post).toHaveBeenCalledWith('/platform/v1/last-leg/exception-orders', expect.objectContaining({ inboundCode: 'IN-1', lastMileOrderNo: 'LM-1' })) + expect(http.post).toHaveBeenCalledWith('/platform/v1/last-leg/exception-orders', expect.objectContaining({ inboundCode: 'IN-1', lastMileOrderNo: 'LM-1' })) }) }) diff --git a/src/modules/overseas-warehouse/api/exceptionOrders.ts b/src/modules/overseas-warehouse/api/exceptionOrders.ts index 241c8f9a..7481f3cc 100644 --- a/src/modules/overseas-warehouse/api/exceptionOrders.ts +++ b/src/modules/overseas-warehouse/api/exceptionOrders.ts @@ -1,5 +1,5 @@ -import { overseasHttp } from '@/shared/api/overseasHttp' -import { unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' +import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' + import { normalizeOverseasPage, type OverseasPagePayload } from './lastMileOrders' export interface ExceptionOrderQuery { page: number; size: number; keyword?: string; exceptionType?: string; status?: string; customerId?: string; warehouseId?: string; createdFrom?: string; createdTo?: string } @@ -19,14 +19,14 @@ export interface ExceptionRelation { export interface CreateExceptionOrderPayload { customerId?: string; inboundCode?: string; lastMileOrderNo?: string; exceptionType: string; reason: string; packageCount?: number; skuCode?: string } export async function listExceptionOrders(query: ExceptionOrderQuery) { - const response = await overseasHttp.get>, ApiEnvelope>>('/platform/v1/last-leg/exception-orders', { params: query }) + const response = await http.get>, ApiEnvelope>>('/platform/v1/last-leg/exception-orders', { params: query }) return normalizeOverseasPage(unwrapApiResponse(response)) } export async function resolveExceptionRelation(params: { customerId?: string; inboundCode?: string; lastMileOrderNo?: string }) { - const response = await overseasHttp.get, ApiEnvelope>('/platform/v1/last-leg/exception-orders/resolve', { params }) + const response = await http.get, ApiEnvelope>('/platform/v1/last-leg/exception-orders/resolve', { params }) return unwrapApiResponse(response) } export async function createExceptionOrder(payload: CreateExceptionOrderPayload) { - const response = await overseasHttp.post, ApiEnvelope<{ id: string; exceptionNo: string; status: string }>>('/platform/v1/last-leg/exception-orders', payload) + const response = await http.post, ApiEnvelope<{ id: string; exceptionNo: string; status: string }>>('/platform/v1/last-leg/exception-orders', payload) return unwrapApiResponse(response) } diff --git a/src/modules/overseas-warehouse/api/inboundOrders.test.ts b/src/modules/overseas-warehouse/api/inboundOrders.test.ts index 443982d0..2dc8e355 100644 --- a/src/modules/overseas-warehouse/api/inboundOrders.test.ts +++ b/src/modules/overseas-warehouse/api/inboundOrders.test.ts @@ -1,38 +1,38 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { overseasHttp } from '@/shared/api/overseasHttp' +import { http } from '@/shared/api/http' import { createFbaAppointment, getFbaAppointment, listInboundOrders, updateFbaAppointment } from './inboundOrders' -vi.mock('@/shared/api/overseasHttp', () => ({ overseasHttp: { get: vi.fn(), post: vi.fn(), put: vi.fn() } })) +vi.mock('@/shared/api/http', async (importOriginal) => ({ ...(await importOriginal()), http: { get: vi.fn(), post: vi.fn(), put: vi.fn() } })) describe('inbound order overseas API', () => { beforeEach(() => { - vi.mocked(overseasHttp.get).mockReset() - vi.mocked(overseasHttp.post).mockReset() - vi.mocked(overseasHttp.put).mockReset() + vi.mocked(http.get).mockReset() + vi.mocked(http.post).mockReset() + vi.mocked(http.put).mockReset() }) it('loads appointment details and updates the existing appointment without replacing its lines', async () => { const detail = { id: '1', customerId: '190000000000000001', appointmentNo: 'APT/1', appointmentAt: '2026-08-20T10:30:00', expectedLoadingAt: '2026-08-20T09:00:00', appointmentStatus: 'SUCCESS', selectedCount: 2, fbaWarehouseCode: 'ONT8', items: [] } - vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', message: 'success', data: detail }) - vi.mocked(overseasHttp.put).mockResolvedValue({ code: '200', message: 'success', data: detail }) + vi.mocked(http.get).mockResolvedValue({ code: '200', message: 'success', data: detail }) + vi.mocked(http.put).mockResolvedValue({ code: '200', message: 'success', data: detail }) await getFbaAppointment('190000000000000001', 'APT/1') await updateFbaAppointment('APT/1', { customerId: '190000000000000001', appointmentAt: detail.appointmentAt, expectedLoadingAt: detail.expectedLoadingAt, appointmentStatus: 'SUCCESS' }) - expect(overseasHttp.get).toHaveBeenCalledWith('/platform/v1/last-leg/inbound-plans/fba-appointments/APT%2F1', { params: { customerId: '190000000000000001' } }) - expect(overseasHttp.put).toHaveBeenCalledWith('/platform/v1/last-leg/inbound-plans/fba-appointments/APT%2F1', expect.objectContaining({ customerId: '190000000000000001' })) + expect(http.get).toHaveBeenCalledWith('/platform/v1/last-leg/inbound-plans/fba-appointments/APT%2F1', { params: { customerId: '190000000000000001' } }) + expect(http.put).toHaveBeenCalledWith('/platform/v1/last-leg/inbound-plans/fba-appointments/APT%2F1', expect.objectContaining({ customerId: '190000000000000001' })) }) it('loads the paginated list across all customers without a customer scope', async () => { - vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', message: 'success', data: { records: [], page: 1, size: 20, total: 0, pages: 0 } }) + vi.mocked(http.get).mockResolvedValue({ code: '200', message: 'success', data: { records: [], page: 1, size: 20, total: 0, pages: 0 } }) await listInboundOrders({ page: 1, size: 20, appointmentStatus: 'UNAPPOINTED' }) - expect(overseasHttp.get).toHaveBeenCalledWith('/platform/v1/last-leg/inbound-plans', { + expect(http.get).toHaveBeenCalledWith('/platform/v1/last-leg/inbound-plans', { params: { page: 1, size: 20, appointmentStatus: 'UNAPPOINTED' }, }) }) it('preserves selected line ids as strings when creating an appointment', async () => { - vi.mocked(overseasHttp.post).mockResolvedValue({ code: '200', message: 'success', data: { id: '1', appointmentNo: 'APT-1', appointmentStatus: 'SUCCESS', selectedCount: 1, fbaWarehouseCode: 'ONT8' } }) + vi.mocked(http.post).mockResolvedValue({ code: '200', message: 'success', data: { id: '1', appointmentNo: 'APT-1', appointmentStatus: 'SUCCESS', selectedCount: 1, fbaWarehouseCode: 'ONT8' } }) const payload = { customerId: '190000000000000001', lineIds: ['190000000000000099'], @@ -42,6 +42,6 @@ describe('inbound order overseas API', () => { appointmentStatus: 'SUCCESS' as const, } await createFbaAppointment(payload) - expect(overseasHttp.post).toHaveBeenCalledWith('/platform/v1/last-leg/inbound-plans/fba-appointments/batch', payload) + expect(http.post).toHaveBeenCalledWith('/platform/v1/last-leg/inbound-plans/fba-appointments/batch', payload) }) }) diff --git a/src/modules/overseas-warehouse/api/inboundOrders.ts b/src/modules/overseas-warehouse/api/inboundOrders.ts index 8ece659b..00319f71 100644 --- a/src/modules/overseas-warehouse/api/inboundOrders.ts +++ b/src/modules/overseas-warehouse/api/inboundOrders.ts @@ -1,5 +1,5 @@ -import { overseasHttp } from '@/shared/api/overseasHttp' -import { unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' +import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' + import { normalizeOverseasPage, type OverseasPagePayload } from './lastMileOrders' export interface InboundOrderQuery { @@ -79,21 +79,21 @@ export interface FbaAppointmentDto { export type UpdateFbaAppointmentPayload = Omit export async function listInboundOrders(query: InboundOrderQuery) { - const response = await overseasHttp.get>, ApiEnvelope>>( + const response = await http.get>, ApiEnvelope>>( '/platform/v1/last-leg/inbound-plans', { params: query }, ) return normalizeOverseasPage(unwrapApiResponse(response)) } export async function createFbaAppointment(payload: BatchFbaAppointmentPayload) { - const response = await overseasHttp.post, ApiEnvelope>( + const response = await http.post, ApiEnvelope>( '/platform/v1/last-leg/inbound-plans/fba-appointments/batch', payload, ) return unwrapApiResponse(response) } export async function getFbaAppointment(customerId: string, appointmentNo: string) { - const response = await overseasHttp.get, ApiEnvelope>( + const response = await http.get, ApiEnvelope>( `/platform/v1/last-leg/inbound-plans/fba-appointments/${encodeURIComponent(appointmentNo)}`, { params: { customerId } }, ) @@ -101,7 +101,7 @@ export async function getFbaAppointment(customerId: string, appointmentNo: strin } export async function updateFbaAppointment(appointmentNo: string, payload: UpdateFbaAppointmentPayload) { - const response = await overseasHttp.put, ApiEnvelope>( + const response = await http.put, ApiEnvelope>( `/platform/v1/last-leg/inbound-plans/fba-appointments/${encodeURIComponent(appointmentNo)}`, payload, ) @@ -109,7 +109,7 @@ export async function updateFbaAppointment(appointmentNo: string, payload: Updat } export async function updateInboundPlan(planNo: string, payload: { customerId: string; overseasWarehouseId: string; warehouseCode?: string; warehouseName?: string; version: number }) { - const response = await overseasHttp.put, ApiEnvelope>( + const response = await http.put, ApiEnvelope>( `/platform/v1/last-leg/inbound-plans/${encodeURIComponent(planNo)}`, payload, ) return unwrapApiResponse(response) diff --git a/src/modules/overseas-warehouse/api/inboundPlans.test.ts b/src/modules/overseas-warehouse/api/inboundPlans.test.ts index 36256b1a..407687ae 100644 --- a/src/modules/overseas-warehouse/api/inboundPlans.test.ts +++ b/src/modules/overseas-warehouse/api/inboundPlans.test.ts @@ -1,18 +1,18 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { overseasHttp } from '@/shared/api/overseasHttp' +import { http } from '@/shared/api/http' import { getInboundPlan, listInboundPlans } from './inboundPlans' -vi.mock('@/shared/api/overseasHttp', () => ({ overseasHttp: { get: vi.fn() } })) +vi.mock('@/shared/api/http', async (importOriginal) => ({ ...(await importOriginal()), http: { get: vi.fn() } })) describe('inbound plan overseas API', () => { - beforeEach(() => vi.mocked(overseasHttp.get).mockReset()) + beforeEach(() => vi.mocked(http.get).mockReset()) it('pages plans with explicit customer scope', async () => { - vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', message: 'ok', data: { records: [], page: 1, size: 20, total: 0, pages: 0 } }) + vi.mocked(http.get).mockResolvedValue({ code: '200', message: 'ok', data: { records: [], page: 1, size: 20, total: 0, pages: 0 } }) await listInboundPlans({ customerId: '190000000000000001', page: 1, size: 20, inboundCode: 'IN-1' }) - expect(overseasHttp.get).toHaveBeenCalledWith('/platform/v1/last-leg/inbound-plans', { params: { customerId: '190000000000000001', page: 1, size: 20, inboundCode: 'IN-1' } }) + expect(http.get).toHaveBeenCalledWith('/platform/v1/last-leg/inbound-plans', { params: { customerId: '190000000000000001', page: 1, size: 20, inboundCode: 'IN-1' } }) }) it('gets detail by encoded plan number', async () => { - vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', message: 'ok', data: { id: '1', planNo: 'IP/1', customerId: '2', sourceOrderId: '190000000000000099', lineCount: 0, palletCount: 0, totalGrossWeightKg: 0, totalNetWeightKg: 0, totalVolumeCbm: 0, lines: [], pallets: [], allocations: [] } }) + vi.mocked(http.get).mockResolvedValue({ code: '200', message: 'ok', data: { id: '1', planNo: 'IP/1', customerId: '2', sourceOrderId: '190000000000000099', lineCount: 0, palletCount: 0, totalGrossWeightKg: 0, totalNetWeightKg: 0, totalVolumeCbm: 0, lines: [], pallets: [], allocations: [] } }) await getInboundPlan('IP/1', '2') - expect(overseasHttp.get).toHaveBeenCalledWith('/platform/v1/last-leg/inbound-plans/IP%2F1', { params: { customerId: '2' } }) + expect(http.get).toHaveBeenCalledWith('/platform/v1/last-leg/inbound-plans/IP%2F1', { params: { customerId: '2' } }) }) }) diff --git a/src/modules/overseas-warehouse/api/inboundPlans.ts b/src/modules/overseas-warehouse/api/inboundPlans.ts index dc9c5d3d..e31767d2 100644 --- a/src/modules/overseas-warehouse/api/inboundPlans.ts +++ b/src/modules/overseas-warehouse/api/inboundPlans.ts @@ -1,5 +1,5 @@ -import { overseasHttp } from '@/shared/api/overseasHttp' -import { unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' +import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' + import { normalizeOverseasPage, type OverseasPagePayload } from './lastMileOrders' export interface InboundPlanQuery { @@ -74,11 +74,11 @@ export interface InboundPlanDetailDto { export async function listInboundPlans(query: InboundPlanQuery) { const { customerId, ...params } = query - return normalizeOverseasPage(unwrapApiResponse(await overseasHttp.get>, ApiEnvelope>>('/platform/v1/last-leg/inbound-plans', { params: { customerId, ...params } }))) + return normalizeOverseasPage(unwrapApiResponse(await http.get>, ApiEnvelope>>('/platform/v1/last-leg/inbound-plans', { params: { customerId, ...params } }))) } export async function getInboundPlan(planNo: string, customerId: string) { - return unwrapApiResponse(await overseasHttp.get, ApiEnvelope>(`/platform/v1/last-leg/inbound-plans/${encodeURIComponent(planNo)}`, { params: { customerId } })) + return unwrapApiResponse(await http.get, ApiEnvelope>(`/platform/v1/last-leg/inbound-plans/${encodeURIComponent(planNo)}`, { params: { customerId } })) } export interface PlatformInventoryMoveRequest { @@ -101,6 +101,6 @@ export interface PlatformInventoryMoveResult { } export async function moveInboundPallet(payload: PlatformInventoryMoveRequest) { - const response = await overseasHttp.post, ApiEnvelope>('/platform/v1/last-leg/inventory-moves', payload, { headers: { 'Idempotency-Key': payload.idempotencyKey } }) + const response = await http.post, ApiEnvelope>('/platform/v1/last-leg/inventory-moves', payload, { headers: { 'Idempotency-Key': payload.idempotencyKey } }) return unwrapApiResponse(response) } diff --git a/src/modules/overseas-warehouse/api/inventory.test.ts b/src/modules/overseas-warehouse/api/inventory.test.ts index 843b98c2..c8590a11 100644 --- a/src/modules/overseas-warehouse/api/inventory.test.ts +++ b/src/modules/overseas-warehouse/api/inventory.test.ts @@ -1,8 +1,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { overseasHttp } from '@/shared/api/overseasHttp' +import { http } from '@/shared/api/http' import { downloadInventoryBalances, downloadInventoryLedger, getInventorySummary, listInventoryBalances, listInventoryLedger, listInventoryPositions } from './inventory' -vi.mock('@/shared/api/overseasHttp', () => ({ overseasHttp: { get: vi.fn() } })) +vi.mock('@/shared/api/http', async (importOriginal) => ({ ...(await importOriginal()), http: { get: vi.fn() } })) describe('overseas inventory API', () => { beforeEach(() => { vi.clearAllMocks() }) @@ -10,44 +10,44 @@ describe('overseas inventory API', () => { it('returns the binary response directly for inventory exports', async () => { const balances = new Blob(['customerId\\r\\n10001\\r\\n'], { type: 'text/csv' }) const ledger = new Blob(['occurredAt\\r\\n'], { type: 'text/csv' }) - vi.mocked(overseasHttp.get).mockResolvedValueOnce(balances).mockResolvedValueOnce(ledger) + vi.mocked(http.get).mockResolvedValueOnce(balances).mockResolvedValueOnce(ledger) await expect(downloadInventoryBalances({ skuCode: 'SKU-1' })).resolves.toBe(balances) await expect(downloadInventoryLedger({ inboundCode: 'IN-1' })).resolves.toBe(ledger) - expect(overseasHttp.get).toHaveBeenNthCalledWith(1, '/platform/v1/last-leg/inventory/balances/export', { + expect(http.get).toHaveBeenNthCalledWith(1, '/platform/v1/last-leg/inventory/balances/export', { params: { skuCode: 'SKU-1' }, responseType: 'blob', }) - expect(overseasHttp.get).toHaveBeenNthCalledWith(2, '/platform/v1/last-leg/inventory/ledger/export', { + expect(http.get).toHaveBeenNthCalledWith(2, '/platform/v1/last-leg/inventory/ledger/export', { params: { inboundCode: 'IN-1' }, responseType: 'blob', }) }) it('keeps skuCode and nullable location filters on real balance queries', async () => { - vi.mocked(overseasHttp.get).mockResolvedValueOnce({ code: '200', data: { records: [], page: 1, size: 20, total: 0, pages: 0 } }).mockResolvedValueOnce({ code: '200', data: { totalPackages: 0 } }) + vi.mocked(http.get).mockResolvedValueOnce({ code: '200', data: { records: [], page: 1, size: 20, total: 0, pages: 0 } }).mockResolvedValueOnce({ code: '200', data: { totalPackages: 0 } }) const query = { page: 1, size: 20, skuCode: 'SKU-1', warehouseLocationCode: 'A-01' } await listInventoryBalances(query) await getInventorySummary(query) - expect(overseasHttp.get).toHaveBeenNthCalledWith(1, '/platform/v1/last-leg/inventory/balances', { params: query }) - expect(overseasHttp.get).toHaveBeenNthCalledWith(2, '/platform/v1/last-leg/inventory/summary', { params: { page: 1, size: 200, skuCode: 'SKU-1', warehouseLocationCode: 'A-01' } }) + expect(http.get).toHaveBeenNthCalledWith(1, '/platform/v1/last-leg/inventory/balances', { params: query }) + expect(http.get).toHaveBeenNthCalledWith(2, '/platform/v1/last-leg/inventory/summary', { params: { page: 1, size: 200, skuCode: 'SKU-1', warehouseLocationCode: 'A-01' } }) }) it('uses the read-only position and ledger endpoints without customer scope', async () => { - vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', data: [] }) + vi.mocked(http.get).mockResolvedValue({ code: '200', data: [] }) await listInventoryPositions('190000000000000099') await listInventoryLedger({ page: 1, size: 20, skuCode: 'SKU-1' }) - expect(overseasHttp.get).toHaveBeenNthCalledWith(1, '/platform/v1/last-leg/inventory/balances/190000000000000099/positions', { params: {} }) - expect(overseasHttp.get).toHaveBeenNthCalledWith(2, '/platform/v1/last-leg/inventory/ledger', { params: { page: 1, size: 20, skuCode: 'SKU-1' } }) + expect(http.get).toHaveBeenNthCalledWith(1, '/platform/v1/last-leg/inventory/balances/190000000000000099/positions', { params: {} }) + expect(http.get).toHaveBeenNthCalledWith(2, '/platform/v1/last-leg/inventory/ledger', { params: { page: 1, size: 20, skuCode: 'SKU-1' } }) }) it('passes the inbound code as a business filter and preserves the returned customer name', async () => { const record = { id: '2093539779795795969', customerId: '2093539779795795970', customerName: '华远贸易', inboundCode: 'NYEXX2608300001', beforePackages: 0, deltaPackages: 2, afterPackages: 2 } - vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', data: { records: [record], page: 1, size: 20, total: 1, pages: 1 } }) + vi.mocked(http.get).mockResolvedValue({ code: '200', data: { records: [record], page: 1, size: 20, total: 1, pages: 1 } }) await expect(listInventoryLedger({ page: 1, size: 20, inboundCode: 'NYEXX2608300001', customerName: '华远贸易' })).resolves.toMatchObject({ records: [record] }) - expect(overseasHttp.get).toHaveBeenCalledWith('/platform/v1/last-leg/inventory/ledger', { params: { page: 1, size: 20, inboundCode: 'NYEXX2608300001', customerName: '华远贸易' } }) + expect(http.get).toHaveBeenCalledWith('/platform/v1/last-leg/inventory/ledger', { params: { page: 1, size: 20, inboundCode: 'NYEXX2608300001', customerName: '华远贸易' } }) }) }) diff --git a/src/modules/overseas-warehouse/api/inventory.ts b/src/modules/overseas-warehouse/api/inventory.ts index 02e71210..e8b882d1 100644 --- a/src/modules/overseas-warehouse/api/inventory.ts +++ b/src/modules/overseas-warehouse/api/inventory.ts @@ -1,5 +1,5 @@ -import { overseasHttp } from '@/shared/api/overseasHttp' -import { unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' +import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' + import { normalizeOverseasPage, type OverseasPagePayload } from './lastMileOrders' export interface InventoryBalanceDto { @@ -111,18 +111,18 @@ export interface InventoryBalanceQuery { function params(query: InventoryBalanceQuery) { return { ...query } } export async function listInventoryBalances(query: InventoryBalanceQuery) { - const response = await overseasHttp.get>, ApiEnvelope>>( + const response = await http.get>, ApiEnvelope>>( '/platform/v1/last-leg/inventory/balances', { params: params(query) }, ) return normalizeOverseasPage(unwrapApiResponse(response)) } export async function downloadInventoryBalances(query: Partial) { - return overseasHttp.get('/platform/v1/last-leg/inventory/balances/export', { params: query, responseType: 'blob' }) + return http.get('/platform/v1/last-leg/inventory/balances/export', { params: query, responseType: 'blob' }) } export async function getInventorySummary(query: InventoryBalanceQuery) { - const response = await overseasHttp.get, ApiEnvelope>( + const response = await http.get, ApiEnvelope>( '/platform/v1/last-leg/inventory/summary', { params: params({ ...query, page: 1, size: 200 }) }, ) return unwrapApiResponse(response) @@ -132,7 +132,7 @@ export async function listInventoryPositions( balanceId: string, query: { palletNo?: string; batchNo?: string; inventoryStatus?: string } = {}, ) { - const response = await overseasHttp.get, ApiEnvelope>( + const response = await http.get, ApiEnvelope>( `/platform/v1/last-leg/inventory/balances/${encodeURIComponent(balanceId)}/positions`, { params: query }, ) @@ -159,7 +159,7 @@ export interface InventoryLedgerQuery { } export async function listInventoryLedger(query: InventoryLedgerQuery) { - const response = await overseasHttp.get>, ApiEnvelope>>( + const response = await http.get>, ApiEnvelope>>( '/platform/v1/last-leg/inventory/ledger', { params: { ...query } }, ) @@ -167,5 +167,5 @@ export async function listInventoryLedger(query: InventoryLedgerQuery) { } export async function downloadInventoryLedger(query: Partial) { - return overseasHttp.get('/platform/v1/last-leg/inventory/ledger/export', { params: query, responseType: 'blob' }) + return http.get('/platform/v1/last-leg/inventory/ledger/export', { params: query, responseType: 'blob' }) } diff --git a/src/modules/overseas-warehouse/api/lastMileOrders.test.ts b/src/modules/overseas-warehouse/api/lastMileOrders.test.ts index e424038b..87979865 100644 --- a/src/modules/overseas-warehouse/api/lastMileOrders.test.ts +++ b/src/modules/overseas-warehouse/api/lastMileOrders.test.ts @@ -1,29 +1,29 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { overseasHttp } from '@/shared/api/overseasHttp' +import { http } from '@/shared/api/http' import { changeContainerReceivingWarehouse, convertLastMileContainerToInboundPlans, createManualInboundPlan, downloadLastMileOrders, getContainerPlanProgress, getLastMileOrder, getLastMileOrderManualNodes, listContainerPlanProgress, listLastMileOrders, transferLastMileOrders, updateLastMileOrderHold, updateLastMileOrderManualNodes } from './lastMileOrders' -vi.mock('@/shared/api/overseasHttp', () => ({ overseasHttp: { get: vi.fn(), put: vi.fn(), post: vi.fn() } })) +vi.mock('@/shared/api/http', async (importOriginal) => ({ ...(await importOriginal()), http: { get: vi.fn(), put: vi.fn(), post: vi.fn() } })) describe('last-mile order overseas API', () => { beforeEach(() => { - vi.mocked(overseasHttp.get).mockReset() - vi.mocked(overseasHttp.put).mockReset() - vi.mocked(overseasHttp.post).mockReset() + vi.mocked(http.get).mockReset() + vi.mocked(http.put).mockReset() + vi.mocked(http.post).mockReset() }) it('loads all platform orders without a customer scope', async () => { const record = { id: '190000000000000099', customerId: '190000000000000001', sourceContainerBookingId: '190000000000000088', customerName: '华远贸易', orderNo: 'LMO-260827-0001' } - vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', message: 'success', data: { records: [record], page: '2', size: '20', total: '1', pages: '1' } }) + vi.mocked(http.get).mockResolvedValue({ code: '200', message: 'success', data: { records: [record], page: '2', size: '20', total: '1', pages: '1' } }) await expect(listLastMileOrders({ page: 2, size: 20, orderType: 'FIRST_LEG', currentStatus: 'PENDING_ASSIGNMENT', warehouse: 'LAX' })).resolves.toEqual({ records: [record], page: 2, size: 20, total: 1, pages: 1 }) - expect(overseasHttp.get).toHaveBeenCalledWith('/platform/v1/last-leg/orders', { + expect(http.get).toHaveBeenCalledWith('/platform/v1/last-leg/orders', { params: { page: 2, size: 20, orderType: 'FIRST_LEG', currentStatus: 'PENDING_ASSIGNMENT', warehouse: 'LAX' }, }) }) it('preserves a long order id as a string in the detail path', async () => { - vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', message: 'success', data: { order: { id: '190000000000000099' }, cargo: [], statusHistory: [] } }) + vi.mocked(http.get).mockResolvedValue({ code: '200', message: 'success', data: { order: { id: '190000000000000099' }, cargo: [], statusHistory: [] } }) await getLastMileOrder('190000000000000099') - expect(overseasHttp.get).toHaveBeenCalledWith('/platform/v1/last-leg/orders/190000000000000099') + expect(http.get).toHaveBeenCalledWith('/platform/v1/last-leg/orders/190000000000000099') }) it('does not call outbound or delivery endpoints', () => { @@ -32,43 +32,43 @@ describe('last-mile order overseas API', () => { }) it('passes all supported list filters and pagination without customerId', async () => { - vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', message: 'success', data: { records: [], page: 2, size: 50, total: 0, pages: 0 } }) + vi.mocked(http.get).mockResolvedValue({ code: '200', message: 'success', data: { records: [], page: 2, size: 50, total: 0, pages: 0 } }) await listLastMileOrders({ page: 2, size: 50, orderNo: 'LM-1', inboundCode: 'IN-1', sourceOrderNo: 'SRC-1', containerNo: 'CONT-1', status: 'PENDING_DELIVERY', transferWarehouseCode: 'ONT8', abnormal: true, detained: false, createdFrom: '2026-08-01T00:00:00', createdTo: '2026-08-31T23:59:59', }) - expect(overseasHttp.get).toHaveBeenCalledWith('/platform/v1/last-leg/orders', { + expect(http.get).toHaveBeenCalledWith('/platform/v1/last-leg/orders', { params: expect.objectContaining({ page: 2, size: 50, sourceOrderNo: 'SRC-1', transferWarehouseCode: 'ONT8', abnormal: true, detained: false }), }) - expect(JSON.stringify(vi.mocked(overseasHttp.get).mock.calls[0]?.[1])).not.toContain('customerId') + expect(JSON.stringify(vi.mocked(http.get).mock.calls[0]?.[1])).not.toContain('customerId') }) it('uses the real destination transfer endpoint and edit contract', async () => { - vi.mocked(overseasHttp.put).mockResolvedValue({ code: '200', message: 'success', data: 1 }) + vi.mocked(http.put).mockResolvedValue({ code: '200', message: 'success', data: 1 }) await transferLastMileOrders({ orderIds: ['190000000000000099'], warehouseId: 'WH-1', warehouseCode: 'ONT8', warehouseName: 'Ontario', }) - expect(overseasHttp.put).toHaveBeenCalledWith('/platform/v1/last-leg/orders/destination', expect.objectContaining({ orderIds: ['190000000000000099'] })) + expect(http.put).toHaveBeenCalledWith('/platform/v1/last-leg/orders/destination', expect.objectContaining({ orderIds: ['190000000000000099'] })) }) it('returns the exported CSV blob without reading data twice', async () => { const blob = new Blob(['orders'], { type: 'text/csv' }) - vi.mocked(overseasHttp.get).mockResolvedValue(blob) + vi.mocked(http.get).mockResolvedValue(blob) await expect(downloadLastMileOrders({ orderNo: 'LM-1' })).resolves.toBe(blob) - expect(overseasHttp.get).toHaveBeenCalledWith('/platform/v1/last-leg/orders/export', { + expect(http.get).toHaveBeenCalledWith('/platform/v1/last-leg/orders/export', { params: { orderNo: 'LM-1' }, responseType: 'blob', }) }) it('exports all authorized orders without pagination or empty filters', async () => { const blob = new Blob(['orders'], { type: 'text/csv' }) - vi.mocked(overseasHttp.get).mockResolvedValue(blob) + vi.mocked(http.get).mockResolvedValue(blob) await downloadLastMileOrders({ page: 1, size: 10, orderNo: '', createdFrom: undefined }) - expect(overseasHttp.get).toHaveBeenCalledWith('/platform/v1/last-leg/orders/export', { + expect(http.get).toHaveBeenCalledWith('/platform/v1/last-leg/orders/export', { params: {}, responseType: 'blob', }) }) @@ -77,7 +77,7 @@ describe('last-mile order overseas API', () => { const blob = new Blob([JSON.stringify({ code: 'S0500', message: '系统内部错误', data: null, requestId: 'req-1', traceId: 'trace-1', })], { type: 'application/json' }) - vi.mocked(overseasHttp.get).mockResolvedValue(blob) + vi.mocked(http.get).mockResolvedValue(blob) await expect(downloadLastMileOrders({})).rejects.toMatchObject({ code: 'S0500', message: '系统内部错误', requestId: 'req-1', traceId: 'trace-1', @@ -85,12 +85,12 @@ describe('last-mile order overseas API', () => { }) it('uses the manual inbound-plan endpoint with the complete form and idempotency evidence', async () => { - vi.mocked(overseasHttp.post).mockResolvedValue({ code: '200', message: 'success', data: {} }) + vi.mocked(http.post).mockResolvedValue({ code: '200', message: 'success', data: {} }) const payload = { orderId: '190000000000000099', customerId: '190000000000000001', expectedOrderVersion: 4, palletCount: 8, palletType: 'STANDARD' as const, assigned: false, mergeRequired: false, urgent: true, remark: '加急' } await createManualInboundPlan(payload, 'plan-key-1') - expect(overseasHttp.post).toHaveBeenCalledWith('/platform/v1/last-leg/inbound-plans/manual', payload, + expect(http.post).toHaveBeenCalledWith('/platform/v1/last-leg/inbound-plans/manual', payload, { headers: { 'Idempotency-Key': 'plan-key-1' } }) expect(payload).not.toHaveProperty('inboundCode') expect(payload).not.toHaveProperty('plannedArrivalDate') @@ -100,28 +100,28 @@ describe('last-mile order overseas API', () => { }) it('uses versioned idempotent order hold and complete manual-node snapshot endpoints', async () => { - vi.mocked(overseasHttp.put).mockResolvedValue({ code: '200', message: 'success', data: {} }) - vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', message: 'success', data: { nodes: [] } }) + vi.mocked(http.put).mockResolvedValue({ code: '200', message: 'success', data: {} }) + vi.mocked(http.get).mockResolvedValue({ code: '200', message: 'success', data: { nodes: [] } }) await updateLastMileOrderHold('190000000000000099', { held: true, chargeable: true, reason: '海关查验', expectedVersion: 4 }, 'hold-key-1') - expect(overseasHttp.put).toHaveBeenLastCalledWith('/platform/v1/last-leg/orders/190000000000000099/hold', + expect(http.put).toHaveBeenLastCalledWith('/platform/v1/last-leg/orders/190000000000000099/hold', { held: true, chargeable: true, reason: '海关查验', expectedVersion: 4 }, { headers: { 'Idempotency-Key': 'hold-key-1' } }) await getLastMileOrderManualNodes('190000000000000099') - expect(overseasHttp.get).toHaveBeenLastCalledWith('/platform/v1/last-leg/orders/190000000000000099/manual-nodes') + expect(http.get).toHaveBeenLastCalledWith('/platform/v1/last-leg/orders/190000000000000099/manual-nodes') const nodes = [{ nodeCode: 'ESTIMATED_ARRIVAL' as const, nodeAt: '2026-09-10T10:00:00' }] await updateLastMileOrderManualNodes('190000000000000099', 5, nodes, 'node-key-1') - expect(overseasHttp.put).toHaveBeenLastCalledWith('/platform/v1/last-leg/orders/190000000000000099/manual-nodes', + expect(http.put).toHaveBeenLastCalledWith('/platform/v1/last-leg/orders/190000000000000099/manual-nodes', { expectedVersion: 5, nodes }, { headers: { 'Idempotency-Key': 'node-key-1' } }) }) it('uses one atomic container request without looping individual conversions', async () => { - vi.mocked(overseasHttp.post).mockResolvedValue({ code: '200', message: 'success', data: [] }) + vi.mocked(http.post).mockResolvedValue({ code: '200', message: 'success', data: [] }) await expect(convertLastMileContainerToInboundPlans({ sourceContainerBookingId: '2092873463568597001', snapshotVersion: 9, snapshotHash: 'a'.repeat(64), }, 'container-plan-idempotency-key')).resolves.toEqual([]) - expect(overseasHttp.post).toHaveBeenCalledTimes(1) - expect(overseasHttp.post).toHaveBeenCalledWith('/platform/v1/last-leg/inbound-plans/convert-container', { + expect(http.post).toHaveBeenCalledTimes(1) + expect(http.post).toHaveBeenCalledWith('/platform/v1/last-leg/inbound-plans/convert-container', { sourceContainerBookingId: '2092873463568597001', snapshotVersion: 9, snapshotHash: 'a'.repeat(64), @@ -134,14 +134,14 @@ describe('last-mile order overseas API', () => { inboundCodeCount: 13, missingInboundCodeCount: 2, planCount: 10, plannedInboundCodeCount: 10, pendingPlanCount: 5, resourcePendingPlanCount: 0, plannedArrivalAt: '2026-09-01T08:00:00', allPlansReady: false, blockingReason: '存在2票缺少入仓码', } - vi.mocked(overseasHttp.get).mockResolvedValue({ + vi.mocked(http.get).mockResolvedValue({ code: '200', message: 'success', data: { records: [record], page: '2', size: '10', total: '21', pages: '3' }, }) await expect(listContainerPlanProgress({ page: 2, size: 10, containerNo: 'CONT-15', createdFrom: '2026-08-01T00:00:00', createdTo: '2026-08-31T23:59:59', })).resolves.toEqual({ records: [record], page: 2, size: 10, total: 21, pages: 3 }) - expect(overseasHttp.get).toHaveBeenCalledWith('/platform/v1/last-leg/orders/container-summaries', { + expect(http.get).toHaveBeenCalledWith('/platform/v1/last-leg/orders/container-summaries', { params: { page: 2, size: 10, containerNo: 'CONT-15', createdFrom: '2026-08-01T00:00:00', createdTo: '2026-08-31T23:59:59', @@ -151,18 +151,18 @@ describe('last-mile order overseas API', () => { it('loads one container detail with the encoded stable long id', async () => { const detail = { summary: { sourceContainerBookingId: '2092873463568597001' }, orders: [] } - vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', message: 'success', data: detail }) + vi.mocked(http.get).mockResolvedValue({ code: '200', message: 'success', data: detail }) await expect(getContainerPlanProgress('2092873463568597001/unsafe')).resolves.toEqual(detail) - expect(overseasHttp.get).toHaveBeenCalledWith('/platform/v1/last-leg/orders/container-summaries/2092873463568597001%2Funsafe') + expect(http.get).toHaveBeenCalledWith('/platform/v1/last-leg/orders/container-summaries/2092873463568597001%2Funsafe') }) it('changes one whole container receiving warehouse with version and idempotency evidence', async () => { const updated = { sourceContainerBookingId: '2092873463568597001', overseasWarehouseId: '9007199254740993' } - vi.mocked(overseasHttp.put).mockResolvedValue({ code: '200', message: 'success', data: updated }) + vi.mocked(http.put).mockResolvedValue({ code: '200', message: 'success', data: updated }) await expect(changeContainerReceivingWarehouse('2092873463568597001/unsafe', { overseasWarehouseId: '9007199254740993', expectedVersion: 7, remark: '改派洛杉矶接柜', }, 'receiving-key-1')).resolves.toEqual(updated) - expect(overseasHttp.put).toHaveBeenCalledWith( + expect(http.put).toHaveBeenCalledWith( '/platform/v1/last-leg/orders/container-summaries/2092873463568597001%2Funsafe/receiving-warehouse', { overseasWarehouseId: '9007199254740993', expectedVersion: 7, remark: '改派洛杉矶接柜' }, { headers: { 'Idempotency-Key': 'receiving-key-1' } }, diff --git a/src/modules/overseas-warehouse/api/lastMileOrders.ts b/src/modules/overseas-warehouse/api/lastMileOrders.ts index d5136bee..0f69350d 100644 --- a/src/modules/overseas-warehouse/api/lastMileOrders.ts +++ b/src/modules/overseas-warehouse/api/lastMileOrders.ts @@ -1,5 +1,5 @@ -import { overseasHttp } from '@/shared/api/overseasHttp' -import { ApiResponseError, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' +import { http, ApiResponseError, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' + import type { InboundPlanDetailDto } from './inboundPlans' export interface OverseasPageResult { @@ -351,7 +351,7 @@ export interface LastMileOrderDetailDto { } export async function listLastMileOrders(query: LastMileOrderQuery) { - const response = await overseasHttp.get>, ApiEnvelope>>( + const response = await http.get>, ApiEnvelope>>( '/platform/v1/last-leg/orders', { params: query }, ) @@ -359,14 +359,14 @@ export async function listLastMileOrders(query: LastMileOrderQuery) { } export async function getLastMileOrder(orderId: string) { - const response = await overseasHttp.get, ApiEnvelope>( + const response = await http.get, ApiEnvelope>( `/platform/v1/last-leg/orders/${encodeURIComponent(orderId)}`, ) return unwrapApiResponse(response) } export async function reviewReturnOrder(returnNo: string, customerId: string, decision: 'APPROVE' | 'REJECT', remark?: string) { - const response = await overseasHttp.post, ApiEnvelope>( + const response = await http.post, ApiEnvelope>( `/platform/v1/last-leg/return-orders/${encodeURIComponent(returnNo)}/review`, { decision, remark: remark?.trim() || undefined }, { headers: { 'X-Customer-Id': customerId } }, @@ -375,7 +375,7 @@ export async function reviewReturnOrder(returnNo: string, customerId: string, de } export async function reviewDirectRelabelOrder(orderId: string, customerId: string, decision: 'APPROVE' | 'REJECT', remark?: string) { - const response = await overseasHttp.put, ApiEnvelope>( + const response = await http.put, ApiEnvelope>( `/platform/v1/last-leg/orders/${encodeURIComponent(orderId)}/relabel-review`, { status: decision === 'APPROVE' ? 'PENDING_PROCESSING' : 'REJECTED', remark: remark?.trim() || undefined }, { params: { customerId } }, @@ -388,7 +388,7 @@ export async function downloadLastMileOrders(query: Partial) const params = Object.fromEntries( Object.entries(filters).filter(([, value]) => value !== undefined && value !== null && value !== ''), ) - const blob = await overseasHttp.get('/platform/v1/last-leg/orders/export', { params, responseType: 'blob' }) + const blob = await http.get('/platform/v1/last-leg/orders/export', { params, responseType: 'blob' }) if (!blob.type.toLowerCase().includes('json')) return blob let error: Partial> try { @@ -407,7 +407,7 @@ export async function downloadLastMileOrders(query: Partial) } export async function createLastMileOrderFee(orderId: string, payload: CreateLastMileFeePayload) { - const response = await overseasHttp.post, ApiEnvelope>( + const response = await http.post, ApiEnvelope>( `/platform/v1/last-leg/orders/${encodeURIComponent(orderId)}/fees`, payload, { headers: { 'Idempotency-Key': `last-mile-fee-${orderId}-${Date.now()}-${Math.random().toString(36).slice(2)}` } }, ) @@ -415,14 +415,14 @@ export async function createLastMileOrderFee(orderId: string, payload: CreateLas } export async function transferLastMileOrders(payload: TransferDestinationPayload) { - const response = await overseasHttp.put, ApiEnvelope>( + const response = await http.put, ApiEnvelope>( '/platform/v1/last-leg/orders/destination', payload, ) return unwrapApiResponse(response) } export async function listContainerPlanProgress(query: ContainerPlanProgressQuery): Promise> { - const response = await overseasHttp.get>, ApiEnvelope>>( + const response = await http.get>, ApiEnvelope>>( '/platform/v1/last-leg/orders/container-summaries', { params: query }, ) @@ -430,14 +430,14 @@ export async function listContainerPlanProgress(query: ContainerPlanProgressQuer } export async function getContainerPlanProgress(sourceContainerBookingId: string) { - const response = await overseasHttp.get, ApiEnvelope>( + const response = await http.get, ApiEnvelope>( `/platform/v1/last-leg/orders/container-summaries/${encodeURIComponent(sourceContainerBookingId)}`, ) return unwrapApiResponse(response) } export async function createManualInboundPlan(payload: ManualInboundPlanPayload, idempotencyKey: string) { - const response = await overseasHttp.post, ApiEnvelope>( + const response = await http.post, ApiEnvelope>( '/platform/v1/last-leg/inbound-plans/manual', payload, { headers: { 'Idempotency-Key': idempotencyKey } }, ) @@ -445,7 +445,7 @@ export async function createManualInboundPlan(payload: ManualInboundPlanPayload, } export async function updateLastMileOrderHold(orderId: string, payload: HoldLastMileOrderPayload, idempotencyKey: string) { - const response = await overseasHttp.put, ApiEnvelope>( + const response = await http.put, ApiEnvelope>( `/platform/v1/last-leg/orders/${encodeURIComponent(orderId)}/hold`, payload, { headers: { 'Idempotency-Key': idempotencyKey } }, ) @@ -453,7 +453,7 @@ export async function updateLastMileOrderHold(orderId: string, payload: HoldLast } export async function getLastMileOrderManualNodes(orderId: string) { - const response = await overseasHttp.get, ApiEnvelope>( + const response = await http.get, ApiEnvelope>( `/platform/v1/last-leg/orders/${encodeURIComponent(orderId)}/manual-nodes`, ) return unwrapApiResponse(response) @@ -465,7 +465,7 @@ export async function updateLastMileOrderManualNodes( nodes: ManualNodeValue[], idempotencyKey: string, ) { - const response = await overseasHttp.put, ApiEnvelope>( + const response = await http.put, ApiEnvelope>( `/platform/v1/last-leg/orders/${encodeURIComponent(orderId)}/manual-nodes`, { expectedVersion, nodes }, { headers: { 'Idempotency-Key': idempotencyKey } }, @@ -478,7 +478,7 @@ export async function changeContainerReceivingWarehouse( payload: ChangeContainerReceivingWarehousePayload, idempotencyKey: string, ) { - const response = await overseasHttp.put, ApiEnvelope>( + const response = await http.put, ApiEnvelope>( `/platform/v1/last-leg/orders/container-summaries/${encodeURIComponent(sourceContainerBookingId)}/receiving-warehouse`, payload, { headers: { 'Idempotency-Key': idempotencyKey } }, @@ -487,7 +487,7 @@ export async function changeContainerReceivingWarehouse( } export async function getContainerInboundPlanPreview(sourceContainerBookingId: string) { - const response = await overseasHttp.get, ApiEnvelope>( + const response = await http.get, ApiEnvelope>( '/platform/v1/last-leg/inbound-plans/container-preview', { params: { sourceContainerBookingId } }, ) @@ -495,7 +495,7 @@ export async function getContainerInboundPlanPreview(sourceContainerBookingId: s } export async function convertLastMileContainerToInboundPlans(payload: ConvertContainerInboundPlanPayload, idempotencyKey: string) { - const response = await overseasHttp.post, ApiEnvelope>( + const response = await http.post, ApiEnvelope>( '/platform/v1/last-leg/inbound-plans/convert-container', payload, { headers: { 'Idempotency-Key': idempotencyKey } }, ) diff --git a/src/modules/overseas-warehouse/api/locations.ts b/src/modules/overseas-warehouse/api/locations.ts index 04e46be4..146f7f22 100644 --- a/src/modules/overseas-warehouse/api/locations.ts +++ b/src/modules/overseas-warehouse/api/locations.ts @@ -1,5 +1,5 @@ -import { overseasHttp } from '@/shared/api/overseasHttp' -import { unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' +import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' + import { normalizeOverseasPage, type OverseasPagePayload } from './lastMileOrders' export type OverseasLocationType = 'VIRTUAL' | 'NORMAL' | 'SHARED_OVERFLOW' @@ -33,10 +33,10 @@ export interface SaveOverseasLocationPayload { export interface OverseasLocationQuery { page?: number; size?: number; keyword?: string; locationType?: OverseasLocationType | ''; status?: OverseasLocationStatus | '' } -function base(warehouseId: string) { return `/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/locations` } +function base(warehouseId: string) { return `/platform/v1/overseas-warehouse-resources/${encodeURIComponent(warehouseId)}/locations` } export async function listOverseasLocations(warehouseId: string, query: OverseasLocationQuery = {}) { - const response = await overseasHttp.get>, ApiEnvelope>>(base(warehouseId), { params: { page: query.page ?? 1, size: query.size ?? 20, ...query } }) + const response = await http.get>, ApiEnvelope>>(base(warehouseId), { params: { page: query.page ?? 1, size: query.size ?? 20, ...query } }) return normalizeOverseasPage(unwrapApiResponse(response)) } @@ -47,21 +47,21 @@ export async function listAllOverseasLocations(warehouseIds: string[], query: Ov } export async function createOverseasLocation(warehouseId: string, payload: SaveOverseasLocationPayload) { - const response = await overseasHttp.post, ApiEnvelope>(base(warehouseId), payload) + const response = await http.post, ApiEnvelope>(base(warehouseId), payload) return unwrapApiResponse(response) } export async function updateOverseasLocation(warehouseId: string, locationId: string, payload: SaveOverseasLocationPayload & { version: number }) { - const response = await overseasHttp.put, ApiEnvelope>(`${base(warehouseId)}/${encodeURIComponent(locationId)}`, payload) + const response = await http.put, ApiEnvelope>(`${base(warehouseId)}/${encodeURIComponent(locationId)}`, payload) return unwrapApiResponse(response) } export async function changeOverseasLocationStatus(warehouseId: string, locationId: string, payload: SaveOverseasLocationPayload & { version: number }) { - const response = await overseasHttp.put, ApiEnvelope>(`${base(warehouseId)}/${encodeURIComponent(locationId)}`, payload) + const response = await http.put, ApiEnvelope>(`${base(warehouseId)}/${encodeURIComponent(locationId)}`, payload) return unwrapApiResponse(response) } export async function deleteOverseasLocation(warehouseId: string, locationId: string, version: number) { - const response = await overseasHttp.delete, ApiEnvelope>(`${base(warehouseId)}/${encodeURIComponent(locationId)}`, { params: { version } }) + const response = await http.delete, ApiEnvelope>(`${base(warehouseId)}/${encodeURIComponent(locationId)}`, { params: { version } }) return unwrapApiResponse(response) } diff --git a/src/modules/overseas-warehouse/api/outbounds.test.ts b/src/modules/overseas-warehouse/api/outbounds.test.ts index 5e8b265c..65d2631d 100644 --- a/src/modules/overseas-warehouse/api/outbounds.test.ts +++ b/src/modules/overseas-warehouse/api/outbounds.test.ts @@ -1,36 +1,36 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { overseasHttp } from '@/shared/api/overseasHttp' +import { http } from '@/shared/api/http' import { cancelOutbound, createOneClickDropshipOutbound, getOutbound, listOneClickOutboundCandidates, listOutbounds } from './outbounds' -vi.mock('@/shared/api/overseasHttp', () => ({ overseasHttp: { get: vi.fn(), post: vi.fn() } })) +vi.mock('@/shared/api/http', async (importOriginal) => ({ ...(await importOriginal()), http: { get: vi.fn(), post: vi.fn() } })) describe('outbound overseas API', () => { - beforeEach(() => { vi.mocked(overseasHttp.get).mockReset(); vi.mocked(overseasHttp.post).mockReset() }) + beforeEach(() => { vi.mocked(http.get).mockReset(); vi.mocked(http.post).mockReset() }) it('lists outbound orders with supported filters', async () => { - vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', data: { records: [], page: 1, size: 20, total: 0, pages: 0 } }) + vi.mocked(http.get).mockResolvedValue({ code: '200', data: { records: [], page: 1, size: 20, total: 0, pages: 0 } }) await listOutbounds({ page: 1, size: 20, documentNo: 'OUT-1', warehouseId: 'WH-1', status: 'PICKED' }) - expect(overseasHttp.get).toHaveBeenCalledWith('/platform/v1/last-leg/outbound-orders', { params: { page: 1, size: 20, documentNo: 'OUT-1', warehouseId: 'WH-1', status: 'PICKED' } }) + expect(http.get).toHaveBeenCalledWith('/platform/v1/last-leg/outbound-orders', { params: { page: 1, size: 20, documentNo: 'OUT-1', warehouseId: 'WH-1', status: 'PICKED' } }) }) it('loads detail and cancels only by outboundNo without customerId', async () => { - vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', data: { outbound: { id: '1', outboundNo: 'OUT/1', status: 'PICKED' }, deliveries: [] } }) - vi.mocked(overseasHttp.post).mockResolvedValue({ code: '200', data: null }) + vi.mocked(http.get).mockResolvedValue({ code: '200', data: { outbound: { id: '1', outboundNo: 'OUT/1', status: 'PICKED' }, deliveries: [] } }) + vi.mocked(http.post).mockResolvedValue({ code: '200', data: null }) await getOutbound('OUT/1') await cancelOutbound('OUT/1', '运营取消') - expect(overseasHttp.get).toHaveBeenCalledWith('/platform/v1/last-leg/outbound-orders/OUT%2F1') - expect(overseasHttp.post).toHaveBeenCalledWith('/platform/v1/last-leg/outbound-orders/OUT%2F1/cancel', { reason: '运营取消' }) + expect(http.get).toHaveBeenCalledWith('/platform/v1/last-leg/outbound-orders/OUT%2F1') + expect(http.post).toHaveBeenCalledWith('/platform/v1/last-leg/outbound-orders/OUT%2F1/cancel', { reason: '运营取消' }) }) it('keeps customerId explicit only for one-click dropship creation', async () => { - vi.mocked(overseasHttp.post).mockResolvedValue({ code: '200', data: { id: '1', outboundNo: 'OUT-1' } }) + vi.mocked(http.post).mockResolvedValue({ code: '200', data: { id: '1', outboundNo: 'OUT-1' } }) const payload = { recipient: 'John / 555 / LA', items: [{ inventoryPositionId: '99', packages: 2, sku: 'SKU-1' }], idempotencyKey: 'dropship-1' } await createOneClickDropshipOutbound('customer-1', payload) - expect(overseasHttp.post).toHaveBeenCalledWith('/platform/v1/last-leg/outbound-orders/one-click-dropship', payload, { params: { customerId: 'customer-1' } }) + expect(http.post).toHaveBeenCalledWith('/platform/v1/last-leg/outbound-orders/one-click-dropship', payload, { params: { customerId: 'customer-1' } }) }) it('normalizes one-click candidate measurements returned as decimal strings', async () => { - vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', data: [{ + vi.mocked(http.get).mockResolvedValue({ code: '200', data: [{ inventoryPositionId: '99', availablePackages: 4, grossWeightKg: '40.000', volumeWeightKg: '80.000', volumeCbm: '4.0000', }] }) diff --git a/src/modules/overseas-warehouse/api/outbounds.ts b/src/modules/overseas-warehouse/api/outbounds.ts index d1126fdc..d83cff5d 100644 --- a/src/modules/overseas-warehouse/api/outbounds.ts +++ b/src/modules/overseas-warehouse/api/outbounds.ts @@ -1,5 +1,5 @@ -import { overseasHttp } from '@/shared/api/overseasHttp' -import { unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' +import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' + import { normalizeOverseasPage, type OverseasPagePayload } from './lastMileOrders' import type { DeliveryOrderRow } from './deliveryOrders' @@ -26,19 +26,19 @@ export interface OneClickOutboundCandidate { inventoryPositionId: string; orderN export interface WarehouseTransferOutboundCandidate { customerId: string; customerName?: string | null; transferNo: string; overseasWarehouseId: string; transferStatus: string; needRelabel: boolean; relabelNo?: string | null; destinationType?: 'FBA' | 'PRIVATE' | null; destinationWarehouseCode?: string | null; privateDestinationAddress?: string | null; privateDestinationPostalCode?: string | null; packageCount: number; grossWeightKg?: number | null; volumeCbm?: number | null } export interface WarehouseTransferDestinationPayload { transfers: Array<{ customerId: string; transferNo: string }>; destinationType: 'FBA' | 'PRIVATE'; destinationWarehouseId?: string; destinationWarehouseCode?: string; destinationWarehouseName?: string; privateDestinationAddress?: string; privateDestinationPostalCode?: string; privateDestinationContactName?: string; privateDestinationContactPhone?: string; idempotencyKey: string } export async function listOutbounds(query: OutboundQuery) { - const response = await overseasHttp.get>, ApiEnvelope>>('/platform/v1/last-leg/outbound-orders', { params: query }) + const response = await http.get>, ApiEnvelope>>('/platform/v1/last-leg/outbound-orders', { params: query }) return normalizeOverseasPage(unwrapApiResponse(response)) } export async function getOutbound(outboundNo: string) { - const response = await overseasHttp.get, ApiEnvelope>(`/platform/v1/last-leg/outbound-orders/${encodeURIComponent(outboundNo)}`) + const response = await http.get, ApiEnvelope>(`/platform/v1/last-leg/outbound-orders/${encodeURIComponent(outboundNo)}`) return unwrapApiResponse(response) } export async function createOneClickDropshipOutbound(customerId: string, payload: { recipient: string; items: Array<{ inventoryPositionId: string; packages: number; sku?: string; cartonMarks?: string[] }>; remark?: string; idempotencyKey: string }) { - const response = await overseasHttp.post, ApiEnvelope>('/platform/v1/last-leg/outbound-orders/one-click-dropship', payload, { params: { customerId } }) + const response = await http.post, ApiEnvelope>('/platform/v1/last-leg/outbound-orders/one-click-dropship', payload, { params: { customerId } }) return unwrapApiResponse(response) } export async function listOneClickOutboundCandidates(customerId?: string, query: { warehouseId?: string } = {}) { - const response = await overseasHttp.get, ApiEnvelope>('/platform/v1/last-leg/outbound-orders/one-click-dropship/candidates', { params: { customerId, ...query } }) + const response = await http.get, ApiEnvelope>('/platform/v1/last-leg/outbound-orders/one-click-dropship/candidates', { params: { customerId, ...query } }) return unwrapApiResponse(response).map((item) => ({ ...item, inventoryPositionId: String(item.inventoryPositionId), @@ -49,22 +49,22 @@ export async function listOneClickOutboundCandidates(customerId?: string, query: })) } export async function listReturnOutboundCandidates(customerId: string, warehouseId: string) { - const response = await overseasHttp.get, ApiEnvelope>('/platform/v1/last-leg/outbound-orders/return/candidates', { params: { customerId, warehouseId } }) + const response = await http.get, ApiEnvelope>('/platform/v1/last-leg/outbound-orders/return/candidates', { params: { customerId, warehouseId } }) return unwrapApiResponse(response) } export async function createReturnOutbound(customerId: string, payload: { recipient: string; overseasWarehouseId: string; returnNo: string; cartonMarks: string[]; remark?: string; idempotencyKey: string }) { - const response = await overseasHttp.post, ApiEnvelope>('/platform/v1/last-leg/outbound-orders/return', payload, { params: { customerId } }) + const response = await http.post, ApiEnvelope>('/platform/v1/last-leg/outbound-orders/return', payload, { params: { customerId } }) return unwrapApiResponse(response) } export async function listWarehouseTransferOutboundCandidates(query: { customerId?: string; warehouseId?: string } = {}) { - const response = await overseasHttp.get, ApiEnvelope>('/platform/v1/last-leg/outbound-orders/warehouse-transfer/candidates', { params: query }) + const response = await http.get, ApiEnvelope>('/platform/v1/last-leg/outbound-orders/warehouse-transfer/candidates', { params: query }) return unwrapApiResponse(response).map(item => ({ ...item, packageCount: Number(item.packageCount || 0) })) } export async function bindWarehouseTransferDestination(payload: WarehouseTransferDestinationPayload) { - const response = await overseasHttp.post, ApiEnvelope>('/platform/v1/last-leg/warehouse-transfers/bind-destination', payload) + const response = await http.post, ApiEnvelope>('/platform/v1/last-leg/warehouse-transfers/bind-destination', payload) return unwrapApiResponse(response) } export async function cancelOutbound(outboundNo: string, reason: string) { - const response = await overseasHttp.post, ApiEnvelope>(`/platform/v1/last-leg/outbound-orders/${encodeURIComponent(outboundNo)}/cancel`, { reason }) + const response = await http.post, ApiEnvelope>(`/platform/v1/last-leg/outbound-orders/${encodeURIComponent(outboundNo)}/cancel`, { reason }) return unwrapApiResponse(response) } diff --git a/src/modules/overseas-warehouse/api/palletWarehouseRouting.contract.test.ts b/src/modules/overseas-warehouse/api/palletWarehouseRouting.contract.test.ts index e08c1509..71fb74af 100644 --- a/src/modules/overseas-warehouse/api/palletWarehouseRouting.contract.test.ts +++ b/src/modules/overseas-warehouse/api/palletWarehouseRouting.contract.test.ts @@ -19,9 +19,9 @@ describe('Admin pallet warehouse routing contract', () => { }) it('never calls the nonexistent overseas warehouse-admin root and always scopes pallet calls by warehouseId', () => { - expect(palletsApi).not.toMatch(/overseasHttp\.get[^\n]*\(\s*['"]\/platform\/v1\/warehouse-admin['"]/) + expect(palletsApi).not.toMatch(/http\.get[^\n]*\(\s*['"]\/platform\/v1\/warehouse-admin['"]/) expect(palletsApi).not.toContain('listCustomerWarehouses') - expect(palletsApi).toContain('`/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/pallets`') - expect(palletsApi).toContain('`/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/pallets/${encodeURIComponent(palletId)}`') + expect(palletsApi).toContain('`/platform/v1/overseas-warehouse-resources/${encodeURIComponent(warehouseId)}/pallets`') + expect(palletsApi).toContain('`/platform/v1/overseas-warehouse-resources/${encodeURIComponent(warehouseId)}/pallets/${encodeURIComponent(palletId)}`') }) }) diff --git a/src/modules/overseas-warehouse/api/pallets.test.ts b/src/modules/overseas-warehouse/api/pallets.test.ts index 26bb06b4..180685db 100644 --- a/src/modules/overseas-warehouse/api/pallets.test.ts +++ b/src/modules/overseas-warehouse/api/pallets.test.ts @@ -1,32 +1,32 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { listSharedReceivingOverseasWarehouseOptions } from '@/modules/iam' -import { overseasHttp } from '@/shared/api/overseasHttp' +import { http } from '@/shared/api/http' import { createPallet, getPallet, getPalletLabelPdf, listPallets, listReceivingOverseasWarehouses, updatePallet } from './pallets' vi.mock('@/modules/iam', () => ({ listSharedReceivingOverseasWarehouseOptions: vi.fn() })) -vi.mock('@/shared/api/overseasHttp', () => ({ overseasHttp: { get: vi.fn(), post: vi.fn(), put: vi.fn() } })) +vi.mock('@/shared/api/http', async (importOriginal) => ({ ...(await importOriginal()), http: { get: vi.fn(), post: vi.fn(), put: vi.fn() } })) describe('overseas pallet API', () => { beforeEach(() => { vi.clearAllMocks() }) it('downloads only the selected pallet through the overseas gateway', async () => { const pdf = new Blob(['%PDF-label'], { type: 'application/pdf' }) - vi.mocked(overseasHttp.get).mockResolvedValueOnce(pdf) + vi.mocked(http.get).mockResolvedValueOnce(pdf) await expect(getPalletLabelPdf('200', '9007199254740993')).resolves.toBe(pdf) - expect(overseasHttp.get).toHaveBeenCalledWith('/platform/v1/warehouse-admin/200/pallets/9007199254740993/label', { responseType: 'blob', timeout: 60_000 }) + expect(http.get).toHaveBeenCalledWith('/platform/v1/overseas-warehouse-resources/200/pallets/9007199254740993/label', { responseType: 'blob', timeout: 60_000 }) }) it('surfaces a JSON business failure instead of saving it as a PDF', async () => { const error = new Blob([JSON.stringify({ code: 'A0422', message: '托盘编码不支持打印', requestId: 'r1' })], { type: 'application/json' }) - vi.mocked(overseasHttp.get).mockResolvedValueOnce(error) + vi.mocked(http.get).mockResolvedValueOnce(error) await expect(getPalletLabelPdf('200', '9')).rejects.toMatchObject({ message: '托盘编码不支持打印', code: 'A0422', requestId: 'r1' }) }) it('uses the public warehouse scoped pallet CRUD contract', async () => { const page = { records: [], page: 1, size: 20, total: 0, pages: 0 } - vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', data: page }) - vi.mocked(overseasHttp.post).mockResolvedValue({ code: '200', data: '9' }) - vi.mocked(overseasHttp.put).mockResolvedValue({ code: '200', data: null }) + vi.mocked(http.get).mockResolvedValue({ code: '200', data: page }) + vi.mocked(http.post).mockResolvedValue({ code: '200', data: '9' }) + vi.mocked(http.put).mockResolvedValue({ code: '200', data: null }) vi.mocked(listSharedReceivingOverseasWarehouseOptions).mockResolvedValue([{ id: '200', warehouseCode: 'LAX', warehouseName: '洛杉矶仓', countryCode: 'US' }]) const payload = { palletCode: 'PLT-NEW', palletType: 'WOOD', fbaWarehouseCode: 'ABE3', capacityCbm: 2, unitPrice: 10, status: 'IDLE' } @@ -37,11 +37,11 @@ describe('overseas pallet API', () => { await updatePallet('200', '9', { ...payload, version: 7 }) expect(listSharedReceivingOverseasWarehouseOptions).toHaveBeenCalledOnce() - expect(overseasHttp.get).toHaveBeenNthCalledWith(1, '/platform/v1/warehouse-admin/200/pallets', { params: { page: 1, size: 20, keyword: 'PLT' } }) - expect(overseasHttp.get).toHaveBeenNthCalledWith(2, '/platform/v1/warehouse-admin/200/pallets/9', {}) - expect(overseasHttp.post).toHaveBeenCalledWith('/platform/v1/warehouse-admin/200/pallets', payload) - expect(overseasHttp.put).toHaveBeenCalledWith('/platform/v1/warehouse-admin/200/pallets/9', { ...payload, version: 7 }) - expect(overseasHttp.put).toHaveBeenCalledWith('/platform/v1/warehouse-admin/200/pallets/9', expect.objectContaining({ palletCode: 'PLT-NEW', fbaWarehouseCode: 'ABE3', status: 'IDLE', version: 7 })) - expect(overseasHttp.post).toHaveBeenCalledWith('/platform/v1/warehouse-admin/200/pallets', expect.not.objectContaining({ version: expect.anything() })) + expect(http.get).toHaveBeenNthCalledWith(1, '/platform/v1/overseas-warehouse-resources/200/pallets', { params: { page: 1, size: 20, keyword: 'PLT' } }) + expect(http.get).toHaveBeenNthCalledWith(2, '/platform/v1/overseas-warehouse-resources/200/pallets/9', {}) + expect(http.post).toHaveBeenCalledWith('/platform/v1/overseas-warehouse-resources/200/pallets', payload) + expect(http.put).toHaveBeenCalledWith('/platform/v1/overseas-warehouse-resources/200/pallets/9', { ...payload, version: 7 }) + expect(http.put).toHaveBeenCalledWith('/platform/v1/overseas-warehouse-resources/200/pallets/9', expect.objectContaining({ palletCode: 'PLT-NEW', fbaWarehouseCode: 'ABE3', status: 'IDLE', version: 7 })) + expect(http.post).toHaveBeenCalledWith('/platform/v1/overseas-warehouse-resources/200/pallets', expect.not.objectContaining({ version: expect.anything() })) }) }) diff --git a/src/modules/overseas-warehouse/api/pallets.ts b/src/modules/overseas-warehouse/api/pallets.ts index abc0218a..f75ba007 100644 --- a/src/modules/overseas-warehouse/api/pallets.ts +++ b/src/modules/overseas-warehouse/api/pallets.ts @@ -1,5 +1,5 @@ -import { overseasHttp } from '@/shared/api/overseasHttp' -import { unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' +import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' + import { listSharedReceivingOverseasWarehouseOptions } from '@/modules/iam' import { normalizeOverseasPage, type OverseasPagePayload } from './lastMileOrders' @@ -58,29 +58,29 @@ export async function listReceivingOverseasWarehouses() { } export async function listPallets(warehouseId: string | undefined, query: { page: number; size: number; keyword?: string; palletType?: string; status?: string }) { - const response = await overseasHttp.get>, ApiEnvelope>>( - warehouseId ? `/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/pallets` : '/platform/v1/warehouse-admin/pallets', + const response = await http.get>, ApiEnvelope>>( + warehouseId ? `/platform/v1/overseas-warehouse-resources/${encodeURIComponent(warehouseId)}/pallets` : '/platform/v1/overseas-warehouse-resources/pallets', { params: { ...query } }, ) return normalizeOverseasPage(unwrapApiResponse(response)) } export async function getPalletSummary(warehouseId: string, query: { keyword?: string; palletType?: string }) { - const path = warehouseId ? `/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/pallets/summary` : '/platform/v1/warehouse-admin/pallets/summary' - const response = await overseasHttp.get, ApiEnvelope>(path, { params: { page: 1, size: 1, ...query } }) + const path = warehouseId ? `/platform/v1/overseas-warehouse-resources/${encodeURIComponent(warehouseId)}/pallets/summary` : '/platform/v1/overseas-warehouse-resources/pallets/summary' + const response = await http.get, ApiEnvelope>(path, { params: { page: 1, size: 1, ...query } }) const value = unwrapApiResponse(response); return { totalCount: Number(value.totalCount), idleCount: Number(value.idleCount), occupiedCount: Number(value.occupiedCount), scrappedCount: Number(value.scrappedCount) } } export async function getPallet(warehouseId: string, palletId: string) { - const response = await overseasHttp.get, ApiEnvelope>( - `/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/pallets/${encodeURIComponent(palletId)}`, + const response = await http.get, ApiEnvelope>( + `/platform/v1/overseas-warehouse-resources/${encodeURIComponent(warehouseId)}/pallets/${encodeURIComponent(palletId)}`, {}, ) return unwrapApiResponse(response) } export async function getPalletLabelPdf(warehouseId: string, palletId: string) { - const blob = await overseasHttp.get( - `/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/pallets/${encodeURIComponent(palletId)}/label`, + const blob = await http.get( + `/platform/v1/overseas-warehouse-resources/${encodeURIComponent(warehouseId)}/pallets/${encodeURIComponent(palletId)}/label`, { responseType: 'blob', timeout: 60_000 }, ) if (blob.type.includes('application/json')) unwrapApiResponse(JSON.parse(await blob.text()) as ApiEnvelope) @@ -89,15 +89,15 @@ export async function getPalletLabelPdf(warehouseId: string, palletId: string) { } export async function createPallet(warehouseId: string, payload: SavePalletPayload) { - const response = await overseasHttp.post, ApiEnvelope>( - `/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/pallets`, payload, + const response = await http.post, ApiEnvelope>( + `/platform/v1/overseas-warehouse-resources/${encodeURIComponent(warehouseId)}/pallets`, payload, ) return unwrapApiResponse(response) } export async function updatePallet(warehouseId: string, palletId: string, payload: UpdatePalletPayload) { - const response = await overseasHttp.put, ApiEnvelope>( - `/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/pallets/${encodeURIComponent(palletId)}`, + const response = await http.put, ApiEnvelope>( + `/platform/v1/overseas-warehouse-resources/${encodeURIComponent(warehouseId)}/pallets/${encodeURIComponent(palletId)}`, payload, ) return unwrapApiResponse(response) diff --git a/src/modules/overseas-warehouse/api/relabelOrders.test.ts b/src/modules/overseas-warehouse/api/relabelOrders.test.ts index ba328e35..187543a8 100644 --- a/src/modules/overseas-warehouse/api/relabelOrders.test.ts +++ b/src/modules/overseas-warehouse/api/relabelOrders.test.ts @@ -1,45 +1,45 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { listSharedReceivingOverseasWarehouseOptions } from '@/modules/iam' -import { overseasHttp } from '@/shared/api/overseasHttp' +import { http } from '@/shared/api/http' import { changeRelabelOrderStatus, createRelabelOrder, getRelabelOrder, listRelabelOrders, listRelabelWarehouseOptions } from './relabelOrders' vi.mock('@/modules/iam', () => ({ listSharedReceivingOverseasWarehouseOptions: vi.fn() })) -vi.mock('@/shared/api/overseasHttp', () => ({ overseasHttp: { get: vi.fn(), post: vi.fn(), put: vi.fn() } })) +vi.mock('@/shared/api/http', async (importOriginal) => ({ ...(await importOriginal()), http: { get: vi.fn(), post: vi.fn(), put: vi.fn() } })) describe('relabel overseas API', () => { beforeEach(() => { - vi.mocked(overseasHttp.get).mockReset() - vi.mocked(overseasHttp.post).mockReset() - vi.mocked(overseasHttp.put).mockReset() + vi.mocked(http.get).mockReset() + vi.mocked(http.post).mockReset() + vi.mocked(http.put).mockReset() vi.mocked(listSharedReceivingOverseasWarehouseOptions).mockReset() }) it('uses the platform page endpoint without customer scope', async () => { - vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', message: 'success', data: { records: [], page: 1, size: 20, total: 0, pages: 0 } }) + vi.mocked(http.get).mockResolvedValue({ code: '200', message: 'success', data: { records: [], page: 1, size: 20, total: 0, pages: 0 } }) await listRelabelOrders({ page: 1, size: 20, skuCode: 'SKU-1' }) - expect(overseasHttp.get).toHaveBeenCalledWith('/platform/v1/last-leg/relabel-applications', { params: { page: 1, size: 20, skuCode: 'SKU-1' } }) + expect(http.get).toHaveBeenCalledWith('/platform/v1/last-leg/relabel-applications', { params: { page: 1, size: 20, skuCode: 'SKU-1' } }) }) it('encodes the relabel number and does not call a mutation endpoint', async () => { - vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', message: 'success', data: { application: { id: '1', relabelNo: 'RL/1' }, items: [], history: [] } }) + vi.mocked(http.get).mockResolvedValue({ code: '200', message: 'success', data: { application: { id: '1', relabelNo: 'RL/1' }, items: [], history: [] } }) await getRelabelOrder('RL/1') - expect(overseasHttp.get).toHaveBeenCalledWith('/platform/v1/last-leg/relabel-applications/RL%2F1') + expect(http.get).toHaveBeenCalledWith('/platform/v1/last-leg/relabel-applications/RL%2F1') }) it('uses the real create and status endpoints', async () => { - vi.mocked(overseasHttp.post).mockResolvedValue({ code: '200', data: {} }) - vi.mocked(overseasHttp.put).mockResolvedValue({ code: '200', data: {} }) + vi.mocked(http.post).mockResolvedValue({ code: '200', data: {} }) + vi.mocked(http.put).mockResolvedValue({ code: '200', data: {} }) const payload = { customerOrderNo: 'CO-1', overseasWarehouseId: '9', labelDeadline: '2026-08-20T10:00:00', contactName: 'A', contactPhone: '1', chargeAmount: 20, idempotencyKey: 'k', items: [{ skuCode: 'SKU', productNameZh: 'P', totalPackageCount: 1, notifiedPackageCount: 1, shipmentNo: 'S', newSkuCode: 'SKU-2' }] } await createRelabelOrder('10001', payload) await changeRelabelOrderStatus('RL/1', 'PROCESSING', 'ok') - expect(overseasHttp.post).toHaveBeenCalledWith('/platform/v1/last-leg/relabel-applications', payload, { params: { customerId: '10001' } }) - expect(overseasHttp.put).toHaveBeenCalledWith('/platform/v1/last-leg/relabel-applications/RL%2F1/status', { status: 'PROCESSING', remark: 'ok' }) + expect(http.post).toHaveBeenCalledWith('/platform/v1/last-leg/relabel-applications', payload, { params: { customerId: '10001' } }) + expect(http.put).toHaveBeenCalledWith('/platform/v1/last-leg/relabel-applications/RL%2F1/status', { status: 'PROCESSING', remark: 'ok' }) }) it('loads shared ACTIVE OVERSEAS RECEIVING warehouses from the domestic candidate contract', async () => { vi.mocked(listSharedReceivingOverseasWarehouseOptions).mockResolvedValue([{ id: '200', warehouseCode: 'LAX', warehouseName: '洛杉矶仓', countryCode: 'US' }]) await expect(listRelabelWarehouseOptions()).resolves.toMatchObject({ records: [{ id: '200', customerId: '0', warehouseType: 'OVERSEAS', status: 'ACTIVE' }] }) expect(listSharedReceivingOverseasWarehouseOptions).toHaveBeenCalledOnce() - expect(overseasHttp.get).not.toHaveBeenCalledWith('/platform/v1/warehouse-admin', expect.anything()) + expect(http.get).not.toHaveBeenCalledWith('/platform/v1/warehouse-admin', expect.anything()) }) }) diff --git a/src/modules/overseas-warehouse/api/relabelOrders.ts b/src/modules/overseas-warehouse/api/relabelOrders.ts index a79aa178..c19eaf2a 100644 --- a/src/modules/overseas-warehouse/api/relabelOrders.ts +++ b/src/modules/overseas-warehouse/api/relabelOrders.ts @@ -1,5 +1,5 @@ -import { overseasHttp } from '@/shared/api/overseasHttp' -import { unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' +import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' + import { listSharedReceivingOverseasWarehouseOptions } from '@/modules/iam' import { normalizeOverseasPage, type OverseasPagePayload } from './lastMileOrders' @@ -122,14 +122,14 @@ export interface RelabelDetailDto { } export async function listRelabelOrders(query: RelabelOrderQuery) { - const response = await overseasHttp.get>, ApiEnvelope>>( + const response = await http.get>, ApiEnvelope>>( '/platform/v1/last-leg/relabel-applications', { params: query }, ) return normalizeOverseasPage(unwrapApiResponse(response)) } export async function getRelabelOrder(relabelNo: string) { - const response = await overseasHttp.get, ApiEnvelope>( + const response = await http.get, ApiEnvelope>( `/platform/v1/last-leg/relabel-applications/${encodeURIComponent(relabelNo)}`, ) return unwrapApiResponse(response) @@ -148,14 +148,14 @@ export async function listRelabelWarehouseOptions() { } export async function createRelabelOrder(customerId: string, payload: CreateRelabelPayload) { - const response = await overseasHttp.post, ApiEnvelope>( + const response = await http.post, ApiEnvelope>( '/platform/v1/last-leg/relabel-applications', payload, { params: { customerId } }, ) return unwrapApiResponse(response) } export async function changeRelabelOrderStatus(relabelNo: string, status: string, remark?: string) { - const response = await overseasHttp.put, ApiEnvelope>( + const response = await http.put, ApiEnvelope>( `/platform/v1/last-leg/relabel-applications/${encodeURIComponent(relabelNo)}/status`, { status, remark }, ) @@ -163,17 +163,17 @@ export async function changeRelabelOrderStatus(relabelNo: string, status: string } export async function getRelabelReviewTask(relabelNo: string) { - const response = await overseasHttp.get, ApiEnvelope<{ version: number; taskStatus: string }>>(`/platform/v1/last-leg/relabel-applications/${encodeURIComponent(relabelNo)}/review-task`) + const response = await http.get, ApiEnvelope<{ version: number; taskStatus: string }>>(`/platform/v1/last-leg/relabel-applications/${encodeURIComponent(relabelNo)}/review-task`) return unwrapApiResponse(response) } export async function returnRelabelForRework(relabelNo: string, expectedVersion: number, remark: string, idempotencyKey: string) { - const response = await overseasHttp.post, ApiEnvelope>(`/platform/v1/last-leg/relabel-applications/${encodeURIComponent(relabelNo)}/rework`, { decision: 'REWORK', expectedVersion, remark, idempotencyKey }) + const response = await http.post, ApiEnvelope>(`/platform/v1/last-leg/relabel-applications/${encodeURIComponent(relabelNo)}/rework`, { decision: 'REWORK', expectedVersion, remark, idempotencyKey }) return unwrapApiResponse(response) } export async function confirmRelabelCompletion(relabelNo: string, remark?: string) { - const response = await overseasHttp.post, ApiEnvelope>( + const response = await http.post, ApiEnvelope>( `/platform/v1/last-leg/relabel-applications/${encodeURIComponent(relabelNo)}/confirm-completion`, { idempotencyKey: crypto.randomUUID(), remark }, ) @@ -183,7 +183,7 @@ export async function confirmRelabelCompletion(relabelNo: string, remark?: strin export async function uploadRelabelPdf(relabelNo: string, file: File, scope: 'TOTAL' | 'ITEM', itemId?: string) { const body = new FormData() body.append('file', file) - const response = await overseasHttp.post, ApiEnvelope>( + const response = await http.post, ApiEnvelope>( `/platform/v1/last-leg/relabel-applications/${encodeURIComponent(relabelNo)}/files`, body, { params: { scope, itemId }, headers: { 'Content-Type': 'multipart/form-data' } }, ) @@ -191,14 +191,14 @@ export async function uploadRelabelPdf(relabelNo: string, file: File, scope: 'TO } export async function downloadRelabelPdf(relabelNo: string, fileId: string, scope: 'TOTAL' | 'ITEM', itemId?: string) { - return overseasHttp.get( + return http.get( `/platform/v1/last-leg/relabel-applications/${encodeURIComponent(relabelNo)}/files/${encodeURIComponent(fileId)}/download`, { params: { scope, itemId }, responseType: 'blob' }, ) } export async function downloadRelabelCompletionPhoto(relabelNo: string, fileId: string) { - return overseasHttp.get( + return http.get( `/platform/v1/last-leg/relabel-applications/${encodeURIComponent(relabelNo)}/completion-photos/${encodeURIComponent(fileId)}/download`, { responseType: 'blob' }, ) diff --git a/src/modules/overseas-warehouse/api/returnInventory.ts b/src/modules/overseas-warehouse/api/returnInventory.ts index 05b1ef14..da468685 100644 --- a/src/modules/overseas-warehouse/api/returnInventory.ts +++ b/src/modules/overseas-warehouse/api/returnInventory.ts @@ -1,5 +1,5 @@ -import { overseasHttp } from '@/shared/api/overseasHttp' -import { unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' +import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' + import { normalizeOverseasPage, type OverseasPagePayload } from './lastMileOrders' export interface ReturnStockRow { customerId: string; overseasWarehouseId: string; skuCode: string; productNameZh?: string; totalQuantity: number; availableQuantity: number; cartonQuantity: number; updatedAt: string } @@ -9,8 +9,8 @@ export interface ReturnCartonLabel extends ReturnCartonDetail { printableHtml: s export interface ReturnInventoryMovement { customerId: string; overseasWarehouseId: string; skuCode: string; productNameZh?: string; movementType: 'RECEIPT' | 'MERGE' | 'OUTBOUND'; referenceNo: string; totalDelta: number; availableDelta: number; occurredAt: string } export interface ReturnInventoryQuery { page: number; size: number; customerId?: string; overseasWarehouseId?: string; skuCode?: string; cartonMark?: string; returnNo?: string; status?: string } -export async function listReturnStock(params: ReturnInventoryQuery) { const response = await overseasHttp.get>, ApiEnvelope>>('/platform/v1/last-leg/return-inventory', { params }); return normalizeOverseasPage(unwrapApiResponse(response)) } -export async function listReturnCartons(params: ReturnInventoryQuery) { const response = await overseasHttp.get>, ApiEnvelope>>('/platform/v1/last-leg/return-inventory/cartons', { params }); return normalizeOverseasPage(unwrapApiResponse(response)) } -export async function listReturnInventoryMovements(params: ReturnInventoryQuery) { const response = await overseasHttp.get>, ApiEnvelope>>('/platform/v1/last-leg/return-inventory/movements', { params }); return normalizeOverseasPage(unwrapApiResponse(response)) } -export async function getReturnCarton(cartonMark: string) { const response = await overseasHttp.get, ApiEnvelope>(`/platform/v1/last-leg/return-inventory/cartons/${encodeURIComponent(cartonMark)}`); return unwrapApiResponse(response) } -export async function getReturnCartonLabel(cartonMark: string) { const response = await overseasHttp.get, ApiEnvelope>(`/platform/v1/last-leg/return-inventory/cartons/${encodeURIComponent(cartonMark)}/label`); return unwrapApiResponse(response) } +export async function listReturnStock(params: ReturnInventoryQuery) { const response = await http.get>, ApiEnvelope>>('/platform/v1/last-leg/return-inventory', { params }); return normalizeOverseasPage(unwrapApiResponse(response)) } +export async function listReturnCartons(params: ReturnInventoryQuery) { const response = await http.get>, ApiEnvelope>>('/platform/v1/last-leg/return-inventory/cartons', { params }); return normalizeOverseasPage(unwrapApiResponse(response)) } +export async function listReturnInventoryMovements(params: ReturnInventoryQuery) { const response = await http.get>, ApiEnvelope>>('/platform/v1/last-leg/return-inventory/movements', { params }); return normalizeOverseasPage(unwrapApiResponse(response)) } +export async function getReturnCarton(cartonMark: string) { const response = await http.get, ApiEnvelope>(`/platform/v1/last-leg/return-inventory/cartons/${encodeURIComponent(cartonMark)}`); return unwrapApiResponse(response) } +export async function getReturnCartonLabel(cartonMark: string) { const response = await http.get, ApiEnvelope>(`/platform/v1/last-leg/return-inventory/cartons/${encodeURIComponent(cartonMark)}/label`); return unwrapApiResponse(response) } diff --git a/src/modules/overseas-warehouse/api/warehouseTransfers.test.ts b/src/modules/overseas-warehouse/api/warehouseTransfers.test.ts index ab84f311..d90b08ed 100644 --- a/src/modules/overseas-warehouse/api/warehouseTransfers.test.ts +++ b/src/modules/overseas-warehouse/api/warehouseTransfers.test.ts @@ -1,25 +1,25 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { overseasHttp } from '@/shared/api/overseasHttp' +import { http } from '@/shared/api/http' import { getWarehouseTransfer, listWarehouseTransfers, reviewWarehouseTransfer } from './warehouseTransfers' -vi.mock('@/shared/api/overseasHttp', () => ({ overseasHttp: { get: vi.fn(), post: vi.fn() } })) +vi.mock('@/shared/api/http', async (importOriginal) => ({ ...(await importOriginal()), http: { get: vi.fn(), post: vi.fn() } })) describe('warehouseTransfers API', () => { beforeEach(() => vi.clearAllMocks()) it('always scopes platform list and detail requests to the selected customer', async () => { - vi.mocked(overseasHttp.get).mockResolvedValueOnce({ code: '200', data: { records: [], page: 1, size: 10, total: 0, pages: 0 } }) - vi.mocked(overseasHttp.get).mockResolvedValueOnce({ code: '200', data: { transferNo: 'WT-1', items: [], cartons: [], relabelBindings: [] } }) + vi.mocked(http.get).mockResolvedValueOnce({ code: '200', data: { records: [], page: 1, size: 10, total: 0, pages: 0 } }) + vi.mocked(http.get).mockResolvedValueOnce({ code: '200', data: { transferNo: 'WT-1', items: [], cartons: [], relabelBindings: [] } }) await listWarehouseTransfers('20001', { page: 1, size: 10, status: 'PENDING_REVIEW' }) await getWarehouseTransfer('20001', 'WT-1') - expect(vi.mocked(overseasHttp.get).mock.calls[0]).toEqual(['/platform/v1/last-leg/warehouse-transfers', { params: { page: 1, size: 10, status: 'PENDING_REVIEW', customerId: '20001' } }]) - expect(vi.mocked(overseasHttp.get).mock.calls[1]).toEqual(['/platform/v1/last-leg/warehouse-transfers/WT-1', { params: { customerId: '20001' } }]) + expect(vi.mocked(http.get).mock.calls[0]).toEqual(['/platform/v1/last-leg/warehouse-transfers', { params: { page: 1, size: 10, status: 'PENDING_REVIEW', customerId: '20001' } }]) + expect(vi.mocked(http.get).mock.calls[1]).toEqual(['/platform/v1/last-leg/warehouse-transfers/WT-1', { params: { customerId: '20001' } }]) }) it('posts the real review decision with customer scope', async () => { - vi.mocked(overseasHttp.post).mockResolvedValue({ code: '200', data: { transferNo: 'WT-1', items: [], cartons: [], relabelBindings: [] } }) + vi.mocked(http.post).mockResolvedValue({ code: '200', data: { transferNo: 'WT-1', items: [], cartons: [], relabelBindings: [] } }) await reviewWarehouseTransfer('20001', 'WT-1', 'APPROVE', '资料无误') - expect(overseasHttp.post).toHaveBeenCalledWith( + expect(http.post).toHaveBeenCalledWith( '/platform/v1/last-leg/warehouse-transfers/WT-1/review', { decision: 'APPROVE', remark: '资料无误' }, { params: { customerId: '20001' } }, diff --git a/src/modules/overseas-warehouse/api/warehouseTransfers.ts b/src/modules/overseas-warehouse/api/warehouseTransfers.ts index 10407f11..cb69262f 100644 --- a/src/modules/overseas-warehouse/api/warehouseTransfers.ts +++ b/src/modules/overseas-warehouse/api/warehouseTransfers.ts @@ -1,5 +1,5 @@ -import { overseasHttp } from '@/shared/api/overseasHttp' -import { unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' +import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' + import { normalizeOverseasPage, type OverseasPagePayload } from './lastMileOrders' export interface WarehouseTransferQuery { @@ -87,7 +87,7 @@ export interface WarehouseTransferDetailDto extends WarehouseTransferSummaryDto } export async function listWarehouseTransfers(customerId: string, query: WarehouseTransferQuery) { - const response = await overseasHttp.get>, ApiEnvelope>>( + const response = await http.get>, ApiEnvelope>>( '/platform/v1/last-leg/warehouse-transfers', { params: { ...query, customerId } }, ) @@ -95,7 +95,7 @@ export async function listWarehouseTransfers(customerId: string, query: Warehous } export async function getWarehouseTransfer(customerId: string, transferNo: string) { - const response = await overseasHttp.get, ApiEnvelope>( + const response = await http.get, ApiEnvelope>( `/platform/v1/last-leg/warehouse-transfers/${encodeURIComponent(transferNo)}`, { params: { customerId } }, ) @@ -108,7 +108,7 @@ export async function reviewWarehouseTransfer( decision: 'APPROVE' | 'REJECT', remark?: string, ) { - const response = await overseasHttp.post, ApiEnvelope>( + const response = await http.post, ApiEnvelope>( `/platform/v1/last-leg/warehouse-transfers/${encodeURIComponent(transferNo)}/review`, { decision, remark }, { params: { customerId } }, diff --git a/src/shared/api/inboundCodeRelations.test.ts b/src/shared/api/inboundCodeRelations.test.ts index 6afe83c3..c1477019 100644 --- a/src/shared/api/inboundCodeRelations.test.ts +++ b/src/shared/api/inboundCodeRelations.test.ts @@ -1,23 +1,21 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { http } from './http' -import { overseasHttp } from './overseasHttp' import { getLoadingAssociationSummary, getOverseasInboundCodeRelationship, getPlatformInboundCodeAssociation } from './inboundCodeRelations' vi.mock('./http', async (original) => ({ ...(await original()), http: { get: vi.fn() } })) -vi.mock('./overseasHttp', () => ({ overseasHttp: { get: vi.fn() } })) describe('inbound-code relationship APIs', () => { beforeEach(() => vi.clearAllMocks()) it('uses the frozen domestic and overseas read models and encodes the business code', async () => { vi.mocked(http.get).mockResolvedValue({ code: '200', data: { inboundCode: 'IN/01', orderNo: 'ORD-1' } }) - vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', data: { inboundCode: 'IN/01', found: false, taillegOrders: [], inboundPlans: [], palletCodes: [], inventory: [] } }) + vi.mocked(http.get).mockResolvedValue({ code: '200', data: { inboundCode: 'IN/01', found: false, taillegOrders: [], inboundPlans: [], palletCodes: [], inventory: [] } }) await getPlatformInboundCodeAssociation('IN/01') await getLoadingAssociationSummary('IN/01') await getOverseasInboundCodeRelationship('IN/01') expect(http.get).toHaveBeenCalledWith('/platform/v1/orders/first-leg/inbound-code-associations/IN%2F01') expect(http.get).toHaveBeenCalledWith('/platform/v1/first-leg/loading-associations/summary', { params: { inboundCode: 'IN/01' } }) - expect(overseasHttp.get).toHaveBeenCalledWith('/platform/v1/last-leg/inventory/inbound-code-relationships/IN%2F01') + expect(http.get).toHaveBeenCalledWith('/platform/v1/last-leg/inventory/inbound-code-relationships/IN%2F01') }) it('does not accept customer or internal ids as lookup parameters', () => { diff --git a/src/shared/api/inboundCodeRelations.ts b/src/shared/api/inboundCodeRelations.ts index 5362f252..b6740cfd 100644 --- a/src/shared/api/inboundCodeRelations.ts +++ b/src/shared/api/inboundCodeRelations.ts @@ -1,5 +1,4 @@ import { http, unwrapApiResponse, type ApiEnvelope } from './http' -import { overseasHttp } from './overseasHttp' export interface PlatformInboundCodeAssociation { inboundCode: string @@ -68,7 +67,7 @@ export async function getLoadingAssociationSummaryByInboundId(inboundId: string) } export async function getOverseasInboundCodeRelationship(inboundCode: string) { - const response = await overseasHttp.get, ApiEnvelope>( + const response = await http.get, ApiEnvelope>( `/platform/v1/last-leg/inventory/inbound-code-relationships/${encodeURIComponent(inboundCode)}`, ) return unwrapApiResponse(response) diff --git a/src/shared/api/overseasHttp.test.ts b/src/shared/api/overseasHttp.test.ts deleted file mode 100644 index 9c5f4846..00000000 --- a/src/shared/api/overseasHttp.test.ts +++ /dev/null @@ -1,99 +0,0 @@ -import axios, { type InternalAxiosRequestConfig } from 'axios' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { registerAuthRefreshHandler } from './http' -import { clearOverseasAccessToken, overseasHttp, replaceOverseasAccessToken } from './overseasHttp' - -const originalOverseasAdapter = overseasHttp.defaults.adapter - -function success(config: InternalAxiosRequestConfig, data: unknown) { - return Promise.resolve({ status: 200, statusText: 'OK', headers: {}, config, data }) -} - -describe('overseas HTTP client', () => { - beforeEach(() => { - localStorage.setItem('cc-admin-token', 'domestic-token') - clearOverseasAccessToken() - registerAuthRefreshHandler(async () => 'domestic-token') - }) - - afterEach(() => { - overseasHttp.defaults.adapter = originalOverseasAdapter - localStorage.clear() - clearOverseasAccessToken() - }) - - it('uses the overseas token supplied by the login session for concurrent requests', async () => { - replaceOverseasAccessToken('overseas-token', 900) - const authorizations: unknown[] = [] - overseasHttp.defaults.adapter = async (config) => { - authorizations.push(config.headers.Authorization) - return success(config, { code: '200', message: 'success', data: [] }) - } - - await Promise.all([ - overseasHttp.get('/platform/v1/last-leg/orders'), - overseasHttp.get('/platform/v1/warehouse-admin'), - ]) - - expect(authorizations).toEqual(['Bearer overseas-token', 'Bearer overseas-token']) - }) - - it('refreshes the unified session once and replays once after an overseas 401', async () => { - replaceOverseasAccessToken('overseas-1', 900) - const refresh = vi.fn(async () => { - replaceOverseasAccessToken('overseas-2', 900) - return 'domestic-2' - }) - registerAuthRefreshHandler(refresh) - let requests = 0 - const authorizations: unknown[] = [] - overseasHttp.defaults.adapter = async (config) => { - authorizations.push(config.headers.Authorization) - if (requests++ === 0) { - throw new axios.AxiosError('Unauthorized', 'ERR_BAD_REQUEST', config, undefined, { - status: 401, - statusText: 'Unauthorized', - headers: {}, - config, - data: { code: 'A0401', message: '海外令牌失效' }, - }) - } - return success(config, { code: '200', message: 'success', data: [] }) - } - - await overseasHttp.get('/platform/v1/last-leg/orders') - - expect(refresh).toHaveBeenCalledTimes(1) - expect(requests).toBe(2) - expect(authorizations).toEqual(['Bearer overseas-1', 'Bearer overseas-2']) - }) - - it('refreshes the unified session when no usable overseas token is present', async () => { - const refresh = vi.fn(async () => { - replaceOverseasAccessToken('overseas-refreshed', 900) - return 'domestic-refreshed' - }) - registerAuthRefreshHandler(refresh) - let authorization: unknown - overseasHttp.defaults.adapter = async (config) => { - authorization = config.headers.Authorization - return success(config, { code: '200', message: 'success', data: [] }) - } - - await overseasHttp.get('/platform/v1/last-leg/orders') - - expect(refresh).toHaveBeenCalledTimes(1) - expect(authorization).toBe('Bearer overseas-refreshed') - }) - - it('fails before sending a business request when refresh is domestic-only', async () => { - const refresh = vi.fn(async () => 'domestic-only') - registerAuthRefreshHandler(refresh) - const adapter = vi.fn(async (config: InternalAxiosRequestConfig) => success(config, { code: '200', message: 'success', data: [] })) - overseasHttp.defaults.adapter = adapter - - await expect(overseasHttp.get('/platform/v1/last-leg/orders')).rejects.toThrow('当前环境未签发海外访问令牌') - expect(refresh).toHaveBeenCalledTimes(1) - expect(adapter).not.toHaveBeenCalled() - }) -}) diff --git a/src/shared/api/overseasHttp.ts b/src/shared/api/overseasHttp.ts deleted file mode 100644 index a2206a1c..00000000 --- a/src/shared/api/overseasHttp.ts +++ /dev/null @@ -1,77 +0,0 @@ -import axios, { type InternalAxiosRequestConfig } from 'axios' -import { parseApiJsonResponse } from '@chongchong/shared' -import { refreshAuthenticatedSession } from './http' - -interface CachedOverseasToken { - accessToken: string - refreshAfter: number -} - -type RetryableOverseasRequestConfig = InternalAxiosRequestConfig & { _overseasAuthRetry?: boolean } - -const TOKEN_REFRESH_SAFETY_WINDOW_MS = 30_000 - -let cachedToken: CachedOverseasToken | null = null - -export const overseasHttp = axios.create({ - baseURL: import.meta.env.VITE_OVERSEAS_API_BASE_URL || '/overseas-api', - timeout: 15_000, - transformResponse: [(data: unknown) => parseApiJsonResponse(data)], -}) - -function currentOverseasAccessToken() { - return cachedToken && cachedToken.refreshAfter > Date.now() ? cachedToken.accessToken : '' -} - -async function requireOverseasAccessToken() { - const current = currentOverseasAccessToken() - if (current) return current - await refreshAuthenticatedSession() - const refreshed = currentOverseasAccessToken() - if (!refreshed) throw new Error('当前环境未签发海外访问令牌') - return refreshed -} - -export function replaceOverseasAccessToken(accessToken: string | null, expiresIn: number) { - if (!accessToken && expiresIn === 0) { - cachedToken = null - return - } - if (!accessToken || !Number.isFinite(expiresIn) || expiresIn <= 0) { - throw new Error('海外访问令牌响应无效') - } - cachedToken = { - accessToken, - refreshAfter: Date.now() + Math.max(0, expiresIn * 1000 - TOKEN_REFRESH_SAFETY_WINDOW_MS), - } -} - -export function clearOverseasAccessToken() { - cachedToken = null -} - -overseasHttp.interceptors.request.use(async (config) => { - const accessToken = await requireOverseasAccessToken() - config.headers.Authorization = `Bearer ${accessToken}` - return config -}) - -overseasHttp.interceptors.response.use( - (response) => response.data, - async (error) => { - const config = error.config as RetryableOverseasRequestConfig | undefined - if (error.response?.status !== 401 || !config || config._overseasAuthRetry) { - return Promise.reject(error) - } - config._overseasAuthRetry = true - clearOverseasAccessToken() - try { - await refreshAuthenticatedSession() - const accessToken = await requireOverseasAccessToken() - config.headers.Authorization = `Bearer ${accessToken}` - return overseasHttp.request(config) - } catch { - return Promise.reject(error) - } - }, -) diff --git a/src/shared/api/unifiedHttp.test.ts b/src/shared/api/unifiedHttp.test.ts new file mode 100644 index 00000000..3af4db2b --- /dev/null +++ b/src/shared/api/unifiedHttp.test.ts @@ -0,0 +1,49 @@ +import axios from 'axios' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { http, registerAuthRefreshHandler } from './http' + +const originalAdapter = http.defaults.adapter +const paths = ['/platform/v1/orders', '/platform/v1/last-leg/orders', '/platform/v1/overseas-warehouse-resources/pallets'] + +describe('unified business session', () => { + beforeEach(() => localStorage.setItem('cc-admin-token', 'session-1')) + afterEach(() => { + http.defaults.adapter = originalAdapter + localStorage.clear() + registerAuthRefreshHandler(async () => { throw new Error('no active test session') }) + }) + + it('uses one base URL and the same token across order, lastleg and warehouse resources', async () => { + const requests: unknown[] = [] + const refresh = vi.fn() + registerAuthRefreshHandler(refresh) + http.defaults.adapter = async (config) => { + requests.push([config.baseURL, config.headers.Authorization]) + return { status: 200, statusText: 'OK', headers: {}, config, data: { code: '200', data: [] } } + } + await Promise.all(paths.map(path => http.get(path))) + expect(requests).toEqual(paths.map(() => [http.defaults.baseURL, 'Bearer session-1'])) + expect(refresh).not.toHaveBeenCalled() + }) + + it('shares a single refresh across simultaneous 401s from both business domains', async () => { + const refresh = vi.fn(async () => { + await Promise.resolve() + localStorage.setItem('cc-admin-token', 'session-2') + return 'session-2' + }) + registerAuthRefreshHandler(refresh) + const successes: string[] = [] + http.defaults.adapter = async (config) => { + if (config.headers.Authorization === 'Bearer session-1') { + throw new axios.AxiosError('Unauthorized', 'ERR_BAD_REQUEST', config, undefined, + { status: 401, statusText: 'Unauthorized', headers: {}, config, data: {} }) + } + successes.push(String(config.headers.Authorization)) + return { status: 200, statusText: 'OK', headers: {}, config, data: { code: '200', data: [] } } + } + await Promise.all(paths.map(path => http.get(path))) + expect(refresh).toHaveBeenCalledTimes(1) + expect(successes).toEqual(paths.map(() => 'Bearer session-2')) + }) +}) diff --git a/vite.config.ts b/vite.config.ts index 336c3c00..fcce35f4 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -11,7 +11,6 @@ export default defineConfig(({ mode }) => { const authProxyTarget = process.env.AUTH_API_PROXY_TARGET || env.AUTH_API_PROXY_TARGET || proxyTarget const iamProxyTarget = process.env.IAM_API_PROXY_TARGET || env.IAM_API_PROXY_TARGET || proxyTarget const warehouseProxyTarget = process.env.WAREHOUSE_API_PROXY_TARGET || env.WAREHOUSE_API_PROXY_TARGET || proxyTarget - const overseasProxyTarget = process.env.OVERSEAS_API_PROXY_TARGET || env.OVERSEAS_API_PROXY_TARGET || 'http://localhost:9300' return { plugins: [ @@ -69,16 +68,6 @@ export default defineConfig(({ mode }) => { }) }, }, - '/overseas-api': { - target: overseasProxyTarget, - changeOrigin: true, - rewrite: (path) => path.replace(/^\/overseas-api/, '/api'), - configure(proxy) { - proxy.on('proxyReq', (proxyRequest) => { - proxyRequest.removeHeader('origin') - }) - }, - }, }, }, } From a3005d1c02f26b340286c84561f4dd1ea387f6ee Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 29 Sep 2026 18:47:41 +0800 Subject: [PATCH 17/17] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .dockerignore | 4 +- .env.example | 15 +- AGENTS.md | 14 +- Jenkinsfile | 246 ++---------------- README.md | 14 +- docker-compose.development.yml | 22 -- docker-compose.production.yml | 24 -- docker-compose.test.yml | 26 +- nginx/default.conf.template | 13 +- package.json | 2 +- scripts/deploy-production.sh | 150 ----------- ...js => phase4-test-frontend-acceptance.mjs} | 24 +- ...=> phase4-test-frontend-contract.test.mjs} | 12 +- ....ts => phase4-test-frontend.acceptance.ts} | 22 +- src/deployment/blueGreenDeployment.test.mjs | 27 -- src/deployment/nginxConfig.test.ts | 132 ++++------ .../components/ContractPendingNotice.vue | 2 +- src/modules/overseas-warehouse/AGENTS.md | 2 +- .../components/ContractPendingNotice.vue | 2 +- src/shared/api/AGENTS.md | 29 +-- vite.config.ts | 32 +-- ....config.ts => vitest.phase4-test.config.ts | 2 +- 22 files changed, 147 insertions(+), 669 deletions(-) delete mode 100644 docker-compose.development.yml delete mode 100644 docker-compose.production.yml delete mode 100644 scripts/deploy-production.sh rename scripts/{phase4-development-frontend-acceptance.mjs => phase4-test-frontend-acceptance.mjs} (52%) rename scripts/{phase4-development-frontend-contract.test.mjs => phase4-test-frontend-contract.test.mjs} (69%) rename scripts/{phase4-development-frontend.acceptance.ts => phase4-test-frontend.acceptance.ts} (97%) delete mode 100644 src/deployment/blueGreenDeployment.test.mjs rename vitest.phase4-development.config.ts => vitest.phase4-test.config.ts (84%) diff --git a/.dockerignore b/.dockerignore index b59e8c23..2b5cc55a 100644 --- a/.dockerignore +++ b/.dockerignore @@ -3,6 +3,8 @@ dist node_modules node_modules.broken-by-path -.env.local +.env +.env.* +!.env.example npm-debug.log* pnpm-debug.log* diff --git a/.env.example b/.env.example index 5086bd91..f0d7ce14 100644 --- a/.env.example +++ b/.env.example @@ -1,10 +1,13 @@ VITE_API_BASE_URL=/api -# 按本机联调环境填写国内 Gateway origin,不提交真实环境地址。 +# 按本机联调环境填写统一 Gateway origin,不提交真实环境地址。 API_PROXY_TARGET=http://localhost:9000 -# 可选:本地分别直连 Auth、IAM、Warehouse;未设置时复用 API_PROXY_TARGET。 -# AUTH_API_PROXY_TARGET=http://localhost:9001 -# IAM_API_PROXY_TARGET=http://localhost:9002 -# WAREHOUSE_API_PROXY_TARGET=http://localhost:9003 -# 按本机联调环境填写海外 Gateway origin,不提交真实环境地址。 # Local Vite fallback. Container deployments should use CLIENT_WEB_URL instead. VITE_CLIENT_WEB_URL=http://localhost:5174 + +# Test container deployment (Jenkins environment or untracked .env). +# API_UPSTREAM: unified Gateway origin, no /api suffix. +# TEST_NETWORK: shared test Docker network. +API_UPSTREAM=http://gateway:9000 +TEST_NETWORK=firstleg-test-network +IMAGE_TAG= +CLIENT_WEB_URL= diff --git a/AGENTS.md b/AGENTS.md index b39a57b2..29825dce 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,7 +6,7 @@ 2. 视觉还原、布局/交互承载方式变更或真实页面验收时读取 `../../docs/Admin-Client前端原型还原验收规则.md`;需要原型依据才读取 `../../docs/悦/货代SAAS平台后台/AGENTS.md` 并定位目标片段。纯文案、逻辑或单元测试任务不自动读取。 Admin 样式优化还须读取 `../../docs/Admin高密度界面规范.md` 的“样式维护边界”和“新模块接入规则”;以已确认密度与彩色页签为视觉基线,逐操作保留业务语义和承载方式。国内仓、客户管理是样板;新增菜单按本轮范围显式接入,货柜管理入口见 `../../docs/Admin货柜管理样式核对记录.md`。 3. 标准列表或公共组件任务读取 `src/shared/components/AGENTS.md`;国内/海外 HTTP、Token 或刷新任务读取 `src/shared/api/AGENTS.md`;Finance 任务读取 `src/modules/finance/AGENTS.md`;海外仓全流程任务先读 `../../docs/海外仓业务/README.md`。 -4. 平台/客户层级与权限公共语义读取 `../../docs/平台客户层级与权限契约.md`;双区域 API、海外 Token 和跨区事件读取 `../../docs/跨区域认证与服务通信契约.md`。 +4. 平台/客户层级与权限公共语义读取 `../../docs/平台客户层级与权限契约.md`;双区域 API、海外 Token 和跨区事件读取 `../../docs/统一认证与服务通信契约.md`。 普通单端任务的最小读取集是本文件、目标模块最近的 `AGENTS.md` 和目标代码命中段;只有跨服务状态、权限或接口边界无法判断时才增加后端契约。累计超过约 250 行时改用定向检索,不全文加载无关专题。 @@ -31,8 +31,8 @@ - 依赖方向为 `app → modules/core/shared`、`modules → core/shared`、`core → shared`、`shared → 无上层依赖`。模块间只通过对方公开入口或契约协作,不引用其他模块的页面和内部目录。 - 路由级页面放 `modules/*/pages/`,模块路由放 `modules/*/routes.ts` 并由 `app/router/index.ts` 装配;新增模块同步维护 `core/navigation/adminNavigation.ts`。 -- 领域接口放 `modules/*/api/`;国内、海外公共客户端分别为 `shared/api/http.ts`、`shared/api/overseasHttp.ts`,具体 Token 与刷新规则见 `shared/api/AGENTS.md`。 -- `VITE_API_BASE_URL`、`VITE_OVERSEAS_API_BASE_URL` 分别控制国内、海外浏览器前缀;开发代理上游只由 Vite 读取。环境变量变化同步 `.env.example`,不提交真实地址或凭据。 +- 领域接口放 `modules/*/api/`;全部业务共用 `shared/api/http.ts`,具体 Token 与刷新规则见 `shared/api/AGENTS.md`。 +- `VITE_API_BASE_URL` 控制唯一浏览器前缀;`API_PROXY_TARGET` 由 Vite 读取并指向统一 Gateway。环境变量变化同步 `.env.example`,不提交真实地址或凭据。 ## 模块快速路由 @@ -40,7 +40,7 @@ | 业务 | Admin 入口 | 联查 | |---|---|---| -| 登录、会话与双区域 Token | `modules/auth/`、`core/auth/` | 国内 `auth/`、`iam/`;海外请求客户端再读 `shared/api/AGENTS.md` | +| 登录、会话与统一 Token | `modules/auth/`、`core/auth/` | 国内 `auth/`、`iam/`;海外请求客户端再读 `shared/api/AGENTS.md` | | 启动与懒加载资源恢复 | `main.ts`、`core/routing/navigationRecovery.ts` | 仅识别动态模块/Chunk 资源过期;30 秒内同目标最多自动恢复一次,存在未保存编辑时必须先确认 | | 工作台与指标 | `modules/dashboard/` | 指标所属模块与服务端,不复制领域契约 | | 船期日历 | `modules/sailing-calendar/` | 当前日历入口覆盖渠道、周模板、单次船期、收货截止时间和按渠道货量统计;后端按 `firstleg/` 船期接口定位,不读取已归档的货柜开发规划 | @@ -68,8 +68,8 @@ | 目的港码头拖车与还空预警 | 海外清关提柜工作台/详情 `modules/container-loading/pages/OverseasContainersView.vue`、`OverseasContainerDetailView.vue`,API `modules/container-loading/api/terminalDrayages.ts`、`containerArrivalTimes.ts` | 与订舱级拖车预约是不同生命周期;按 `containerBookingId` 读取服务端状态、`allowedActions` 和历史,不能复用订舱级拖车表单或自行推进状态。列表“预计到仓”列直接提供人工日期时间选择器,保存时通过 Lastleg 柜级版本和幂等键原子更新该柜全部尾程订单,已有入库计划后禁止修改;“实际到仓”只读 `ll_receiving_container_session.receiving_at`,即海外 PDA 首次开始收货时间。还空截止、超时天/小时只展示服务端基于权威卸柜完成时间加 72 小时计算的结果;Backend `firstleg/terminaldrayage/`、`lastleg-service/`,邮件记录通过 `terminalDrayages.ts` 查询与重试。 | | 历史报关协同与国内出仓 | `modules/customs/`、`modules/outbound-dispatch/` | `modules/customs/` 是历史报关案件/清单/代理提交与回执入口;装柜完成后的整柜退税/买单资料从货柜详情进入,不在旧模块复制;Backend `firstleg/outbound/dispatch/`、`firstleg/customs/`、`file/` | | 主数据、下载、退避任务重试、审计 | `modules/master-data/`、`modules/downloads/pages/DownloadCenterView.vue`、`modules/audit/` | File 导出任务返回 `attemptCount/maxAttempts/nextRetryAt`;仅失败或已进入自动退避的等待任务显示重试,等待任务重试用于立即执行当前投递,不创建新任务;联查 `warehouse/`、`file/`、`audit-service/` | -| 海外接柜、入库、库存与库内服务 | `modules/overseas-warehouse/` | `gateway-overseas/`、`lastleg-service/`、`warehouse-overseas-service/`;与排车、出库、派送、异常退回和一件代发共同组成海外仓全流程 | -| 海外采购、卡车、散板 | `modules/overseas-business/` | `gateway-overseas/`、`lastleg-service/` | +| 海外接柜、入库、库存与库内服务 | `modules/overseas-warehouse/` | `gateway/`、`lastleg-service/`、`warehouse-overseas-service/`;与排车、出库、派送、异常退回和一件代发共同组成海外仓全流程 | +| 海外采购、卡车、散板 | `modules/overseas-business/` | `gateway/`、`lastleg-service/` | | 海外仓业务合作仓与通用资料 | 合作仓 `modules/warehouses/`;报价/汇率/车辆 `modules/overseas-master-data/` | 国内 `warehouse/`、海外实际资源与服务 | | 海外仓业务国内出仓后船节点 | `modules/port-schedules/`、`modules/ocean-bookings/`、货柜详情 | `firstleg/portschedule/`、`integration-service/`;预计节点只展示不推进实际进度,手工实际节点必须严格向后追加并防重复,十节点标准见 `../../docs/海外仓业务/船节点标准化契约.md` | | 海外排车、出库与派送 | `modules/overseas-warehouse/AGENTS.md` | 按模块表定位排车、出库、派送页面及 API;履约边界在模块内维护 | @@ -98,7 +98,7 @@ - 国内出仓以真实货柜为原子边界:货柜详情只在装柜完成、全部入仓绑定门禁通过且有写权限时显示确认离仓;成功后由服务端同事务推进全部技术行并幂等创建船节点查询。前端不得逐入仓确认、把拖车状态当阻断门禁或重复追加实际船节点。 - `core/navigation/adminNavigation.ts`、`app/router/index.ts` 和模块 `routes.ts` 是共享装配文件;并行任务不得覆盖总控已确认语义,重叠时保留现有改动并回传总控。 - `core/routing/navigationRecovery.ts` 只处理已识别的动态 import/Chunk 资源过期;普通路由错误直接提示。自动恢复前保护未保存表单,同一目标 30 秒内不得重复刷新或形成重载循环。 -- 认证统一经过 `core/auth/auth.ts`、`shared/api/http.ts` 和 `shared/api/overseasHttp.ts`。登录/刷新原子替换国内、海外 Token,海外 401 统一刷新并最多重放一次;页面不读写 Token 或手工拼 `Authorization`。 +- 认证统一经过 `core/auth/auth.ts` 和 `shared/api/http.ts`。登录/刷新替换统一 Access/Refresh Token,全部 401 共用刷新并最多重放一次;页面不读写 Token 或手工拼 `Authorization`。 - 接入真实接口时删除对应模拟路径,不允许生产逻辑静默回退假数据。客户专属写操作显式选择客户,长整型 ID 按字符串透传,不从本地存储推断。 - 订舱代理、拖车承运商和报关代理提交服务端供应商 ID,从公共供应商列表选择 `ACTIVE` 且类别/范围匹配的记录;参数变化同步检查 API、类型、表单、展示、Mock/Fixture、测试和 OpenAPI。 - 删除、停用、改权限等高风险操作必须有确认、处理中、成功和失败反馈;失败保留用户上下文。日志、提示和示例不得包含真实账号、Token、隐私或生产数据。 diff --git a/Jenkinsfile b/Jenkinsfile index 8cec3b3b..695afd69 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -1,252 +1,38 @@ -def sendProductionWeComNotification(String title, String status, String color, String detail) { - withCredentials([string(credentialsId: 'wecom-prod-webhook', variable: 'WECOM_WEBHOOK')]) { - String sourceVersion = env.SOURCE_TAG ?: env.GIT_COMMIT ?: '未解析' - String duration = currentBuild.durationString?.replace(' and counting', '') ?: '—' - String content = """## ${title} -> 应用:管理后台 -> Job:${env.JOB_NAME} -> 构建:[#${env.BUILD_NUMBER}](${env.BUILD_URL}) -> 提交:${sourceVersion} -> 状态:${status} -${detail} -> 耗时:${duration} -> 时间:${new Date().format('yyyy-MM-dd HH:mm:ss', TimeZone.getTimeZone('Asia/Shanghai'))} -""" - String payloadFile = 'wecom-production-notification.json' - writeFile( - file: payloadFile, - text: groovy.json.JsonOutput.toJson([ - msgtype: 'markdown', - markdown: [content: content] - ]), - encoding: 'UTF-8' - ) - sh ''' - set -eu - payload_file='wecom-production-notification.json' - trap 'rm -f "$payload_file"' EXIT HUP INT TERM - response="$(curl --fail --silent --show-error \ - -H 'Content-Type: application/json' \ - --data-binary @"$payload_file" \ - "$WECOM_WEBHOOK")" - printf '%s\n' "$response" - printf '%s' "$response" | grep -Eq '"errcode"[[:space:]]*:[[:space:]]*0([[:space:]]*[,}])' - ''' - } -} - pipeline { - agent { - label "${env.JOB_BASE_NAME?.endsWith('-prod') ? 'java-builder' : (env.JOB_BASE_NAME?.endsWith('-dev') ? 'java-dev' : 'java-test')}" - } - - environment { - DEVELOPMENT_COMPOSE_FILE = 'docker-compose.development.yml' - DEVELOPMENT_COMPOSE_PROJECT = 'admin-web' - REGISTRY_HOST = '172.31.198.7:10082' - REGISTRY_CREDENTIALS = 'gitea-registry-token' - NODE_BASE_IMAGE = '172.31.198.7:10082/java-project/node:22-alpine' - NGINX_BASE_IMAGE = '172.31.198.7:10082/java-project/nginx:1.27-alpine' - PRODUCTION_IMAGE_REPOSITORY = 'git.buggoglobal.com/html/admin-web' - } - + agent { label 'java-test' } options { timestamps() disableConcurrentBuilds() } - stages { - stage('确认提交版本') { + stage('检查测试发布参数') { steps { script { - env.IS_DEVELOPMENT = env.JOB_BASE_NAME?.endsWith('-dev') ? 'true' : 'false' - env.IS_PRODUCTION = env.JOB_BASE_NAME?.endsWith('-prod') ? 'true' : 'false' - if (env.IS_PRODUCTION == 'true') { - sh ''' - set -eu - git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main - test "$(git rev-parse HEAD)" = "$(git rev-parse origin/main)" || { - echo "生产部署只允许 origin/main 当前提交;请先合并代码到 main" >&2 - exit 1 - } - ''' + if (env.JOB_BASE_NAME?.endsWith('-dev') || env.JOB_BASE_NAME?.endsWith('-prod')) { + error('当前仅支持 test,请使用测试环境 Job') } - env.SOURCE_TAG = sh(script: 'git rev-parse --short=12 HEAD', returnStdout: true).trim() + env.IMAGE_TAG = sh(script: 'git rev-parse --short=12 HEAD', returnStdout: true).trim() } - sh 'git log -1 --oneline' + sh '''set -eu + export API_UPSTREAM="${API_UPSTREAM:-http://gateway:9000}" + export TEST_NETWORK="${TEST_NETWORK:-firstleg-test-network}" + : "${CLIENT_WEB_URL:?CLIENT_WEB_URL 未设置}" + docker network inspect "$TEST_NETWORK" >/dev/null + docker compose -f docker-compose.test.yml config --quiet + ''' } } - stage('构建并部署测试前端') { - when { expression { env.IS_DEVELOPMENT != 'true' && env.IS_PRODUCTION != 'true' } } steps { - withCredentials([usernamePassword(credentialsId: env.REGISTRY_CREDENTIALS, usernameVariable: 'REGISTRY_USER', passwordVariable: 'REGISTRY_PASSWORD')]) { - sh ''' - set -eu - export DOCKER_CONFIG="${WORKSPACE}@tmp/docker-auth" - rm -rf "$DOCKER_CONFIG" - mkdir -p "$DOCKER_CONFIG" - cleanup_registry() { - docker logout "$REGISTRY_HOST" >/dev/null 2>&1 || true - rm -rf "$DOCKER_CONFIG" - } - trap cleanup_registry EXIT HUP INT TERM - printf %s "$REGISTRY_PASSWORD" | docker login -u "$REGISTRY_USER" --password-stdin "$REGISTRY_HOST" - docker compose -f docker-compose.test.yml build --pull=false - docker compose -f docker-compose.test.yml up -d --remove-orphans - ''' - } - } - } - - stage('构建并发布生产镜像') { - when { expression { env.IS_PRODUCTION == 'true' } } - steps { - withCredentials([usernamePassword(credentialsId: env.REGISTRY_CREDENTIALS, usernameVariable: 'REGISTRY_USER', passwordVariable: 'REGISTRY_PASSWORD')]) { - sh ''' - set -eu - export DOCKER_CONFIG="${WORKSPACE}@tmp/docker-auth" - rm -rf "$DOCKER_CONFIG" - mkdir -p "$DOCKER_CONFIG" - cleanup_registry() { - docker logout git.buggoglobal.com >/dev/null 2>&1 || true - rm -rf "$DOCKER_CONFIG" - } - trap cleanup_registry EXIT HUP INT TERM - printf %s "$REGISTRY_PASSWORD" | docker login -u "$REGISTRY_USER" --password-stdin git.buggoglobal.com - image="$PRODUCTION_IMAGE_REPOSITORY:$SOURCE_TAG" - docker build \ - --build-arg NODE_IMAGE="$NODE_BASE_IMAGE" \ - --build-arg NGINX_IMAGE="$NGINX_BASE_IMAGE" \ - --build-arg VITE_CLIENT_WEB_URL=https://app.buggoglobal.com/ \ - -t "$image" . - docker push "$image" - ''' - } - } - } - - stage('部署生产前端') { - when { expression { env.IS_PRODUCTION == 'true' } } - steps { - lock(resource: 'firstleg-production-admin-web-deploy', inversePrecedence: true) { - withCredentials([ - usernamePassword(credentialsId: 'gitea-production-pull-token', usernameVariable: 'REGISTRY_USER', passwordVariable: 'REGISTRY_PASSWORD'), - sshUserPrivateKey(credentialsId: 'firstleg-production-ssh', keyFileVariable: 'TARGET_SSH_KEY', usernameVariable: 'TARGET_SSH_USER'), - string(credentialsId: 'firstleg-production-host', variable: 'TARGET_SSH_HOST'), - file(credentialsId: 'firstleg-production-known-hosts', variable: 'TARGET_SSH_KNOWN_HOSTS') - ]) { - withEnv([ - 'TARGET_DEPLOY_DIR=/data/firstleg/deploy/frontend', - 'TARGET_SSH_TARGET=' + env.TARGET_SSH_USER + '@' + env.TARGET_SSH_HOST - ]) { - sh "sh scripts/deploy-production.sh admin-web '${env.PRODUCTION_IMAGE_REPOSITORY}' '${env.SOURCE_TAG}' docker-compose.production.yml" - } - } - } - } - } - - stage('构建并部署开发前端') { - when { expression { env.IS_DEVELOPMENT == 'true' } } - steps { - withCredentials([usernamePassword(credentialsId: env.REGISTRY_CREDENTIALS, usernameVariable: 'REGISTRY_USER', passwordVariable: 'REGISTRY_PASSWORD')]) { - sh ''' - set -eu - export DOCKER_CONFIG="${WORKSPACE}@tmp/docker-auth" - rm -rf "$DOCKER_CONFIG" - mkdir -p "$DOCKER_CONFIG" - cleanup_registry() { - docker logout "$REGISTRY_HOST" >/dev/null 2>&1 || true - rm -rf "$DOCKER_CONFIG" - } - trap cleanup_registry EXIT HUP INT TERM - printf %s "$REGISTRY_PASSWORD" | docker login -u "$REGISTRY_USER" --password-stdin "$REGISTRY_HOST" - image="admin-web-development:${SOURCE_TAG}" - - docker build \ - --build-arg NODE_IMAGE="$NODE_BASE_IMAGE" \ - --build-arg NGINX_IMAGE="$NGINX_BASE_IMAGE" \ - --build-arg VITE_CLIENT_WEB_URL="${CLIENT_WEB_URL:-https://dev-app.buggoglobal.com/}" \ - -t "$image" . - export IMAGE_TAG="$SOURCE_TAG" - docker compose -p "$DEVELOPMENT_COMPOSE_PROJECT" -f "$DEVELOPMENT_COMPOSE_FILE" up -d --remove-orphans --pull never - docker compose -p "$DEVELOPMENT_COMPOSE_PROJECT" -f "$DEVELOPMENT_COMPOSE_FILE" ps - docker compose -p "$DEVELOPMENT_COMPOSE_PROJECT" -f "$DEVELOPMENT_COMPOSE_FILE" exec -T admin-web wget -qO- http://127.0.0.1/healthz - ''' - } - } - } - - stage('部署检查') { - when { expression { env.IS_DEVELOPMENT != 'true' && env.IS_PRODUCTION != 'true' } } - steps { - sh ''' - set -eu - docker compose -f docker-compose.test.yml ps + 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 docker compose -f docker-compose.test.yml exec -T admin-web wget -qO- http://127.0.0.1/healthz ''' } } } - post { - success { - script { - if (env.JOB_BASE_NAME?.endsWith('-prod')) { - try { - sendProductionWeComNotification( - '✅ 生产部署成功', - '成功', - 'info', - '> 结果:Admin 生产前端已完成发布和部署流程' - ) - } catch (Exception error) { - echo "企业微信通知失败,不影响原构建结果:${error.message}" - } - } - } - } - failure { - sh ''' - if [ "${IS_PRODUCTION:-false}" = "true" ]; then - echo "生产部署失败,请查看上方远程部署日志" - elif [ "${IS_DEVELOPMENT:-false}" = "true" ]; then - docker logs --tail=100 admin-web-development || true - else - docker compose -f docker-compose.test.yml logs --tail=100 || true - fi - ''' - script { - if (env.JOB_BASE_NAME?.endsWith('-prod')) { - try { - sendProductionWeComNotification( - '❌ 生产发布失败', - '失败', - 'warning', - '> 说明:请打开 Jenkins 构建日志查看失败阶段' - ) - } catch (Exception error) { - echo "企业微信通知失败,不影响原构建结果:${error.message}" - } - } - } - } - aborted { - script { - if (env.JOB_BASE_NAME?.endsWith('-prod')) { - try { - sendProductionWeComNotification( - '⏹ 生产部署已取消', - '已取消', - 'comment', - '> 说明:生产部署流程被人工取消或中止' - ) - } catch (Exception error) { - echo "企业微信通知失败,不影响原构建结果:${error.message}" - } - } - } - } + failure { sh 'docker compose -f docker-compose.test.yml logs --tail=100 || true' } } } diff --git a/README.md b/README.md index 6384a94d..2f7f5136 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ pnpm install pnpm dev ``` -生产构建: +发布资源构建: ```bash pnpm typecheck @@ -54,16 +54,16 @@ pnpm test ```dotenv VITE_API_BASE_URL=/api API_PROXY_TARGET=http://localhost:9000 -VITE_OVERSEAS_API_BASE_URL=/overseas-api -OVERSEAS_API_PROXY_TARGET=http://localhost:9300 VITE_CLIENT_WEB_URL=http://localhost:5174 ``` -国内业务继续使用 `http` 和 `/api` 代理;海外业务统一使用 `overseasHttp` 和 `/overseas-api` 代理。Vite 会把 `/overseas-api/platform/...` 重写为海外 Gateway 的 `/api/platform/...`。海外 Token 的签发、缓存和会话刷新由统一认证层维护,业务模块不得读取 Token、手拼 `Authorization` 或复用国内 `http`。 +全部业务使用共享 `http` 和同源 `/api`,通过统一 Gateway 访问。Access/Refresh Token 与并发刷新由认证层维护,业务模块不得读取 Token 或手拼 `Authorization`。 -切换开发或测试环境时,只修改未提交的 `.env.local` 中 `API_PROXY_TARGET`、`OVERSEAS_API_PROXY_TARGET` 和 `VITE_CLIENT_WEB_URL`,然后重启 `pnpm dev`;无需修改业务代码。真实环境地址不写入受版本控制的配置模板或说明。 +本地工具只连接同一测试环境:在未提交 `.env.local` 配置 `API_PROXY_TARGET` 和 `VITE_CLIENT_WEB_URL`;不提供业务服务直连或独立海外代理。 -客户代登录打开的工作台地址是运行时配置,不需要为每个环境重新构建镜像。Docker 部署时为 Admin 容器设置当前环境的 `CLIENT_WEB_URL`;容器启动时会生成 `/runtime-config.js`。开发、测试和生产分别在各自部署任务中注入自己的 Client 根地址,禁止复用其他环境的域名。本地 Vite 开发使用 `VITE_CLIENT_WEB_URL` 作为兜底。部署后可读取 `/runtime-config.js` 核对最终生效值。 +唯一部署入口是 `docker-compose.test.yml` 和 test Jenkins Job。发布前在 Job/未提交环境文件配置 `API_UPSTREAM`(统一 Gateway origin,不含 `/api`)、`TEST_NETWORK`(后端共享测试网络)、`CLIENT_WEB_URL`(客户工作台地址)及 `IMAGE_TAG`。可选 `HOST_PORT` 默认 9001,`NODE_BASE_IMAGE`/`NGINX_BASE_IMAGE` 可指定已授权拉取的基础镜像;私有镜像仓库认证由构建节点配置,不在源码存放凭据。Jenkins 从当前提交生成 IMAGE_TAG,不再按 Job 后缀切换 dev/prod。宿主机反向代理连接 loopback 端口。 + +容器启动通过 `CLIENT_WEB_URL` 生成 `/runtime-config.js`;本地 Vite 使用 `VITE_CLIENT_WEB_URL`。本地构建通过不代表容器或测试环境已发布。真实 HTTP 验收入口为 `pnpm test:phase4:test --environment test --gateway <统一入口>`,凭据通过环境变量传入,仅在取得对应测试写入授权后执行。 认证请求使用 `/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` 类型。其余业务页面需在对应服务契约确认后逐项替换,生产逻辑不得静默回退到模拟数据。 @@ -74,3 +74,5 @@ VITE_CLIENT_WEB_URL=http://localhost:5174 真实接口错误会保留后端返回的业务错误码、`requestId` 和 `traceId`。登录、企业审核、平台角色及权限资源的主要失败态支持直接复制追踪标识,便于与后端日志关联。 登录表单在开发和生产环境均默认留空,不包含联调账号、密码或演示提示。菜单、路由和操作权限只使用真实登录会话返回的服务端权限快照;前端控制仅改善体验,服务端仍须独立校验身份、角色权限和数据范围。接口失败时页面展示真实错误及追踪标识,不回退到假数据或模拟成功。 + +同机目标默认值:`API_UPSTREAM=http://gateway:9000`、`TEST_NETWORK=firstleg-test-network`;必须与后端同批 test Compose 配置一致。 diff --git a/docker-compose.development.yml b/docker-compose.development.yml deleted file mode 100644 index a677b495..00000000 --- a/docker-compose.development.yml +++ /dev/null @@ -1,22 +0,0 @@ -services: - admin-web: - image: admin-web-development:${IMAGE_TAG:?IMAGE_TAG is required} - container_name: admin-web-development - restart: unless-stopped - ports: - - "127.0.0.1:9001:80" - environment: - CLIENT_WEB_URL: "${CLIENT_WEB_URL:-https://dev-app.buggoglobal.com/}" - API_UPSTREAM: "http://firstleg-development-gateway:9000" - OVERSEAS_API_UPSTREAM: "http://firstleg-development-gateway-overseas:9300" - networks: - - development-backend - - development-overseas-backend - -networks: - development-backend: - external: true - name: firstleg-development-network - development-overseas-backend: - external: true - name: firstleg-development-overseas-network diff --git a/docker-compose.production.yml b/docker-compose.production.yml deleted file mode 100644 index 96c9dbe6..00000000 --- a/docker-compose.production.yml +++ /dev/null @@ -1,24 +0,0 @@ -name: firstleg-prod-admin-web - -services: - admin-web: - image: ${IMAGE_REPOSITORY:?IMAGE_REPOSITORY is required}:${IMAGE_TAG:?IMAGE_TAG is required} - container_name: firstleg-prod-admin-web - restart: unless-stopped - ports: - - "127.0.0.1:${HOST_PORT:-9001}:80" - environment: - CLIENT_WEB_URL: "https://app.buggoglobal.com/" - API_UPSTREAM: "http://gateway-active:9000" - OVERSEAS_API_UPSTREAM: "http://gateway-overseas-active:9300" - networks: - - infra-network - - routing-network - -networks: - infra-network: - external: true - name: firstleg-prod-infra-network - routing-network: - external: true - name: firstleg-production-routing diff --git a/docker-compose.test.yml b/docker-compose.test.yml index 3f75c718..6b00b25b 100644 --- a/docker-compose.test.yml +++ b/docker-compose.test.yml @@ -1,3 +1,4 @@ +name: firstleg-test-admin-web services: admin-web: build: @@ -6,16 +7,23 @@ services: args: NODE_IMAGE: "${NODE_BASE_IMAGE:-node:22-alpine}" NGINX_IMAGE: "${NGINX_BASE_IMAGE:-nginx:1.27-alpine}" - image: admin-web-test:local + image: admin-web-test:${IMAGE_TAG:?IMAGE_TAG is required} container_name: admin-web-test restart: unless-stopped + healthcheck: + test: ["CMD", "wget", "-qO-", "http://127.0.0.1/healthz"] + interval: 5s + timeout: 3s + retries: 12 + start_period: 5s ports: - - "${HOST_PORT:-9001}:80" - extra_hosts: - - "host.docker.internal:host-gateway" + - "127.0.0.1:${HOST_PORT:-9001}:80" environment: - # Runtime URL used by the secure customer-workbench handoff. Each - # environment may override it without rebuilding the Admin image. - CLIENT_WEB_URL: "${CLIENT_WEB_URL:-https://test-app.buggoglobal.com/}" - API_UPSTREAM: "${API_UPSTREAM:-http://host.docker.internal:9000}" - OVERSEAS_API_UPSTREAM: "${OVERSEAS_API_UPSTREAM:-http://host.docker.internal:9300}" + CLIENT_WEB_URL: "${CLIENT_WEB_URL:?CLIENT_WEB_URL is required}" + API_UPSTREAM: "${API_UPSTREAM:-http://gateway:9000}" + networks: + - test-backend +networks: + test-backend: + external: true + name: "${TEST_NETWORK:-firstleg-test-network}" diff --git a/nginx/default.conf.template b/nginx/default.conf.template index 24d470dc..1ab97e09 100644 --- a/nginx/default.conf.template +++ b/nginx/default.conf.template @@ -30,7 +30,7 @@ server { proxy_set_header X-Forwarded-Proto $scheme; } - # 墨家测量设备使用独立 OpenAPI 前缀,直接转发到国内 Gateway。 + # 墨家测量设备使用独立 OpenAPI 前缀,直接转发到统一 Gateway。 # 该入口不经过 Admin 前端路由,供设备以固定 AppKey/HMAC 调用。 location /openapi/ { client_max_body_size 55m; @@ -42,17 +42,6 @@ server { proxy_set_header X-Forwarded-Proto $scheme; } - # 海外业务使用独立 Gateway;将浏览器前缀改写为 Gateway 的 /api 前缀。 - location /overseas-api/ { - client_max_body_size 55m; - rewrite ^/overseas-api/(.*)$ /api/$1 break; - proxy_pass ${OVERSEAS_API_UPSTREAM}; - proxy_http_version 1.1; - proxy_set_header Host $proxy_host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - } # HTML must be revalidated on every visit so a new deployment cannot keep # referencing hashed chunks that were removed with the previous image. diff --git a/package.json b/package.json index c5494404..bc5bbdb5 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "build": "vue-tsc --noEmit && vite build", "typecheck": "vue-tsc --noEmit", "test": "vitest run", - "test:phase4:development": "node scripts/phase4-development-frontend-acceptance.mjs", + "test:phase4:test": "node scripts/phase4-test-frontend-acceptance.mjs", "preview": "vite preview --port 4173" }, "dependencies": { diff --git a/scripts/deploy-production.sh b/scripts/deploy-production.sh deleted file mode 100644 index c27fb0b1..00000000 --- a/scripts/deploy-production.sh +++ /dev/null @@ -1,150 +0,0 @@ -#!/usr/bin/env sh -set -eu - -if [ "$#" -ne 4 ]; then - echo "用法: $0 <服务名> <镜像仓库> <镜像标签> " >&2 - exit 2 -fi - -SERVICE="$1" -IMAGE_REPOSITORY="$2" -IMAGE_TAG="$3" -COMPOSE_FILE="$4" -IMAGE="$IMAGE_REPOSITORY:$IMAGE_TAG" -REGISTRY_HOST="${IMAGE_REPOSITORY%%/*}" -SSH_TARGET="${TARGET_SSH_TARGET:?TARGET_SSH_TARGET 未设置}" -SSH_KEY="${TARGET_SSH_KEY:?TARGET_SSH_KEY 未设置}" -SSH_KNOWN_HOSTS="${TARGET_SSH_KNOWN_HOSTS:?TARGET_SSH_KNOWN_HOSTS 未设置}" -REMOTE_DIR="${TARGET_DEPLOY_DIR:?TARGET_DEPLOY_DIR 未设置}" -: "${REGISTRY_USER:?REGISTRY_USER 未设置}" -: "${REGISTRY_PASSWORD:?REGISTRY_PASSWORD 未设置}" - -run_ssh() { - ssh -i "$SSH_KEY" -o BatchMode=yes -o StrictHostKeyChecking=yes \ - -o UserKnownHostsFile="$SSH_KNOWN_HOSTS" -o ConnectTimeout=10 "$SSH_TARGET" "$@" -} - -run_scp() { - scp -i "$SSH_KEY" -o BatchMode=yes -o StrictHostKeyChecking=yes \ - -o UserKnownHostsFile="$SSH_KNOWN_HOSTS" -o ConnectTimeout=10 "$@" -} - -run_ssh install -d -m 750 "$REMOTE_DIR" -REMOTE_COMPOSE="$REMOTE_DIR/docker-compose.$SERVICE.yml" -run_scp "$COMPOSE_FILE" "$SSH_TARGET:$REMOTE_COMPOSE.tmp" -run_ssh sh -s -- "$REMOTE_COMPOSE.tmp" "$REMOTE_COMPOSE" <<'REMOTE_PREPARE' -set -eu -compose_tmp="$1" -compose_target="$2" -chmod 644 "$compose_tmp" -mv -f "$compose_tmp" "$compose_target" -REMOTE_PREPARE - -printf '%s' "$REGISTRY_PASSWORD" | run_ssh docker login -u "$REGISTRY_USER" --password-stdin "$REGISTRY_HOST" -trap 'run_ssh docker logout "$REGISTRY_HOST" >/dev/null 2>&1 || true' EXIT HUP INT TERM -run_ssh docker pull "$IMAGE" - -run_ssh sh -s -- "$REMOTE_DIR" "$SERVICE" "$IMAGE_REPOSITORY" "$IMAGE_TAG" <<'REMOTE_DEPLOY' -set -eu -remote_dir="$1" -service="$2" -image_repository="$3" -image_tag="$4" -compose_file="docker-compose.$service.yml" -project_name="firstleg-prod-$service" -cd "$remote_dir" -export IMAGE_REPOSITORY="$image_repository" IMAGE_TAG="$image_tag" - -compose() { - docker compose --project-name "$project_name" -f "$compose_file" "$@" -} - -case "$service" in - admin-web) - base_port=9001 - nginx_config=/www/server/panel/vhost/nginx/00-blue-green-admin-web.conf - legacy_container=firstleg-prod-admin-web - ;; - client-web) - base_port=9002 - nginx_config=/www/server/panel/vhost/nginx/00-blue-green-client-web.conf - legacy_container=firstleg-prod-client-web - ;; - *) echo "不支持蓝绿部署的前端服务: $service" >&2; exit 2 ;; -esac - -state_root=/data/firstleg/deploy/blue-green-state -state_file="$state_root/frontend-$service.active" -routing_network=firstleg-production-routing -mkdir -p "$state_root" -chmod 750 "$state_root" -docker network inspect "$routing_network" >/dev/null 2>&1 || docker network create "$routing_network" >/dev/null - -for pair in gateway-active:firstleg-production-gateway gateway-overseas-active:firstleg-production-gateway-overseas; do - alias_name="${pair%%:*}" - container="${pair#*:}" - if docker inspect "$container" >/dev/null 2>&1; then - docker network inspect "$routing_network" --format '{{json .Containers}}' | grep -q "\"$container\"" \ - || docker network connect --alias "$alias_name" "$routing_network" "$container" - fi -done - -active_slot="" -active_container="" -active_port="" -if [ -s "$state_file" ]; then - IFS='|' read -r active_slot active_container active_port active_image < "$state_file" -elif docker inspect "$legacy_container" >/dev/null 2>&1; then - active_slot=legacy - active_container="$legacy_container" - active_port="$base_port" -fi - -if [ "$active_slot" = blue ]; then target_slot=green; else target_slot=blue; fi -[ "$active_slot" = legacy ] && target_slot=green -if [ "$target_slot" = blue ]; then target_port=$((base_port + 20000)); else target_port=$((base_port + 10000)); fi -candidate="${legacy_container}-${target_slot}" -docker rm -f "$candidate" >/dev/null 2>&1 || true - -if ! compose run -d --no-deps --name "$candidate" \ - -p "127.0.0.1:$target_port:80" "$service" >/dev/null; then - docker rm -f "$candidate" >/dev/null 2>&1 || true - exit 1 -fi - -healthy=false -elapsed=0 -while [ "$elapsed" -lt 120 ]; do - if docker exec "$candidate" wget -qO- http://127.0.0.1/healthz >/dev/null 2>&1; then - healthy=true - break - fi - sleep 2 - elapsed=$((elapsed + 2)) -done -if [ "$healthy" != true ]; then - docker logs --tail 100 "$candidate" >&2 || true - docker rm -f "$candidate" >/dev/null 2>&1 || true - exit 1 -fi - -backup="$nginx_config.blue-green-backup" -cp -f "$nginx_config" "$backup" -if ! sed -i "s#127\.0\.0\.1:${active_port:-$base_port}#127.0.0.1:$target_port#g" "$nginx_config" \ - || cmp -s "$nginx_config" "$backup" || ! nginx -t || ! nginx -s reload; then - cp -f "$backup" "$nginx_config" - nginx -t && nginx -s reload || true - docker rm -f "$candidate" >/dev/null 2>&1 || true - exit 1 -fi -rm -f "$backup" - -if [ -n "$active_container" ] && docker inspect "$active_container" >/dev/null 2>&1; then - docker stop --time 30 "$active_container" >/dev/null - docker rm "$active_container" >/dev/null -fi -printf '%s|%s|%s|%s\n' "$target_slot" "$candidate" "$target_port" "$image_repository:$image_tag" > "$state_file.tmp" -chmod 640 "$state_file.tmp" -mv -f "$state_file.tmp" "$state_file" -echo "生产前端蓝绿部署成功: service=$service slot=$target_slot port=$target_port image=$image_repository:$image_tag" -REMOTE_DEPLOY diff --git a/scripts/phase4-development-frontend-acceptance.mjs b/scripts/phase4-test-frontend-acceptance.mjs similarity index 52% rename from scripts/phase4-development-frontend-acceptance.mjs rename to scripts/phase4-test-frontend-acceptance.mjs index 7321b557..708fd814 100644 --- a/scripts/phase4-development-frontend-acceptance.mjs +++ b/scripts/phase4-test-frontend-acceptance.mjs @@ -1,4 +1,3 @@ -import { existsSync, readFileSync } from 'node:fs' import { spawnSync } from 'node:child_process' function option(name) { @@ -15,22 +14,13 @@ function normalizeOrigin(value, name) { return url.origin } -function acceptancePassword() { - if (process.env.CHONGCHONG_ADMIN_ACCEPTANCE_PASSWORD) return process.env.CHONGCHONG_ADMIN_ACCEPTANCE_PASSWORD - const file = 'C:/Users/BY-OY/.codex/chongchong-global-dev-acceptance.env' - if (!existsSync(file)) return '' - const match = readFileSync(file, 'utf8').match(/^CHONGCHONG_ADMIN_ACCEPTANCE_PASSWORD=(.*)$/m) - return match?.[1]?.trim() || '' -} - const environment = option('--environment') -if (environment !== 'development') throw new Error('本入口仅允许显式指定 --environment development') -const gateway = normalizeOrigin(option('--gateway') || process.env.ADMIN_DEVELOPMENT_GATEWAY, '--gateway') -const overseasGateway = normalizeOrigin(option('--overseas-gateway') || process.env.ADMIN_DEVELOPMENT_OVERSEAS_GATEWAY, '--overseas-gateway') -const password = acceptancePassword() -if (!password) throw new Error('缺少 CHONGCHONG_ADMIN_ACCEPTANCE_PASSWORD 或本机开发验收凭据文件') +if (environment !== 'test') throw new Error('本入口仅允许显式指定 --environment test') +const gateway = normalizeOrigin(option('--gateway') || process.env.ADMIN_TEST_GATEWAY, '--gateway') +const password = process.env.CHONGCHONG_ADMIN_ACCEPTANCE_PASSWORD +if (!password) throw new Error('缺少 CHONGCHONG_ADMIN_ACCEPTANCE_PASSWORD') -const result = spawnSync('pnpm', ['exec', 'vitest', 'run', '--config', 'vitest.phase4-development.config.ts'], { +const result = spawnSync('pnpm', ['exec', 'vitest', 'run', '--config', 'vitest.phase4-test.config.ts'], { cwd: process.cwd(), stdio: 'inherit', shell: process.platform === 'win32', @@ -39,10 +29,8 @@ const result = spawnSync('pnpm', ['exec', 'vitest', 'run', '--config', 'vitest.p ADMIN_PHASE4_LIVE: '1', ADMIN_ACCEPTANCE_USERNAME: process.env.ADMIN_ACCEPTANCE_USERNAME || 'platform_admin', ADMIN_ACCEPTANCE_PASSWORD: password, - ADMIN_DEVELOPMENT_GATEWAY: gateway, - ADMIN_DEVELOPMENT_OVERSEAS_GATEWAY: overseasGateway, + ADMIN_TEST_GATEWAY: gateway, VITE_API_BASE_URL: `${gateway}/api`, - VITE_OVERSEAS_API_BASE_URL: `${overseasGateway}/api`, }, }) if (result.error) throw result.error diff --git a/scripts/phase4-development-frontend-contract.test.mjs b/scripts/phase4-test-frontend-contract.test.mjs similarity index 69% rename from scripts/phase4-development-frontend-contract.test.mjs rename to scripts/phase4-test-frontend-contract.test.mjs index 04f14f9e..683c7bac 100644 --- a/scripts/phase4-development-frontend-contract.test.mjs +++ b/scripts/phase4-test-frontend-contract.test.mjs @@ -2,14 +2,14 @@ import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import test from 'node:test' -const runner = readFileSync(new URL('./phase4-development-frontend-acceptance.mjs', import.meta.url), 'utf8') -const suite = readFileSync(new URL('./phase4-development-frontend.acceptance.ts', import.meta.url), 'utf8') +const runner = readFileSync(new URL('./phase4-test-frontend-acceptance.mjs', import.meta.url), 'utf8') +const suite = readFileSync(new URL('./phase4-test-frontend.acceptance.ts', import.meta.url), 'utf8') -test('development runner requires an explicit development environment and gateways', () => { +test('test runner requires an explicit test environment and unified gateway', () => { assert.match(runner, /--environment/) - assert.match(runner, /environment !== 'development'/) + assert.match(runner, /environment !== 'test'/) assert.match(runner, /--gateway/) - assert.match(runner, /--overseas-gateway/) + assert.doesNotMatch(runner, /--overseas-gateway|VITE_OVERSEAS/) assert.match(runner, /shell: process\.platform === 'win32'/) assert.match(runner, /if \(result\.error\) throw result\.error/) assert.doesNotMatch(runner, /ADMIN_ACCEPTANCE_PASSWORD\s*[:=]\s*['"][^'"]+['"]/) @@ -17,7 +17,7 @@ test('development runner requires an explicit development environment and gatewa test('live suite uses production auth clients and domain adapters', () => { assert.match(suite, /useAuthStore/) - assert.match(suite, /clearOverseasAccessToken/) + assert.match(suite, /refreshAuthenticatedSession/) assert.match(suite, /createQuote/) assert.match(suite, /createUsTruckOrder/) assert.match(suite, /createLocalLooseboardOrder/) diff --git a/scripts/phase4-development-frontend.acceptance.ts b/scripts/phase4-test-frontend.acceptance.ts similarity index 97% rename from scripts/phase4-development-frontend.acceptance.ts rename to scripts/phase4-test-frontend.acceptance.ts index 2a9bb088..faf84320 100644 --- a/scripts/phase4-development-frontend.acceptance.ts +++ b/scripts/phase4-test-frontend.acceptance.ts @@ -8,7 +8,7 @@ import { defineComponent, nextTick } from 'vue' import { mount, type VueWrapper } from '@vue/test-utils' import ElementPlus from 'element-plus' import { http, ApiResponseError } from '@/shared/api/http' -import { clearOverseasAccessToken, overseasHttp } from '@/shared/api/overseasHttp' +import { refreshAuthenticatedSession } from '@/shared/api/http' import { useAuthStore } from '@/core/auth/auth' import { listPlatformTenants } from '@/modules/iam/api/platformIam' import { @@ -45,7 +45,7 @@ import InventoryTransactionsView from '@/modules/overseas-warehouse/pages/Invent import PalletsView from '@/modules/overseas-warehouse/pages/PalletsView.vue' import QueryToolbar from '@/shared/components/QueryToolbar.vue' -type Region = 'domestic' | 'overseas' +type Region = 'unified' interface TraceStep { region: Region; method: string; path: string; params: unknown; body: unknown; httpStatus: number; code?: string; requestId?: string; traceId?: string; authorization: boolean } interface CoverageGap { area: string; reason: string } @@ -150,7 +150,7 @@ async function masterDataLifecycle() { } async function cleanupTransport(path: string, customerId: string, version: number) { - await overseasHttp.patch(`${path}/status`, { version, status: 'CANCELLED', remark: `${marker} cleanup` }, { params: { customerId } }) + await http.patch(`${path}/status`, { version, status: 'CANCELLED', remark: `${marker} cleanup` }, { params: { customerId } }) } async function overseasBusinessLifecycle() { const customers = (await listPlatformTenants({ page: 1, size: 200, status: 'ACTIVE' })).records.filter(item => item.status === 'ACTIVE') @@ -355,25 +355,25 @@ async function pageRequestCoverage() { { occurredDate: ['2098-01-01', '2098-01-02'], customerName: `${marker}-CUSTOMER`, warehouse: `${marker}-WAREHOUSE`, sku: `${marker}-SKU`, businessType: 'ADJUST', businessOrderNo: `${marker}-BUSINESS`, inboundCode: `${marker}-INBOUND`, inboundOrderNo: `${marker}-INBOUND-ORDER`, outboundOrderNo: `${marker}-OUTBOUND` }, { customerName: `${marker}-CUSTOMER`, destinationWarehouseCode: `${marker}-WAREHOUSE`, skuCode: `${marker}-SKU`, businessType: 'ADJUST', businessOrderNo: `${marker}-BUSINESS`, inboundCode: `${marker}-INBOUND`, inboundOrderNo: `${marker}-INBOUND-ORDER`, outboundOrderNo: `${marker}-OUTBOUND`, occurredFrom: '2098-01-01T00:00:00', occurredTo: '2098-01-02T23:59:59' }) - const pallets = await mountPage(PalletsView, 'GET', '/platform/v1/warehouse-admin/pallets') - await exerciseListSearch(pallets, '/platform/v1/warehouse-admin/pallets', + const pallets = await mountPage(PalletsView, 'GET', '/platform/v1/overseas-warehouse-resources/pallets') + await exerciseListSearch(pallets, '/platform/v1/overseas-warehouse-resources/pallets', { warehouseId: '', code: `${marker}-PALLET`, type: 'WOOD', status: 'IDLE' }, { keyword: `${marker}-PALLET`, palletType: 'WOOD', status: 'IDLE' }) } -describe.skipIf(!enabled).sequential('phase4 development frontend acceptance', () => { +describe.skipIf(!enabled).sequential('phase4 test frontend acceptance', () => { beforeAll(async () => { Object.defineProperty(window, 'matchMedia', { writable: true, value: () => ({ matches: false, addEventListener() {}, removeEventListener() {} }) }) globalThis.ResizeObserver = class { observe() {}; unobserve() {}; disconnect() {} } - installTracingAdapter(http, 'domestic'); installTracingAdapter(overseasHttp, 'overseas') + installTracingAdapter(http, 'unified') const pinia = createPinia(); setActivePinia(pinia) const auth = useAuthStore(pinia) await auth.login(process.env.ADMIN_ACCEPTANCE_USERNAME || 'platform_admin', process.env.ADMIN_ACCEPTANCE_PASSWORD || '') expect(localStorage.getItem('cc-admin-token')).toBeTruthy() - clearOverseasAccessToken() + await refreshAuthenticatedSession() await listVehicles({ page: 1, size: 1 }) - expect(traces.some(item => item.region === 'domestic' && item.path === '/auth/refresh')).toBe(true) - expect(traces.some(item => item.region === 'overseas' && item.authorization)).toBe(true) + expect(traces.some(item => item.region === 'unified' && item.path === '/auth/refresh')).toBe(true) + expect(traces.some(item => item.region === 'unified' && item.authorization)).toBe(true) }) it('runs marker-owned adapter lifecycles and real page requests', async () => { await masterDataLifecycle() @@ -388,7 +388,7 @@ describe.skipIf(!enabled).sequential('phase4 development frontend acceptance', ( const reportDir = resolve(process.cwd(), 'reports') mkdirSync(reportDir, { recursive: true }) const report = { generatedAt: new Date().toISOString(), marker, summary: { requests: traces.length, coverageGaps: coverageGaps.length }, traces, coverageGaps, createdResources } - const file = resolve(reportDir, `phase4-frontend-development-${marker.replace(/[^A-Za-z0-9-]/g, '')}.json`) + const file = resolve(reportDir, `phase4-frontend-test-${marker.replace(/[^A-Za-z0-9-]/g, '')}.json`) writeFileSync(file, `${JSON.stringify(report, null, 2)}\n`, 'utf8') console.info(`PHASE4_FRONTEND_REPORT=${file}`) }) diff --git a/src/deployment/blueGreenDeployment.test.mjs b/src/deployment/blueGreenDeployment.test.mjs deleted file mode 100644 index ab0275c3..00000000 --- a/src/deployment/blueGreenDeployment.test.mjs +++ /dev/null @@ -1,27 +0,0 @@ -import assert from 'node:assert/strict' -import { readFile } from 'node:fs/promises' -import test from 'node:test' - -const script = (await readFile(new URL('../../scripts/deploy-production.sh', import.meta.url), 'utf8')) - .replaceAll('\r\n', '\n') - -test('production frontend starts and validates the candidate before switching nginx', () => { - const start = script.indexOf('compose run -d --no-deps --name "$candidate"') - const health = script.indexOf('docker exec "$candidate" wget') - const nginx = script.indexOf('nginx -t || ! nginx -s reload') - const stop = script.indexOf('docker stop --time 30 "$active_container"') - const state = script.indexOf('mv -f "$state_file.tmp" "$state_file"') - assert.ok(start >= 0 && start < health && health < nginx && nginx < stop && stop < state) -}) - -test('production frontend supports both slots and restores nginx on failure', () => { - assert.match(script, /target_port=\$\(\(base_port \+ 20000\)\)/) - assert.match(script, /target_port=\$\(\(base_port \+ 10000\)\)/) - assert.match(script, /00-blue-green-admin-web\.conf/) - assert.match(script, /00-blue-green-client-web\.conf/) - assert.doesNotMatch(script, /nginx_config=\/www\/server\/panel\/vhost\/nginx\/(?:admin|app)\.buggoglobal\.com\.conf/) - assert.match(script, /cp -f "\$nginx_config" "\$backup"/) - assert.match(script, /cp -f "\$backup" "\$nginx_config"/) - assert.match(script, /docker rm -f "\$candidate"/) - assert.match(script, /gateway-active:firstleg-production-gateway/) -}) diff --git a/src/deployment/nginxConfig.test.ts b/src/deployment/nginxConfig.test.ts index 6a8f0f47..de37fa7b 100644 --- a/src/deployment/nginxConfig.test.ts +++ b/src/deployment/nginxConfig.test.ts @@ -1,85 +1,55 @@ -import { readFileSync } from 'node:fs' +import assert from 'node:assert/strict' +import { readFileSync, existsSync } from 'node:fs' import { resolve } from 'node:path' -import { describe, expect, it } from 'vitest' +import { test } from 'vitest' -const config = readFileSync(resolve(process.cwd(), 'nginx/default.conf.template'), 'utf8').replace(/\r\n/g, '\n') -const testCompose = readFileSync(resolve(process.cwd(), 'docker-compose.test.yml'), 'utf8') -const developmentCompose = readFileSync(resolve(process.cwd(), 'docker-compose.development.yml'), 'utf8') -const productionCompose = readFileSync(resolve(process.cwd(), 'docker-compose.production.yml'), 'utf8') -const jenkinsfile = readFileSync(resolve(process.cwd(), 'Jenkinsfile'), 'utf8') -const dockerfile = readFileSync(resolve(process.cwd(), 'Dockerfile'), 'utf8') +const root = process.cwd() +const config = readFileSync(resolve(root, 'nginx/default.conf.template'), 'utf8') +const compose = readFileSync(resolve(root, 'docker-compose.test.yml'), 'utf8') +const jenkins = readFileSync(resolve(root, 'Jenkinsfile'), 'utf8') +const vite = readFileSync(resolve(root, 'vite.config.ts'), 'utf8') -describe('admin nginx deployment config', () => { - it('allows 50 MiB files plus multipart overhead through both bounded API proxies', () => { - expect(config.match(/client_max_body_size 55m;/g)).toHaveLength(3) - expect(config).not.toContain('client_max_body_size 12m;') - expect(config).not.toContain('client_max_body_size 0;') - }) - - it('prevents the application shell from retaining stale chunk references', () => { - expect(config).toContain('location = /index.html') - expect(config).toContain('Cache-Control "no-cache, no-store, must-revalidate"') - }) - - it('serves hashed assets immutably and never falls back missing chunks to HTML', () => { - expect(config).toContain('location /assets/') - expect(config).toContain('try_files $uri =404;') - expect(config).toContain('Cache-Control "public, max-age=31536000, immutable"') - }) - - it('keeps Vue history routes on the application shell without caching them', () => { - expect(config).toContain('try_files $uri $uri/ /index.html;') - expect(config).toContain('Cache-Control "no-cache"') - }) - - it('proxies overseas API requests to the gateway API prefix', () => { - expect(config).toContain('location /overseas-api/') - expect(config).toContain('rewrite ^/overseas-api/(.*)$ /api/$1 break;') - expect(config).toContain('proxy_pass ${OVERSEAS_API_UPSTREAM};') - }) - - it('declares an explicit overseas gateway upstream in container deployments', () => { - expect(testCompose).toContain('- "host.docker.internal:host-gateway"') - expect(testCompose).toContain('API_UPSTREAM: "${API_UPSTREAM:-http://host.docker.internal:9000}"') - expect(testCompose).toContain('OVERSEAS_API_UPSTREAM: "${OVERSEAS_API_UPSTREAM:-http://host.docker.internal:9300}"') - expect(testCompose).not.toContain('http://test-api-cn.buggoglobal.com') - expect(testCompose).not.toContain('http://test-api-global.buggoglobal.com') - expect(testCompose).toContain('CLIENT_WEB_URL: "${CLIENT_WEB_URL:-https://test-app.buggoglobal.com/}"') - expect(testCompose).not.toContain('CLIENT_WEB_URL: "http://8.163.') - expect(developmentCompose).toContain('CLIENT_WEB_URL: "${CLIENT_WEB_URL:-https://dev-app.buggoglobal.com/}"') - expect(developmentCompose).toContain('OVERSEAS_API_UPSTREAM: "http://firstleg-development-gateway-overseas:9300"') - expect(config).toContain('proxy_set_header Host $proxy_host;') - }) - - it('keeps local environments on target nodes and uses credential-managed SSH for production', () => { - expect(jenkinsfile).toContain("env.JOB_BASE_NAME?.endsWith('-prod') ? 'java-builder'") - expect(jenkinsfile).toContain("DEVELOPMENT_COMPOSE_PROJECT = 'admin-web'") - expect(jenkinsfile).toContain('docker compose -p "$DEVELOPMENT_COMPOSE_PROJECT" -f "$DEVELOPMENT_COMPOSE_FILE" up -d') - expect(jenkinsfile).not.toContain('/var/jenkins_home/.ssh') - expect(jenkinsfile).not.toContain('docker save') - expect(jenkinsfile).toContain("credentialsId: 'firstleg-production-ssh'") - expect(jenkinsfile).toContain('git rev-parse origin/main') - expect(productionCompose).toContain('127.0.0.1:${HOST_PORT:-9001}:80') - expect(productionCompose).toContain('API_UPSTREAM: "http://gateway-active:9000"') - expect(productionCompose).toContain('OVERSEAS_API_UPSTREAM: "http://gateway-overseas-active:9300"') - expect(productionCompose).toContain('name: firstleg-production-routing') - expect(productionCompose).toContain('name: firstleg-prod-infra-network') - }) - - it('uses authenticated internal base images in CI instead of pulling from Docker Hub', () => { - expect(dockerfile).toContain('ARG NODE_IMAGE=node:22-alpine') - expect(dockerfile).toContain('ARG NGINX_IMAGE=nginx:1.27-alpine') - expect(dockerfile).toContain('FROM ${NODE_IMAGE} AS build') - expect(dockerfile).toContain('FROM ${NGINX_IMAGE}') - expect(jenkinsfile).toContain("NODE_BASE_IMAGE = '172.31.198.7:10082/java-project/node:22-alpine'") - expect(jenkinsfile).toContain("NGINX_BASE_IMAGE = '172.31.198.7:10082/java-project/nginx:1.27-alpine'") - expect(jenkinsfile).toContain('withCredentials([usernamePassword(') - expect(jenkinsfile).toContain('export DOCKER_CONFIG="${WORKSPACE}@tmp/docker-auth"') - expect(jenkinsfile).toContain('--build-arg VITE_CLIENT_WEB_URL="${CLIENT_WEB_URL:-https://dev-app.buggoglobal.com/}"') - }) - - it('proxies Mojia measurement OpenAPI requests to the domestic gateway', () => { - expect(config).toContain('location /openapi/') - expect(config).toContain('location /openapi/ {\n client_max_body_size 55m;\n proxy_pass ${API_UPSTREAM};') - }) +test('all business traffic uses one Gateway without rewriting API paths', () => { + assert.match(config, /location \/api\//) + assert.match(config, /proxy_pass \$\{API_UPSTREAM\};/) + assert.doesNotMatch(config, /overseas-api|OVERSEAS_API_UPSTREAM|rewrite/) + assert.match(config, /client_max_body_size 55m;/) + assert.match(config, /proxy_set_header Host \$proxy_host;/) + assert.doesNotMatch(vite, /OVERSEAS_API|AUTH_API_PROXY_TARGET|IAM_API_PROXY_TARGET|WAREHOUSE_API_PROXY_TARGET/) +}) + +test('test deployment requires an explicit shared network, Gateway and version', () => { + for (const key of ['IMAGE_TAG']) { + assert.ok(compose.includes('${' + key + ':?')) + } + assert.ok(compose.includes('${API_UPSTREAM:-http://gateway:9000}')) + assert.ok(compose.includes('${TEST_NETWORK:-firstleg-test-network}')) + assert.match(compose, /external: true/) + assert.match(compose, /127\.0\.0\.1:/) + assert.doesNotMatch(compose, /overseas|host\.docker\.internal|production|development/) + assert.match(jenkins, /label 'java-test'/) + assert.match(jenkins, /docker compose -f docker-compose\.test\.yml config --quiet/) + assert.match(jenkins, /docker network inspect/) + assert.doesNotMatch(jenkins, /docker-compose\.(production|development)|deploy-production|sshUserPrivateKey/) + for (const file of ['docker-compose.production.yml', 'docker-compose.development.yml', 'scripts/deploy-production.sh']) { + assert.equal(existsSync(resolve(root, file)), false) + } +}) + +test('application shell is revalidated and missing chunks remain 404', () => { + assert.match(config, /location = \/index\.html/) + assert.match(config, /no-store/) + assert.match(config, /max-age=31536000, immutable/) + assert.match(config, /try_files \$uri =404;/) + assert.match(config, /try_files \$uri \$uri\/ \/index\.html;/) +}) + + +test('container build excludes local environment values and waits for Nginx health', () => { + const ignore = readFileSync(resolve(root, '.dockerignore'), 'utf8') + assert.match(ignore, /^\.env$/m) + assert.match(ignore, /^\.env\.\*$/m) + assert.match(compose, /healthcheck:/) + assert.match(compose, /http:\/\/127\.0\.0\.1\/healthz/) + assert.match(jenkins, /up -d --wait --wait-timeout 120/) }) diff --git a/src/modules/overseas-master-data/components/ContractPendingNotice.vue b/src/modules/overseas-master-data/components/ContractPendingNotice.vue index 208fdf3a..e1e33dbf 100644 --- a/src/modules/overseas-master-data/components/ContractPendingNotice.vue +++ b/src/modules/overseas-master-data/components/ContractPendingNotice.vue @@ -5,7 +5,7 @@ withDefaults(defineProps<{ domain: string; detail?: boolean }>(), { detail: fals diff --git a/src/modules/overseas-warehouse/AGENTS.md b/src/modules/overseas-warehouse/AGENTS.md index caa948ee..0aae33fb 100644 --- a/src/modules/overseas-warehouse/AGENTS.md +++ b/src/modules/overseas-warehouse/AGENTS.md @@ -9,7 +9,7 @@ - 本文件适用于 `src/modules/overseas-warehouse/`,路径默认相对此目录;其他模块以 `../` 开头。应用公共规则见 `../../../AGENTS.md`。 - 先按下表定位页面,再沿 `routes.ts`、页面 import 与 `api/` 追踪;页面是否开放以正式路由、权限和测试为准。后台接口与状态进入 `../../../../../baiyue-firstleg/lastleg-service/AGENTS.md`,不扩大扫描其他业务模块。 -- 请求统一使用 `../../shared/api/overseasHttp.ts`;不以 `PROTOTYPE_MATRIX.md` 或 `CONTRACT_GAPS.md` 代替代码与服务端契约。 +- 请求统一使用 `../../shared/api/http.ts`;不以 `PROTOTYPE_MATRIX.md` 或 `CONTRACT_GAPS.md` 代替代码与服务端契约。 ## 页面与业务快速索引 diff --git a/src/modules/overseas-warehouse/components/ContractPendingNotice.vue b/src/modules/overseas-warehouse/components/ContractPendingNotice.vue index b42eb653..f061ac67 100644 --- a/src/modules/overseas-warehouse/components/ContractPendingNotice.vue +++ b/src/modules/overseas-warehouse/components/ContractPendingNotice.vue @@ -5,7 +5,7 @@ defineProps<{ domain: string }>() diff --git a/src/shared/api/AGENTS.md b/src/shared/api/AGENTS.md index 315b232b..e0293f1d 100644 --- a/src/shared/api/AGENTS.md +++ b/src/shared/api/AGENTS.md @@ -1,25 +1,8 @@ # Admin Shared API Instructions -## 适用范围与入口 - -- 本文件适用于 `src/shared/api/` 及其测试。业务模块只通过领域 `api/` 适配层调用这里的客户端,不在页面和组件直接创建 Axios 实例。 -- 国内 API 客户端为 `http.ts`;海外 API 客户端为 `overseasHttp.ts`。认证会话编排在 `../../core/auth/auth.ts`,不得在本目录复制登录状态。 - -## 国内与海外请求 - -- `http` 使用 `VITE_API_BASE_URL`,默认走国内代理;国内登录、刷新和退出仍由统一 Auth 处理。 -- `overseasHttp` 使用 `VITE_OVERSEAS_API_BASE_URL`,默认 `/overseas-api`;Vite 将该前缀改写为海外 Gateway 的 `/api`。代理上游由开发环境配置,不在源码或指引中记录真实地址。 -- 登录和刷新响应一次性返回国内 Token、`overseasAccessToken`、`overseasExpiresIn`;`core/auth/auth.ts` 必须原子替换两套访问凭据。不得恢复 Token 兑换端点或让业务模块单独刷新海外 Token。 -- 海外 Token 只由 `replaceOverseasAccessToken`、`clearOverseasAccessToken` 管理;业务模块不得读写缓存、LocalStorage 或手工拼装 `Authorization`。 -- 国内、海外 401 共用统一会话刷新编排。海外请求最多统一刷新一次并重放一次;刷新失败按会话失效处理,不循环重试或回退假数据。 - -## 领域调用边界 - -- 国内业务领域 API 使用 `http`;海外尾程、海外仓资源和海外本土业务使用 `overseasHttp`,不得经国内 Gateway 中转。 -- 页面只调用所属模块的 API 封装;模块 API 负责路径、DTO 和错误适配,不把 Axios 响应或区域选择泄漏到页面。 -- 跨区域 Token、Audience、JWKS 和服务通信公共契约读取 `../../../../../docs/跨区域认证与服务通信契约.md`。 - -## 修改与验证 - -- 修改客户端、拦截器、刷新、Token 生命周期或 Base URL 时,联查 `http.test.ts`、`overseasHttp.test.ts`、`core/auth/auth.test.ts`、Vite 代理、`.env.example` 和全部实际消费者。 -- 至少运行相关 API/Auth 测试和 `pnpm typecheck`;交付前按项目指引运行完整测试与构建。 +- 本目录及测试承载统一请求基础设施。全部领域 API 使用 `http.ts`,业务模块不得创建区域客户端或自行持久化 Token。 +- 会话编排唯一入口为 `../../core/auth/auth.ts`;统一 Access/Refresh Token 由 Auth 签发,并发 401 共用一次刷新,每个原请求最多重放一次。 +- `VITE_API_BASE_URL` 默认 `/api`,Vite 只通过 `API_PROXY_TARGET` 代理到统一 Gateway,不直连业务服务。 +- 页面只调用领域 API 封装;模块适配路径、DTO 与错误,不暴露客户端选择或手工 Authorization。 +- 正式契约见 `../../../../../docs/统一认证与服务通信契约.md`;业务服务仍负责权限和客户隔离。 +- 修改拦截器、刷新或 Base URL 后联查 `http.test.ts`、`../../core/auth/auth.test.ts`、Vite、`.env.example` 及直接消费者,运行定向测试、`pnpm typecheck`、完整测试与构建。 diff --git a/vite.config.ts b/vite.config.ts index fcce35f4..abe5eab4 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -7,10 +7,7 @@ import { ElementPlusResolver } from 'unplugin-vue-components/resolvers' export default defineConfig(({ mode }) => { const env = loadEnv(mode, process.cwd(), '') - const proxyTarget = process.env.API_PROXY_TARGET || env.API_PROXY_TARGET || 'http://localhost:8080' - const authProxyTarget = process.env.AUTH_API_PROXY_TARGET || env.AUTH_API_PROXY_TARGET || proxyTarget - const iamProxyTarget = process.env.IAM_API_PROXY_TARGET || env.IAM_API_PROXY_TARGET || proxyTarget - const warehouseProxyTarget = process.env.WAREHOUSE_API_PROXY_TARGET || env.WAREHOUSE_API_PROXY_TARGET || proxyTarget + const proxyTarget = process.env.API_PROXY_TARGET || env.API_PROXY_TARGET || 'http://localhost:9000' return { plugins: [ @@ -32,33 +29,6 @@ export default defineConfig(({ mode }) => { server: { strictPort: true, proxy: { - '/api/auth': { - target: authProxyTarget, - changeOrigin: true, - configure(proxy) { - proxy.on('proxyReq', (proxyRequest) => { - proxyRequest.removeHeader('origin') - }) - }, - }, - '/api/platform/v1/iam': { - target: iamProxyTarget, - changeOrigin: true, - configure(proxy) { - proxy.on('proxyReq', (proxyRequest) => { - proxyRequest.removeHeader('origin') - }) - }, - }, - '/api/platform/v1/warehouse-admin': { - target: warehouseProxyTarget, - changeOrigin: true, - configure(proxy) { - proxy.on('proxyReq', (proxyRequest) => { - proxyRequest.removeHeader('origin') - }) - }, - }, '/api': { target: proxyTarget, changeOrigin: true, diff --git a/vitest.phase4-development.config.ts b/vitest.phase4-test.config.ts similarity index 84% rename from vitest.phase4-development.config.ts rename to vitest.phase4-test.config.ts index d97a8405..fe9a2c75 100644 --- a/vitest.phase4-development.config.ts +++ b/vitest.phase4-test.config.ts @@ -7,7 +7,7 @@ export default defineConfig({ resolve: { alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) } }, test: { environment: 'jsdom', - include: ['scripts/phase4-development-frontend.acceptance.ts'], + include: ['scripts/phase4-test-frontend.acceptance.ts'], fileParallelism: false, testTimeout: 120_000, hookTimeout: 120_000,