-
+
@@ -158,12 +158,11 @@ onBeforeUnmount(() => { active = false; ++requestSequence })
最终国内收件资料
-
-
+
diff --git a/src/modules/dashboard/views/DashboardView.vue b/src/modules/dashboard/views/DashboardView.vue
index 21d97b3..3f32c2c 100644
--- a/src/modules/dashboard/views/DashboardView.vue
+++ b/src/modules/dashboard/views/DashboardView.vue
@@ -13,8 +13,8 @@ const primaryActions = computed(() => [
{ title: '美本土散板', description: '拼车拼载 · 打盘服务 · 灵活派送', icon: Forklift, route: '/cross-border/pallet-shipping/create', permission: 'merchant:cross-border:pallet-shipping:create', tone: 'cyan', featured: false },
{ title: '海外采购', description: '采购代购 · 验货质检 · 集中发货', icon: ShoppingCart, route: '/cross-border/procurement/create', permission: 'merchant:cross-border:procurement:create', tone: 'orange', featured: false },
{ title: '贴标申请', description: '产品建档 · 标签上传 · 批量贴标', icon: Tags, route: '/overseas-warehouse/labeling/create', permission: 'merchant:overseas-warehouse:relabel:create', tone: 'rose', featured: false },
- { title: '仓储转运', description: '仓储管理 · 货物转运', icon: Warehouse, route: '/overseas-warehouse/storage-transfer', permission: 'merchant:overseas-warehouse:transfer:list', tone: 'teal', featured: false, actionLabel: '开始办理' },
- { title: '货物废弃', description: '废弃申请 · 处理进度', icon: PackageOpen, route: '/overseas-warehouse/cargo-disposal', permission: 'merchant:overseas-warehouse:discard:list', tone: 'brown', featured: false, actionLabel: '开始办理' },
+ { title: '仓储转运', description: '仓储管理 · 货物转运', icon: Warehouse, route: '/overseas-warehouse/storage-transfer/create', permission: 'merchant:overseas-warehouse:transfer:create', tone: 'teal', featured: false, actionLabel: '开始办理' },
+ { title: '货物废弃', description: '废弃申请 · 处理进度', icon: PackageOpen, route: '/overseas-warehouse/cargo-disposal/create', permission: 'merchant:overseas-warehouse:discard:create', tone: 'brown', featured: false, actionLabel: '开始办理' },
].filter((action) => auth.hasPermission(action.permission)))
diff --git a/src/modules/first-leg/api/chargeableWeights.test.mjs b/src/modules/first-leg/api/chargeableWeights.test.mjs
new file mode 100644
index 0000000..3c60a8d
--- /dev/null
+++ b/src/modules/first-leg/api/chargeableWeights.test.mjs
@@ -0,0 +1,20 @@
+import assert from 'node:assert/strict'
+import test from 'node:test'
+import { readFileSync } from 'node:fs'
+import ts from 'typescript'
+
+test('计费重确认保留大整数来源和原幂等键,只提交预期重量版本', async () => {
+ const calls = []
+ const source = readFileSync(new URL('./chargeableWeights.ts', import.meta.url), 'utf8')
+ const code = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS } }).outputText
+ const exports = {}
+ new Function('require', 'exports', code)(() => ({ apiRequest: async request => { calls.push(request); return [] } }), exports)
+ const id = '9223372036854775806'
+ await exports.getChargeableWeights(id)
+ await exports.confirmChargeableWeight({ inboundId: id, fingerprint: 'abc' }, 'original-command')
+ assert.equal(calls[0].url, `/merchant/v1/first-leg/orders/${id}/chargeable-weights`)
+ assert.equal(calls[1].url, `/merchant/v1/first-leg/chargeable-weights/${id}/confirm`)
+ assert.deepEqual(calls[1].headers, { 'Idempotency-Key': 'original-command' })
+ assert.deepEqual(calls[1].data, { expectedFingerprint: 'abc' })
+ assert.equal(calls.some(call => call.url.includes('inbound-billing')), false)
+})
diff --git a/src/modules/first-leg/api/chargeableWeights.ts b/src/modules/first-leg/api/chargeableWeights.ts
new file mode 100644
index 0000000..f34ce49
--- /dev/null
+++ b/src/modules/first-leg/api/chargeableWeights.ts
@@ -0,0 +1,16 @@
+import { apiRequest } from '@/api/http'
+export interface ChargeableWeight {
+ inboundId: string
+ orderId: string
+ chargeableWeightKg: number
+ fingerprint: string
+ confirmed: boolean
+ confirmedAt: string | null
+}
+export function getChargeableWeights(orderId: string) {
+ return apiRequest
({ method: 'GET', url: `/merchant/v1/first-leg/orders/${encodeURIComponent(orderId)}/chargeable-weights` })
+}
+export function confirmChargeableWeight(weight: ChargeableWeight, key: string) {
+ return apiRequest({ method: 'POST', url: `/merchant/v1/first-leg/chargeable-weights/${encodeURIComponent(weight.inboundId)}/confirm`,
+ headers: { 'Idempotency-Key': key }, data: { expectedFingerprint: weight.fingerprint } })
+}
diff --git a/src/modules/first-leg/components/ChargeableWeightDialog.test.mjs b/src/modules/first-leg/components/ChargeableWeightDialog.test.mjs
new file mode 100644
index 0000000..2d09f05
--- /dev/null
+++ b/src/modules/first-leg/components/ChargeableWeightDialog.test.mjs
@@ -0,0 +1,48 @@
+import assert from 'node:assert/strict'
+import test from 'node:test'
+import { readFileSync } from 'node:fs'
+import ts from 'typescript'
+import { ref } from 'vue'
+
+const flush = async () => { for (let i = 0; i < 8; i++) await Promise.resolve() }
+function harness(api, props = { orderId: 'one', canConfirm: true }) {
+ let changed, disposed
+ const events = []
+ const source = readFileSync(new URL('./ChargeableWeightDialog.vue', import.meta.url), 'utf8').split('')[0]
+ const code = ts.transpileModule(source + '\nexports.state = { rows, loading, saving, error, confirm, load };', {
+ compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 },
+ }).outputText
+ const exports = {}
+ new Function('require', 'exports', 'defineProps', 'defineEmits', code)(name => {
+ if (name === 'vue') return { ref, watch: (_, callback) => { changed = callback; callback() }, onBeforeUnmount: callback => { disposed = callback } }
+ if (name === 'element-plus') return { ElMessage: { success() {} } }
+ return api
+ }, exports, () => props, () => name => events.push(name))
+ return { ...exports.state, props, change: () => changed(), dispose: () => disposed(), events }
+}
+
+test('确认结果未知时重试原命令,成功后回查并通知工作台刷新', async () => {
+ const row = { inboundId: '100', fingerprint: 'weight-v1', chargeableWeightKg: 50, confirmed: false }
+ const commands = []
+ let fail = true
+ const h = harness({ getChargeableWeights: async () => [row], confirmChargeableWeight: async (_, key) => {
+ commands.push(key); if (fail) throw new Error('响应丢失'); row.confirmed = true
+ } })
+ await flush(); await h.confirm(row)
+ assert.equal(h.error.value, '响应丢失'); assert.equal(h.saving.value, '')
+ fail = false; await h.confirm(row)
+ assert.equal(commands[0], commands[1]); assert.deepEqual(h.events, ['confirmed'])
+ assert.equal(h.rows.value[0].confirmed, true); assert.equal(h.saving.value, '')
+})
+
+test('切换订单或关闭后丢弃迟到的读取结果,无权限不能确认', async () => {
+ let resolveOld
+ const h = harness({ getChargeableWeights: order => order === 'one' ? new Promise(resolve => { resolveOld = resolve }) : Promise.resolve([]),
+ confirmChargeableWeight: () => { throw new Error('无权限不得写入') } }, { orderId: 'one', canConfirm: false })
+ h.props.orderId = 'two'; h.change(); await flush()
+ resolveOld([{ inboundId: 'old' }]); await flush()
+ assert.deepEqual(h.rows.value, [])
+ await h.confirm({ inboundId: 'old', fingerprint: 'old' })
+ assert.deepEqual(h.events, [])
+ h.dispose()
+})
diff --git a/src/modules/first-leg/components/ChargeableWeightDialog.vue b/src/modules/first-leg/components/ChargeableWeightDialog.vue
new file mode 100644
index 0000000..f8ab6a8
--- /dev/null
+++ b/src/modules/first-leg/components/ChargeableWeightDialog.vue
@@ -0,0 +1,55 @@
+
+
+
+
+ 重新加载
+
+
+
+ 来源入仓单:{{ row.inboundId }}
计费重:{{ row.chargeableWeightKg }} kg
+ 已确认
+ 确认计费重
+ 待确认
+
+
+
+
+
diff --git a/src/modules/first-leg/components/InboundBillingPanel.test.mjs b/src/modules/first-leg/components/InboundBillingPanel.test.mjs
index d6ebbff..868609c 100644
--- a/src/modules/first-leg/components/InboundBillingPanel.test.mjs
+++ b/src/modules/first-leg/components/InboundBillingPanel.test.mjs
@@ -20,7 +20,7 @@ test('supports a standalone dialog and keeps the unpushed billing-weight state v
assert.match(source, /watch\(\(\) => props\.autoOpen/)
assert.match(source, /if \(autoOpen\) feeDialogOpen\.value = true/)
assert.match(source, /v-else-if="props\.unavailable \|\| !props\.data"/)
- assert.match(source, /平台推送计费重后,可在这里核对并确认/)
+ assert.match(source, /平台推送费用后,可在这里核对并确认/)
assert.match(source, /@closed="emit\('closed'\)"/)
})
diff --git a/src/modules/first-leg/components/InboundBillingPanel.vue b/src/modules/first-leg/components/InboundBillingPanel.vue
index 1027174..dfc2873 100644
--- a/src/modules/first-leg/components/InboundBillingPanel.vue
+++ b/src/modules/first-leg/components/InboundBillingPanel.vue
@@ -198,19 +198,19 @@ async function confirmFee() {
-
+
- 正在加载计费重
+ 正在加载费用
-
计费重加载失败{{ props.error }}
+
费用加载失败{{ props.error }}
重新加载
-
{{ props.unavailableMessage }}平台推送计费重后,可在这里核对并确认。
+
{{ props.unavailableMessage }}平台推送费用后,可在这里核对并确认。
diff --git a/src/modules/first-leg/views/FirstLegOrdersView.test.mjs b/src/modules/first-leg/views/FirstLegOrdersView.test.mjs
index 40d0e2d..75afed4 100644
--- a/src/modules/first-leg/views/FirstLegOrdersView.test.mjs
+++ b/src/modules/first-leg/views/FirstLegOrdersView.test.mjs
@@ -623,7 +623,7 @@ test('loads merchant inbound billing as an isolated read-only detail section', (
assert.match(source, /error instanceof ApiError && error\.code === 'A0404'/)
assert.match(source, /error\.message \|\| unavailableMessage/)
assert.match(source, /order\.status === 'DRAFT'/)
- assert.match(source, /feeConfirmationOpen\.value[\s\S]*\? '未推送计费重'[\s\S]*: '订单提交后可查看入库结果'/)
+ assert.match(source, /feeConfirmationOpen\.value[\s\S]*\? '未推送费用'[\s\S]*: '订单提交后可查看入库结果'/)
assert.match(source, /订单提交后可查看入库结果/)
assert.doesNotMatch(source, /confirmMerchantInbound|adjustMerchantInbound|sendMerchantInbound/)
})
@@ -636,10 +636,10 @@ test('keeps platform enterprise scope and tenant terminology out of the customer
test('opens billing-weight confirmation independently from order details', () => {
const handler = source.slice(source.indexOf('function openFeeConfirmation'), source.indexOf('async function retryDetail'))
assert.match(handler, /feeConfirmationOpen\.value = true/)
- assert.match(handler, /loadInboundBilling\(order, '未推送计费重'\)/)
+ assert.match(handler, /loadInboundBilling\(order, '未推送费用'\)/)
assert.doesNotMatch(handler, /openDetail\(/)
assert.match(source, /v-if="feeConfirmationOpen"[\s\S]*dialog-only[\s\S]*auto-open/)
- assert.match(source, /unavailable-message="未推送计费重"/)
+ assert.match(source, /unavailable-message="未推送费用"/)
})
test('shows billing-weight confirmation only when the backend marks it pending', () => {
diff --git a/src/modules/first-leg/views/FirstLegOrdersView.vue b/src/modules/first-leg/views/FirstLegOrdersView.vue
index fe8dd77..3ddba92 100644
--- a/src/modules/first-leg/views/FirstLegOrdersView.vue
+++ b/src/modules/first-leg/views/FirstLegOrdersView.vue
@@ -9,6 +9,7 @@ import DataTable, { type DataTableColumn } from '@/components/common/DataTable.v
import type { TableAction } from '@/components/common/tableActions'
import { ApiError } from '@/api/http'
import FirstLegOrderEditorShell from '@/modules/first-leg/components/FirstLegOrderEditorShell.vue'
+import ChargeableWeightDialog from '@/modules/first-leg/components/ChargeableWeightDialog.vue'
import InboundBillingPanel from '@/modules/first-leg/components/InboundBillingPanel.vue'
import InboundCodeAssociationDialog from '@/modules/first-leg/components/InboundCodeAssociationDialog.vue'
import OrderProgressPanel from '@/modules/first-leg/components/OrderProgressPanel.vue'
@@ -267,6 +268,7 @@ const inboundBillingUnavailableMessage = ref('还未测量推送')
const inboundBillingError = ref('')
const inboundBillingOrderId = ref('')
const feeConfirmationOpen = ref(false)
+const weightConfirmationOrderId = ref('')
const customsByOrder = reactive>({})
const customsEligibilityPending = new Set()
const customsDialogOpen = ref(false)
@@ -1361,8 +1363,11 @@ function getOrderRowActions(row: object): TableAction[] {
if (canCreateOrder.value) {
actions.push({ key: 'copy', label: '复制下单', loading: copyingOrderId.value === order.id, run: () => copyOrder(order) })
}
+ if (canConfirmInboundFee.value && !['DRAFT', 'CANCELLED', 'REJECTED', 'SUBMITTED'].includes(order.status)) {
+ actions.push({ key: 'weight', label: '确认计费重', run: () => { weightConfirmationOrderId.value = order.id } })
+ }
if (canConfirmInboundFee.value && order.businessSummary.customerFeeConfirmationRequired) {
- actions.push({ key: 'fee', label: '确认计费重', run: () => openFeeConfirmation(order) })
+ actions.push({ key: 'fee', label: '确认费用', run: () => openFeeConfirmation(order) })
}
if (canEditOrder.value && canClientEditFirstLegOrder(order.status)) {
actions.push({ key: 'edit', label: '编辑', loading: openingEditor.value, run: () => openEdit(order) })
@@ -1471,9 +1476,9 @@ async function loadDetailPage(orderId: string) {
function openFeeConfirmation(order: FirstLegOrder) {
resetInboundBillingState()
- inboundBillingUnavailableMessage.value = '未推送计费重'
+ inboundBillingUnavailableMessage.value = '未推送费用'
feeConfirmationOpen.value = true
- void loadInboundBilling(order, '未推送计费重')
+ void loadInboundBilling(order, '未推送费用')
}
async function retryDetail() {
@@ -1508,7 +1513,7 @@ async function loadInboundBilling(order: FirstLegOrder, unavailableMessage = '
if (order.status === 'DRAFT') {
inboundBillingUnavailable.value = true
inboundBillingUnavailableMessage.value = feeConfirmationOpen.value
- ? '未推送计费重'
+ ? '未推送费用'
: '订单提交后可查看入库结果'
return
}
@@ -1523,7 +1528,7 @@ async function loadInboundBilling(order: FirstLegOrder, unavailableMessage = '
if (error instanceof ApiError && error.code === 'A0404') {
inboundBillingUnavailable.value = true
inboundBillingUnavailableMessage.value = feeConfirmationOpen.value
- ? '未推送计费重'
+ ? '未推送费用'
: error.message || unavailableMessage
} else {
inboundBillingError.value = error instanceof Error ? error.message : '入库与费用加载失败'
@@ -1535,7 +1540,7 @@ async function loadInboundBilling(order: FirstLegOrder, unavailableMessage = '
async function retryInboundBilling() {
const order = inboundBillingOrderId.value ? store.getById(inboundBillingOrderId.value) : undefined
- if (order) await loadInboundBilling(order, feeConfirmationOpen.value ? '未推送计费重' : '还未测量推送')
+ if (order) await loadInboundBilling(order, feeConfirmationOpen.value ? '未推送费用' : '还未测量推送')
}
function handleInboundFeeConfirmed() {
@@ -2548,13 +2553,14 @@ async function searchOrders(model: QueryModel) {
:data="inboundBilling"
:loading="inboundBillingLoading"
:unavailable="inboundBillingUnavailable"
- unavailable-message="未推送计费重"
+ unavailable-message="未推送费用"
:error="inboundBillingError"
:can-confirm="canConfirmInboundFee"
@retry="retryInboundBilling"
@confirmed="handleInboundFeeConfirmed"
@closed="feeConfirmationOpen = false"
/>
+
{ if (!value || previous === '') r
SAILING PRICE INQUIRY船期价格查询
重量或体积填写一项即可,系统会按计价规则自动换算
KG
CBM
立即查询
{{ recommendation(item)?.label }}
{{ item.channel.channelName }}{{ item.quote.pricingTaxMode === 'TAX_INCLUDED' ? '包税' : '不包税' }} / {{ item.quote.chargeUnit || 'CBM' }}
{{ formatMoney(item.quote) }}预估运费
下单
-
匹配渠道
{{ channels.length }} 个渠道 · 参考价格以服务端估价为准 {{ row.channelName }}{{ row.channelCode }}{{ row.estimatedDepartureText || row.estimatedDepartureDate || '待更新' }}{{ row.latestWarehouseEntry || '—' }}{{ row.referenceTransit || '—' }}{{ formatMoney(quotes.find((quote) => quote.channelCode === row.channelCode)!) }}查询后显示下单
+
匹配渠道
{{ channels.length }} 个渠道 · 参考价格以服务端估价为准 {{ row.channelName }}{{ row.channelCode }}{{ row.estimatedDepartureText || row.estimatedDepartureDate || '待更新' }}{{ row.latestWarehouseEntry || '—' }}{{ row.referenceTransit || '—' }}{{ formatMoney(quotes.find((quote) => quote.channelCode === row.channelCode)!) }}查询后显示
diff --git a/src/modules/overseas-warehouse/api/relabelQuantities.ts b/src/modules/overseas-warehouse/api/relabelQuantities.ts
new file mode 100644
index 0000000..77827ef
--- /dev/null
+++ b/src/modules/overseas-warehouse/api/relabelQuantities.ts
@@ -0,0 +1,21 @@
+import { apiRequest } from '@/api/http'
+
+export interface RelabelQuantity {
+ labelPieceCount: number | string | null
+ cartonCount: number | string | null
+ skuCount: number | string
+ duplicateCarton: boolean
+}
+export interface RelabelQuantityTotals {
+ orderCount: number | string
+ skuCount: number | string
+ knownLabelPieceCount: number | string
+ unknownPieceOrders: number | string
+ knownCartonCount: number | string
+ unknownCartonOrders: number | string
+ orders: Record
+}
+/** 客户范围来自会话,数量汇总覆盖全部筛选结果。 */
+export function getRelabelQuantities(params: Record) {
+ return apiRequest({ method: 'GET', url: '/merchant/v1/last-leg/relabel-applications/quantity-summary', params })
+}
diff --git a/src/modules/overseas-warehouse/components/DiscardStatistics.vue b/src/modules/overseas-warehouse/components/DiscardStatistics.vue
new file mode 100644
index 0000000..69e1b70
--- /dev/null
+++ b/src/modules/overseas-warehouse/components/DiscardStatistics.vue
@@ -0,0 +1,25 @@
+
+
+ 全筛选:{{ statistics.orderCount }} 票 · {{ statistics.skuCount }} 种 SKU · 申请 {{ statistics.requestedPackages }} 箱 · 已确认实际 {{ quantity(statistics.actualPackages, '箱') }} · 商品 {{ quantity(statistics.requestedPieces, '个') }} · 托盘 {{ quantity(statistics.palletCount, '托盘') }}
+ {{ error ? '数量汇总加载失败,请重新查询' : '数量汇总加载中…' }}
+
+
diff --git a/src/modules/overseas-warehouse/components/RelabelQuantityPanel.vue b/src/modules/overseas-warehouse/components/RelabelQuantityPanel.vue
new file mode 100644
index 0000000..8eb7fe3
--- /dev/null
+++ b/src/modules/overseas-warehouse/components/RelabelQuantityPanel.vue
@@ -0,0 +1,37 @@
+
+
+
+ {{ error }}
+
+ {{ relabelNo ? '本单数量' : '全部筛选结果' }}
+ {{ data.orderCount }} 票{{ data.skuCount }} 种 SKU
+ 贴标件数:{{ dimension(Number(data.knownLabelPieceCount), Number(data.unknownPieceOrders), '件') }}
+ 箱数:{{ dimension(Number(data.knownCartonCount), Number(data.unknownCartonOrders), '箱') }}
+ 存在重复箱唛关联,箱数已去重,贴标件数待核查。
+
+ 数量加载中…
+
+
+
diff --git a/src/modules/overseas-warehouse/components/TransferPaneDivider.vue b/src/modules/overseas-warehouse/components/TransferPaneDivider.vue
new file mode 100644
index 0000000..31233b5
--- /dev/null
+++ b/src/modules/overseas-warehouse/components/TransferPaneDivider.vue
@@ -0,0 +1,52 @@
+
+
+
+
+
+
+
+
+
diff --git a/src/modules/overseas-warehouse/routes.ts b/src/modules/overseas-warehouse/routes.ts
index f9a225c..392c0de 100644
--- a/src/modules/overseas-warehouse/routes.ts
+++ b/src/modules/overseas-warehouse/routes.ts
@@ -5,6 +5,17 @@ export const overseasWarehouseModule = {
key: 'overseas-warehouse',
navigationGroup: { title: '海外仓', icon: Warehouse },
routes: [
+ {
+ path: 'overseas-warehouse/storage-transfer/create', name: 'overseas-storage-transfer-create',
+ component: () => import('./views/WarehouseServiceView.vue'), props: { service: 'transfer', createPage: true }, release: 'active',
+ meta: { title: '新增仓储转运单', workspaceTitle: '仓储转运 · 新建', description: '填写并提交仓储转运申请', permission: 'merchant:overseas-warehouse:transfer:create', keepAlive: false },
+ },
+ {
+ path: 'overseas-warehouse/cargo-disposal/create', name: 'overseas-cargo-disposal-create',
+ component: () => import('./views/WarehouseServiceView.vue'), props: { service: 'disposal', createPage: true }, release: 'active',
+ meta: { title: '新增货物废弃申请', workspaceTitle: '货物废弃 · 新建', description: '填写并提交货物废弃申请', permission: 'merchant:overseas-warehouse:discard:create', keepAlive: false },
+ },
+
{
path: 'overseas-warehouse/labeling/create', name: 'overseas-labeling-create',
component: () => import('./views/WarehouseServiceView.vue'), props: { service: 'labeling', createPage: true }, release: 'active',
diff --git a/src/modules/overseas-warehouse/utils/discardDocuments.test.mjs b/src/modules/overseas-warehouse/utils/discardDocuments.test.mjs
index 042799f..7b11798 100644
--- a/src/modules/overseas-warehouse/utils/discardDocuments.test.mjs
+++ b/src/modules/overseas-warehouse/utils/discardDocuments.test.mjs
@@ -36,3 +36,16 @@ test('缺少箱级记录时合并仓位可用箱数并保持全部废弃明细',
assert.equal(result.cartonSelectionSupported, false)
assert.equal(result.sources.length, 2)
})
+
+test('接口 Long 字符串按数值相加,不串联数字', () => {
+ const source = { ...returned('1'), sourceType: 'INVENTORY', cartonSelectionSupported: false }
+ const sources = ['50', '100', '97'].map((availablePackages, index) => ({ ...source, orderId: `inventory:${index}`, returnCartonId: undefined, availablePackages }))
+ assert.equal(groupDiscardDocuments(sources)[0].availablePackages, 247)
+ assert.equal(groupDiscardDocuments([{ ...source, orderId: 'i:1', returnCartonId: undefined, availablePackages: '125' }, { ...source, orderId: 'i:2', returnCartonId: undefined, availablePackages: '125' }])[0].availablePackages, 250)
+})
+
+test('相同仓位重复关联只计一次,零值与未知可用量不进入可选来源', () => {
+ const source = { ...returned('1'), returnCartonId: undefined, sourceType: 'INVENTORY', inventoryPositionId: 'position:1', cartonSelectionSupported: false, availablePackages: 2 }
+ assert.equal(groupDiscardDocuments([source, source])[0].availablePackages, 2)
+ for (const availablePackages of [0, null, undefined, 'invalid']) assert.equal(groupDiscardDocuments([{ ...source, availablePackages }]).length, 0)
+})
diff --git a/src/modules/overseas-warehouse/utils/discardDocuments.ts b/src/modules/overseas-warehouse/utils/discardDocuments.ts
index 8e876b8..c094516 100644
--- a/src/modules/overseas-warehouse/utils/discardDocuments.ts
+++ b/src/modules/overseas-warehouse/utils/discardDocuments.ts
@@ -7,7 +7,15 @@ export interface DiscardDocument extends DiscardCandidate {
/** 下拉按来源单据和操作仓汇总;原始库存身份保留给选箱与提交。 */
export function groupDiscardDocuments(candidates: DiscardCandidate[]): DiscardDocument[] {
const groups = new Map()
+ const identities = new Set()
for (const candidate of candidates) {
+ const available = Number(candidate.availablePackages)
+ if (candidate.availablePackages == null || !Number.isFinite(available) || available <= 0) continue
+ // 按真实来源身份去重;缺身份的旧接口行仍独立保留,不合并不同库存。
+ const identity = candidate.sourceType === 'RETURN_CARTON' ? candidate.returnCartonId : candidate.inventoryPositionId || candidate.orderId
+ const sourceKey = identity ? JSON.stringify([candidate.overseasWarehouseId, candidate.sourceType, identity]) : null
+ if (sourceKey && identities.has(sourceKey)) continue
+ if (sourceKey) identities.add(sourceKey)
const documentNo = candidate.inboundOrderNo || candidate.inboundCode
const key = JSON.stringify([candidate.overseasWarehouseId, candidate.sourceType, documentNo || candidate.orderId])
let document = groups.get(key)
@@ -27,7 +35,9 @@ export function groupDiscardDocuments(candidates: DiscardCandidate[]): DiscardDo
} else if (source.cartonSelectionSupported && source.cartons?.length) {
for (const box of source.cartons) boxes.add(`${source.sourceType}:${box.cartonId}`)
} else {
- untrackedPackages += source.availablePackages
+ // Long 序列化可能返回字符串,必须先转数值再汇总,避免拼成 005010097。
+ const packages = Number(source.availablePackages)
+ untrackedPackages += Number.isFinite(packages) && packages > 0 ? packages : 0
}
}
document.availablePackages = boxes.size + untrackedPackages
diff --git a/src/modules/overseas-warehouse/utils/returnReceiptDisplay.test.mjs b/src/modules/overseas-warehouse/utils/returnReceiptDisplay.test.mjs
index b9d6bc3..32adc56 100644
--- a/src/modules/overseas-warehouse/utils/returnReceiptDisplay.test.mjs
+++ b/src/modules/overseas-warehouse/utils/returnReceiptDisplay.test.mjs
@@ -20,6 +20,14 @@ test('签收箱号只包含箱子和托盘签收,包裹实物记录不重复
assert.deepEqual(exports.receiptBoxDetails(receipts.slice(0, 1), cartons), [])
})
+test('零箱、未记录箱数和包裹不补造一箱,缺少箱数时可使用真实标签', () => {
+ assert.equal(exports.receiptPrintableBoxCount([{ unitType: 'BOX', boxCount: 0, labels: [] }]), 0)
+ assert.equal(exports.receiptPrintableBoxCount([{ unitType: 'PALLET', boxCount: null, labels: [] }]), 0)
+ assert.equal(exports.receiptPrintableBoxCount([{ unitType: 'PARCEL', boxCount: 1, labels: [{}] }]), 0)
+ assert.equal(exports.receiptPrintableBoxCount([{ unitType: 'PALLET', boxCount: null, labels: [{}, {}] }]), 2)
+ assert.equal(exports.receiptPrintableBoxCount([{ boxCount: -1 }, { boxCount: 'bad' }]), 0)
+})
+
test('volume rounds upward to exactly three decimal places without increasing exact thousandths', () => {
for (const [input, expected] of [[0.186624, '0.187'], [0.15, '0.150'], [1.001, '1.001'], [1.00100001, '1.002'], [0.0000001, '0.001'], [0, '0.000'], [null, '—'], [NaN, '—']]) {
assert.equal(exports.formatReturnVolume(input), expected)
diff --git a/src/modules/overseas-warehouse/utils/returnReceiptDisplay.ts b/src/modules/overseas-warehouse/utils/returnReceiptDisplay.ts
index 25a137d..2f5e1f7 100644
--- a/src/modules/overseas-warehouse/utils/returnReceiptDisplay.ts
+++ b/src/modules/overseas-warehouse/utils/returnReceiptDisplay.ts
@@ -1,7 +1,12 @@
import type { ReturnPallet, ReturnReceipt, ReturnReceiptCarton } from '../api/returnOrders'
export function receiptPrintableBoxCount(receipts: ReturnReceipt[]) {
- return receipts.reduce((total, receipt) => total + Number(receipt.labels?.length || receipt.boxCount || 1), 0)
+ // 只累计真实箱数;缺少箱数时使用已生成标签,不凭空补一箱。
+ return receipts.reduce((total, receipt) => {
+ if (receipt.unitType === 'PARCEL') return total
+ const count = Number(receipt.boxCount ?? receipt.labels?.length ?? 0)
+ return total + (Number.isSafeInteger(count) && count >= 0 ? count : 0)
+ }, 0)
}
export function receiptPhysicalDetails(pallets: ReturnPallet[], boxes: ReturnReceiptCarton[]) {
diff --git a/src/modules/overseas-warehouse/views/ReturnOrdersView.test.mjs b/src/modules/overseas-warehouse/views/ReturnOrdersView.test.mjs
index 39b6251..d397cc0 100644
--- a/src/modules/overseas-warehouse/views/ReturnOrdersView.test.mjs
+++ b/src/modules/overseas-warehouse/views/ReturnOrdersView.test.mjs
@@ -77,11 +77,10 @@ test('新建退货预报使用独立路由页面,编辑仍保留加宽弹框',
assert.match(view, /class="return-create-actions"[\s\S]*?提交预报<\/el-button>/)
})
-test('新建预报铺满工作区,联系字段与数量输入保持紧凑', () => {
+test('新建预报铺满工作区,基础字段等宽对齐且数量输入保持紧凑', () => {
assert.match(view, /return-create-surface \{ width:100%; min-width:0;[^}]*margin:0;/)
assert.match(view, //)
- assert.match(view, /return-create-form :deep\(\.el-input\) \{ max-width:320px;/)
- assert.match(view, /return-create-form :deep\(\.el-select\) \{ max-width:520px;/)
+ assert.match(view, /return-create-form :deep\(\.el-input\), \.return-create-body \.return-create-form :deep\(\.el-select\) \{ width:100%; max-width:none;/)
assert.match(view, /:deep\(\.el-table \.el-input-number\) \{ width:120px;/)
assert.match(view, /]*>/)
assert.match(view, /return-forecast-table \{[^}]*border-radius:8px;/)
diff --git a/src/modules/overseas-warehouse/views/ReturnOrdersView.vue b/src/modules/overseas-warehouse/views/ReturnOrdersView.vue
index 586915e..81fc567 100644
--- a/src/modules/overseas-warehouse/views/ReturnOrdersView.vue
+++ b/src/modules/overseas-warehouse/views/ReturnOrdersView.vue
@@ -484,16 +484,15 @@ onBeforeUnmount(clearPhotoUrls)
.relabel-suborder { display: flex; align-items: center; gap: 8px; min-height: 30px; white-space: nowrap; }
.return-orders-page { display:grid; gap:12px; } .return-create-form { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); column-gap:20px; } .return-form-wide { grid-column:1 / -1; } .items-title { display:flex; justify-content:space-between; align-items:center; margin:16px 0 4px; font-weight:600; } h3 { margin:24px 0 10px; font-size:16px; } h4 { margin:16px 0 8px; font-size:14px; } .remark { white-space:pre-wrap; } .warehouse-error, .duplicate-sku-error { margin-top:6px; color:var(--el-color-danger); font-size:12px; } :deep(.duplicate-sku-input .el-input__wrapper) { box-shadow:0 0 0 1px var(--el-color-danger) inset; } :deep(.el-dialog .el-select) { width:100%; }
.return-create-surface { width:100%; min-width:0; min-height:calc(100dvh - 198px); margin:0; box-sizing:border-box; overflow:hidden; border:1px solid var(--client-border); border-radius:var(--client-radius); background:#fff; }
-.return-create-header, .return-create-body, .return-create-actions { width:calc(100% - 24px); max-width:1118px; margin-left:24px; box-sizing:border-box; }
+.return-create-header, .return-create-body, .return-create-actions { width:100%; min-width:0; box-sizing:border-box; }
.return-create-header { display:flex; min-height:68px; align-items:center; justify-content:space-between; gap:20px; padding:12px 20px; border-bottom:1px solid var(--client-border); }
.return-create-header h2 { margin:0; color:#1f2d45; font-size:20px; }
.return-create-body { padding:18px 20px 14px; }
.return-create-body :deep(.el-select), .return-create-body :deep(.el-input-number) { width:100%; }
-.return-create-body .return-create-form { max-width:900px; column-gap:24px; }
+.return-create-body .return-create-form { width:100%; column-gap:24px; }
.return-create-body .return-create-form :deep(.el-form-item) { margin-bottom:16px; }
.return-create-body .return-create-form :deep(.el-form-item__content) { min-width:0; }
-.return-create-body .return-create-form :deep(.el-input) { max-width:320px; }
-.return-create-body .return-create-form :deep(.el-select) { max-width:520px; }
+.return-create-body .return-create-form :deep(.el-input), .return-create-body .return-create-form :deep(.el-select) { width:100%; max-width:none; }
.return-create-body .return-create-form :deep(.el-textarea) { max-width:100%; }
.return-create-body :deep(.el-table .el-input-number) { width:120px; }
.return-forecast-table { overflow:hidden; border:1px solid var(--client-border); border-radius:8px; --el-table-header-bg-color:#f5f7fb; --el-table-header-text-color:#526176; --el-table-border-color:#edf1f5; --el-table-row-hover-bg-color:#f8faff; }
diff --git a/src/modules/overseas-warehouse/views/WarehouseServiceView.vue b/src/modules/overseas-warehouse/views/WarehouseServiceView.vue
index 2662bbe..e09f533 100644
--- a/src/modules/overseas-warehouse/views/WarehouseServiceView.vue
+++ b/src/modules/overseas-warehouse/views/WarehouseServiceView.vue
@@ -1,5 +1,9 @@
-
+
+
+
{{ props.service === 'relabel' ? '新建退货换标单' : props.service === 'transfer' ? '新增订单' : `新增${config.title}` }}
{{ row.__relabelStatusLabel }}
- —{{ label }}
+ —{{ label }}
{{ statusLabel(row.__status) }}
{{ row.__statusLabel }}
@@ -1691,8 +1719,8 @@ function addReturnBoxBinding(source: RelabelInventoryCandidate) {
{{ mode === 'edit' ? `编辑转运草稿 ${editingTransferNo}` : config.createTitle }}
返回列表
-