Compare commits
14
Commits
dev
...
af3cacd2db
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
af3cacd2db | ||
|
|
a674ec2435 | ||
|
|
8c064cfb0d | ||
|
|
d1e9002bef | ||
|
|
ded625f2a1 | ||
|
|
3d3ca053a6 | ||
|
|
3d73f1c82d | ||
|
|
bb674eb3a0 | ||
|
|
c79b5492c6 | ||
|
|
9b58c12012 | ||
|
|
d89830fe41 | ||
|
|
4d7b6eb6dc | ||
|
|
5b320a3293 | ||
|
|
63e169b051 |
@@ -151,6 +151,7 @@ export interface MerchantTransportFragment {
|
||||
}
|
||||
|
||||
export interface MerchantUnifiedOrderDetail {
|
||||
progress: MerchantOrderProgress
|
||||
transportFragments: MerchantTransportFragment[]
|
||||
summary: MerchantUnifiedOrderRow
|
||||
order: MerchantFirstLegOrderDetail
|
||||
|
||||
@@ -4,6 +4,7 @@ import { readFileSync } from 'node:fs'
|
||||
import ts from 'typescript'
|
||||
import * as vue from 'vue'
|
||||
import * as pinia from 'pinia'
|
||||
import { requireOrderProgress } from '../utils/orderProgress.ts'
|
||||
|
||||
function compile(path, dependencies) {
|
||||
const source = readFileSync(new URL(path, import.meta.url), 'utf8')
|
||||
@@ -31,10 +32,11 @@ function harness(t) {
|
||||
vue, pinia,
|
||||
'@/modules/first-leg/api/firstLeg': api,
|
||||
'@/modules/first-leg/api/unifiedOrders': {
|
||||
getMerchantUnifiedOrder: async () => ({ summary: { orderId: 'one', mainStatus: null }, order: { order: { id: 'one', status: behavior.status, version: behavior.version, receivingWarehouseId: 'warehouse', receivingWarehouseAddress: behavior.receivingWarehouseAddress } }, timeline: [] }),
|
||||
getMerchantUnifiedOrder: async () => ({ progress: { orderId: 'one', stage: 'none', partial: false }, summary: { orderId: 'one', mainStatus: null }, order: { order: { id: 'one', status: behavior.status, version: behavior.version, receivingWarehouseId: 'warehouse', receivingWarehouseAddress: behavior.receivingWarehouseAddress } }, timeline: [] }),
|
||||
},
|
||||
'@/modules/first-leg/utils/firstLegOrder': { canClientEditFirstLegOrder: status => status === 'DRAFT', getFirstLegStatusLabel: value => value },
|
||||
'@/modules/first-leg/utils/warehousePagination': {},
|
||||
'@/modules/first-leg/utils/orderProgress': { requireOrderProgress },
|
||||
})
|
||||
const store = module.useFirstLegOrdersStore()
|
||||
store.warehouses = [{ id: 'warehouse', name: '国内仓', warehouseType: 'DOMESTIC' }]
|
||||
|
||||
@@ -8,6 +8,32 @@ const storeSource = readFileSync(new URL('./firstLegOrders.ts', import.meta.url)
|
||||
const apiSource = readFileSync(new URL('../api/firstLeg.ts', import.meta.url), 'utf8')
|
||||
const unifiedApiSource = readFileSync(new URL('../api/unifiedOrders.ts', import.meta.url), 'utf8')
|
||||
|
||||
test('detail fetches authoritative progress on direct entry and refresh, and clears stale progress on failure', async () => {
|
||||
const functionSource = storeSource.slice(storeSource.indexOf(' async function loadDetail('), storeSource.indexOf(' async function loadWarehouseDetail('))
|
||||
const progressSource = readFileSync(new URL('../utils/orderProgress.ts', import.meta.url), 'utf8')
|
||||
const validationSource = progressSource.slice(progressSource.indexOf('export function requireOrderProgress(')).replace('export ', '')
|
||||
const javascript = ts.transpileModule(`${validationSource}\n${functionSource}`, { compilerOptions: { target: ts.ScriptTarget.ES2022 } }).outputText
|
||||
const id = '2110663764278312961'
|
||||
const orderProgress = { value: {} }
|
||||
let calls = 0
|
||||
let response = { progress: { orderId: id, stage: 'scheduled', partial: true } }
|
||||
const loadDetail = runInNewContext(`${javascript}; loadDetail`, {
|
||||
orderProgress, warehouses: { value: [] },
|
||||
getMerchantUnifiedOrder: async requested => { assert.equal(requested, id); calls++; return response },
|
||||
mapUnifiedOrderDetail: detail => ({ id, detail }), upsertOrder: order => order,
|
||||
})
|
||||
await loadDetail(id)
|
||||
assert.equal(orderProgress.value[id].stage, 'scheduled')
|
||||
assert.equal(orderProgress.value[id].partial, true)
|
||||
response = { progress: { orderId: id, stage: 'delivering', partial: false } }
|
||||
await loadDetail(id)
|
||||
assert.equal(calls, 2)
|
||||
assert.equal(orderProgress.value[id].stage, 'delivering')
|
||||
response = { progress: { orderId: 'another-order', stage: 'delivered', partial: false } }
|
||||
await assert.rejects(loadDetail(id), /订单履约进度不完整/)
|
||||
assert.equal(orderProgress.value[id], undefined)
|
||||
})
|
||||
|
||||
test('renders estimated sailing from the current API without substituting actual departure', () => {
|
||||
const mapper = storeSource.slice(storeSource.indexOf('function businessSummary('), storeSource.indexOf('\nconst unifiedActionLabels'))
|
||||
const javascript = ts.transpileModule(mapper, { compilerOptions: { target: ts.ScriptTarget.ES2022 } }).outputText
|
||||
|
||||
@@ -32,6 +32,7 @@ import type { FirstLegPartySnapshot } from '@/modules/first-leg/data/firstLegPar
|
||||
import type { FirstLegOrder, FirstLegOrderDraft, FirstLegOrderTimelineItem } from '@/modules/first-leg/types/firstLegOrder'
|
||||
import { canClientEditFirstLegOrder, getFirstLegStatusLabel } from '@/modules/first-leg/utils/firstLegOrder'
|
||||
import { loadAllWarehouseRecords } from '@/modules/first-leg/utils/warehousePagination'
|
||||
import { requireOrderProgress } from '@/modules/first-leg/utils/orderProgress'
|
||||
|
||||
export type ClientWarehouseType = 'DOMESTIC' | 'FBA' | 'OVERSEAS'
|
||||
export type ClientWarehouse = PlatformReceivingWarehouse & {
|
||||
@@ -507,8 +508,12 @@ export const useFirstLegOrdersStore = defineStore('client-first-leg-orders', ()
|
||||
}
|
||||
|
||||
async function loadDetail(id: string) {
|
||||
delete orderProgress.value[id]
|
||||
const detail = await getMerchantUnifiedOrder(id)
|
||||
return upsertOrder(mapUnifiedOrderDetail(detail, warehouses.value))
|
||||
const progress = requireOrderProgress(id, detail.progress)
|
||||
const mapped = mapUnifiedOrderDetail(detail, warehouses.value)
|
||||
orderProgress.value[id] = progress
|
||||
return upsertOrder(mapped)
|
||||
}
|
||||
|
||||
async function loadWarehouseDetail(id: string) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { readFileSync } from 'node:fs'
|
||||
import ts from 'typescript'
|
||||
import * as vue from 'vue'
|
||||
import * as pinia from 'pinia'
|
||||
import { requireOrderProgress } from '../utils/orderProgress.ts'
|
||||
|
||||
const source = readFileSync(new URL('./firstLegOrders.ts', import.meta.url), 'utf8')
|
||||
const code = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 } }).outputText
|
||||
@@ -27,11 +28,12 @@ function harness(t, read) {
|
||||
getMerchantOrderWorkbench: query => { calls.push(structuredClone(query)); return read(query) },
|
||||
getMerchantUnifiedOrder: async id => {
|
||||
details.push(id)
|
||||
return { summary: row(id, 'DRAFT'), order: { order: { id, status: 'DRAFT', version: 7 } }, timeline: [] }
|
||||
return { progress: { orderId: id, stage: 'none', partial: false }, summary: row(id, 'DRAFT'), order: { order: { id, status: 'DRAFT', version: 7 } }, timeline: [] }
|
||||
},
|
||||
},
|
||||
'@/modules/first-leg/utils/firstLegOrder': { getFirstLegStatusLabel: value => value },
|
||||
'@/modules/first-leg/utils/warehousePagination': { loadAllWarehouseRecords: async type => { warehouseCalls.push(type); return [] } },
|
||||
'@/modules/first-leg/utils/orderProgress': { requireOrderProgress },
|
||||
}
|
||||
const exports = {}
|
||||
new Function('require', 'exports', code)(id => { assert.ok(id in deps, id); return deps[id] }, exports)
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import { orderBillingMetric } from './orderDetailSummary.ts'
|
||||
|
||||
test('detail uses the published billing quantity without substituting physical gross weight', () => {
|
||||
const billing = { inbound: { summary: { actualGrossWeightKg: 23, chargeableWeightKg: null } },
|
||||
billingBasis: { chargeUnit: 'KG', chargeableQuantity: 17.0006, chargeableWeightKg: null } }
|
||||
assert.deepEqual(orderBillingMetric(billing, null), { label: '计费重量', quantity: 17.0006, unit: 'kg' })
|
||||
assert.equal(orderBillingMetric(billing, null).quantity.toLocaleString('zh-CN'), '17.001')
|
||||
})
|
||||
|
||||
test('volume charging retains its real unit and missing billing does not invent a quantity', () => {
|
||||
assert.deepEqual(orderBillingMetric({ billingBasis: { chargeUnit: 'CBM', chargeableQuantity: 0.102 } }, 23),
|
||||
{ label: '计费体积', quantity: 0.102, unit: 'm³' })
|
||||
assert.equal(orderBillingMetric(null, null).quantity, null)
|
||||
assert.equal(orderBillingMetric(null, 0).quantity, 0)
|
||||
})
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { MerchantInboundBilling } from '../types/inboundBilling'
|
||||
|
||||
/** Published billing quantity and physical gross weight have different meanings. */
|
||||
export function orderBillingMetric(billing: MerchantInboundBilling | null, measuredChargeableWeight: number | null) {
|
||||
if (billing) {
|
||||
const basis = billing.billingBasis
|
||||
return basis.chargeUnit === 'KG'
|
||||
? { label: '计费重量', quantity: basis.chargeableQuantity, unit: 'kg' }
|
||||
: basis.chargeUnit === 'CBM'
|
||||
? { label: '计费体积', quantity: basis.chargeableQuantity, unit: 'm³' }
|
||||
: { label: '计费量', quantity: null, unit: '' }
|
||||
}
|
||||
return { label: '计费重量', quantity: measuredChargeableWeight, unit: 'kg' }
|
||||
}
|
||||
@@ -13,3 +13,13 @@ test('prototype queues preserve order and distinguish partial progress from comp
|
||||
assert.equal(exports.orderProgressLabel({ stage: 'none', partial: false }), '—')
|
||||
assert.equal(exports.orderProgressLabel(), '—')
|
||||
})
|
||||
|
||||
test('detail progress must belong to the requested order and retain partial fulfillment', () => {
|
||||
const progress = { orderId: '2110663764278312961', stage: 'scheduled', partial: true }
|
||||
assert.equal(exports.requireOrderProgress(progress.orderId, progress), progress)
|
||||
assert.equal(exports.orderProgressLabel(exports.requireOrderProgress(progress.orderId, progress)), '部分已排车')
|
||||
assert.throws(() => exports.requireOrderProgress('other-order', progress), /订单履约进度不完整/)
|
||||
assert.throws(() => exports.requireOrderProgress(progress.orderId, undefined), /订单履约进度不完整/)
|
||||
assert.throws(() => exports.requireOrderProgress(progress.orderId, { ...progress, stage: 'UNKNOWN' }), /订单履约进度不完整/)
|
||||
assert.throws(() => exports.requireOrderProgress(progress.orderId, { ...progress, partial: null }), /订单履约进度不完整/)
|
||||
})
|
||||
|
||||
@@ -18,3 +18,12 @@ export function orderProgressLabel(progress?: MerchantOrderProgress) {
|
||||
const label = ORDER_QUEUES.find(q => q.key === progress.stage)?.label ?? '—'
|
||||
return `${progress.partial ? '部分' : ''}${label}`
|
||||
}
|
||||
|
||||
export function requireOrderProgress(orderId: string, progress: MerchantOrderProgress) {
|
||||
if (!progress || String(progress.orderId) !== orderId
|
||||
|| !['none', 'loaded', 'declared', 'cleared', 'scheduled', 'delivering', 'delivered'].includes(progress.stage)
|
||||
|| typeof progress.partial !== 'boolean') {
|
||||
throw new Error('订单履约进度不完整,请重新加载')
|
||||
}
|
||||
return progress
|
||||
}
|
||||
|
||||
@@ -223,7 +223,8 @@ test('shows one unified progress line and C02 business summaries without detail
|
||||
assert.match(source, /progressSummaryItems/)
|
||||
assert.match(source, /summary\.preloadStatus/)
|
||||
assert.match(source, /summary\.expectedPickCount/)
|
||||
assert.match(source, /summary\.deliveryTrackingNo/)
|
||||
assert.match(source, /label: '后续履约', value: orderProgressLabel\(store\.orderProgress\[order\.id\]\)/)
|
||||
assert.doesNotMatch(source, /待海外同步/)
|
||||
assert.match(progressSource, /FIRST_LEG_PROGRESS_STAGES/)
|
||||
assert.match(progressSource, /订单主节点与关联柜次统一展示/)
|
||||
assert.match(progressSource, /履约进度/)
|
||||
|
||||
@@ -60,6 +60,7 @@ import {
|
||||
validateFirstLegOrderDraft,
|
||||
} from '@/modules/first-leg/utils/firstLegOrder'
|
||||
import { ORDER_QUEUES, orderProgressLabel } from '../utils/orderProgress'
|
||||
import { orderBillingMetric } from '../utils/orderDetailSummary'
|
||||
import type { OrderQueue } from '../api/unifiedOrders'
|
||||
import { validateProductImageFile } from '@/modules/first-leg/utils/productImage'
|
||||
import { createPriceEstimateBreakdown } from '@/modules/first-leg/utils/priceEstimate'
|
||||
@@ -603,8 +604,10 @@ const pagedDetailCargoItems = computed(() => {
|
||||
const progressSummaryItems = computed(() => {
|
||||
const order = detailOrder.value
|
||||
if (!order || order.status === 'CANCELLED') return []
|
||||
const inbound = inboundBilling.value?.inbound.summary
|
||||
const billing = inboundBillingOrderId.value === order.id ? inboundBilling.value : null
|
||||
const inbound = billing?.inbound.summary
|
||||
const summary = order.businessSummary
|
||||
const billingMetric = orderBillingMetric(billing, summary.chargeableWeightKg)
|
||||
const formatMetric = (value: number | null | undefined, unit = '') => value == null ? '待更新' : `${value.toLocaleString()}${unit ? ` ${unit}` : ''}`
|
||||
const preloadStatus = ({ PENDING: '待确认', CONFIRMING: '确认中', CONFIRMED: '已确认' } as Record<string, string>)[summary.preloadStatus] ?? summary.preloadStatus
|
||||
const transportReference = [summary.vesselName, summary.voyageNo].filter(Boolean).join(' · ')
|
||||
@@ -615,12 +618,12 @@ const progressSummaryItems = computed(() => {
|
||||
{ label: '实际箱数', value: formatMetric(summary.totalPackageCount ?? inbound?.totalPackageCount, '箱') },
|
||||
{ label: '实测毛重', value: formatMetric(summary.actualGrossWeightKg ?? inbound?.actualGrossWeightKg, 'kg') },
|
||||
{ label: '实测体积', value: formatMetric(summary.actualVolumeCbm ?? inbound?.actualVolumeCbm, 'm³') },
|
||||
{ label: '预装清单', value: preloadStatus ? `${preloadStatus}${summary.preloadItemCount == null ? '' : ` · ${summary.preloadItemCount} 项`}` : '待更新' },
|
||||
{ label: '预装确认', value: preloadStatus || '待更新' },
|
||||
{ label: '拣货进度', value: pickingProgress },
|
||||
{ label: '关联柜次', value: summary.cabinetNo || summary.containerNo || '—' },
|
||||
{ label: '船期摘要', value: transportReference || '待更新' },
|
||||
{ label: '计费重量', value: formatMetric(summary.chargeableWeightKg, 'kg') },
|
||||
{ label: '后续履约', value: summary.deliveryTrackingNo || (summary.deliveryCompletedAt ? `海外回传于 ${formatDateTime(summary.deliveryCompletedAt)}` : '待海外同步') },
|
||||
{ label: billingMetric.label, value: formatMetric(billingMetric.quantity, billingMetric.unit) },
|
||||
{ label: '后续履约', value: orderProgressLabel(store.orderProgress[order.id]) },
|
||||
]
|
||||
})
|
||||
const cabinetProgressItems = computed(() => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { apiRequest, apiBlobRequest } from '@/api/http'
|
||||
import { createIdempotencyKey } from '@/utils/idempotencyKey'
|
||||
|
||||
export type RelabelDeliveryMethod = 'TRUCK' | 'EXPRESS' | 'TRUCK_PICKUP' | 'EXPRESS_PICKUP'
|
||||
export type RelabelDeliveryMethod = 'TRUCK' | 'EXPRESS' | 'AMAZON_PICKUP' | 'CUSTOMER_PICKUP'
|
||||
|
||||
export interface PageResult<T> { records: T[]; page: number; size: number; total: number; pages: number }
|
||||
export interface LastlegOrderSummary { id: string; orderNo: string; inboundCode: string; orderType: string; packageCount: number; overseasWarehouseId?: string }
|
||||
@@ -23,10 +23,11 @@ export interface RelabelDetail {
|
||||
remark?: string; items: RelabelItemDetail[]
|
||||
history: Array<{ fromStatus?: string; toStatus?: string; operatorId?: string; remark?: string; createdAt: string }>
|
||||
tailOrders?: Array<{ id: string; orderNo: string; overseasWarehouseId: string; packageCount: number; status: string }>
|
||||
completionEvidence?: { taskId: string; photoFileIds: string[]; completionRemark?: string; reviewerName?: string; reviewRemark?: string; completedAt?: string }
|
||||
completionEvidence?: { taskId: string; photoFileIds: string[]; completionRemark?: string; reviewerName?: string; reviewRemark?: string; completedAt?: string; customerConfirmedAt?: string }
|
||||
}
|
||||
export interface DiscardSummary { id: string; discardNo: string; outboundNo?: string; inboundCodes: string; overseasWarehouseId: string; overseasWarehouseName: string; totalPackages: number; totalFee: number; currency: string; status: string; createdAt: string; completedAt?: string }
|
||||
export interface DiscardCandidate { orderId: string; sourceType?: 'DROPSHIP' | 'RETURN_CARTON'; returnCartonId?: string; cartonMark?: string; inboundCode: string; inboundOrderNo: string; overseasWarehouseId: string; overseasWarehouseName: string; availablePackages: number; skus: Array<{ skuCode: string; productName: string; availablePackages: number }> }
|
||||
export interface DiscardSummary { id: string; discardNo: string; outboundNo?: string; inboundCodes: string; overseasWarehouseId: string; overseasWarehouseName: string; totalPackages: number; totalFee: number; currency: string; status: string; createdAt: string; completedAt?: string; customerConfirmedAt?: string }
|
||||
export interface DiscardBox { cartonId: string; cartonMark: string; palletNo?: string; skus: Array<{ skuCode: string; productName: string; availablePackages: number }> }
|
||||
export interface DiscardCandidate { cartons?: DiscardBox[]; cartonSelectionSupported?: boolean; cartonSelectionBlockReason?: string; orderId: string; sourceType?: 'DROPSHIP' | 'RETURN_CARTON' | 'INVENTORY'; inventoryPositionId?: string; palletNo?: string; returnCartonId?: string; cartonMark?: string; inboundCode: string; inboundOrderNo: string; overseasWarehouseId: string; overseasWarehouseName: string; availablePackages: number; skus: Array<{ skuCode: string; productName: string; availablePackages: number }> }
|
||||
export interface RelabelInventoryCandidate {
|
||||
cartonSkuItems: Array<{ skuCode: string; quantity: number }>; cartonAvailablePackages: number | string
|
||||
skuConversions?: import('./returnInventory').ReturnSkuConversion[]
|
||||
@@ -44,10 +45,10 @@ export interface TransferCreate {
|
||||
inboundCode?: string; sourceLastlegOrderIds?: string[]; operatingWarehouseId: string; fbaWarehouseCode?: string; destinationType?: 'NONE' | 'FBA' | 'PRIVATE'
|
||||
destinationWarehouseId?: string; destinationWarehouseCode?: string; destinationWarehouseName?: string
|
||||
privateDestinationAddress?: string; privateDestinationPostalCode?: string; privateDestinationContactName?: string; privateDestinationContactPhone?: string
|
||||
deliveryAddress: string; packageCount: number; volumeCbm: number; deliveryMethod: string; estimatedArrivalAt: string; estimatedShipAt?: string
|
||||
deliveryAddress?: string; packageCount: number; volumeCbm: number; deliveryMethod?: string; estimatedArrivalAt?: string; estimatedShipAt?: string
|
||||
fbaShipmentNo?: string; trackingNo?: string; fbaBoxNos?: string; remark?: string; needRelabel: boolean; idempotencyKey: string
|
||||
labelDeadline?: string; pickupContactName?: string; pickupContactPhone?: string; relabelContactName?: string; relabelContactPhone?: string; relabelChargeAmount?: number; relabelDeliveryMethod?: RelabelDeliveryMethod
|
||||
items: Array<{ skuCode?: string; productNameZh?: string; packageCount: number; shippingMark?: string }>
|
||||
items: Array<{ skuCode?: string; productNameZh?: string; quantity: number; packageCount: number; shippingMark?: string }>
|
||||
relabelItems?: Array<{ operationType: 'CARTON_LABEL' | 'SKU_LABEL' | 'CARTON_AND_SKU'; customerCartonMarks: string[]; originalSku?: string; newSku?: string; quantity: number; fbaCartonMark?: string; destinationWarehouseCode?: string; remark?: string }>
|
||||
}
|
||||
export interface RelabelCreate {
|
||||
@@ -75,6 +76,10 @@ export function updateTransfer(transferNo: string, payload: TransferCreate) {
|
||||
export function submitTransfer(transferNo: string) {
|
||||
return apiRequest<Record<string, unknown>>({ method: 'POST', url: `/merchant/v1/last-leg/warehouse-transfers/${encodeURIComponent(transferNo)}/submit` })
|
||||
}
|
||||
/** 补充既有SKU的预报箱数,原箱数快照用于并发核对与安全重试。 */
|
||||
export function increaseTransferForecastCartons(transferNo: string, payload: { expectedPackageCount: number; packageCount: number }) {
|
||||
return apiRequest<Record<string, unknown>>({ method: 'POST', url: `/merchant/v1/last-leg/warehouse-transfers/${encodeURIComponent(transferNo)}/increase-forecast-cartons`, data: payload })
|
||||
}
|
||||
export function listRelabels(params: Record<string, unknown>) {
|
||||
return apiRequest<PageResult<RelabelSummary>>({ method: 'GET', url: '/merchant/v1/last-leg/relabel-applications', params })
|
||||
}
|
||||
@@ -127,14 +132,28 @@ export function getDiscard(discardNo: string) {
|
||||
export function listDiscardCandidates(inboundCode?: string) {
|
||||
return apiRequest<DiscardCandidate[]>({ method: 'GET', url: '/merchant/v1/last-leg/cargo-discards/inventory-candidates', params: { inboundCode: inboundCode || undefined } })
|
||||
}
|
||||
export function createDiscard(payload: { items: Array<{ orderId?: string; returnCartonId?: string; skuCode: string; discardPackages: number }>; idempotencyKey: string; remark?: string }) {
|
||||
export function createDiscard(payload: { items: Array<{ orderId?: string; returnCartonId?: string; inventoryPositionId?: string; cartonIds?: string[]; skuCode: string; discardPackages: number }>; idempotencyKey: string; remark?: string }) {
|
||||
return apiRequest<Record<string, unknown>>({ method: 'POST', url: '/merchant/v1/last-leg/cargo-discards', data: payload })
|
||||
}
|
||||
export function cancelDiscard(discardNo: string) {
|
||||
return apiRequest<Record<string, unknown>>({ method: 'POST', url: `/merchant/v1/last-leg/cargo-discards/${encodeURIComponent(discardNo)}/cancel` })
|
||||
}
|
||||
export function confirmDiscardCompletion(discardNo: string) {
|
||||
return apiRequest<Record<string, unknown>>({ method: 'POST', url: `/merchant/v1/last-leg/cargo-discards/${encodeURIComponent(discardNo)}/confirm-completion` })
|
||||
}
|
||||
|
||||
export interface DiscardPhoto { fileId: string; palletNo: string; fileName: string; createdAt: string; submittedAt: string }
|
||||
export function downloadDiscardPhoto(discardNo: string, fileId: string) {
|
||||
return apiBlobRequest({ method: 'GET', url: `/merchant/v1/last-leg/cargo-discards/${encodeURIComponent(discardNo)}/photos/files/${encodeURIComponent(fileId)}/download` })
|
||||
}
|
||||
|
||||
export interface TransferSourceCarton { cartonMark: string; productNameZh?: string; quantity?: number; destinationWarehouseCode?: string; destinationWarehouseName?: string; items: Array<{skuCode?: string; quantity?: number}> }
|
||||
export function getTransferSourceCartons(operatingWarehouseId: string, inboundCode: string) {
|
||||
return apiRequest<TransferSourceCarton[]>({method:'GET',url:'/merchant/v1/last-leg/warehouse-transfers/inventory-source-cartons',params:{operatingWarehouseId,inboundCode}})
|
||||
}
|
||||
|
||||
/** 通过客户授权入口读取申请绑定的资料,不使用平台文件地址。 */
|
||||
export function downloadRelabelDocument(relabelNo: string, fileId: string, scope: string, itemId?: string) {
|
||||
const base = '/merchant/v1/last-leg/relabel-applications/' + encodeURIComponent(relabelNo)
|
||||
return apiBlobRequest({ method: 'GET', url: scope === 'SHIPPING' ? base + '/shipping-label/' + encodeURIComponent(fileId) + '/download' : base + '/files/' + encodeURIComponent(fileId) + '/download', params: scope === 'SHIPPING' ? undefined : { scope, itemId } })
|
||||
}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import ts from 'typescript'
|
||||
|
||||
const source = readFileSync(new URL('./CompletionConfirmationDialog.vue', import.meta.url), 'utf8')
|
||||
const script = source.match(/<script setup lang="ts">([\s\S]*?)<\/script>/)[1]
|
||||
const js = ts.transpileModule(script, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ESNext } }).outputText
|
||||
.replace(/^import .*$/gm, '').replace(/^export \{\};?$/gm, '')
|
||||
|
||||
function harness(kind = 'disposal', overrides = {}) {
|
||||
const props = { modelValue: true, kind, documentNo: 'CD/1' }
|
||||
const calls = []; const events = []; const revoked = []
|
||||
const api = {
|
||||
getDiscard: async () => ({ summary: { status: 'PDA_COMPLETED' }, photos: [{ fileId: '101' }] }),
|
||||
getRelabel: async () => ({ application: { status: 'CUSTOMER_CONFIRM_PENDING' }, completionEvidence: { photoFileIds: ['101'] } }),
|
||||
getTransfer: async () => ({ status: 'WAIT_CUSTOMER_CONFIRMATION', measurementPhotos: [{ fileId: '101' }], cartons: [{ cartonMark: 'BOX-1', status: 'COUNTED', weight: 5 }], pallets: [{ palletNo: 'PLT-1', grossWeightKg: 5 }] }),
|
||||
downloadTransferMeasurementPhoto: async () => ({}),
|
||||
confirmTransferMeasurement: async no => calls.push(['transfer', no]),
|
||||
downloadDiscardPhoto: async () => ({}), downloadRelabelCompletionPhoto: async () => ({}),
|
||||
confirmDiscardCompletion: async no => calls.push(['discard', no]),
|
||||
confirmRelabelCompletion: async no => calls.push(['relabel', no]), ...overrides,
|
||||
}
|
||||
const env = {
|
||||
computed: fn => ({ get value() { return fn() } }), ref: value => ({ value }), watch: () => {}, onBeforeUnmount: () => {},
|
||||
defineProps: () => props, defineEmits: () => (...args) => events.push(args),
|
||||
useAuthStore: () => ({ hasPermission: () => true }), ElMessage: { success() {}, error() {} },
|
||||
URL: { createObjectURL: () => 'blob:photo', revokeObjectURL: url => revoked.push(url) }, ...api,
|
||||
}
|
||||
const state = new Function(...Object.keys(env), js + '\nreturn { loadEvidence, loadPhoto, submit, clear, checked, canConfirm, urls, failed, error, busy, measurement };')(...Object.values(env))
|
||||
return { ...state, props, calls, events, revoked }
|
||||
}
|
||||
|
||||
for (const kind of ['disposal', 'labeling', 'transfer']) {
|
||||
test(`${kind}: opening loads photos without confirming; only reviewed evidence can be submitted`, async () => {
|
||||
const state = harness(kind)
|
||||
await state.loadEvidence()
|
||||
assert.deepEqual(state.calls, [])
|
||||
await state.submit(); assert.deepEqual(state.calls, [])
|
||||
state.checked.value = true
|
||||
assert.equal(state.canConfirm.value, true)
|
||||
await state.submit()
|
||||
assert.deepEqual(state.calls, [[kind === 'transfer' ? 'transfer' : kind === 'disposal' ? 'discard' : 'relabel', 'CD/1']])
|
||||
assert.deepEqual(state.events, [['confirmed', 'CD/1'], ['update:modelValue', false]])
|
||||
state.clear(); assert.deepEqual(state.revoked, ['blob:photo'])
|
||||
})
|
||||
}
|
||||
|
||||
test('transfer confirmation reads current measurements and blocks missing, failed or outdated evidence', async () => {
|
||||
const state = harness('transfer', { downloadTransferMeasurementPhoto: async () => { throw Error('offline') } })
|
||||
await state.loadEvidence()
|
||||
assert.equal(state.measurement.value.pallets[0].grossWeightKg, 5)
|
||||
assert.equal(state.measurement.value.cartons[0].cartonMark, 'BOX-1')
|
||||
state.checked.value = true
|
||||
await state.submit()
|
||||
assert.equal(state.canConfirm.value, false)
|
||||
assert.deepEqual(state.calls, [])
|
||||
for (const detail of [
|
||||
{ status: 'WAIT_CUSTOMER_CONFIRMATION', measurementPhotos: [] },
|
||||
{ status: 'COMPLETED', measurementPhotos: [{ fileId: '101' }] },
|
||||
{ status: 'WAIT_CUSTOMER_CONFIRMATION', customerConfirmedAt: '2026-10-16', measurementPhotos: [{ fileId: '101' }] },
|
||||
]) {
|
||||
const invalid = harness('transfer', { getTransfer: async () => detail })
|
||||
await invalid.loadEvidence(); invalid.checked.value = true; await invalid.submit()
|
||||
assert.equal(invalid.canConfirm.value, false)
|
||||
assert.deepEqual(invalid.calls, [])
|
||||
}
|
||||
})
|
||||
|
||||
test('photo failure blocks confirmation and supports retry', async () => {
|
||||
let reject = true
|
||||
const state = harness('disposal', { downloadDiscardPhoto: async () => { if (reject) throw Error('offline'); return {} } })
|
||||
await state.loadEvidence(); state.checked.value = true
|
||||
assert.equal(state.failed.value['101'], true); assert.equal(state.canConfirm.value, false)
|
||||
reject = false; await state.loadPhoto('101')
|
||||
assert.equal(state.canConfirm.value, true)
|
||||
})
|
||||
|
||||
test('closing while photos load prevents stale preview URLs', async () => {
|
||||
let resolve
|
||||
const state = harness('disposal', { downloadDiscardPhoto: () => new Promise(done => { resolve = done }) })
|
||||
const pending = state.loadEvidence()
|
||||
await Promise.resolve()
|
||||
state.props.modelValue = false; state.clear(); resolve({}); await pending
|
||||
assert.deepEqual(state.urls.value, {})
|
||||
})
|
||||
|
||||
test('completed orders and missing evidence cannot be confirmed', async () => {
|
||||
for (const detail of [{ summary: { status: 'COMPLETED' }, photos: [{ fileId: '101' }] }, { summary: { status: 'PDA_COMPLETED' }, photos: [] }]) {
|
||||
const state = harness('disposal', { getDiscard: async () => detail })
|
||||
await state.loadEvidence(); state.checked.value = true; await state.submit()
|
||||
assert.equal(state.canConfirm.value, false); assert.deepEqual(state.calls, [])
|
||||
}
|
||||
})
|
||||
|
||||
test('failed submission preserves dialog and a pending submission is not sent twice', async () => {
|
||||
let reject
|
||||
const state = harness('disposal', { confirmDiscardCompletion: () => new Promise((_, fail) => { reject = fail }) })
|
||||
await state.loadEvidence(); state.checked.value = true
|
||||
const pending = state.submit()
|
||||
assert.equal(state.busy.value, true); await state.submit()
|
||||
reject(Error('offline')); await pending
|
||||
assert.deepEqual(state.events, []); assert.equal(state.busy.value, false)
|
||||
assert.equal(state.canConfirm.value, true)
|
||||
})
|
||||
@@ -0,0 +1,136 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useAuthStore } from '@/modules/auth/public'
|
||||
import { confirmDiscardCompletion, confirmRelabelCompletion, confirmTransferMeasurement, downloadDiscardPhoto, downloadRelabelCompletionPhoto, downloadTransferMeasurementPhoto, getDiscard, getRelabel, getTransfer, type DiscardPhoto } from '../api/warehouseServices'
|
||||
|
||||
const props = defineProps<{ modelValue: boolean; kind: 'disposal' | 'labeling' | 'transfer'; documentNo: string }>()
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: boolean]; confirmed: [documentNo: string] }>()
|
||||
const auth = useAuthStore()
|
||||
const title = computed(() => props.kind === 'transfer' ? '确认测量数据' : props.kind === 'disposal' ? '废弃确认' : '贴标完成确认')
|
||||
interface MeasurementEvidence {
|
||||
status?: string
|
||||
customerConfirmedAt?: string
|
||||
cartons?: Array<{ cartonMark: string; status: string; length?: number; width?: number; height?: number; weight?: number }>
|
||||
pallets?: Array<{ palletNo?: string; sequenceNo?: number; cartonCount?: number; lengthCm?: number; widthCm?: number; heightCm?: number; grossWeightKg?: number; volumeCbm?: number }>
|
||||
measurementPhotos?: Array<{ fileId: string | number }>
|
||||
}
|
||||
const measurement = ref<MeasurementEvidence | null>(null)
|
||||
const loading = ref(false)
|
||||
const busy = ref(false)
|
||||
const error = ref('')
|
||||
const fileIds = ref<string[]>([])
|
||||
const urls = ref<Record<string, string>>({})
|
||||
const failed = ref<Record<string, boolean>>({})
|
||||
const photoLoading = ref<Record<string, boolean>>({})
|
||||
const checked = ref(false)
|
||||
const eligible = ref(false)
|
||||
let generation = 0
|
||||
const previewUrls = computed(() => fileIds.value.map(id => urls.value[id]).filter(Boolean))
|
||||
const canConfirm = computed(() => eligible.value && checked.value && !loading.value && !busy.value && !error.value
|
||||
&& fileIds.value.length > 0 && fileIds.value.every(id => Boolean(urls.value[id]))
|
||||
&& auth.hasPermission(`merchant:overseas-warehouse:${props.kind === 'transfer' ? 'transfer' : props.kind === 'disposal' ? 'discard' : 'relabel'}:confirm`))
|
||||
function clear() {
|
||||
generation++
|
||||
Object.values(urls.value).forEach(URL.revokeObjectURL)
|
||||
urls.value = {}; failed.value = {}; photoLoading.value = {}; fileIds.value = []; checked.value = false; eligible.value = false
|
||||
measurement.value = null
|
||||
}
|
||||
async function loadPhoto(fileId: string) {
|
||||
if (photoLoading.value[fileId]) return
|
||||
const currentGeneration = generation; const no = props.documentNo; const kind = props.kind
|
||||
photoLoading.value[fileId] = true; failed.value[fileId] = false
|
||||
try {
|
||||
const blob = kind === 'transfer' ? await downloadTransferMeasurementPhoto(no, fileId) : kind === 'disposal' ? await downloadDiscardPhoto(no, fileId) : await downloadRelabelCompletionPhoto(no, fileId)
|
||||
if (generation !== currentGeneration || !props.modelValue) return
|
||||
if (urls.value[fileId]) URL.revokeObjectURL(urls.value[fileId])
|
||||
urls.value[fileId] = URL.createObjectURL(blob)
|
||||
} catch { if (generation === currentGeneration) failed.value[fileId] = true }
|
||||
finally { if (generation === currentGeneration) photoLoading.value[fileId] = false }
|
||||
}
|
||||
async function loadEvidence() {
|
||||
clear(); error.value = ''; loading.value = true
|
||||
const currentGeneration = generation
|
||||
try {
|
||||
if (props.kind === 'transfer') {
|
||||
const detail = await getTransfer(props.documentNo) as MeasurementEvidence
|
||||
if (generation !== currentGeneration) return
|
||||
measurement.value = detail
|
||||
eligible.value = detail.status === 'WAIT_CUSTOMER_CONFIRMATION' && !detail.customerConfirmedAt
|
||||
fileIds.value = (detail.measurementPhotos || []).map(photo => String(photo.fileId))
|
||||
} else if (props.kind === 'disposal') {
|
||||
const detail = await getDiscard(props.documentNo) as { summary?: { status?: string }; photos?: DiscardPhoto[] }
|
||||
if (generation !== currentGeneration) return
|
||||
eligible.value = detail.summary?.status === 'PDA_COMPLETED'
|
||||
fileIds.value = (detail.photos || []).map(photo => String(photo.fileId))
|
||||
} else {
|
||||
const detail = await getRelabel(props.documentNo)
|
||||
if (generation !== currentGeneration) return
|
||||
eligible.value = detail.application.status === 'CUSTOMER_CONFIRM_PENDING' && !detail.application.customerConfirmedAt
|
||||
fileIds.value = (detail.completionEvidence?.photoFileIds || []).map(String)
|
||||
}
|
||||
fileIds.value = [...new Set(fileIds.value)]
|
||||
if (!eligible.value) error.value = '当前单据已确认或尚未完成 PDA 作业,请刷新列表核对。'
|
||||
else if (!fileIds.value.length) error.value = props.kind === 'transfer' ? '暂无 PDA 测量打托照片,暂不能确认。' : '暂无 PDA 完工作业照片,暂不能确认。'
|
||||
await Promise.all(fileIds.value.map(loadPhoto))
|
||||
} catch (e) { if (generation === currentGeneration) error.value = e instanceof Error ? e.message : '作业照片加载失败' }
|
||||
finally { if (generation === currentGeneration) loading.value = false }
|
||||
}
|
||||
async function submit() {
|
||||
if (!canConfirm.value) return
|
||||
busy.value = true
|
||||
try {
|
||||
if (props.kind === 'transfer') await confirmTransferMeasurement(props.documentNo)
|
||||
else if (props.kind === 'disposal') await confirmDiscardCompletion(props.documentNo)
|
||||
else await confirmRelabelCompletion(props.documentNo)
|
||||
ElMessage.success(props.kind === 'transfer' ? '仓库实测数据已确认' : props.kind === 'disposal' ? '废弃已确认,单据已完结' : '贴标已确认,订单进入后续出库流程')
|
||||
emit('confirmed', props.documentNo)
|
||||
emit('update:modelValue', false)
|
||||
} catch (e) { ElMessage.error(e instanceof Error ? e.message : '确认失败,请重试') }
|
||||
finally { busy.value = false }
|
||||
}
|
||||
watch(() => [props.modelValue, props.kind, props.documentNo], () => { if (props.modelValue) void loadEvidence(); else clear() }, { immediate: true })
|
||||
onBeforeUnmount(clear)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-dialog :model-value="modelValue" :title="title" width="760px" :close-on-click-modal="false" :close-on-press-escape="!busy" :show-close="!busy" @update:model-value="!busy && emit('update:modelValue', $event)">
|
||||
<p>单号:{{ documentNo }}</p>
|
||||
<p>{{ kind === 'transfer' ? '请核对 PDA 回传的箱数、尺寸、重量、打托数据及现场照片,确认无误后再提交。' : '请核对以下 PDA 作业照片,确认作业结果无误后再提交。' }}</p>
|
||||
<template v-if="kind === 'transfer' && measurement">
|
||||
<p>实际签收箱数:{{ measurement.cartons?.filter(carton => carton.status === 'COUNTED').length ?? 0 }} 箱;托盘数:{{ measurement.pallets?.length ?? 0 }} 托</p>
|
||||
<el-table :data="measurement.pallets || []" max-height="220" size="small" border empty-text="暂无 PDA 打托测量数据">
|
||||
<el-table-column label="托盘号" min-width="180"><template #default="{ row }">{{ row.palletNo || `第${row.sequenceNo || '—'}托` }}</template></el-table-column>
|
||||
<el-table-column prop="cartonCount" label="箱数" width="65" />
|
||||
<el-table-column label="尺寸(cm)" min-width="130"><template #default="{ row }">{{ row.lengthCm ?? '—' }} × {{ row.widthCm ?? '—' }} × {{ row.heightCm ?? '—' }}</template></el-table-column>
|
||||
<el-table-column prop="grossWeightKg" label="毛重(kg)" width="95" />
|
||||
<el-table-column prop="volumeCbm" label="体积(m³)" width="95" />
|
||||
</el-table>
|
||||
<el-table :data="measurement.cartons?.filter(carton => carton.status === 'COUNTED') || []" max-height="220" size="small" border empty-text="暂无已签收箱子">
|
||||
<el-table-column prop="cartonMark" label="箱唛" min-width="200" />
|
||||
<el-table-column label="尺寸(cm)" min-width="180"><template #default="{ row }">{{ row.length ?? '—' }} × {{ row.width ?? '—' }} × {{ row.height ?? '—' }}</template></el-table-column>
|
||||
<el-table-column prop="weight" label="重量(kg)" width="110" />
|
||||
</el-table>
|
||||
</template>
|
||||
<p v-if="loading">正在读取作业照片…</p>
|
||||
<el-alert v-if="error" :title="error" type="error" :closable="false" />
|
||||
<el-button v-if="error" :disabled="loading || busy" @click="loadEvidence">重新读取</el-button>
|
||||
<div class="completion-photo-grid">
|
||||
<div v-for="(fileId, index) in fileIds" :key="fileId">
|
||||
<el-image v-if="urls[fileId]" :src="urls[fileId]" :preview-src-list="previewUrls" :initial-index="previewUrls.indexOf(urls[fileId])" fit="contain" preview-teleported />
|
||||
<span v-else-if="photoLoading[fileId]">照片 {{ index + 1 }} 加载中…</span>
|
||||
<el-button v-else :disabled="busy" @click="loadPhoto(fileId)">{{ failed[fileId] ? '照片加载失败,点击重试' : '加载照片' }}</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<el-checkbox v-model="checked" :disabled="loading || busy || !!error || previewUrls.length !== fileIds.length">{{ kind === 'transfer' ? '我已核对 PDA 实测数据及现场照片,确认无误' : '我已核对 PDA 作业照片,确认作业结果无误' }}</el-checkbox>
|
||||
<template #footer>
|
||||
<el-button :disabled="busy" @click="emit('update:modelValue', false)">取消</el-button>
|
||||
<el-button type="primary" :loading="busy" :disabled="!canConfirm" @click="submit">{{ kind === 'transfer' ? '确认数据' : kind === 'disposal' ? '确认废弃完结' : '确认贴标完成' }}</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.completion-photo-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; margin: 16px 0; }
|
||||
.completion-photo-grid .el-image { width: 100%; height: 180px; }
|
||||
</style>
|
||||
@@ -0,0 +1,82 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch, onBeforeUnmount } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { getRelabel, downloadRelabelDocument, type RelabelDetail } from '../api/warehouseServices'
|
||||
const props = defineProps<{ relabelNo: string; detail?: RelabelDetail | null }>()
|
||||
const loaded = ref<RelabelDetail | null>(null)
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const busy = ref(false)
|
||||
const previewUrl = ref('')
|
||||
const previewOpen = ref(false)
|
||||
const previewTitle = ref('')
|
||||
let generation = 0
|
||||
const current = computed(() => props.detail || loaded.value)
|
||||
/** 各申请分别加载,失败只影响资料区,不阻断订单详情。 */
|
||||
async function load() {
|
||||
const ticket = ++generation
|
||||
loaded.value = null; error.value = ''
|
||||
if (props.detail || !props.relabelNo) { loading.value = false; return }
|
||||
loading.value = true
|
||||
try { const result = await getRelabel(props.relabelNo); if (ticket === generation) loaded.value = result }
|
||||
catch (cause) { if (ticket === generation) error.value = cause instanceof Error ? cause.message : '贴标资料加载失败' }
|
||||
finally { if (ticket === generation) loading.value = false }
|
||||
}
|
||||
watch(() => [props.relabelNo, props.detail], () => { clearPreview(); void load() }, { immediate: true })
|
||||
interface DocumentRow { key: string; kind: string; fileId: string; fileName: string; scope: string; itemId?: string; context?: string }
|
||||
const documents = computed<DocumentRow[]>(() => {
|
||||
const result: DocumentRow[] = []
|
||||
const application = current.value?.application
|
||||
if (!application) return result
|
||||
// 旧总文件仅作为兼容回显,避免与已分类型的总文件重复展示。
|
||||
const add = (kind: string, id: string | null | undefined, name: string | null | undefined, scope: string, itemId?: string, context?: string) => {
|
||||
if (id) result.push({ key: scope + ':' + (itemId || '') + ':' + id, kind, fileId: id, fileName: name || '未命名文件', scope, itemId, context })
|
||||
}
|
||||
add('箱唛总文件', application.cartonTotalFileId, application.cartonTotalFileName, 'CARTON_TOTAL')
|
||||
add('SKU 标签总文件', application.skuTotalFileId, application.skuTotalFileName, 'SKU_TOTAL')
|
||||
if (application.totalFileId !== application.cartonTotalFileId && application.totalFileId !== application.skuTotalFileId)
|
||||
add('贴标总文件', application.totalFileId, application.totalFileName, 'TOTAL')
|
||||
add('面单文件', application.shippingLabelFileId, application.shippingLabelFileName, 'SHIPPING')
|
||||
for (const item of current.value?.items || []) add('明细文件', item.relabelFileId, item.relabelFileName, 'ITEM', String(item.id), [item.cartonMark, item.skuCode].filter(Boolean).join(' / '))
|
||||
return result
|
||||
})
|
||||
/** 切换申请及关闭预览时释放文件对象。 */
|
||||
function clearPreview() { if (previewUrl.value) URL.revokeObjectURL(previewUrl.value); previewUrl.value = ''; previewOpen.value = false }
|
||||
/** 使用带身份认证的下载接口,预览和保存共享同一归属校验。 */
|
||||
async function openFile(key: string, download = false) {
|
||||
const row = documents.value.find(document => document.key === key)
|
||||
if (!row || busy.value) return
|
||||
busy.value = true
|
||||
const ticket = generation
|
||||
try {
|
||||
const blob = await downloadRelabelDocument(props.relabelNo, row.fileId, row.scope, row.itemId)
|
||||
if (ticket !== generation) return
|
||||
if (download) {
|
||||
const url = URL.createObjectURL(blob); const link = document.createElement('a')
|
||||
link.href = url; link.download = row.fileName; link.click(); window.setTimeout(() => URL.revokeObjectURL(url), 1000)
|
||||
} else { clearPreview(); previewUrl.value = URL.createObjectURL(blob); previewTitle.value = row.fileName; previewOpen.value = true }
|
||||
} catch (cause) { ElMessage.error(cause instanceof Error ? cause.message : '文件读取失败,请重试') }
|
||||
finally { busy.value = false }
|
||||
}
|
||||
onBeforeUnmount(() => { ++generation; clearPreview() })
|
||||
</script>
|
||||
<template>
|
||||
<section class="relabel-documents" aria-label="客户上传的贴标文件">
|
||||
<h3>贴标文件<span v-if="relabelNo"> · {{ relabelNo }}</span></h3>
|
||||
<p v-if="loading">资料加载中…</p>
|
||||
<p v-else-if="error" role="alert">{{ error }} <el-button link type="primary" @click="load">重试</el-button></p>
|
||||
<el-table v-else :data="documents" row-key="key" border empty-text="暂无上传的贴标文件">
|
||||
<el-table-column prop="kind" label="文件类型" width="150" />
|
||||
<el-table-column prop="context" label="对应箱唛 / SKU" min-width="180" />
|
||||
<el-table-column prop="fileName" label="文件名称" min-width="240" />
|
||||
<el-table-column label="操作" width="150"><template #default="{ row }"><el-button link type="primary" :disabled="busy" @click="openFile(String(row.key))">查看</el-button><el-button link type="primary" :disabled="busy" @click="openFile(String(row.key), true)">下载</el-button></template></el-table-column>
|
||||
</el-table>
|
||||
<el-dialog v-model="previewOpen" :title="previewTitle" width="80%" append-to-body @closed="clearPreview"><iframe v-if="previewUrl" :src="previewUrl" :title="previewTitle" class="relabel-document-preview" /></el-dialog>
|
||||
</section>
|
||||
</template>
|
||||
<style scoped>
|
||||
.relabel-documents { margin: 16px 0; }
|
||||
.relabel-documents h3 { margin: 0 0 12px; font-size: 14px; }
|
||||
.relabel-documents h3 span { font-weight: 400; }
|
||||
.relabel-document-preview { width: 100%; height: 70vh; border: 0; }
|
||||
</style>
|
||||
@@ -1,6 +1,7 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
import ts from 'typescript'
|
||||
|
||||
const routes = readFileSync(new URL('./routes.ts', import.meta.url), 'utf8')
|
||||
const services = readFileSync(new URL('./views/WarehouseServiceView.vue', import.meta.url), 'utf8')
|
||||
@@ -41,13 +42,31 @@ test('opens warehouse service and return receipt details as dedicated protected
|
||||
})
|
||||
|
||||
test('returns to the labeling list after a labeling application is submitted', () => {
|
||||
assert.match(services, /ElMessage\.success\(`\$\{config\.value\.title\}提交成功`\)[\s\S]*?router\.push\(\{ name: serviceListRouteName\(props\.service\) \}\)/)
|
||||
assert.match(services, /ElMessage\.success\(`\$\{config\.value\.title\}提交成功`\)[\s\S]*?await goToServiceList\(\)/)
|
||||
// Return-carton sources use the unified relabel application and its file endpoint.
|
||||
assert.match(services, /returnCartonId: item\.returnCartonId/)
|
||||
assert.match(services, /uploadRelabelFile\(created\.application\.relabelNo,[\s\S]*?router\.push\(\{ name: serviceListRouteName\(props\.service\) \}\)/)
|
||||
assert.match(services, /uploadRelabelFile\(created\.application\.relabelNo,[\s\S]*?await goToServiceList\(\)/)
|
||||
assert.match(services, /申请已创建,附件待补传/)
|
||||
})
|
||||
|
||||
test('service list return navigates standalone pages and refreshes embedded lists', async () => {
|
||||
const source = services.slice(services.indexOf('function serviceListRouteName('), services.indexOf('async function openEdit('))
|
||||
const javascript = ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2022 } }).outputText
|
||||
for (const props of [
|
||||
{ service: 'labeling', createPage: true, detailPage: false },
|
||||
{ service: 'labeling', createPage: false, detailPage: true },
|
||||
{ service: 'labeling', createPage: false, detailPage: false },
|
||||
]) {
|
||||
const calls = [], mode = { value: 'create' }
|
||||
const goToServiceList = new Function('props', 'router', 'mode', 'loadPage', `${javascript}; return goToServiceList`)(
|
||||
props, { push: async route => calls.push(route) }, mode, async () => calls.push('refresh'),
|
||||
)
|
||||
await goToServiceList()
|
||||
assert.deepEqual(calls, props.createPage || props.detailPage ? [{ name: 'overseas-labeling' }] : ['refresh'])
|
||||
if (!props.createPage && !props.detailPage) assert.equal(mode.value, 'list')
|
||||
}
|
||||
})
|
||||
|
||||
test('keeps overseas warehouse writes behind operation-specific button permissions', () => {
|
||||
for (const permission of [
|
||||
'merchant:overseas-warehouse:relabel:create',
|
||||
|
||||
@@ -39,6 +39,8 @@ test('箱子在签收箱号明细展示一次,真实托盘和缺少箱号明
|
||||
test('parcel waybills appear as tracking numbers while generated parcel codes remain physical codes', () => {
|
||||
assert.equal(exports.receiptTrackingNo({ unitType: 'PARCEL', unitCode: 'sf561626' }), 'sf561626')
|
||||
assert.equal(exports.receiptTrackingNo({ unitType: 'PARCEL', unitCode: 'PK0123456789ABCDEF' }), '')
|
||||
assert.equal(exports.receiptTrackingNo({ unitType: 'PARCEL', unitCode: 'PK-FUW3Q9N0B8RL' }), '')
|
||||
assert.equal(exports.receiptTrackingNo({ unitType: 'PARCEL', unitCode: 'PK-FUW3Q9N0B8RL', trackingNo: ' SF123 ' }), 'SF123')
|
||||
assert.equal(exports.receiptTrackingNo({ unitType: 'PARCEL', unitCode: 'PK0123456789ABCDEF', trackingNo: ' SF123 ' }), 'SF123')
|
||||
assert.equal(exports.receiptTrackingNo({ unitType: 'PALLET', unitCode: 'PLT123' }), '')
|
||||
})
|
||||
|
||||
@@ -18,7 +18,7 @@ export function receiptTrackingNo(receipt: Pick<ReturnReceipt, 'trackingNo' | 'u
|
||||
const trackingNo = typeof receipt.trackingNo === 'string' ? receipt.trackingNo.trim() : ''
|
||||
if (trackingNo) return trackingNo
|
||||
const code = typeof receipt.unitCode === 'string' ? receipt.unitCode.trim() : ''
|
||||
return receipt.unitType === 'PARCEL' && code && !/^PK[0-9A-F]{16}$/.test(code) ? code : ''
|
||||
return receipt.unitType === 'PARCEL' && code && !/^(?:PK[0-9A-F]{16}|PK-[0-9A-Z]+)$/.test(code) ? code : ''
|
||||
}
|
||||
|
||||
export function formatReturnVolume(value?: number | null) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
import ts from 'typescript'
|
||||
|
||||
const view = readFileSync(new URL('./ReturnOrdersView.vue', import.meta.url), 'utf8')
|
||||
const api = readFileSync(new URL('../api/returnOrders.ts', import.meta.url), 'utf8')
|
||||
@@ -254,7 +255,25 @@ test('待审核退货可编辑,完成入库后从独立新增页发起贴标',
|
||||
assert.match(view, /name: 'overseas-labeling-create'/)
|
||||
assert.match(view, /query: \{ returnNo: order\.returnNo, warehouseId: String\(order\.overseasWarehouseId\)/)
|
||||
assert.match(view, /cartonMark \? \{ cartonMark \}/)
|
||||
assert.match(view, /openRelabel\(selected\.order, current\.carton\.cartonMark\)/)
|
||||
assert.match(view, /v-if="canCreateRelabel && canApplyRelabel\(selected\.order\)"[^>]*@click="openRelabel\(selected\.order\)">贴标申请/)
|
||||
assert.match(view, /v-for="application in row\.relabelApplications \|\| \[\]"/)
|
||||
assert.match(view, /relabelStatusLabel\(application\.status\)/)
|
||||
})
|
||||
|
||||
test('贴标入口保留完成入库门禁与退货来源,箱号只作为目标页库存筛选', () => {
|
||||
const source = view.slice(view.indexOf('function canApplyRelabel('), view.indexOf('async function submit()'))
|
||||
const javascript = ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2022 } }).outputText
|
||||
const routes = []
|
||||
const { canApplyRelabel, openRelabel } = new Function('router', `${javascript}; return { canApplyRelabel, openRelabel }`)({ push: route => routes.push(route) })
|
||||
const order = { returnNo: 'RT-TEST', overseasWarehouseId: 'WH-TEST', status: 'COMPLETED', needRelabel: true }
|
||||
assert.equal(canApplyRelabel(order), true)
|
||||
assert.equal(canApplyRelabel({ ...order, status: 'PENDING_RECEIPT' }), false)
|
||||
assert.equal(canApplyRelabel({ ...order, needRelabel: false }), false)
|
||||
assert.equal(canApplyRelabel({ ...order, relabelNo: 'RL-EXISTS' }), false)
|
||||
openRelabel(order)
|
||||
openRelabel(order, 'RC-TEST')
|
||||
assert.deepEqual(routes, [
|
||||
{ name: 'overseas-labeling-create', query: { returnNo: 'RT-TEST', warehouseId: 'WH-TEST' } },
|
||||
{ name: 'overseas-labeling-create', query: { returnNo: 'RT-TEST', warehouseId: 'WH-TEST', cartonMark: 'RC-TEST' } },
|
||||
])
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { computed, onActivated, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { ArrowLeft, ChevronDown, Plus } from '@lucide/vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
@@ -280,6 +280,11 @@ onMounted(() => {
|
||||
else if (props.detailPage) void Promise.all([loadDetail(), loadOperationWarehouses()])
|
||||
else void Promise.all([load(), loadOperationWarehouses()])
|
||||
})
|
||||
let activatedOnce = false
|
||||
onActivated(() => {
|
||||
if (activatedOnce && !props.createPage && !props.detailPage) void load()
|
||||
activatedOnce = true
|
||||
})
|
||||
watch(() => [query.page, query.size] as const, ([nextPage, nextSize], [previousPage, previousSize]) => {
|
||||
if (nextSize !== previousSize && nextPage !== 1) { query.page = 1; return }
|
||||
if (!props.createPage && !props.detailPage) void load()
|
||||
@@ -443,24 +448,6 @@ onBeforeUnmount(clearPhotoUrls)
|
||||
<template #footer><el-button @click="showCartons = false">关闭</el-button></template>
|
||||
</el-dialog>
|
||||
</section>
|
||||
<section class="return-detail-section">
|
||||
<h3>当前关联实物箱</h3>
|
||||
<el-collapse v-if="selected.currentCartons?.length">
|
||||
<el-collapse-item v-for="current in selected.currentCartons" :key="current.carton.cartonMark" :name="current.carton.cartonMark">
|
||||
<template #title>{{ current.carton.cartonMark }} · 整箱 {{ current.carton.totalQuantity }} 件 · 本单 {{ current.orderQuantity }} 件 · {{ current.carton.sourceReturnNo?.includes(',') ? '跨来源单合箱' : '单一来源' }}</template>
|
||||
<p>来源退货单:{{ current.carton.sourceReturnNo }} · 我方箱唛:{{ current.labelStatus === 'CONFIRMED' ? '已确认贴标' : '待贴标确认' }}</p>
|
||||
<el-table :data="current.ownership" border>
|
||||
<el-table-column prop="returnNo" label="来源退货单" min-width="200" />
|
||||
<el-table-column prop="skuCode" label="SKU" min-width="180" />
|
||||
<el-table-column prop="quantity" label="来源贡献数量" width="130" />
|
||||
</el-table>
|
||||
<p>换箱唛以当前实物箱为单位;跨来源单同 SKU 的部分贴标须先现场分拣确认。</p>
|
||||
<el-button v-if="canCreateRelabel" :disabled="!current.canRelabel" @click="openRelabel(selected.order, current.carton.cartonMark)">选择此箱贴标</el-button>
|
||||
<p v-if="!current.canRelabel">{{ current.blockReason }}</p>
|
||||
</el-collapse-item>
|
||||
</el-collapse>
|
||||
<el-empty v-else description="暂无当前关联实物箱" :image-size="64" />
|
||||
</section>
|
||||
<section v-if="selected.photos?.length" class="return-detail-section">
|
||||
<h3>退货打托照片({{ selected.photos?.length || 0 }} 张)</h3>
|
||||
<div v-if="selected.photos?.length" class="return-photo-grid">
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,86 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { transpileModule, ScriptTarget } from 'typescript'
|
||||
|
||||
const view = readFileSync(new URL('./WarehouseServiceView.vue', import.meta.url), 'utf8')
|
||||
|
||||
test('退货箱明细与来源下拉使用同一份逐箱库存,不请求普通入仓箱接口', async () => {
|
||||
const body = view.slice(view.indexOf('async function loadTransferSourceCartons('), view.indexOf('// Snowflake IDs'))
|
||||
const code = transpileModule(body, { compilerOptions: { target: ScriptTarget.ES2022 } }).outputText
|
||||
const ctx = { transferSourceCartons: { value: [] }, transferSourceError: { value: '' },
|
||||
transferSourceLoading: { value: false }, inventoryTransfer: { value: true },
|
||||
selectedTransferInventory: { value: 'RETURN_ORDER:RET-1' }, form: { warehouse: 'WH' },
|
||||
transferInventoryCandidates: { value: [
|
||||
{ sourceType: 'RETURN_CARTON', inboundOrderNo: 'RET-1', cartonMark: 'BOX-1', cartonAvailablePackages: 1,
|
||||
cartonSkuItems: [{ skuCode: 'A', quantity: 3 }, { skuCode: 'B', quantity: 2 }] },
|
||||
{ sourceType: 'RETURN_PARCEL', inboundOrderNo: 'RET-1', cartonMark: 'BOX-2', cartonAvailablePackages: 1,
|
||||
cartonSkuItems: [{ skuCode: 'A', quantity: 1 }] },
|
||||
{ sourceType: 'RETURN_CARTON', inboundOrderNo: 'OTHER', cartonMark: 'FOREIGN', cartonAvailablePackages: 1 },
|
||||
{ sourceType: 'RETURN_CARTON', inboundOrderNo: 'RET-1', cartonMark: 'OCCUPIED', cartonAvailablePackages: 0 },
|
||||
] }, getTransferSourceCartons: () => { throw Error('不应调用普通来源箱查询') } }
|
||||
const load = new Function(...Object.keys(ctx), 'let transferSourceRequest=0;' + code + ';return loadTransferSourceCartons')(...Object.values(ctx))
|
||||
await load()
|
||||
assert.deepEqual(ctx.transferSourceCartons.value.map(box => box.cartonMark), ['BOX-1', 'BOX-2'])
|
||||
assert.equal(ctx.transferSourceCartons.value[0].quantity, 5)
|
||||
assert.equal(ctx.transferSourceCartons.value[0].items.length, 2)
|
||||
assert.equal(ctx.transferSourceError.value, '')
|
||||
assert.equal(ctx.transferSourceLoading.value, false)
|
||||
})
|
||||
|
||||
function setup() {
|
||||
const body = view.slice(view.indexOf('async function selectTransferInventory('), view.indexOf('function selectTransferInboundMethod('))
|
||||
const code = transpileModule(body, { compilerOptions: { target: ScriptTarget.ES2022 } }).outputText
|
||||
const ctx = { selectedTransferInventory: {value:''}, selectedTransferSourceOrderIds:{value:[]},
|
||||
transferSourceCartons:{value:[{cartonMark:'OLD'}]}, transferSourceError:{value:'OLD'}, transferSourceLoading:{value:true},
|
||||
transferDestinationType:{value:'NONE'}, form:{warehouse:'WH',inboundNo:''},
|
||||
transferInventoryCandidates:{value:[{inboundCode:'IN',skuCode:'SKU',productNameZh:'货品',availablePackages:2},
|
||||
{inboundCode:'IN',skuCode:'SKU',availablePackages:3},{inboundCode:'OTHER',skuCode:'OTHER',availablePackages:99}]},
|
||||
cargoRows:{value:[]}, inventoryQuantity:Number, createCargoRow:()=>({expectedPackageCount:1}),
|
||||
loadTransferSourceCartons:async()=>{}, listRelabelHistoricalOrders:async()=>[{id:'1234567890123456789'}],
|
||||
ElMessage:{error(message){throw Error(message)}} }
|
||||
const select = new Function(...Object.keys(ctx), 'let transferSourceRequest=0;'+code+'; return selectTransferInventory')(...Object.values(ctx))
|
||||
return {ctx,select}
|
||||
}
|
||||
test('inventory source populates full immutable cargo counts without mixing other inbound orders',async()=>{
|
||||
const {ctx,select}=setup(); await select('IN')
|
||||
assert.equal(ctx.transferDestinationType.value,'FBA')
|
||||
assert.equal(ctx.cargoRows.value.length,1)
|
||||
assert.equal(ctx.cargoRows.value[0].expectedPackageCount,5)
|
||||
assert.equal(ctx.cargoRows.value[0].quantity,5)
|
||||
assert.deepEqual(ctx.selectedTransferSourceOrderIds.value,['1234567890123456789'])
|
||||
await select('')
|
||||
assert.equal(ctx.form.inboundNo,'')
|
||||
assert.deepEqual(ctx.selectedTransferSourceOrderIds.value,[])
|
||||
assert.deepEqual(ctx.transferSourceCartons.value,[])
|
||||
})
|
||||
test('inventory view hides inbound fields and editing actions and shows original carton destination',()=>{
|
||||
assert.match(view,/v-if="props.service === 'transfer' && !inventoryTransfer"[^>]*><span><i>\*<\/i>入仓方式/)
|
||||
assert.match(view,/<el-radio-button v-if="!inventoryTransfer" value="NONE">/)
|
||||
const panel=view.slice(view.indexOf('<section v-if="inventoryTransfer"'),view.indexOf('</section>',view.indexOf('<section v-if="inventoryTransfer"')))
|
||||
assert.match(panel,/box.cartonMark/); assert.match(panel,/box.destinationWarehouseCode/)
|
||||
assert.doesNotMatch(panel,/v-model|addCargoRow|removeCargoRow|清空/)
|
||||
assert.match(view,/!inventoryTransfer\.value && \(!form.warehouse/)
|
||||
assert.match(view,/estimatedArrivalAt: inventoryTransfer.value \? undefined : form.estimatedArrivalAt/)
|
||||
})
|
||||
|
||||
test('仓储转运来源下拉包含退货单并使用独立来源编码', () => {
|
||||
assert.match(view, /RETURN_ORDER:\$\{returnNo\}/)
|
||||
assert.match(view, /退货单|\$\{returnNo\}/)
|
||||
assert.match(view, /item\.sourceType === 'RETURN_CARTON' \|\| item\.sourceType === 'RETURN_PARCEL'/)
|
||||
assert.match(view, /isReturnSource[\s\S]*item\.sourceType === 'RETURN_CARTON'/)
|
||||
})
|
||||
|
||||
test('退货来源带出实物 SKU 数量并跳过普通历史订单查询', async () => {
|
||||
const { ctx, select } = setup()
|
||||
ctx.transferInventoryCandidates.value = [{ sourceType: 'RETURN_CARTON', inboundOrderNo: 'RET-1',
|
||||
cartonAvailablePackages: 1, skuItems: [{ skuCode: 'SKU', quantity: 1 }],
|
||||
cartonSkuItems: [{ skuCode: 'SKU', quantity: 6 }] },
|
||||
{ sourceType: 'RETURN_CARTON', inboundOrderNo: 'OTHER', cartonAvailablePackages: 1,
|
||||
cartonSkuItems: [{ skuCode: 'FOREIGN', quantity: 99 }] }]
|
||||
await select('RETURN_ORDER:RET-1')
|
||||
assert.equal(ctx.cargoRows.value.length, 1)
|
||||
assert.equal(ctx.cargoRows.value[0].quantity, 6)
|
||||
assert.equal(ctx.cargoRows.value[0].expectedPackageCount, 1)
|
||||
assert.deepEqual(ctx.selectedTransferSourceOrderIds.value, [])
|
||||
})
|
||||
@@ -129,9 +129,9 @@ test('贴标货物明细通过单一换标类型选择三种模式', () => {
|
||||
test('箱唛相关换标必须选择四选一派送方式', () => {
|
||||
assert.match(view, /label="卡车派送" value="TRUCK"/)
|
||||
assert.match(view, /label="快递派送" value="EXPRESS"/)
|
||||
assert.match(view, /label="卡车自提" value="TRUCK_PICKUP"/)
|
||||
assert.match(view, /label="快递自提" value="EXPRESS_PICKUP"/)
|
||||
assert.match(api, /RelabelDeliveryMethod = 'TRUCK' \| 'EXPRESS' \| 'TRUCK_PICKUP' \| 'EXPRESS_PICKUP'/)
|
||||
assert.match(view, /label="亚马逊自提" value="AMAZON_PICKUP"/)
|
||||
assert.match(view, /label="客户自提" value="CUSTOMER_PICKUP"/)
|
||||
assert.match(api, /RelabelDeliveryMethod = 'TRUCK' \| 'EXPRESS' \| 'AMAZON_PICKUP' \| 'CUSTOMER_PICKUP'/)
|
||||
assert.match(view, /labelingMode\.value !== 'sku' && !form\.deliveryMethod/)
|
||||
})
|
||||
|
||||
|
||||
@@ -5,6 +5,26 @@ import ts from 'typescript'
|
||||
const view = readFileSync(new URL('./WarehouseServiceView.vue', import.meta.url), 'utf8')
|
||||
const source = view.slice(view.indexOf('function returnSourceNos'), view.indexOf('const relabelInboundOptions'))
|
||||
const compiled = ts.transpileModule(source + '\nexports.sourceNos = returnSourceNos; exports.progress = returnRelabelProgress', { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS } }).outputText
|
||||
test('carton validation ignores unselected stock after SKU relabel and consolidation', () => {
|
||||
const start = view.indexOf('async function submit(')
|
||||
const validation = view.slice(view.indexOf(' const invalidCargo =', start), view.indexOf(" if ((props.service === 'relabel'", start))
|
||||
const compiledValidation = ts.transpileModule(validation, { compilerOptions: { target: ts.ScriptTarget.ES2022 } }).outputText
|
||||
const validate = new Function('props', 'labelingMode', 'relabelRows', 'cartonDetailRows', 'cargoRows', 'skuRelabelRows', compiledValidation + '; return invalidCargo')
|
||||
const stockRows = [
|
||||
{ included: false, name: '', sku: 'OLD-SKU', quantity: 1 },
|
||||
{ included: false, name: '', sku: 'OLD-SKU,NEW-SKU', quantity: 1 },
|
||||
]
|
||||
const selected = { included: true, name: 'Product', sku: 'OLD-SKU,NEW-SKU', quantity: 1 }
|
||||
function invalid(details) {
|
||||
return validate({ service: 'labeling' }, { value: 'carton' }, details,
|
||||
{ value: details }, { value: [...stockRows, ...details] }, { value: [] })
|
||||
}
|
||||
assert.equal(invalid([selected]), false)
|
||||
assert.equal(invalid([{ ...selected, name: '' }]), true)
|
||||
assert.equal(invalid([]), true)
|
||||
assert.equal(invalid([{ ...selected, sku: '' }]), false, 'SKU is optional for carton labels')
|
||||
})
|
||||
|
||||
const exports = {}
|
||||
new Function('exports', compiled)(exports)
|
||||
test('合箱来源按单号集合精确匹配并去重,不能用子串匹配', () => {
|
||||
|
||||
@@ -7,6 +7,43 @@ const view = readFileSync(new URL('./WarehouseServiceView.vue', import.meta.url)
|
||||
test('货物废弃先选择海外操作仓,再加载并选择该仓入库单', () => {
|
||||
assert.match(view, /props\.service === 'disposal'.*操作仓.*v-model="form\.warehouse".*请选择海外仓.*@change="selectDiscardWarehouse"/s)
|
||||
assert.match(view, /props\.service === 'disposal'.*选择入库单.*:disabled="!form\.warehouse".*请先选择操作仓/s)
|
||||
assert.match(view, /async function selectDiscardWarehouse\(\).*selectedDiscardOrderId\.value = ''.*cargoRows\.value = \[\].*listRelabelInventoryCandidates\(form\.warehouse\).*grouped/s)
|
||||
assert.match(view, /async function selectDiscardWarehouse\(\).*selectedDiscardOrderId\.value = ''.*cargoRows\.value = \[\].*listDiscardCandidates\(\)/s)
|
||||
assert.match(view, /\|\| !primaryWarehouse \|\| invalidCargo/)
|
||||
})
|
||||
|
||||
|
||||
test('废弃来源包含通用仓位,选择后带出全部库存且不把选择键提交为订单ID', () => {
|
||||
const body = view.match(/function selectDiscardInbound\(orderId: string\) \{([\s\S]*?)\n\}/)[1]
|
||||
const select = new Function('discardSelectedBoxKeys', 'discardBoxSearch', 'selectAllDiscardBoxes', 'discardCandidates', 'cargoRows', 'form', 'cargoSequence', 'orderId', body)
|
||||
const cargoRows = { value: [] }; const form = {}
|
||||
select({ value: [] }, { value: '' }, () => {}, { value: [{ orderId: 'inventory:42', sourceType: 'INVENTORY', inventoryPositionId: '42', overseasWarehouseId: '99', inboundCode: 'WT-1', skus: [{ skuCode: 'A', productName: '商品', availablePackages: 100 }] }] }, cargoRows, form, 0, 'inventory:42')
|
||||
assert.equal(cargoRows.value[0].quantity, 100)
|
||||
assert.equal(cargoRows.value[0].sourceInventoryPositionId, '42')
|
||||
assert.equal(form.inboundNo, 'WT-1')
|
||||
assert.match(view, /orderId: item.sourceType === 'DROPSHIP' \? item.sourceOrderId : undefined/)
|
||||
assert.match(view, /inventoryPositionId: item.sourceType === 'INVENTORY'/)
|
||||
})
|
||||
|
||||
function selectionRows(boxes, selectedKeys) {
|
||||
const body = view.match(/function syncDiscardSelection\(\) \{([\s\S]*?)\n\}/)[1].replace('new Map<string, CargoRow>()', 'new Map()')
|
||||
const rows = { value: [] }
|
||||
new Function('discardSelectableBoxes', 'discardSelectedBoxKeys', 'cargoRows', 'createCargoRow', body)({ value: boxes }, { value: selectedKeys }, rows, () => ({}))
|
||||
return rows.value
|
||||
}
|
||||
test('部分废弃只提交勾选的箱子,数量由箱子汇总,清空后无明细', () => {
|
||||
const source = { orderId: 'inventory:42', inventoryPositionId: '42', sourceType: 'INVENTORY', availablePackages: 3, skus: [{ skuCode: 'SKU', productName: '商品' }] }
|
||||
const boxes = ['1', '2', '3'].map(id => ({ key: `INVENTORY:${id}`, cartonId: id, cartonMark: `BOX-${id}`, source, skus: source.skus }))
|
||||
const rows = selectionRows(boxes, ['INVENTORY:2'])
|
||||
assert.equal(rows.length, 1); assert.equal(rows[0].quantity, 1)
|
||||
assert.deepEqual(rows[0].discardCartonIds, ['2']); assert.equal(rows[0].sourceInventoryPositionId, '42')
|
||||
assert.equal(selectionRows(boxes, []).length, 0)
|
||||
})
|
||||
test('混合SKU箱按实物箱只计一次,退货箱仍整箱携带全部SKU', () => {
|
||||
const skus = [{ skuCode: 'A', availablePackages: 2 }, { skuCode: 'B', availablePackages: 3 }]
|
||||
const dropship = { orderId: '42', sourceType: 'DROPSHIP', availablePackages: 1, skus }
|
||||
const rows = selectionRows([{ key: 'DROPSHIP:1', cartonId: '1', source: dropship, skus }], ['DROPSHIP:1'])
|
||||
assert.equal(rows.length, 1); assert.equal(rows[0].quantity, 1); assert.deepEqual(rows[0].discardCartonIds, ['1'])
|
||||
const returned = { orderId: 'return:2', returnCartonId: '2', sourceType: 'RETURN_CARTON', availablePackages: 5, skus }
|
||||
const returnRows = selectionRows([{ key: 'RETURN_CARTON:2', cartonId: '2', source: returned, skus }], ['RETURN_CARTON:2'])
|
||||
assert.deepEqual(returnRows.map(row => row.quantity), [2, 3]); assert.ok(returnRows.every(row => row.returnCartonId === '2' && row.discardCartonIds === undefined))
|
||||
})
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { computed, effectScope, nextTick, reactive, ref, watch } from 'vue'
|
||||
import ts from 'typescript'
|
||||
|
||||
const source = readFileSync(new URL('./WarehouseServiceView.vue', import.meta.url), 'utf8')
|
||||
const detailNumber = source.match(/^const detailNumber = .*$/m)[0]
|
||||
const routeWatcher = source.slice(source.indexOf('watch(() => [props.service,'), source.indexOf('watch(mode,'))
|
||||
const activation = source.slice(source.indexOf('let activatedOnce = false'), source.indexOf('onBeforeUnmount(() =>'))
|
||||
const code = ts.transpileModule([detailNumber, routeWatcher, activation].join('\n'), { compilerOptions: { target: ts.ScriptTarget.ES2022 } }).outputText
|
||||
function harness(detailPage = false) {
|
||||
const scope = effectScope(), props = reactive({ service: 'transfer', createPage: false, detailPage, detailNo: '' })
|
||||
const route = reactive({ params: { detailNo: detailPage ? 'TRANSFER-A' : undefined } })
|
||||
const mode = ref(detailPage ? 'detail' : 'list'), page = ref(3), filters = reactive({ number: 'TRANSFER-FILTER', warehouse: 'warehouse-1', status: 'RECEIVED' })
|
||||
const calls = [], activationCallbacks = []
|
||||
const resetFilters = () => { calls.push('resetFilters'); Object.assign(filters, { number: '', warehouse: '', status: '' }) }
|
||||
const loadPage = () => { calls.push({ kind: 'list', page: page.value, ...filters }) }
|
||||
scope.run(() => new Function('computed', 'watch', 'props', 'route', 'mode', 'page', 'resetFilters', 'resetForm', 'openCreate', 'loadDetailPage', 'loadPage', 'onActivated', code)(computed, watch, props, route, mode, page, resetFilters, () => calls.push('resetForm'), () => calls.push('create'), () => calls.push('detail'), loadPage, callback => activationCallbacks.push(callback)))
|
||||
return { scope, props, route, mode, page, filters, calls, activate: () => activationCallbacks.forEach(callback => callback()) }
|
||||
}
|
||||
|
||||
test('cached transfer list ignores detail navigation and refreshes once on return with its original filters and page', async () => {
|
||||
const state = harness()
|
||||
try {
|
||||
state.activate()
|
||||
state.route.params.detailNo = 'TRANSFER-A'
|
||||
await nextTick()
|
||||
assert.deepEqual(state.calls, [])
|
||||
assert.equal(state.page.value, 3)
|
||||
assert.equal(state.filters.number, 'TRANSFER-FILTER')
|
||||
state.route.params.detailNo = 'TRANSFER-B'
|
||||
await nextTick()
|
||||
assert.deepEqual(state.calls, [])
|
||||
state.route.params.detailNo = undefined
|
||||
await nextTick()
|
||||
state.activate()
|
||||
assert.deepEqual(state.calls, [{ kind: 'list', page: 3, number: 'TRANSFER-FILTER', warehouse: 'warehouse-1', status: 'RECEIVED' }])
|
||||
} finally { state.scope.stop() }
|
||||
})
|
||||
|
||||
test('real detail pages still react to another detail number without loading a background list', async () => {
|
||||
const state = harness(true)
|
||||
try {
|
||||
state.route.params.detailNo = 'TRANSFER-B'
|
||||
await nextTick()
|
||||
assert.deepEqual(state.calls, ['resetFilters', 'resetForm', 'detail'])
|
||||
} finally { state.scope.stop() }
|
||||
})
|
||||
@@ -0,0 +1,21 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import ts from 'typescript'
|
||||
|
||||
const source = readFileSync(new URL('./WarehouseServiceView.vue', import.meta.url), 'utf8')
|
||||
const functions = source.slice(source.indexOf('function transferCartonItem('), source.indexOf("const detailError = ref('')"))
|
||||
const code = ts.transpileModule(functions, { compilerOptions: { target: ts.ScriptTarget.ES2022 } }).outputText
|
||||
const load = selectedDetail => new Function('selectedDetail', `${code}; return { transferCartonLines, palletSkuRows, palletQuantity, receivedItemCartons, receivedItemQuantity };`)(selectedDetail)
|
||||
|
||||
test('mixed carton detail and pallet summaries preserve every SKU without duplicating boxes', () => {
|
||||
const mixed = { status: 'COUNTED', transferItemId: '1', transferPalletId: '9', quantity: 10, items: [{ transferItemId: '1', skuCode: 'A', quantity: 3 }, { transferItemId: '2', skuCode: 'B', quantity: 7 }] }
|
||||
const display = load({ value: { items: [{ id: '1', skuCode: 'A', productNameZh: '产品A' }, { id: '2', skuCode: 'B', productNameZh: '产品B' }], cartons: [mixed, { ...mixed, status: 'NOT_RECEIVED', quantity: 999 }] } })
|
||||
assert.equal(display.transferCartonLines(mixed).length, 2)
|
||||
assert.equal(display.transferCartonLines(mixed)[1].productNameZh, '产品B')
|
||||
assert.deepEqual(display.palletSkuRows({ id: '9' }), [{ sku: 'A', quantity: 3 }, { sku: 'B', quantity: 7 }])
|
||||
assert.equal(display.palletQuantity({ id: '9' }), 10)
|
||||
assert.equal(display.receivedItemQuantity('1'), 3)
|
||||
assert.equal(display.receivedItemQuantity('2'), 7)
|
||||
assert.equal(display.receivedItemCartons('2').length, 1)
|
||||
})
|
||||
@@ -44,7 +44,8 @@ test('仓储转运支持无目的地、FBA 仓和私人仓并显式选择是否
|
||||
|
||||
test('换标详情待客户确认时提供确认入口并刷新详情', () => {
|
||||
assert.match(view, /application\.status === 'CUSTOMER_CONFIRM_PENDING'[\s\S]*@click="confirmSelectedRelabel">确认换标完成/)
|
||||
assert.match(view, /async function confirmSelectedRelabel\(\)[\s\S]*await confirmRelabelCompletion\(relabelNo\)[\s\S]*await openDetail\(\{ '0': relabelNo \}\)/)
|
||||
assert.match(view, /async function confirmSelectedRelabel\(\)[\s\S]*openCompletionConfirmation\(relabelNo, 'labeling'\)/)
|
||||
assert.match(view, /async function refreshAfterCompletion\(no: string\)[\s\S]*await openDetail\(\{ '0': no \}\)/)
|
||||
})
|
||||
|
||||
test('仓储转运换标绑定明细自动同步只读的旧 SKU', () => {
|
||||
@@ -78,7 +79,7 @@ test('仓储转运不要求客户填写内部入仓号和我方箱唛,但换
|
||||
assert.doesNotMatch(transferConfig, /进仓编号/)
|
||||
assert.doesNotMatch(transferSubmit, /inboundCode|sourceLastlegOrderIds/)
|
||||
assert.match(view, /货物明细/)
|
||||
assert.match(view, /客户箱唛号 \*/)
|
||||
assert.match(view, /仓库唛头 \*/)
|
||||
assert.match(view, /downloadTransferCartonMarkTemplate/)
|
||||
assert.match(view, /importTransferCartonMarks/)
|
||||
assert.match(view, /<strong>SKU 关联<\/strong>/)
|
||||
@@ -101,15 +102,12 @@ test('仓储转运不要求客户填写内部入仓号和我方箱唛,但换
|
||||
assert.doesNotMatch(view, /<th v-if="props\.service === 'transfer'">FNSKU \/ 箱唛<\/th>/)
|
||||
})
|
||||
|
||||
test('仓储转运货物明细可按 SKU 和箱数打印客户预贴箱唛', () => {
|
||||
assert.match(view, /printTransferDraftCartonMarks/)
|
||||
assert.match(view, /请先填写商品 SKU 和对应的整数箱数/)
|
||||
assert.match(view, /Array\.from\(\{ length: Number\(row\.quantity\) \}/)
|
||||
assert.match(view, /row\.customerCartonMarks = marks\.join\('\\n'\)/)
|
||||
assert.match(view, /仓储转运客户箱唛/)
|
||||
assert.match(view, /@click="printTransferDraftCartonMarks"/)
|
||||
assert.match(view, /打印唛头/)
|
||||
assert.match(view, /单次最多打印 500 张箱唛/)
|
||||
test('仓储转运草稿及提交后统一打印服务端预报箱唛', () => {
|
||||
assert.doesNotMatch(view, /printTransferDraftCartonMarks|CT-\$\{batch\}/)
|
||||
assert.match(view, /getTransferCartonLabel\(transferNo\)/)
|
||||
assert.match(view, /打印预报箱唛/)
|
||||
assert.match(view, /@click="submit\('print'\)"/)
|
||||
assert.match(view, /editingTransferNo.value = transferNo/)
|
||||
})
|
||||
|
||||
test('仓储转运换标资料按步骤分组展示', () => {
|
||||
@@ -145,7 +143,7 @@ test('仓储转运换标总文件与明细模板在桌面端左右排列', () =>
|
||||
})
|
||||
|
||||
test('上门提货不提交物流号,需要换标时随转运单登记申请', () => {
|
||||
assert.match(view, /trackingNo:\s*form\.deliveryMethod === 'CUSTOMER_DELIVERY'\s*\?[^:]+:\s*undefined/)
|
||||
assert.match(view, /trackingNo:\s*!inventoryTransfer\.value && form\.deliveryMethod === 'CUSTOMER_DELIVERY'\s*\?[^:]+:\s*undefined/)
|
||||
assert.match(view, /relabelItems: form\.relabel/)
|
||||
assert.match(view, /form\.deliveryMethod === 'CUSTOMER_DELIVERY'.*物流跟踪号/s)
|
||||
assert.match(view, /提货联系人/)
|
||||
@@ -156,8 +154,8 @@ test('上门提货不提交物流号,需要换标时随转运单登记申请',
|
||||
assert.match(view, /form\.relabelContact/)
|
||||
assert.match(view, /form\.relabelPhone/)
|
||||
assert.match(view, /选择海外仓后自动带出,也可手动填写/)
|
||||
assert.match(view, /pickupContactName: form\.deliveryMethod === 'WAREHOUSE_PICKUP' \? form\.pickupContact : undefined/)
|
||||
assert.match(view, /pickupContactPhone: form\.deliveryMethod === 'WAREHOUSE_PICKUP' \? form\.pickupPhone : undefined/)
|
||||
assert.match(view, /pickupContactName: !inventoryTransfer\.value && form\.deliveryMethod === 'WAREHOUSE_PICKUP' \? form\.pickupContact : undefined/)
|
||||
assert.match(view, /pickupContactPhone: !inventoryTransfer\.value && form\.deliveryMethod === 'WAREHOUSE_PICKUP' \? form\.pickupPhone : undefined/)
|
||||
assert.match(view, /relabelContactName: form\.relabel \? form\.relabelContact/)
|
||||
assert.match(api, /pickupContactName\?: string; pickupContactPhone\?: string/)
|
||||
})
|
||||
@@ -210,7 +208,7 @@ test('仓储转运列表展示换标单号、换标状态并为两类状态着
|
||||
test('仓储转运详情订单概览使用详情接口命名字段回填', () => {
|
||||
assert.match(view, /v-else-if="props\.service === 'transfer'" class="warehouse-detail-grid warehouse-transfer-overview-grid"/)
|
||||
assert.doesNotMatch(view, /入库箱数/)
|
||||
assert.match(view, /'转运单号', '箱数', '海外仓'/)
|
||||
assert.match(view, /'转运单号', '预计箱数', '海外仓'/)
|
||||
for (const field of ['transferNo', 'packageCount', 'operatingWarehouseId', 'deliveryMethod', 'needRelabel', 'relabelNo', 'status', 'createdAt']) {
|
||||
assert.match(view, new RegExp(`selectedDetail\\?\\.${field}`))
|
||||
}
|
||||
@@ -258,7 +256,8 @@ test('贴标申请详情使用紧凑订单概览并中文展示换标状态', ()
|
||||
test('仓储转运详情按接口具名字段展示订单概览', () => {
|
||||
const overview = view.split('<h3>订单概览</h3>')[1]?.split('warehouse-transfer-cargo-card')[0] || ''
|
||||
assert.match(overview, /v-if="props\.service === 'transfer'"/)
|
||||
assert.match(overview, /selectedDetail\?\.transferNo/)
|
||||
assert.doesNotMatch(overview, /<span>转运单号<\/span>/)
|
||||
assert.match(view.split('<header class="warehouse-detail-header">')[1]?.split('</header>')[0] || '', /selectedDetail\?\.transferNo/)
|
||||
assert.match(overview, /selectedDetail\?\.packageCount \?\? '—'/)
|
||||
assert.match(overview, /warehouseDisplay\(selectedDetail\?\.operatingWarehouseId\)/)
|
||||
assert.match(overview, /selectedDetail\?\.destinationWarehouseName \|\| warehouseDisplay\(selectedDetail\?\.destinationWarehouseCode \|\| selectedDetail\?\.fbaWarehouseCode\)/)
|
||||
@@ -272,26 +271,26 @@ test('PDA 打托完成后客户可从列表或详情确认并查看实测数据'
|
||||
assert.match(view, /confirmTransferFromList\(item\['0'\]\)/)
|
||||
assert.match(view, /selectedDetail\?\.status === 'WAIT_CUSTOMER_CONFIRMATION'/)
|
||||
assert.match(view, /PDA 打托实测数据/)
|
||||
for (const label of ['托盘号', '库位', '箱数', '长 × 宽 × 高(cm)', '毛重(kg)', '体积(m³)', '完成时间']) assert.equal(view.includes(label), true)
|
||||
for (const label of ['托盘号', '库位', '箱数', 'SKU 汇总', '毛重(kg)', '体积(m³)', '完成时间']) assert.equal(view.includes(label), true)
|
||||
assert.match(view, /pallet\.warehouseLocationCode/)
|
||||
assert.match(view, /pallet\.cartonCount/)
|
||||
assert.match(view, /palletCartons\(pallet\)\.length/)
|
||||
assert.match(view, /pallet\.grossWeightKg/)
|
||||
assert.match(view, /pallet\.volumeCbm/)
|
||||
assert.doesNotMatch(view, /pallet\.items/)
|
||||
})
|
||||
|
||||
test('仓储转运详情按订单概览、PDA 实测数据、货物明细排序并放大测量照片', () => {
|
||||
test('仓储转运详情按订单概览、货物预报、PDA 实测数据排序并放大测量照片', () => {
|
||||
const detailStart = view.indexOf('<header class="warehouse-detail-header">')
|
||||
const detailSource = view.slice(detailStart)
|
||||
const overviewIndex = detailSource.indexOf('<h3>订单概览</h3>')
|
||||
const measurementIndex = detailSource.indexOf('<h3>PDA 打托实测数据</h3>')
|
||||
const cargoIndex = detailSource.indexOf('<h3>货物明细</h3>')
|
||||
const cargoIndex = detailSource.indexOf('<h3>预报与实际签收</h3>')
|
||||
assert.ok(overviewIndex >= 0 && overviewIndex < measurementIndex)
|
||||
assert.ok(measurementIndex < cargoIndex)
|
||||
assert.ok(cargoIndex < measurementIndex)
|
||||
assert.match(view, /class="warehouse-photo-grid warehouse-measurement-photo-grid"/)
|
||||
assert.match(view, /class="warehouse-photo-item warehouse-measurement-photo-item"/)
|
||||
assert.match(view, /warehouse-measurement-photo-grid\{flex:1;grid-template-columns:repeat\(auto-fill,minmax\(240px,1fr\)\)\}/)
|
||||
assert.match(view, /warehouse-measurement-photo-item\{width:100%;height:180px\}/)
|
||||
assert.match(view, /warehouse-measurement-photo-item\{width:100%;height:120px\}/)
|
||||
})
|
||||
|
||||
test('仓储转运详情自动加载 PDA 测量照片并释放 Blob URL', () => {
|
||||
@@ -328,7 +327,7 @@ test('仓储转运可在新建页直接提交,也可保存草稿后编辑提
|
||||
assert.match(view, /await updateTransfer\(editingTransferNo\.value, payload\)/)
|
||||
assert.match(view, /await submitTransfer\(transferNo\)/)
|
||||
assert.match(view, /<form [^>]*@submit\.prevent="submit\('submit'\)"/)
|
||||
assert.match(view, /selectedDetail\?\.status !== 'DRAFT'[^>]*[\s\S]*?printTransferCartons[^>]*[\s\S]*?打印箱唛/)
|
||||
assert.match(view, /selectedDetail\?\.status !== 'DRAFT'[^>]*[\s\S]*?printTransferCartons[^>]*[\s\S]*?打印预报箱唛/)
|
||||
assert.doesNotMatch(view, /row\.__status !== 'DRAFT'.*printTransferCartons\(row\['0'\]\)/s)
|
||||
assert.doesNotMatch(view, /selectedDetail\?\.status === 'DRAFT'[^>]*[\s\S]*?>提交订单/)
|
||||
assert.match(view, /native-type="button"[^>]*:loading="saving"[^>]*@click="submit\('draft'\)"/)
|
||||
@@ -339,7 +338,7 @@ test('仓储转运可在新建页直接提交,也可保存草稿后编辑提
|
||||
test('编辑仓储转运时加载库存转运来源候选', () => {
|
||||
assert.match(view, /async function loadTransferInventoryCandidates\(warehouseId: string\)/)
|
||||
assert.match(view, /if \(props\.service === 'transfer' && form\.warehouse\) \{[\s\S]*await loadTransferInventoryCandidates\(form\.warehouse\)/)
|
||||
assert.match(view, /transferInventoryCandidates\.value = await listRelabelInventoryCandidates\(warehouseId\)/)
|
||||
assert.match(view, /transferInventoryCandidates\.value = \(await listRelabelInventoryCandidates\(warehouseId\)\)\.filter/)
|
||||
assert.match(view, /transferInventoryOptions\.value\.some\(\(option\) => option\.value === sourceInboundCode\)/)
|
||||
assert.match(view, /selectedTransferInventory\.value = sourceInboundCode/)
|
||||
assert.match(view, /detail\.sourceOrders \|\| \[\]/)
|
||||
@@ -367,3 +366,45 @@ test('仓储转运基础货物不强制 SKU 和中文品名,只换箱唛不强
|
||||
assert.match(api, /skuCode\?: string; productNameZh\?: string/)
|
||||
assert.match(api, /originalSku\?: string/)
|
||||
})
|
||||
|
||||
test('SKU 件数和预计箱数独立提交,签收展示实际清点箱数', () => {
|
||||
assert.match(view, /quantity: item.quantity, packageCount: Number\(item.expectedPackageCount\)/)
|
||||
assert.match(view, /sum \+ Number\(item.expectedPackageCount \|\| 0\)/)
|
||||
assert.match(view, /v-model="row.expectedPackageCount"/)
|
||||
assert.match(view, /Number.isSafeInteger\(row.quantity\)/)
|
||||
assert.match(view, /Number.isSafeInteger\(row.expectedPackageCount\)/)
|
||||
assert.match(view, /carton.status === 'COUNTED'/)
|
||||
assert.match(view, /实际签收箱数/)
|
||||
})
|
||||
|
||||
test('转运客户详情保留预报并显示实际签收箱内明细和托盘汇总', () => {
|
||||
for (const label of ['预报数量(件)', '预报 SKU 种数', '签收箱号明细', '查看箱号明细', 'SKU 汇总', '毛重(kg)', '体积(m³)']) assert.ok(view.includes(label), label)
|
||||
assert.match(view, /carton.status === 'COUNTED' && pallet.id != null/)
|
||||
assert.match(view, /String\(carton.transferPalletId\) === String\(pallet.id\)/)
|
||||
assert.match(view, /carton.transferItemId != null && String\(item.id\) === String\(carton.transferItemId\)/)
|
||||
assert.match(view, /@click="showTransferCartons = true"/)
|
||||
assert.match(view, /<el-dialog v-model="showTransferCartons"[\s\S]*?<el-collapse-item v-for="carton in countedTransferCartons\(\)"/)
|
||||
assert.doesNotMatch(view, /warehouse-transfer-receipt-summary|expandedTransferCartons/)
|
||||
const receiptSection = view.slice(view.indexOf('warehouse-transfer-received-card', view.indexOf('<header class="warehouse-detail-header">')), view.indexOf('v-if="props.service === \'transfer\' && selectedDetail?.needRelabel"'))
|
||||
assert.doesNotMatch(receiptSection.split('<el-dialog')[0], /<table|<el-table|<el-collapse/)
|
||||
|
||||
assert.match(view, /暂无实际签收箱,等待 PDA 清点/)
|
||||
assert.doesNotMatch(view, /<td>\{\{ \[pallet.lengthCm/)
|
||||
})
|
||||
|
||||
|
||||
test('预报实收同区对照、托盘卡片换行、签收按钮紧邻标题', () => {
|
||||
const start = view.indexOf('class="warehouse-card warehouse-form-section warehouse-transfer-cargo-card"', view.indexOf('<header class="warehouse-detail-header">'))
|
||||
const section = view.slice(start, view.indexOf('warehouse-transfer-received-card', start))
|
||||
assert.ok(section.includes('预报与实际签收'))
|
||||
assert.ok(section.includes('PDA 打托实测数据'))
|
||||
assert.ok(section.includes('receivedItemQuantity(item.id)'))
|
||||
assert.ok(section.includes('receivedItemCartons(item.id).length'))
|
||||
assert.ok(section.includes('class="warehouse-transfer-pallet-card"'))
|
||||
assert.ok(section.includes('class="warehouse-transfer-pallet-sku"'))
|
||||
assert.doesNotMatch(section, /打印箱唛/)
|
||||
const receipt = view.slice(view.indexOf('warehouse-transfer-received-card', start)).split('<el-dialog')[0]
|
||||
assert.ok(view.includes('打印预报箱唛')); assert.ok(view.includes('增加预报箱数'))
|
||||
assert.ok(receipt.includes('查看箱号明细'))
|
||||
assert.match(view, /warehouse-transfer-receipt-heading h3\{flex:0 0 auto/)
|
||||
})
|
||||
|
||||
@@ -75,6 +75,7 @@ export const TENANT_PERMISSION_GROUPS: readonly TenantPermissionGroup[] = [
|
||||
{ code: 'merchant:overseas-warehouse:transfer:confirm', label: '确认转运实测', description: '确认仓库回传的箱数、尺寸、重量和打托数据' },
|
||||
{ code: 'merchant:overseas-warehouse:discard:list', label: '查看货物废弃', description: '查看本企业货物废弃申请列表和详情' },
|
||||
{ code: 'merchant:overseas-warehouse:discard:create', label: '创建货物废弃', description: '使用本企业可用库存创建废弃申请' },
|
||||
{ code: 'merchant:overseas-warehouse:discard:confirm', label: '确认废弃完成', description: '核对本企业废弃现场照片并确认完结' },
|
||||
{ code: 'merchant:overseas-warehouse:discard:cancel', label: '取消货物废弃', description: '取消本企业尚未审核的废弃申请' },
|
||||
{ code: 'merchant:overseas-warehouse:return-order:list', label: '查看退货预报', description: '查看本企业退货预报和仓库签收结果' },
|
||||
{ code: 'merchant:overseas-warehouse:return-order:create', label: '创建退货预报', description: '创建本企业退货 SKU 和数量预报' },
|
||||
@@ -157,6 +158,7 @@ export const FRONTEND_TO_MERCHANT_PERMISSION_CODES: Readonly<Record<string, read
|
||||
'merchant:overseas-warehouse:transfer:confirm': ['merchant:overseas-warehouse:transfer:confirm'],
|
||||
'merchant:overseas-warehouse:discard:list': ['merchant:overseas-warehouse:discard:list'],
|
||||
'merchant:overseas-warehouse:discard:create': ['merchant:overseas-warehouse:discard:create'],
|
||||
'merchant:overseas-warehouse:discard:confirm': ['merchant:overseas-warehouse:discard:confirm'],
|
||||
'merchant:overseas-warehouse:discard:cancel': ['merchant:overseas-warehouse:discard:cancel'],
|
||||
'merchant:overseas-warehouse:return-order:list': ['merchant:overseas-warehouse:return-order:list'],
|
||||
'merchant:overseas-warehouse:return-order:create': ['merchant:overseas-warehouse:return-order:create'],
|
||||
|
||||
Reference in New Issue
Block a user