From 92bdd32f8679c64645a7e5f62bd58a505f2a1477 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 12 Oct 2026 11:45:00 +0800 Subject: [PATCH 01/57] fix(appointments): unify calendar layout and cabinet numbers --- .../AppointmentCalendarPanel.test.ts | 13 +++++++ .../AppointmentCalendarPanel.vue | 9 ++++- .../AppointmentCalendarView.test.ts | 37 ++++++++++++++----- .../AppointmentCalendarView.vue | 27 +++++++++----- .../AppointmentCargoPanel.test.ts | 29 +++++++++++++-- .../AppointmentCargoPanel.vue | 13 ++++--- .../AppointmentCargoTabs.vue | 28 ++++++++++++++ .../AppointmentExternalCandidates.vue | 7 +++- .../AppointmentTableRendering.test.ts | 4 ++ .../appointment-calendar/PROTOTYPE_MATRIX.md | 3 ++ src/modules/appointment-calendar/api.ts | 4 +- .../api/platformContainerLoading.test.ts | 2 +- .../components/ContainerOverviewPanel.test.ts | 2 +- .../components/OceanBookingLinkDialog.test.ts | 2 +- .../pages/ContainersView.test.ts | 22 +++++------ .../pages/ContainersView.vue | 2 +- .../pages/GlobalSmartLoadingView.test.ts | 4 +- .../pages/SmartLoadingWorkbenchView.test.ts | 2 +- src/modules/container-loading/types.ts | 4 +- .../pages/OutboundDispatchesView.test.ts | 2 +- .../pages/OutboundDispatchesView.vue | 2 +- .../components/FbaAppointmentFields.vue | 18 +++++---- .../ContainerPortSchedulePanel.test.ts | 2 +- .../components/ContainerPortSchedulePanel.vue | 4 +- src/styles/adminDensity.test.ts | 1 + 25 files changed, 178 insertions(+), 65 deletions(-) create mode 100644 src/modules/appointment-calendar/AppointmentCargoTabs.vue diff --git a/src/modules/appointment-calendar/AppointmentCalendarPanel.test.ts b/src/modules/appointment-calendar/AppointmentCalendarPanel.test.ts index b252e473..753a569b 100644 --- a/src/modules/appointment-calendar/AppointmentCalendarPanel.test.ts +++ b/src/modules/appointment-calendar/AppointmentCalendarPanel.test.ts @@ -3,6 +3,19 @@ import { describe, expect, it } from 'vitest' import AppointmentCalendarPanel from './AppointmentCalendarPanel.vue' describe('appointment calendar interactions', () => { + it('keeps creation beside the title and record actions beside data notices', () => { + const wrapper = mount(AppointmentCalendarPanel, { + props: { canCreate: true, events: [], notices: ['数据范围不完整'] }, + slots: { 'toolbar-actions': '' }, + global: { stubs: { ElButton: { template: '' } }, directives: { loading: {} } }, + }) + expect(wrapper.get('.calendar-label').text()).toContain('新增预约') + const actions = wrapper.get('[aria-label="预约记录与数据提示"]') + expect(actions.text()).toContain('未定时安排') + expect(actions.text()).toContain('取消记录') + expect(actions.get('summary').text()).toContain('数据提示') + wrapper.unmount() + }) const render = () => mount(AppointmentCalendarPanel, { props: { initialDate: '2026-09-24', canCreate: true, events: [ { key: 'a', type: 'delivery', date: '2026-09-24', time: '09:00-11:00', title: 'ONT8 预约送货', fields: [], canEdit: true, canDelete: true }, diff --git a/src/modules/appointment-calendar/AppointmentCalendarPanel.vue b/src/modules/appointment-calendar/AppointmentCalendarPanel.vue index cd4d492f..013de53d 100644 --- a/src/modules/appointment-calendar/AppointmentCalendarPanel.vue +++ b/src/modules/appointment-calendar/AppointmentCalendarPanel.vue @@ -84,10 +84,9 @@ defineExpose({ selectedDate, mode })
{{ notice }}
{{ notice }}
{{ phaseText(progress?.phase) }} · {{ scope }} · 第 {{ progress.scanRound }} 轮
-总量发生变化,正在复核;暂不展示比例。
-{{ progress?.totalKnown && Number(progress.total) === 0 ? '本阶段数量为零;是否完成以阶段及发布证据为准。' : '总量或计数依据待确认,不估算完成比例。' }}
+{{ readingDirectory ? '目录总页数尚未返回,已读取数量见“已完成”。' : progress?.totalKnown && Number(progress.total) === 0 ? '本阶段数量为零;是否完成以阶段及发布证据为准。' : '总量或计数依据待确认,不估算完成比例。' }}
+来源账号的阶段数量,不能当作本仓出库量或整个同步完成率。
当前切片:{{ beijingTime(progress?.sliceFrom) }} 至 {{ beijingTime(progress?.sliceUntil) }}(结束边界不含)
{{ text(focused) }}
-所选日期暂无可靠出库资料,未知不按零显示。
-| 日期 | 正式量 | 已采集量 | 状态 | 原因 |
|---|---|---|---|---|
| {{ point.businessDate }} | {{ quantityText(point.outboundQuantity) }} | {{ quantityText(point.observedQuantity) }} | {{ label(point.dataStatus) }} | {{ reasonsText(point.reasonCodes) }} |
| 日期(点击查看) | 正式量 | 已采集量 | 状态 | 原因 |
|---|---|---|---|---|
| {{ quantityText(point.outboundQuantity) }} | {{ quantityText(point.observedQuantity) }} | {{ label(point.dataStatus) }} | {{ reasonsText(point.reasonCodes) }} |
实线/实心点:完整日 · 虚线/空心点:未完整日 · 未知及缺失日期断线;今日为已采集累计
+所选日期暂无可靠出库资料,未知不按零显示。
+{{ ranking.windowStartDate }} 至 {{ ranking.windowEndDateExclusive }}(截止日不含) · {{ label(ranking.dataStatus) }} · {{ reasonsText(ranking.reasonCodes) }}
+按所选时段{{ ranking.dataStatus === 'COMPLETE' ? '出库量' : '已采集出库量' }}排名;点击 SKU 查看每日趋势。数据尚未采齐,排名可能变化;未采集日期不计为零出库。“—”表示暂无可靠数据。
+| 排名 | SKU | 商品名称 | 货主 | {{ ranking.dataStatus === 'COMPLETE' ? '时段出库量' : '时段已采集出库量' }}(件) | 实物库存(件) | 可售库存(件) | 窗口日均(件) | 预计可售(天) |
|---|---|---|---|---|---|---|---|---|
| {{ index + 1 }} | {{ productName(item.sku, item.productName) }} | {{ item.customerCode }} | {{ quantityText(item.value) }} | {{ quantityText(item.physicalQuantity) }} | {{ quantityText(item.availableQuantity) }} | {{ quantityText(item.averageDailyOutboundQuantity) }} | {{ quantityText(item.inventoryCoverageDays) }} |
{{ warehouse || '—' }} · 查询时间 {{ beijingTime(data.context.queryAsOf) }}(北京时间)
+当前为历史查询结果,请整体刷新后再核对最新采集覆盖。
+| 榜单要求的销量区间 | {{ data.longCoverageSkus.windowStartDate }} 至 {{ data.longCoverageSkus.windowEndDateExclusive }}(截止日不含) | |
|---|---|---|
| 已发布销量覆盖 | {{ beijingTime(data.context.salesCoveredFrom) }} 至 {{ beijingTime(data.context.salesCoveredUntil) }}(截止时刻不含) | |
| 完整日覆盖 | {{ data.context.completeDayFrom }} 至 {{ data.context.completeDayUntil }}(截止日不含) | 尚无可确认的完整日覆盖范围 |
| 销量状态 | {{ label(data.context.salesStatus) }};近30日日均:{{ label(data.kpis.averageDailyOutboundQuantity30d.dataStatus) }} · {{ reasonsText(data.kpis.averageDailyOutboundQuantity30d.reasonCodes) }} | |
| 库存采集时间 | {{ beijingTime(data.context.inventoryObservedFrom) }} 至 {{ beijingTime(data.context.inventoryObservedUntil) }} | |
| 库存状态/有效期 | {{ label(data.context.inventoryStatus) }};有效至 {{ beijingTime(data.context.inventoryFreshUntil) }} | |
| 本次查询提示 | {{ reasonsText(data.context.reasonCodes) }} | |
| 预计消化时间最长 | 符合条件 {{ quantityText(data.longCoverageSkus.eligibleCount) }} SKU;待评估 {{ quantityText(data.longCoverageSkus.unassessableCount) }} SKU | |
| 近30日无出库库存 | 符合条件 {{ quantityText(data.noOutboundSkus.eligibleCount) }} SKU;待评估 {{ quantityText(data.noOutboundSkus.unassessableCount) }} SKU |
以下为全仓库存分布的原因计数,不是两个榜单各自的排除明细。每个SKU仅列首个命中的原因,补齐后仍可能有其他条件待满足。
+| 原因 | SKU数 | 需要核对的数据 |
|---|---|---|
| {{ reasonsText([reason]) }} | {{ quantityText(count) }} | {{ explanations[reason] || '接口未提供更细说明,请核对库存分析中的SKU资料。' }} |
本次查询未列出非零的具体原因计数,不能据此认定资料全部齐备。
+消化榜还要求正可售库存且日均出库大于零;无出库榜要求正实物库存且完整窗口出库为零。两榜均需满足本仓观察资格;未知销量不能按零处理。
+所属货主:{{ data.summary.customerCode || '—' }} · {{ data.summary.productName || '—' }}
{{ data.summary.dataStatus === 'COMPLETE' ? '完整出库量' : '已采集出库量' }}:{{ quantityText(shownQuantity(data.summary)) }} {{ data.summary.quantityUnit === 'PCS' ? '件' : '单位未知' }};{{ label(data.summary.dataStatus) }}。今日为已采集累计。
-所选范围尚无可靠数据,未知日期不作为零销量。
-所选范围尚无可靠数据,未知日期不作为零销量。
+实线/实心点:完整日 · 虚线/空心点:未完整日 · 空缺:未知,缺失日期断线
+| 北京时间日期 | 完整出库量 | 已采集量 | 状态 | 覆盖截止 | 缺口原因 |
|---|---|---|---|---|---|
| {{ point.businessDate }} | {{ quantityText(point.outboundQuantity) }} | {{ quantityText(point.observedQuantity) }} | {{ label(point.dataStatus) }} | {{ point.coveredUntil ?? '—' }} | {{ reasonsText(point.reasonCodes) }} |
{{ contextExpired || metric.inventory && stockExpired ? '历史/待刷新 · ' : '' }}{{ label(data.kpis[metric.key].dataStatus) }}
已知小计 {{ quantityText(data.kpis[metric.key].knownSubtotal) }}
另 {{ quantityText(data.kpis[metric.key].unknownSkuCount) }} SKU 待核验
{{ reasonsText(data.kpis[metric.key].reasonCodes) }}{{ data.context.rangeStartDate }} 至 {{ data.context.rangeEndDateExclusive }}(截止日不含)
有实物库存或库存待核验 SKU;{{ data.context.fixedWindowStartDate }} 至 {{ data.context.fixedWindowEndDateExclusive }}(截止日不含)。
候选 {{ quantityText(data.coverageDistribution.candidateSkuCount) }};已排除确定零实物 {{ quantityText(data.coverageDistribution.excludedZeroPhysicalSkuCount) }} SKU。{{ label(data.coverageDistribution.dataStatus) }} · {{ reasonsText(data.coverageDistribution.reasonCodes) }}
无可用库存分布资料;销售候选不作为库存总量。
30/60/90 仅为图形分组,不是经营评级;无可售库存不代表周转良好。零出库不计无限天数。
{{ reasonsText([String(reason)]) }}:{{ quantityText(count) }} SKU
{{ data.context.rangeStartDate }} 至 {{ data.context.rangeEndDateExclusive }}(截止日不含)
+30/60/90 仅为图形分组,不是经营评级;无可售库存不代表周转良好。零出库不计无限天数。
{{ reasonsText([String(reason)]) }}:{{ quantityText(count) }} SKU
有实物库存或库存待核验 SKU;{{ data.context.fixedWindowStartDate }} 至 {{ data.context.fixedWindowEndDateExclusive }}(截止日不含)。
候选 {{ quantityText(data.coverageDistribution.candidateSkuCount) }};已排除确定零实物 {{ quantityText(data.coverageDistribution.excludedZeroPhysicalSkuCount) }} SKU。{{ label(data.coverageDistribution.dataStatus) }} · {{ reasonsText(data.coverageDistribution.reasonCodes) }}
+无可用库存分布资料;销售候选不作为库存总量。
+{{ data[ranking.key].windowStartDate }} 至 {{ data[ranking.key].windowEndDateExclusive }}(截止日不含);符合条件 {{ quantityText(data[ranking.key].eligibleCount) }} SKU,待评估 {{ quantityText(data[ranking.key].unassessableCount) }}。
{{ contextExpired || ranking.key !== 'hotSkus' && stockExpired ? '历史/待刷新 · ' : '' }}{{ label(data[ranking.key].dataStatus) }} · {{ reasonsText(data[ranking.key].reasonCodes) }}
按所选时段已采集量排名,数据尚未采齐,排名可能随采集变化;未采集日期不计为零出库。
本榜仍需完整近30日销量与库存观察资料;资料不足的 SKU 暂不评估,未采集日期不计为零出库。
{{ data[ranking.key].windowStartDate }} 至 {{ data[ranking.key].windowEndDateExclusive }}(截止日不含);符合条件 {{ quantityText(data[ranking.key].eligibleCount) }} SKU,待评估 {{ quantityText(data[ranking.key].unassessableCount) }}。
+{{ contextExpired || ranking.key !== 'hotSkus' && stockExpired ? '历史/待刷新 · ' : '' }}{{ label(data[ranking.key].dataStatus) }} · {{ reasonsText(data[ranking.key].reasonCodes) }}
+按所选时段已采集量排名,数据尚未采齐,排名可能随采集变化;未采集日期不计为零出库。
+本榜仍需完整近30日销量与库存观察资料;资料不足的 SKU 暂不评估,未采集日期不计为零出库。
+ +{{ trendError }}
{{ trendError }}
没有库存分析查看权限。
+ +{{ inputError }}
{{ notice }}
+{{ data.charts.unknownOwnerCount }} 个货主未能完整计算
| 货主 | 实物件数 |
|---|---|
| {{ turnoverNumber(owner.physicalQuantity) }} |
{{ window.days }}日:{{ window.dataStatus === 'COMPLETE' ? `${turnoverNumber(window.averageDailyOutboundQuantity)} 件/日` : `覆盖不足:完整${window.completeDayCount}日,部分${window.partialDayCount}日,未知${window.unknownDayCount}日` }}
{{ window.startDate }} 至 {{ window.endDateExclusive }}(不含):{{ windowText(window) }};{{ reasonsText(window.reasonCodes) }}
7/15/30天均为截至所选日期的完整滚动窗口。日均仅使用完整窗口;预计可售天数使用查询时固定的当前正品可售库存,不是库龄。
期间件数周转次数=同期完整出库÷每日首次合格计划库存的实物均值。缺日、缺商品或单位异常不补零;当前库存不代替历史平均库存。件数不是占用空间,快慢标签仅供人工复核。
货主视图按“含此类SKU的货主”筛选;未知数量保留已知小计,不平均SKU可售天数。库存过期不抹去有完整证据的历史销量与周转。
筛选结果:{{ data?.total ?? 0 }} {{ ownerMode ? '个货主' : '条SKU' }}
+ +{{ evidence.customerCode }} · {{ evidence.sku || '货主汇总' }};合格日样本 {{ evidence.qualifiedSampleDays }}/{{ evidence.requiredSampleDays }};积累起点 {{ data.context.accumulationStartedOn || '尚无日样本' }}
缺口日期:{{ evidence.missingSampleDates.join('、') || '无日期缺口' }}
{{ reasonsText(evidence.sampleReasonCodes) }}
{{ window.days }}日:{{ windowText(window) }};完整{{ window.completeDayCount }}日/部分{{ window.partialDayCount }}日/未知{{ window.unknownDayCount }}日
日代表规则:每天首次合格计划采集;仅纳入 {{ beijingTime(data.context.queryAsOf) }} 以前已发布代表。
{{ selected.error }}
+没有自营经营数据查看权限。
{{ routeProblem }};已停止查询,不能按全仓条件继续。
{{ inputError }}
{{ notice }}
7/15/30天均为截至所选日期的完整滚动窗口。日均仅使用完整窗口;预计可售天数使用查询时固定的当前正品可售库存,不是库龄。
期间件数周转次数=同期完整出库÷每日首次合格计划库存的实物均值。缺日、缺商品或单位异常不补零;当前库存不代替历史平均库存。件数不是占用空间,快慢标签仅供人工复核。
货主视图按“含此类SKU的货主”筛选;未知数量保留已知小计,不平均SKU可售天数。库存过期不抹去有完整证据的历史销量与周转。
当前库存已过期,实物、可售、货主排名及预计可售天数暂不可用;最近库存观测完成于 {{ beijingTime(data.context.inventoryObservedUntil) }}。采集成功后请整体刷新。
+当前尚无可用库存版本,请查看数据采集。
+{{ collectionNotice }};采集状态截至 {{ beijingTime(inventoryRuntime?.observedAt) }}。
{{ runtimeError }}
+{{ data.charts.unknownOwnerCount }} 个货主未能完整计算
| 货主 | 实物件数 |
|---|---|
| {{ turnoverNumber(owner.physicalQuantity) }} |
{{ window.days }}日:{{ window.dataStatus === 'COMPLETE' ? `${turnoverNumber(window.averageDailyOutboundQuantity)} 件/日` : `覆盖不足:完整${window.completeDayCount}日,部分${window.partialDayCount}日,未知${window.unknownDayCount}日` }}
{{ window.startDate }} 至 {{ window.endDateExclusive }}(不含):{{ windowText(window) }};{{ reasonsText(window.reasonCodes) }}
{{ data.charts.unknownOwnerCount }} 个货主未能完整计算
| 货主 | 实物件数 |
|---|---|
| {{ turnoverNumber(owner.physicalQuantity) }} |
{{ window.startDate }} 至 {{ window.endDateExclusive }}(不含):{{ windowText(window) }};{{ reasonsText(window.reasonCodes) }}
7/15/30天均为截至所选日期的完整滚动窗口。日均仅使用完整窗口;预计可售天数使用查询时固定的当前正品可售库存,不是库龄。
期间件数周转次数=同期完整出库÷每日首次合格计划库存的实物均值。缺日、缺商品或单位异常不补零;当前库存不代替历史平均库存。件数不是占用空间,快慢标签仅供人工复核。
货主视图按“含此类SKU的货主”筛选;未知数量保留已知小计,不平均SKU可售天数。库存过期不抹去有完整证据的历史销量与周转。
筛选结果:{{ data?.total ?? 0 }} {{ ownerMode ? '个货主' : '条SKU' }}
{{ evidence.customerCode }} · {{ evidence.sku || '货主汇总' }};合格日样本 {{ evidence.qualifiedSampleDays }}/{{ evidence.requiredSampleDays }};积累起点 {{ data.context.accumulationStartedOn || '尚无日样本' }}
缺口日期:{{ evidence.missingSampleDates.join('、') || '无日期缺口' }}
{{ reasonsText(evidence.sampleReasonCodes) }}
{{ window.days }}日:{{ windowText(window) }};完整{{ window.completeDayCount }}日/部分{{ window.partialDayCount }}日/未知{{ window.unknownDayCount }}日
日代表规则:每天首次合格计划采集;仅纳入 {{ beijingTime(data.context.queryAsOf) }} 以前已发布代表。
{{ evidence.customerCode }} · {{ evidence.sku || '货主汇总' }};合格日样本 {{ evidence.qualifiedSampleDays }}/{{ evidence.requiredSampleDays }};积累起点 {{ data.context.accumulationStartedOn || '尚无日样本' }}
缺口日期:{{ evidence.missingSampleDates.join('、') || '无日期缺口' }}
{{ reasonsText(evidence.sampleReasonCodes) }}
{{ window.days }}日:{{ windowText(window) }};完整{{ window.completeDayCount }}日/部分{{ window.partialDayCount }}日/未知{{ window.unknownDayCount }}日
日代表规则:每天首次合格计划采集;仅纳入 {{ beijingTime(data.context.queryAsOf) }} 以前已发布代表。
日样本是每日库存观测,不是出库历史天数;补采和验证任务不计入。今天成功的计划库存,要在截止日包含今天时才进入窗口,历史缺日不能补零。
{{ selected.error }}
-销量计算区间(北京时间):{{ context.rangeStartDate ?? '—' }} 至 {{ context.rangeEndDateExclusive ? previousDate(context.rangeEndDateExclusive) : '—' }}(含截止日),{{ displayedWindowDays }} 天;销量状态:{{ label(context.salesStatus) }}。未采齐不推算日均及预计可售天数。
{{ summaryMode ? '今日显示已采集量,完整日期显示出库量;未知数据不按零计算。' : mode === 'inventory' ? '按所选截止日及10/30个完整自然日计算;窗口未采齐时不以部分销量推算日均或可售天数。' : '使用截至昨日的完整自然日;日均和可售天数由已采集数据计算。' }}同一SKU属于不同货主时分别展示。
+{{ summaryMode ? '今日显示已采集量,完整日期显示出库量;未知数据不按零计算。' : mode === 'inventory' ? '按页面所示销量区间计算;窗口未采齐时不以部分销量推算日均或可售天数。' : '使用截至昨日的完整自然日;日均和可售天数由已采集数据计算。' }}同一SKU属于不同货主时分别展示。
预计可售天数=当前正品可售库存÷(完整窗口出库量÷窗口天数);窗口无出库时不推算有限天数。实物含全品质,可售仅正品;冻结、次品不重复计入实物,在途不计当前占仓。可售天数阈值只用于本次查询。
查询时间:{{ beijingTime(context.queryAsOf) }};有效至 {{ beijingTime(context.queryExpiresAt) }}。销量覆盖截至 {{ context.salesCoveredUntil ? beijingTime(context.salesCoveredUntil) : '尚无可用数据' }}。{{ context.rebuildRequired ? '数据修订待重建。' : '' }}
实际计算区间:{{ context.rangeStartDate ?? '—' }} 至 {{ context.rangeEndDateExclusive ?? '—' }}(截止日不含);{{ context.calculationWindowDays }} 天。
diff --git a/src/modules/self-operated/components/CollectionAccountMonitor.test.ts b/src/modules/self-operated/components/CollectionAccountMonitor.test.ts index 2e0a09b7..e1de39e5 100644 --- a/src/modules/self-operated/components/CollectionAccountMonitor.test.ts +++ b/src/modules/self-operated/components/CollectionAccountMonitor.test.ts @@ -69,6 +69,7 @@ describe('single account collection monitor', () => { const w = mount(CollectionAccountMonitor, { ...options, props: { overview: overview(), stale: true } }) expect(w.text()).toContain('339'); expect(w.text()).toContain('状态更新失败') expect(w.text()).toContain('已暂停估算') + expect(w.find('.account-flow li[data-state="active"]').exists()).toBe(false) w.unmount() }) }) diff --git a/src/modules/self-operated/components/CollectionAccountMonitor.vue b/src/modules/self-operated/components/CollectionAccountMonitor.vue index a63aba87..834abffd 100644 --- a/src/modules/self-operated/components/CollectionAccountMonitor.vue +++ b/src/modules/self-operated/components/CollectionAccountMonitor.vue @@ -67,7 +67,7 @@ function nodeState(phases: string[]) { if (!p) return 'idle' const evidence = [p, ...(p.stages ?? [])].find(s => phases.includes(s.phase ?? '') && s.attemptNo != null && s.attemptNo === p.attemptNo && s.scanRound === p.scanRound && s.sliceFrom === p.sliceFrom && s.sliceUntil === p.sliceUntil && s.progressScope === p.progressScope && s.stageFinishedAt) if (evidence) return 'done' - return phases.includes(p.phase ?? '') && running.value.includes(current.value!) ? 'active' : 'idle' + return !props.stale && phases.includes(p.phase ?? '') && running.value.includes(current.value!) ? 'active' : 'idle' } @@ -82,22 +82,23 @@ function nodeState(phases: string[]) {同时运行:{{ task.warehouseCode || '账号' }} · {{ phaseText(task.progress?.phase) }};
有新任务时自动更新进度。
{{ phaseText(progress?.phase) }} · {{ scope }} · 第 {{ progress.scanRound }} 轮
@@ -41,6 +42,9 @@ const readingDirectory = computed(() => !props.progress?.totalKnown && ['DIRECTO .collection-ring { width: 156px; height: 156px; position: relative; } .compact-counts { min-width: 0; } .ring-chart { width: 100%; height: 100%; } +.ring-chart.is-indeterminate { animation: collection-spin 2.4s linear infinite; } +@keyframes collection-spin { to { transform: rotate(360deg); } } +@media (prefers-reduced-motion: reduce) { .ring-chart.is-indeterminate { animation: none; } } .ring-label { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; } .ring-label strong { font-size: 28px; font-weight: 500; font-variant-numeric: tabular-nums; } .collection-count-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--admin-space-4); margin: var(--admin-space-4) 0; } diff --git a/src/modules/self-operated/components/CollectionTaskProgress.test.ts b/src/modules/self-operated/components/CollectionTaskProgress.test.ts index 9c630859..0b1dca2f 100644 --- a/src/modules/self-operated/components/CollectionTaskProgress.test.ts +++ b/src/modules/self-operated/components/CollectionTaskProgress.test.ts @@ -11,6 +11,22 @@ const detail = { task, sharedUpstream: source, observedAt: '2026-10-13T01:02:00Z const wrappers: ReturnType已确认关联来源:{{ confirmedSource.taskNo }} · 账号共享 · {{ taskStateText(confirmedSource) }};来源完成后,本仓仍需更新出库数据、计算与发布。
{{ task.blockingReason ? blockingText : task.latestAttempt?.failureSummary }}
-任务累计耗时 {{ taskElapsed == null ? '—' : `${Math.floor(taskElapsed / 60)} 分 ${taskElapsed % 60} 秒` }}(含排队与重试)
diff --git a/src/modules/self-operated/components/InventoryRankingDiagnostics.vue b/src/modules/self-operated/components/InventoryRankingDiagnostics.vue index 288f34fc..86212212 100644 --- a/src/modules/self-operated/components/InventoryRankingDiagnostics.vue +++ b/src/modules/self-operated/components/InventoryRankingDiagnostics.vue @@ -8,7 +8,7 @@ const explanations: Record{{ inputError }}
{{ notice }}
7/15/30天均为截至所选日期的完整滚动窗口。日均仅使用完整窗口;预计可售天数使用查询时固定的当前正品可售库存,不是库龄。
期间件数周转次数=同期完整出库÷每日首次合格计划库存的实物均值。缺日、缺商品或单位异常不补零;当前库存不代替历史平均库存。件数不是占用空间,快慢标签仅供人工复核。
货主视图按“含此类SKU的货主”筛选;未知数量保留已知小计,不平均SKU可售天数。库存过期不抹去有完整证据的历史销量与周转。
{{ data.charts.unknownOwnerCount }} 个货主未能完整计算
| 货主 | 实物件数 |
|---|---|
| {{ turnoverNumber(owner.physicalQuantity) }} |
{{ window.startDate }} 至 {{ window.endDateExclusive }}(不含):{{ windowText(window) }};{{ reasonsText(window.reasonCodes) }}
| 货主 | 实物件数 |
|---|---|
| {{ turnoverNumber(owner.physicalQuantity) }} |
{{ data.charts.unknownOwnerCount }} 个货主未能完整计算
{{ window.startDate }} 至 {{ window.endDateExclusive }}(不含):{{ windowText(window) }};{{ reasonsText(window.reasonCodes) }}
日均出库按 {{ beijingTime(data.context.analyticsContext.salesPublishedAt) }} 已发布销量版本计算;新采集数据待重算,发布后请整体刷新。SKU库存评估仍等待重算完成。
暂无完整窗口的日均出库数据,覆盖情况如下。
筛选结果:{{ data?.total ?? 0 }} {{ ownerMode ? '个货主' : '条SKU' }}
+ +{{ inputError }}
+正在更新筛选结果…
+筛选未更新,当前保留上次结果。{{ error.message }}
+{{ evidence.customerCode }} · {{ evidence.sku || '货主汇总' }};合格日样本 {{ evidence.qualifiedSampleDays }}/{{ evidence.requiredSampleDays }};积累起点 {{ data.context.accumulationStartedOn || '尚无日样本' }}
缺口日期:{{ evidence.missingSampleDates.join('、') || '无日期缺口' }}
{{ reasonsText(evidence.sampleReasonCodes) }}
{{ window.days }}日:{{ windowText(window) }};完整{{ window.completeDayCount }}日/部分{{ window.partialDayCount }}日/未知{{ window.unknownDayCount }}日
日代表规则:每天首次合格计划采集;仅纳入 {{ beijingTime(data.context.queryAsOf) }} 以前已发布代表。
日样本是每日库存观测,不是出库历史天数;补采和验证任务不计入。今天成功的计划库存,要在截止日包含今天时才进入窗口,历史缺日不能补零。
{{ evidence.customerCode }} · {{ evidence.sku || '货主汇总' }};合格日样本 {{ evidence.qualifiedSampleDays }}/{{ evidence.requiredSampleDays }};积累起点 {{ data.context.accumulationStartedOn || '尚无日样本' }}
缺口日期:{{ evidence.missingSampleDates.join('、') || '无日期缺口' }}
{{ reasonsText(evidence.sampleReasonCodes) }}
{{ window.days }}日:{{ windowText(window) }};完整{{ window.completeDayCount }}日/部分{{ window.partialDayCount }}日/未知{{ window.unknownDayCount }}日
日代表规则:每天首次合格计划采集;仅纳入 {{ beijingTime(data.context.queryAsOf) }} 以前已发布代表。
日样本是每日库存观测,不是出库历史天数;补采和验证任务不计入。今天采集成功的合格计划库存样本,最早次日查询时才能纳入窗口;当天样本数仍为0不代表采集未成功,历史缺日不能补零。
来源单:{{ current.carton.sourceReturnNo }} · 状态:{{ current.carton.status }} · 我方箱唛:{{ current.labelStatus === 'CONFIRMED' ? '已确认贴标' : '待贴标确认' }}
+{{ current.canRelabel ? '后续贴标按此当前箱唛选择整箱;跨来源单同 SKU 的部分贴标须先现场分拣确认。' : current.blockReason }}
+结果数量为完成时快照;目标原箱内容包含在整箱数量中,本次合入数量见上表。
+ +复用当前平台会话,只调用正式业务接口。
- -当前仅查询今日,所列商品暂无可用的今日数量;历史采集量不计入今日。
销量统计(北京时间):{{ context.rangeStartDate ?? '—' }} 至 {{ context.rangeEndDateExclusive ? previousDate(context.rangeEndDateExclusive) : '—' }},共 {{ displayedWindowDays }} 天。{{ origin ? '沿用经营总览所选区间;未完整的日期不推算日均。' : '不含今天;今天的出库不计入日均及可售天数。' }}库存使用最新已查询的采集值。
{{ routeProblem }};已停止查询,不能按全仓条件继续。
{{ runtimeError }};最近观测:{{ beijingTime(overview?.observedAt) }}
日期范围尚未应用,请选择完整起止日期后点击查询。当前列表及每日趋势仍使用上次已应用的统计区间。
-{{ newDay ? '北京时间已跨日。' : '有新数据。' }}点击查询更新数据。
+{{ newDay ? '北京时间已跨日。' : '有新数据可用。' }}请返回经营总览刷新原图。当前列表仍使用上次查询版本。
销量计算区间(北京时间):{{ context.rangeStartDate ?? '—' }} 至 {{ context.rangeEndDateExclusive ? previousDate(context.rangeEndDateExclusive) : '—' }}(含截止日),{{ displayedWindowDays }} 天;销量状态:{{ label(context.salesStatus) }}。未采齐不推算日均及预计可售天数。
{{ summaryMode ? '今日显示已采集量,完整日期显示出库量;未知数据不按零计算。' : mode === 'inventory' ? '按页面所示销量区间计算;窗口未采齐时不以部分销量推算日均或可售天数。' : '使用截至昨日的完整自然日;日均和可售天数由已采集数据计算。' }}同一SKU属于不同货主时分别展示。
+{{ summaryMode ? '最近3/7/15/30天均截至昨日(北京时间);自定义按所选起止日期查询。完整区间显示出库量,未完整区间显示已采集量;未知数据不按零计算。' : mode === 'inventory' ? '按页面所示销量区间计算;窗口未采齐时不以部分销量推算日均或可售天数。' : '使用截至昨日的完整自然日;日均和可售天数由已采集数据计算。' }}同一SKU属于不同货主时分别展示。
预计可售天数=当前正品可售库存÷(完整窗口出库量÷窗口天数);窗口无出库时不推算有限天数。实物含全品质,可售仅正品;冻结、次品不重复计入实物,在途不计当前占仓。可售天数阈值只用于本次查询。
查询时间:{{ beijingTime(context.queryAsOf) }};有效至 {{ beijingTime(context.queryExpiresAt) }}。销量覆盖截至 {{ context.salesCoveredUntil ? beijingTime(context.salesCoveredUntil) : '尚无可用数据' }}。{{ context.rebuildRequired ? '数据修订待重建。' : '' }}
实际计算区间:{{ context.rangeStartDate ?? '—' }} 至 {{ context.rangeEndDateExclusive ?? '—' }}(截止日不含);{{ context.calculationWindowDays }} 天。
@@ -318,6 +371,16 @@ onUnmounted(() => { poller.stop(); dataFence.cancel(); dailyFence.cancel(); docu正在核对批次、来源和核实版本…
+原批次 {{ result.replacedAppointmentNos.join('、') }} 已保留历史并被替代。
{{ context.appointment.fbaWarehouseCode }} · {{ context.appointment.items.length }} 条货物来源;客户与原来源逐票保留。
+该批次已被替代,当前只读。{{ context.appointment.repartitionReason }}
{{ reason }}
+当前批次无分批资格,以下为原预约保留的货物资料。
{{ item.fbaWarehouseCode }} · {{ item.packageCount ?? '箱数待核实' }} 箱
货件:{{ item.shipmentReferences?.join('、') || '待核实' }}
正在核对预约货源…
-这些车次缺少地点或时区,暂未计入日历。原计划时间保留 UTC。
-输入更完整的仓库编码或名称,缩小搜索范围。
-{{ linkError }}
+未约成也可保存;只用于预约核实,保留原订单资料。
+正在读取货物资料…
+ +原货源已变化,请核对当前原票和已分配量后重新保存。
{{ reason }}
+ + +正在读取全部关联预约…
+{{ context.source.inboundCode || context.source.bookingNo || '原票业务号未提供' }} · {{ context.source.destinationWarehouseCode || '目的仓待核实' }}
{{ reason }}
共 {{ context.coverage.appointments.length }} 个关联预约,分别查看和处理。
正在读取原预约…
+ +{{ detail.fbaWarehouseCode }} · {{ detail.appointmentNo }}
+原预约已被替代,保留历史只读。当前批次:{{ detail.replacementAppointmentNos?.join('、') || '—' }}。{{ detail.repartitionReason }}
+取消原预约并保留历史;工作需求与后续跟进仍保留。已有运输安排须满足原业务取消条件。
+当前预约仅可查看。
+{{ message }}
' }, ApiErrorMeta: true } } }) } +beforeEach(() => { vi.clearAllMocks(); access.write = true; vi.mocked(previewPlanningBatch).mockResolvedValue(preview); vi.mocked(createSourceAppointment).mockResolvedValue(batchContext().appointment) }) +it('previews and creates the exact canonical external source and customer without inventing times', async () => { + const wrapper = render(); await flushPromises() + expect(previewPlanningBatch).toHaveBeenCalledWith(sources) + await wrapper.get('input').setValue('2026-10-16'); await wrapper.get('button').trigger('click'); await flushPromises() + expect(createSourceAppointment).toHaveBeenCalledWith(expect.objectContaining({ sources, originWarehouseId: 'origin-1', fbaWarehouseCode: 'ONT8', details: expect.objectContaining({ plannedDate: '2026-10-16', plannedAt: null, plannedEndAt: null, appointmentStatus: 'PENDING' }) })) + expect(wrapper.emitted('saved')).toHaveLength(1); expect(wrapper.text()).not.toContain('external-1'); wrapper.unmount() +}) +it('retains the same immutable request after an unknown outcome and blocks leaving', async () => { + vi.mocked(createSourceAppointment).mockRejectedValueOnce(new Error('connection lost')) + const wrapper = render(); await flushPromises(); await wrapper.get('button').trigger('click'); await flushPromises() + const request = vi.mocked(createSourceAppointment).mock.calls[0]![0] + expect(await guard()).toBe(false) + await wrapper.get('button').trigger('click'); await flushPromises() + expect(createSourceAppointment).toHaveBeenNthCalledWith(2, request); wrapper.unmount() +}) +it('refreshes qualification after conflict while keeping user-entered dates', async () => { + vi.mocked(createSourceAppointment).mockRejectedValueOnce(new ApiResponseError('来源变化', 'A0409', 409)) + const wrapper = render(); await flushPromises(); await wrapper.get('input').setValue('2026-10-16') + await wrapper.get('button').trigger('click'); await flushPromises() + expect(previewPlanningBatch).toHaveBeenCalledTimes(2) + expect((wrapper.get('input').element as HTMLInputElement).value).toBe('2026-10-16') + expect(wrapper.emitted('saved')).toBeUndefined(); wrapper.unmount() +}) +it('rejects missing destination scope and lacks a write path without permission', async () => { + const missing = render([{ ...sources[0]!, destinationKey: null }]); await flushPromises() + expect(previewPlanningBatch).not.toHaveBeenCalled(); expect(missing.text()).toContain('缺少完整货源'); missing.unmount() + access.write = false; const readonly = render(); await flushPromises(); await readonly.get('button').trigger('click') + expect(createSourceAppointment).not.toHaveBeenCalled(); readonly.unmount() +}) diff --git a/src/modules/appointment-calendar/WorkCreateAppointmentForm.vue b/src/modules/appointment-calendar/WorkCreateAppointmentForm.vue new file mode 100644 index 00000000..1d169c5a --- /dev/null +++ b/src/modules/appointment-calendar/WorkCreateAppointmentForm.vue @@ -0,0 +1,60 @@ + + +正在核对本次货物范围…
+ +{{ preview.originWarehouseName }} → {{ preview.destinationWarehouseCode }} · {{ preview.group.sourceCount }} 票 · {{ preview.group.packages ?? '箱数待核实' }}
+{{ source.inboundCode || source.bookingNo || '原票业务号未提供' }}
+{{ field }}
+货物备妥窗口待核实。
+{{ message }}
' }, ApiErrorMeta: true, + } } }) +} +beforeEach(() => vi.clearAllMocks()) +it('never sends customer-warehouse dispatch batches to the FBA candidate endpoint', async () => { + const action: WorkAction = { code: 'ARRANGE_TRANSPORT', label: '安排运输', targetWorkItemKey: null, target: { kind: 'FLEET_TRIP', tripId: null, batchKeys: ['DISPATCH:9007199254740999'] } } + const wrapper = render(workDetail({ item: workItem({ allowedActions: [action], nextAction: action }) }), action); await flushPromises() + expect(wrapper.findComponent({ name: 'WorkDispatchForm' }).exists()).toBe(false); wrapper.unmount() +}) +it('resolves a customer-warehouse batch using the frozen work item batch number', async () => { + const action: WorkAction = { code: 'ARRANGE_TRANSPORT', label: '安排运输', targetWorkItemKey: null, target: { kind: 'FLEET_TRIP', tripId: null, batchKeys: ['DISPATCH:9007199254740999'] } } + const wrapper = render(workDetail({ item: workItem({ batchKey: 'DISPATCH:9007199254740999', batchNo: 'DS-ORIGINAL', allowedActions: [action], nextAction: action }) }), action); await flushPromises() + expect(wrapper.getComponent({ name: 'WorkExistingDispatchForm' }).props('dispatches')).toEqual([{ batchKey: 'DISPATCH:9007199254740999', dispatchNo: 'DS-ORIGINAL' }]) + expect(wrapper.findComponent({ name: 'WorkDispatchForm' }).exists()).toBe(false); wrapper.unmount() +}) +it('opens real batch dispatch when the server has not bound a trip yet', async () => { + const action: WorkAction = { code: 'ARRANGE_TRANSPORT', label: '安排运输', targetWorkItemKey: null, target: { kind: 'FLEET_TRIP', tripId: null, batchKeys: ['FBA_APPOINTMENT:1'] } } + const wrapper = render(workDetail({ item: workItem({ allowedActions: [action], nextAction: action }) }), action); await flushPromises() + expect(wrapper.getComponent({ name: 'WorkDispatchForm' }).props('batchKeys')).toEqual(['FBA_APPOINTMENT:1']); wrapper.unmount() +}) +it('loads the exact container plan list for a multiple-plan receiving target', async () => { + const action: WorkAction = { code: 'RECORD_EXECUTION', label: '查看接仓', targetWorkItemKey: null, target: { kind: 'WAREHOUSE_RECEIPT', inboundPlanId: null, inboundPlanNo: null, customerId: null, containerBookingId: '9007199254740999' } } + const wrapper = render(workDetail({ item: workItem({ allowedActions: [action], nextAction: action }) }), action); await flushPromises() + expect(wrapper.getComponent({ name: 'WorkReceiptPlans' }).props('containerBookingId')).toBe('9007199254740999'); wrapper.unmount() +}) +it('opens a real create form for unappointed sources with their exact customer scope', async () => { + const action: WorkAction = { code: 'SAVE_PLAN', label: '安排预约', targetWorkItemKey: null, target: { kind: 'FBA_APPOINTMENT', appointmentId: null, batchKey: null, sources: [{ sourceKey: 'BOOKING:1', customerId: 'customer-a', destinationKey: 'FBA:ONT8' }] } } + const wrapper = render(workDetail({ item: workItem({ allowedActions: [action], nextAction: action }) }), action); await flushPromises() + expect(wrapper.getComponent({ name: 'WorkCreateAppointmentForm' }).props('sources')).toEqual(action.target.kind === 'FBA_APPOINTMENT' ? action.target.sources : []) + expect(wrapper.text()).not.toContain('尚未关联可操作的预约记录'); wrapper.unmount() +}) +it('opens the server-selected prerequisite instead of issuing the current item command', async () => { + const action = { ...followAction, targetWorkItemKey: 'work-pickup-1' } + const wrapper = render(workDetail({ item: workItem({ allowedActions: [action], nextAction: action }) }), action); await flushPromises() + expect(wrapper.emitted('open')).toEqual([['work-pickup-1', action]]) + expect(wrapper.findComponent({ name: 'WorkCommandForm' }).exists()).toBe(false); wrapper.unmount() +}) +it('uses explicit cargo context and preserves large customer IDs without inspecting a business key', async () => { + const action: WorkAction = { code: 'VERIFY_CARGO', label: '核实货物资料', targetWorkItemKey: null, target: { kind: 'CARGO_VERIFICATION', source: { customerId: '9007199254740999', sourceKey: 'BOOKING:B-1', destinationKey: 'FBA:ONT8' } } } + const wrapper = render(workDetail({ item: workItem({ allowedActions: [action], nextAction: action }) }), action); await flushPromises() + expect(wrapper.getComponent({ name: 'CargoVerificationForm' }).props('source')).toEqual(action.target.kind === 'CARGO_VERIFICATION' ? action.target.source : null) + expect(wrapper.text()).not.toContain('9007199254740999'); wrapper.unmount() +}) +it('rechecks current qualification before opening a previously offered write action', async () => { + const wrapper = render(workDetail({ item: workItem({ allowedActions: [], nextAction: null, blockReasons: ['预约已由其他人修改'] }) }), followAction); await flushPromises() + expect(wrapper.findComponent({ name: 'WorkCommandForm' }).exists()).toBe(false) + expect(wrapper.text()).toContain('预约已由其他人修改'); wrapper.unmount() +}) +it('continues the real unbatched cargo action into original appointment creation with exact source scope', async () => { + const source = { customerId: 'customer-a', sourceKey: 'BOOKING:9007199254740999', destinationKey: 'FBA:ONT8' } + const action: WorkAction = { code: 'VERIFY_CARGO', label: '核实货物并建立预约', targetWorkItemKey: null, target: { kind: 'CARGO_VERIFICATION', source } } + const wrapper = render(workDetail({ item: workItem({ allowedActions: [action], nextAction: action }) }), action); await flushPromises() + wrapper.getComponent({ name: 'CargoVerificationForm' }).vm.$emit('saved'); await flushPromises() + expect(wrapper.emitted('saved')).toBeUndefined() + await wrapper.findAll('button').find(button => button.text() === '核对货物并新建原预约')!.trigger('click'); await flushPromises() + expect(wrapper.getComponent({ name: 'WorkCreateAppointmentForm' }).props('sources')).toEqual([source]); wrapper.unmount() +}) +it('uses the matched appointment business number and never passes the internal appointment ID to the old API', async () => { + const action: WorkAction = { code: 'CHANGE_BOOKING', label: '调整预约', targetWorkItemKey: null, target: { kind: 'FBA_APPOINTMENT', appointmentId: 'internal-id', batchKey: 'batch-1', sources: [] } } + const detail = workDetail({ item: workItem({ allowedActions: [action], nextAction: action }), references: [{ kind: 'FBA_APPOINTMENT', key: 'batch-1', businessNo: 'AP-TEST', label: '预约' }] }) + const wrapper = render(detail, action); await flushPromises() + expect(wrapper.getComponent({ name: 'WorkAppointmentForm' }).props('appointmentNo')).toBe('AP-TEST'); wrapper.unmount() + const missing = render({ ...detail, references: [] }, action); await flushPromises() + expect(missing.findComponent({ name: 'WorkAppointmentForm' }).exists()).toBe(false) + expect(missing.text()).toContain('尚未关联可操作的预约记录'); missing.unmount() +}) +it('displays all associated containers of a shared trip without creating duplicate transport work', async () => { + const wrapper = render(workDetail({ item: workItem({ type: 'TRANSPORT', containers: [{ containerBookingId: '1', bookingNo: 'D-1', containerNo: 'TEST-CN-A' }, { containerBookingId: '2', bookingNo: 'D-2', containerNo: 'TEST-CN-B' }] }) })); await flushPromises() + expect(wrapper.findAll('.container-links button')).toHaveLength(2) + await wrapper.findAll('.container-links button')[1]!.trigger('click'); expect(wrapper.emitted('container')).toEqual([['2', 'TEST-CN-B']]); wrapper.unmount() +}) diff --git a/src/modules/appointment-calendar/WorkDetailPanel.vue b/src/modules/appointment-calendar/WorkDetailPanel.vue new file mode 100644 index 00000000..c2738988 --- /dev/null +++ b/src/modules/appointment-calendar/WorkDetailPanel.vue @@ -0,0 +1,116 @@ + + +正在读取当前事项与操作资格…
+ +{{ typeLabels[detail.item.type] }} · {{ flowLabels[detail.item.flow] }} · {{ workStatus(detail.item) }}
+{{ workDate(detail.item) }} · {{ detail.item.timezone || '地点时区待核实' }}
{{ detail.item.destinationName || detail.item.originName || '地点待核实' }} · {{ detail.item.owner?.name || '待分派' }}
+{{ workReason(reason) }}
+还空责任:{{ detail.returnHandoff.from.name }} → {{ detail.returnHandoff.to.name }} · {{ detail.returnHandoff.status === 'PENDING_ACCEPTANCE' ? '接手方待确认,持续跟进' : '已确认接手' }}
+ +当前没有可执行的下一步,请根据缺项或前置说明联系负责人。
+ +{{ actionNotice }}
+{{ reference.label }} · {{ reference.businessNo || '—' }}
{{ entry.summary }}
{{ entry.operatorName || '—' }} · {{ entry.occurredAt }}暂无记录
{{ message }}
' }, ApiErrorMeta: true, TripPreparationDialog: { name: 'TripPreparationDialog', props: ['warehouseId', 'dispatchNos'], template: '' } } } }) } +function button(wrapper: ReturnType每次选择同一客户的实物创建排车单,再安排车辆和司机;逐来源数量由服务端候选确定。
+读取批次实物需要预约、尾程订单和入仓单列表权限。
+正在核对批次、客户与实物来源…
+已创建排车单:{{ result.dispatchNo }}
排车结果未提供起运仓,需在原排车单核对。
{{ reason }}
+| 选择 | 托盘码 | 入库单号 | 来源 | 箱数 | 不可选择原因 |
|---|---|---|---|---|---|
| {{ row.palletNo || '—' }} | {{ row.planNo || '—' }} | {{ row.sourceStage === 'RECEIVED' ? '已收货实物' : row.sourceStage === 'PLAN' ? '计划托盘' : '原业务来源' }} | {{ row.packageCount ?? '待核实' }} | {{ reasons(row).join(';') || '可选' }} |
当前批次尚无可排实物。
{{ message }}
' }, ApiErrorMeta: true, TripPreparationDialog: { name: 'TripPreparationDialog', props: ['warehouseId', 'dispatchNos'], template: '' } } } }) } +beforeEach(() => { vi.clearAllMocks(); state.allowed = true; vi.mocked(getDispatch).mockResolvedValue({ id: '9007199254740999', dispatchNo: 'DS-ORIGINAL', overseasWarehouseId: 'origin-1', overseasWarehouseName: '原起运仓', status: 'PENDING_DEPARTURE', packageCount: 12 }) }) +it('reads the original dispatch and passes its existing cargo and origin to trip preparation', async () => { + const wrapper = render(); await flushPromises() + expect(getDispatch).toHaveBeenCalledExactlyOnceWith('DS-ORIGINAL') + await wrapper.findAll('button').find(button => button.text() === '安排原排车单车次')!.trigger('click') + const dialog = wrapper.getComponent({ name: 'TripPreparationDialog' }); expect(dialog.props()).toMatchObject({ warehouseId: 'origin-1', dispatchNos: ['DS-ORIGINAL'] }) + dialog.vm.$emit('saved', { trip: { tripNo: 'TR/001' } }); expect(wrapper.emitted('external')).toEqual([['/overseas-master-data/trips/TR%2F001']]) + expect(wrapper.text()).not.toContain('9007199254740999'); wrapper.unmount() +}) +it('rejects mismatched dispatch identity instead of arranging unrelated cargo', async () => { + vi.mocked(getDispatch).mockResolvedValue({ id: '9007199254740998', dispatchNo: 'DS-ORIGINAL', status: 'PENDING_DEPARTURE' }) + const wrapper = render(); await flushPromises(); expect(wrapper.text()).toContain('排车单与工作批次关联不一致'); expect(wrapper.find('table').exists()).toBe(false); wrapper.unmount() +}) +it('opens an already bound real trip instead of preparing another one', async () => { + vi.mocked(getDispatch).mockResolvedValue({ id: '9007199254740999', dispatchNo: 'DS-ORIGINAL', workflow: { tripNo: 'TR-EXISTING', dispatchReview: { status: 'PENDING', reason: null, reviewedAt: null }, loadingReview: { status: 'PENDING', reason: null, reviewedAt: null } } }) + const wrapper = render(); await flushPromises(); expect(wrapper.text()).not.toContain('安排原排车单车次') + await wrapper.findAll('button').find(button => button.text() === 'TR-EXISTING')!.trigger('click'); expect(wrapper.emitted('external')).toEqual([['/overseas-master-data/trips/TR-EXISTING']]); wrapper.unmount() +}) +it('does not read the dispatch without its original list permission', async () => { + state.allowed = false; const wrapper = render(); await flushPromises(); expect(getDispatch).not.toHaveBeenCalled(); wrapper.unmount() +}) diff --git a/src/modules/appointment-calendar/WorkExistingDispatchForm.vue b/src/modules/appointment-calendar/WorkExistingDispatchForm.vue new file mode 100644 index 00000000..69900479 --- /dev/null +++ b/src/modules/appointment-calendar/WorkExistingDispatchForm.vue @@ -0,0 +1,38 @@ + + +客户仓批次沿用已选实物和原排车单,确认起运仓后安排车次、车辆与司机。
+查看原排车单需要排车列表权限。
正在核对原排车单与批次…
+| 操作 | 排车单号 | 起运仓 | 箱数 | 车次 |
|---|---|---|---|---|
| {{ row.dispatchNo }} | {{ row.overseasWarehouseName || '起运仓待核实' }} | {{ row.packageCount ?? '待核实' }} |
实际收货与卸柜由仓库原作业流程登记;此处进入对应客户的原入库单查看。
读取本柜计划需要尾程订单列表权限。
+{{ detail.summary.containerNo || '柜号待核实' }} · {{ detail.summary.overseasWarehouseName || '接仓仓库待核实' }}
{{ detail.summary.blockingReason }}
| 操作 | 入库单号 | 入仓码 / 订单号 |
|---|---|---|
| {{ row.planNo || '尚未生成有效计划' }} | {{ row.inboundCode || row.orderNo || '—' }} |
本柜尚无可读取的入仓来源。
+{{ preview.containerNo || '柜号待核实' }} · {{ preview.overseasWarehouseName || '接仓仓库待核实' }} · {{ preview.pendingOrderCount }} 票待生成 · {{ preview.blockedOrderCount }} 票待处理
头程已有 {{ preview.sourcePalletCount }} 托;海外补充 {{ preview.supplementPalletCount }} 托;已保存计划 {{ preview.totalPlannedPalletCount }} 托。
| 客户 | 原票 | 目的地 | 箱数 | 目标托数 | 计划状态 |
|---|---|---|---|---|---|
| {{ row.customerName || '客户名称待核实' }} | {{ row.inboundCode || row.businessOrderNo || '—' }} | {{ row.destinationWarehouseName || row.fbaWarehouseCode || (row.destinationType === 'PRIVATE_ADDRESS' ? '客户地址' : '目的地待核实') }} | {{ row.packageCount }} | {{ row.targetPalletCount }} | {{ row.existingPlanNo || ({ READY: '可生成', RESOURCE_PENDING: '资源待确认', PLANNED: '已有计划', BLOCKED: '来源条件未就绪' }[row.readiness]) }} |
本柜尚不具备生成条件,请在原尾程订单核对阻塞来源。
按真实装柜来源逐票登记签收;全部来源完成后由服务端核验还空资格。海外仓接仓仍使用原仓库收货流程。
+正在读取实际装柜来源…
+| 操作 | 入仓码 / 订单号 | 目的地 | 箱数 | 实际卸货 |
|---|---|---|---|---|
| {{ row.inboundCode || row.orderNo || '—' }} | {{ row.destinationName || '—' }} | {{ row.packageCount ?? '—' }} | {{ row.actualAt || '尚未登记' }} {{ row.timezone || '' }} |
当前没有可登记的直送来源。
+该票已登记,签收事实只读。
+正在核对码头拖车当前状态…
+{{ terminal.containerNo || title }} · 当前车队:{{ terminal.fleetSupplierName || '尚未安排' }}
实际提柜:{{ terminal.actualPickupAt || '尚未登记' }}。时间沿用原业务当地时间。
+PDA 合托后,参与合托的入库单会分别展示同一托盘及本单对应箱数;A0 为系统共享溢出位。
原批次已被替代,当前只读。替代预约:{{ appointmentReplacementNos.join('、') }}
正在核对码头拖车当前状态…
{{ terminal.containerNo || title }} · 当前车队:{{ terminal.fleetSupplierName || '尚未安排' }}
实际提柜:{{ terminal.actualPickupAt || '尚未登记' }}。时间沿用原业务当地时间。
{{ message }}
' }, ApiErrorMeta: true } } }) } beforeEach(() => { vi.clearAllMocks(); access.write = true; vi.mocked(previewPlanningBatch).mockResolvedValue(preview); vi.mocked(createSourceAppointment).mockResolvedValue(batchContext().appointment) }) +it('shows readable preview requirements without exposing missing-field machine codes', async () => { + vi.mocked(previewPlanningBatch).mockResolvedValue({ ...preview, missingFields: ['READY_WINDOW_UNVERIFIED', 'CARGO_QUANTITY_REVIEW'] }) + const wrapper = render(); await flushPromises() + expect(wrapper.text()).toContain('货物备妥窗口待核实') + expect(wrapper.text()).toContain('货量或重复关联需要核对。') + expect(wrapper.text()).not.toContain('READY_WINDOW_UNVERIFIED') + expect(wrapper.text()).not.toContain('CARGO_QUANTITY_REVIEW') + wrapper.unmount() +}) it('previews and creates the exact canonical external source and customer without inventing times', async () => { const wrapper = render(); await flushPromises() expect(previewPlanningBatch).toHaveBeenCalledWith(sources) diff --git a/src/modules/appointment-calendar/WorkCreateAppointmentForm.vue b/src/modules/appointment-calendar/WorkCreateAppointmentForm.vue index 1d169c5a..beef2652 100644 --- a/src/modules/appointment-calendar/WorkCreateAppointmentForm.vue +++ b/src/modules/appointment-calendar/WorkCreateAppointmentForm.vue @@ -9,6 +9,7 @@ import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http' import { previewPlanningBatch, createSourceAppointment, type PlanningBatchPreview, type SourceAppointmentPayload } from './planningApi' import type { SourceReference } from './workApi' import { rejectedCommand, useWorkDraft } from './useWorkDraft' +import { workReason } from './workPresentation' const props = defineProps<{ sources: SourceReference[]; canWrite: boolean }>() const emit = defineEmits<{ saved: [] }>() const access = useAccessPreviewStore(), preview = ref{{ preview.originWarehouseName }} → {{ preview.destinationWarehouseCode }} · {{ preview.group.sourceCount }} 票 · {{ preview.group.packages ?? '箱数待核实' }}
{{ source.inboundCode || source.bookingNo || '原票业务号未提供' }}
-{{ field }}
+{{ workReason(field) }}
货物备妥窗口待核实。
{{ linkError }}