Merge pull request 'Release/test' (#10) from release/test into main
Reviewed-on: #10
This commit was merged in pull request #10.
This commit is contained in:
@@ -39,7 +39,7 @@
|
||||
|
||||
## 模块快速查询
|
||||
|
||||
退货预报与签收结果:`src/modules/overseas-warehouse/views/ReturnOrdersView.vue`、`api/returnOrders.ts`;菜单仍名为“退货换标”,正式路由 `return-relabel` 已使用独立页面,不再由 `WarehouseServiceView.vue` 承载。客户退货库存/箱号共用 `views/ReturnInventoryView.vue`、`api/returnInventory.ts`,`return-cartons` 传 `cartonOnly=true`。Backend 为 Lastleg `returnreceipt/`,退货单签收完成不等于换标或退货出库完成;后续换标、出库须按各自业务状态核对。
|
||||
退货预报与签收结果:`src/modules/overseas-warehouse/views/ReturnOrdersView.vue`、`api/returnOrders.ts`;菜单仍名为“退货换标”。列表路由 `return-relabel`、独立新建路由 `return-relabel/create` 与详情路由均复用该页面,并分别由 `createPage/detailPage` 明确模式;新建页直接填写海外仓、联系人、客户参考号及 SKU 预报明细,取消或提交后返回列表,不再由 `WarehouseServiceView.vue` 或列表内弹框承载。客户退货库存/箱号共用 `views/ReturnInventoryView.vue`、`api/returnInventory.ts`,`return-cartons` 传 `cartonOnly=true`。Backend 为 Lastleg `returnreceipt/`,退货单签收完成不等于换标或退货出库完成;后续换标、出库须按各自业务状态核对。
|
||||
|
||||
先从 `src/modules/registry.ts` 确认模块,再按目标模块的 `routes.ts → views/ → api/、store/、contracts.ts 或 public.ts` 查询。页面是否正式开放以 `routes.ts` 的 `release` 为准,不从任务或进度文档推断。
|
||||
|
||||
@@ -72,7 +72,7 @@
|
||||
|
||||
## UI 与交互
|
||||
|
||||
- 修改已有 Client 原型页面前先读 `../../docs/Admin-Client前端原型还原验收规则.md`,按页面和操作形成原型核对矩阵;本节维护 Client 的实现约束,不复制跨前端验收文档全文。
|
||||
- 视觉还原、布局/交互承载方式变更或真实页面验收时读取 `../../docs/Admin-Client前端原型还原验收规则.md`,只为受影响操作形成原型核对矩阵;纯文案、逻辑或测试修改不自动读取。自动检查与人工视觉验收分开报告,无视觉变化时标记“无需视觉验收”。
|
||||
- 已向客户讲解或确认过的产品原型是页面视觉与布局实现的直接事实来源。字段顺序、字段分组、字段摆放、列宽、控件宽高、对齐方式、换行方式、区块层级、上下左右边距、内边距、间隙、留白、圆角、边框、阴影、背景色、文字颜色和字号均必须按原型还原,不得只参考大致风格后自行重新排版。
|
||||
- 原型中的按钮位置、顺序、尺寸、圆角、边框、背景色、文字色、图标、悬停态、激活态、禁用态和危险语义必须直接按原型实现;不得擅自改为文字链接、不同颜色、不同按钮类型、折叠菜单或自创交互效果,也不得增加原型没有的上浮、缩放、渐变线、装饰图标或其他视觉元素。
|
||||
- 原型已经给出明确图标、字符图标、图片资源或按钮内图标时,直接使用原型中的同一方案和资源,不得以 Lucide、Element Plus 默认图标、手绘图标或个人偏好替换。仅当原型没有定义对应图标时,才按本节的默认图标语义补齐。
|
||||
|
||||
@@ -22,7 +22,7 @@ test('sends multi-inbound scope, optimistic versions and idempotency for every w
|
||||
assert.match(source, /params\.append\('inboundIds', id\)/)
|
||||
assert.match(source, /expectedVersion: document\.source\.version/)
|
||||
assert.match(source, /params: \{ expectedVersion: document\.source\.version \}/)
|
||||
assert.equal(source.match(/'Idempotency-Key'/g)?.length, 4)
|
||||
assert.equal(source.match(/'Idempotency-Key'/g)?.length, 6)
|
||||
assert.match(source, /data\.append\('file', file\)/)
|
||||
assert.match(source, /file\.size > MAX_CUSTOMS_FILE_SIZE/)
|
||||
})
|
||||
|
||||
@@ -14,6 +14,24 @@ export interface MerchantCustomsFile {
|
||||
uploadedAt: string
|
||||
}
|
||||
|
||||
export interface MerchantCustomsReceiptVersion {
|
||||
version: number
|
||||
file: MerchantCustomsFile
|
||||
operatorName: string | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export type MerchantCustomsConfirmationAction = 'CONFIRM' | 'REJECT'
|
||||
|
||||
export interface MerchantCustomsConfirmationHistory {
|
||||
id: string
|
||||
receiptVersion: number
|
||||
action: MerchantCustomsConfirmationAction
|
||||
reason: string | null
|
||||
operatorName: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface MerchantCustomsInbound {
|
||||
inboundId: string
|
||||
orderId: string
|
||||
@@ -32,7 +50,12 @@ export interface MerchantCustomsDocument {
|
||||
inboundIds: string[]
|
||||
inboundCodes: string[]
|
||||
frozen: boolean
|
||||
completion: MerchantCustomsFile | null
|
||||
receipt: MerchantCustomsFile | null
|
||||
receiptStatus: 'PENDING' | 'CONFIRMED' | 'REJECTED' | null
|
||||
receiptRejectReason: string | null
|
||||
finalDocument: MerchantCustomsFile | null
|
||||
receiptHistory?: MerchantCustomsReceiptVersion[]
|
||||
confirmationHistory: MerchantCustomsConfirmationHistory[]
|
||||
}
|
||||
|
||||
export interface MerchantCustomsContainer {
|
||||
@@ -47,12 +70,32 @@ export interface MerchantCustomsContainer {
|
||||
version: number
|
||||
taxRefundCount: number
|
||||
coveredTaxRefundCount: number
|
||||
actualBoxCount: number
|
||||
inbounds: MerchantCustomsInbound[]
|
||||
documents: MerchantCustomsDocument[]
|
||||
startedAt?: string | null
|
||||
brokerSubmittedAt?: string | null
|
||||
completedAt?: string | null
|
||||
}
|
||||
|
||||
export type MerchantClearanceResultType = 'INSPECTION' | 'SUPPLEMENT_REQUIRED' | 'RELEASED' | 'DETAINED' | 'OTHER'
|
||||
export type MerchantClearanceResultStatus = 'OPEN' | 'RESOLVED' | 'CANCELLED'
|
||||
|
||||
export interface MerchantClearanceResult {
|
||||
id: string
|
||||
declarationBatchId: string
|
||||
customsContainerId: string
|
||||
resultType: MerchantClearanceResultType
|
||||
resultStatus: MerchantClearanceResultStatus
|
||||
customsEntryNo: string | null
|
||||
occurredAt: string
|
||||
resolution: string | null
|
||||
evidenceFileId: string | null
|
||||
recordedByName: string
|
||||
recordedAt: string
|
||||
previousResultId: string | null
|
||||
}
|
||||
|
||||
export interface ApiErrorMeta {
|
||||
code: string
|
||||
requestId: string | null
|
||||
@@ -100,6 +143,13 @@ export function getMerchantCustomsForOrder(orderId: string) {
|
||||
})
|
||||
}
|
||||
|
||||
export function getMerchantClearanceResults(orderId: string) {
|
||||
return apiRequest<MerchantClearanceResult[]>({
|
||||
method: 'GET',
|
||||
url: `/merchant/v1/customs/orders/${encodedId(orderId)}/clearance-results`,
|
||||
})
|
||||
}
|
||||
|
||||
export function uploadMerchantCustomsDocument(planId: string, inboundIds: string[], file: File) {
|
||||
validateMerchantCustomsFile(file)
|
||||
return apiRequest<MerchantCustomsDocument>({
|
||||
@@ -183,13 +233,38 @@ export function downloadMerchantCustomsSource(document: MerchantCustomsDocument)
|
||||
return download(`/merchant/v1/customs/documents/${encodedId(document.documentId)}/download`, document.source.fileName)
|
||||
}
|
||||
|
||||
export function downloadMerchantCustomsCompletion(document: MerchantCustomsDocument) {
|
||||
export function downloadMerchantCustomsReceipt(document: MerchantCustomsDocument) {
|
||||
return download(
|
||||
`/merchant/v1/customs/documents/${encodedId(document.documentId)}/completion/download`,
|
||||
document.completion?.fileName || `customs-completion-${document.documentId}`,
|
||||
`/merchant/v1/customs/documents/${encodedId(document.documentId)}/receipt/download`,
|
||||
document.receipt?.fileName || `customs-receipt-${document.documentId}`,
|
||||
)
|
||||
}
|
||||
|
||||
export function downloadMerchantCustomsFinal(document: MerchantCustomsDocument) {
|
||||
return download(
|
||||
`/merchant/v1/customs/documents/${encodedId(document.documentId)}/final/download`,
|
||||
document.finalDocument?.fileName || `customs-final-${document.documentId}`,
|
||||
)
|
||||
}
|
||||
|
||||
export function confirmMerchantCustomsReceipt(document: MerchantCustomsDocument) {
|
||||
return apiRequest<MerchantCustomsContainer>({
|
||||
method: 'POST',
|
||||
url: `/merchant/v1/customs/documents/${encodedId(document.documentId)}/receipt/confirm`,
|
||||
headers: { 'Idempotency-Key': createIdempotencyKey(`merchant-customs-receipt-confirm-${document.documentId}`) },
|
||||
data: { expectedVersion: document.receipt?.version ?? 0, confirm: true, remark: null },
|
||||
})
|
||||
}
|
||||
|
||||
export function rejectMerchantCustomsReceipt(document: MerchantCustomsDocument, reason: string) {
|
||||
return apiRequest<MerchantCustomsContainer>({
|
||||
method: 'POST',
|
||||
url: `/merchant/v1/customs/documents/${encodedId(document.documentId)}/receipt/reject`,
|
||||
headers: { 'Idempotency-Key': createIdempotencyKey(`merchant-customs-receipt-reject-${document.documentId}`) },
|
||||
data: { expectedVersion: document.receipt?.version ?? 0, confirm: false, remark: reason },
|
||||
})
|
||||
}
|
||||
|
||||
export function merchantCustomsErrorMeta(error: unknown): ApiErrorMeta | null {
|
||||
if (!(error instanceof ApiError)) return null
|
||||
return { code: error.code, requestId: error.requestId ?? null, traceId: error.traceId ?? null }
|
||||
|
||||
@@ -296,6 +296,8 @@ export interface MerchantFirstLegOrderRow {
|
||||
inboundedAt: string | null
|
||||
businessDate: string | null
|
||||
shipmentId: string | null
|
||||
fulfillmentType?: 'STANDARD' | 'ONE_CLICK_DROPSHIP' | null
|
||||
lxCode?: string | null
|
||||
createdAt: string
|
||||
cargoes?: Array<{ skuNo?: string | null }>
|
||||
}
|
||||
@@ -439,6 +441,7 @@ export interface MerchantPriceEstimateRequest {
|
||||
destinationWarehouseId?: number | string
|
||||
destinationName?: string
|
||||
pricingTaxMode: ApiPricingTaxMode
|
||||
chargeMode?: 'KG' | 'CBM'
|
||||
actualWeightKg: number
|
||||
actualVolumeCbm: number
|
||||
}
|
||||
|
||||
@@ -48,8 +48,14 @@ export interface MerchantUnifiedTransportSummary {
|
||||
cabinetNo: string | null
|
||||
vesselName: string | null
|
||||
voyageNo: string | null
|
||||
sailingAt: string | null
|
||||
portArrivalAt: string | null
|
||||
estimatedSailingAt: string | null
|
||||
actualSailingAt: string | null
|
||||
effectiveSailingAt: string | null
|
||||
effectiveSailingAtEstimated: boolean | null
|
||||
estimatedPortArrivalAt: string | null
|
||||
actualPortArrivalAt: string | null
|
||||
effectivePortArrivalAt: string | null
|
||||
effectivePortArrivalAtEstimated: boolean | null
|
||||
}
|
||||
|
||||
export interface MerchantUnifiedOrderRow {
|
||||
|
||||
@@ -5,16 +5,21 @@ import { Download, FileText, LockKeyhole, Pencil, Trash2, Upload } from '@lucide
|
||||
import {
|
||||
MERCHANT_CUSTOMS_STATUS_LABELS,
|
||||
deleteMerchantCustomsDocument,
|
||||
downloadMerchantCustomsCompletion,
|
||||
confirmMerchantCustomsReceipt,
|
||||
downloadMerchantCustomsReceipt,
|
||||
downloadMerchantCustomsFinal,
|
||||
downloadMerchantCustomsSource,
|
||||
getMerchantCustomsForOrder,
|
||||
getMerchantClearanceResults,
|
||||
merchantCustomsErrorMessage,
|
||||
replaceMerchantCustomsDocument,
|
||||
rejectMerchantCustomsReceipt,
|
||||
updateMerchantCustomsDocumentInbounds,
|
||||
uploadMerchantCustomsDocument,
|
||||
validateMerchantCustomsFile,
|
||||
type MerchantCustomsContainer,
|
||||
type MerchantCustomsDocument,
|
||||
type MerchantClearanceResult,
|
||||
} from '@/modules/first-leg/api/customs'
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -40,6 +45,7 @@ const uploadInput = ref<HTMLInputElement>()
|
||||
const replaceInput = ref<HTMLInputElement>()
|
||||
const mutating = ref(false)
|
||||
const downloadingKey = ref('')
|
||||
const clearanceResults = ref<MerchantClearanceResult[]>([])
|
||||
|
||||
const taxRefundInbounds = computed(() => detail.value?.inbounds.filter((item) => item.taxMode === 'TAX_REFUND') ?? [])
|
||||
const locked = computed(() => Boolean(detail.value?.startedAt) || detail.value?.status === 'DECLARING' || detail.value?.status === 'COMPLETED')
|
||||
@@ -68,6 +74,7 @@ async function loadDetail() {
|
||||
const result = await getMerchantCustomsForOrder(props.orderId)
|
||||
detail.value = result
|
||||
initializeSelection(result)
|
||||
try { clearanceResults.value = await getMerchantClearanceResults(props.orderId) } catch { clearanceResults.value = [] }
|
||||
emit('updated', result)
|
||||
} catch (error) {
|
||||
detail.value = null
|
||||
@@ -192,13 +199,13 @@ function saveDownload(blob: Blob, fileName: string) {
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
async function downloadFile(document: MerchantCustomsDocument, kind: 'source' | 'completion') {
|
||||
async function downloadFile(document: MerchantCustomsDocument, kind: 'source' | 'receipt' | 'final') {
|
||||
const key = `${document.documentId}:${kind}`
|
||||
downloadingKey.value = key
|
||||
try {
|
||||
const result = kind === 'source'
|
||||
? await downloadMerchantCustomsSource(document)
|
||||
: await downloadMerchantCustomsCompletion(document)
|
||||
: kind === 'receipt' ? await downloadMerchantCustomsReceipt(document) : await downloadMerchantCustomsFinal(document)
|
||||
saveDownload(result.blob, result.fileName)
|
||||
} catch (error) {
|
||||
ElMessage.error(merchantCustomsErrorMessage(error, '文件下载失败'))
|
||||
@@ -207,17 +214,58 @@ async function downloadFile(document: MerchantCustomsDocument, kind: 'source' |
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmReceipt(document: MerchantCustomsDocument) {
|
||||
try {
|
||||
await ElMessageBox.confirm('确认已核对整份报关行回执吗?确认后平台会把确认结果发送给报关行执行。', '确认报关行回执', { type: 'warning' })
|
||||
mutating.value = true
|
||||
await confirmMerchantCustomsReceipt(document)
|
||||
ElMessage.success('回执确认成功')
|
||||
await loadDetail()
|
||||
} catch (error) {
|
||||
if (error === 'cancel' || error === 'close') return
|
||||
ElMessage.error(merchantCustomsErrorMessage(error, '回执确认失败'))
|
||||
} finally { mutating.value = false }
|
||||
}
|
||||
|
||||
async function rejectReceipt(document: MerchantCustomsDocument) {
|
||||
try {
|
||||
const reason = await ElMessageBox.prompt('请填写打回原因', '打回报关行回执', { inputValidator: (value) => value.trim() ? true : '请输入打回原因' })
|
||||
mutating.value = true
|
||||
await rejectMerchantCustomsReceipt(document, reason.value)
|
||||
ElMessage.success('回执已打回')
|
||||
await loadDetail()
|
||||
} catch (error) {
|
||||
if (error === 'cancel' || error === 'close') return
|
||||
ElMessage.error(merchantCustomsErrorMessage(error, '回执打回失败'))
|
||||
} finally { mutating.value = false }
|
||||
}
|
||||
|
||||
function formatDateTime(value: string | null | undefined) {
|
||||
if (!value) return '-'
|
||||
const date = new Date(value)
|
||||
return Number.isNaN(date.getTime()) ? value : date.toLocaleString('zh-CN', { hour12: false })
|
||||
}
|
||||
|
||||
function receiptHistoryLabel(document: MerchantCustomsDocument) {
|
||||
return (document.receiptHistory || []).map((item) => `V${item.version}`).join('、') || '—'
|
||||
}
|
||||
function confirmationActionLabel(action: MerchantCustomsDocument['confirmationHistory'][number]['action']) {
|
||||
return action === 'CONFIRM' ? '确认' : '打回'
|
||||
}
|
||||
|
||||
function formatSize(bytes: number) {
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
function clearanceResultLabel(value: MerchantClearanceResult) {
|
||||
return ({ INSPECTION: '查验', SUPPLEMENT_REQUIRED: '补充资料', RELEASED: '放行', DETAINED: '扣留', OTHER: '其他' } as Record<string, string>)[value.resultType] || value.resultType
|
||||
}
|
||||
|
||||
function clearanceResultStatusLabel(value: MerchantClearanceResult) {
|
||||
return value.resultStatus === 'RESOLVED' ? '已处理' : value.resultStatus === 'CANCELLED' ? '已取消' : '待处理'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -246,12 +294,16 @@ function formatSize(bytes: number) {
|
||||
<span>资料覆盖</span>
|
||||
<strong>{{ detail.coveredTaxRefundCount }} / {{ detail.taxRefundCount }} 票</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>实际箱数</span>
|
||||
<strong>{{ detail.actualBoxCount }}</strong>
|
||||
</div>
|
||||
<el-tag :type="statusTagType" effect="light">{{ merchantStatusLabel }}</el-tag>
|
||||
</header>
|
||||
|
||||
<div v-if="locked" class="customs-locked" role="status">
|
||||
<LockKeyhole :size="18" />
|
||||
<div><strong>报关资料已锁定</strong><span>报关开始后仅可查看和下载,不提供退回或解锁。</span></div>
|
||||
<div><strong>{{ detail.brokerSubmittedAt ? '已发送报关行执行' : '报关资料已锁定' }}</strong><span>{{ detail.brokerSubmittedAt ? '平台已将客户确认后的回执发送给报关行,等待真实报关完税文件。' : '报关开始后仅可查看和下载,不提供退回或解锁。' }}</span></div>
|
||||
</div>
|
||||
|
||||
<section class="customs-section">
|
||||
@@ -269,6 +321,20 @@ function formatSize(bytes: number) {
|
||||
<input ref="replaceInput" class="customs-hidden-input" type="file" @change="handleReplacementFile">
|
||||
</section>
|
||||
|
||||
<section class="customs-section clearance-results-section">
|
||||
<div class="customs-section-title">
|
||||
<div><h3>海关申报结果</h3><p>平台完成正式申报后,客户可查看与本订单相关的查验、补充资料和放行结果。</p></div>
|
||||
</div>
|
||||
<el-empty v-if="!clearanceResults.length" description="暂无海关结果" :image-size="48" />
|
||||
<div v-else class="clearance-result-list">
|
||||
<div v-for="item in clearanceResults" :key="item.id" class="clearance-result-row">
|
||||
<div><strong>{{ clearanceResultLabel(item) }}</strong><span>{{ formatDateTime(item.occurredAt) }} · 报关单号 {{ item.customsEntryNo || '—' }}</span></div>
|
||||
<el-tag size="small" :type="item.resultStatus === 'RESOLVED' ? 'success' : 'warning'">{{ clearanceResultStatusLabel(item) }}</el-tag>
|
||||
<p v-if="item.resolution">{{ item.resolution }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="customs-section">
|
||||
<div class="customs-section-title">
|
||||
<div><h3>资料版本历史</h3><p>每份当前资料展示服务端版本、关联票和上传时间;覆盖及删除操作由服务端保留不可变历史。</p></div>
|
||||
@@ -281,12 +347,20 @@ function formatSize(bytes: number) {
|
||||
<div>
|
||||
<strong>{{ item.source.fileName }}</strong>
|
||||
<span>版本 V{{ item.source.version }} · {{ formatSize(item.source.sizeBytes) }} · {{ formatDateTime(item.source.uploadedAt) }}</span>
|
||||
<small v-if="item.receiptHistory?.length">报关行回执历史:{{ receiptHistoryLabel(item) }}</small>
|
||||
<small v-for="history in item.confirmationHistory" :key="history.id">{{ confirmationActionLabel(history.action) }} · {{ formatDateTime(history.createdAt) }} · {{ history.operatorName || '—' }}<span v-if="history.reason"> · {{ history.reason }}</span></small>
|
||||
<small>关联入仓码:{{ item.inboundCodes.join('、') || '—' }}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="customs-document-actions">
|
||||
<el-button link type="primary" :loading="downloadingKey === `${item.documentId}:source`" @click="downloadFile(item, 'source')"><Download :size="15" />源文件</el-button>
|
||||
<el-button v-if="detail.status === 'COMPLETED' && item.completion" link type="success" :loading="downloadingKey === `${item.documentId}:completion`" @click="downloadFile(item, 'completion')"><Download :size="15" />完成文件</el-button>
|
||||
<el-button v-if="item.receipt" link type="success" :loading="downloadingKey === `${item.documentId}:receipt`" @click="downloadFile(item, 'receipt')"><Download :size="15" />报关行回执</el-button>
|
||||
<el-button v-if="item.finalDocument" link type="success" :loading="downloadingKey === `${item.documentId}:final`" @click="downloadFile(item, 'final')"><Download :size="15" />报关完税文件</el-button>
|
||||
<template v-if="item.receipt && item.receiptStatus !== 'CONFIRMED' && detail.status === 'DECLARING'">
|
||||
<el-button link type="success" :disabled="mutating" @click="confirmReceipt(item)">确认整份回执</el-button>
|
||||
<el-button link type="danger" :disabled="mutating" @click="rejectReceipt(item)">打回回执</el-button>
|
||||
</template>
|
||||
<el-tag v-if="item.receipt" size="small" :type="item.receiptStatus === 'CONFIRMED' ? 'success' : item.receiptStatus === 'REJECTED' ? 'danger' : 'warning'">{{ item.receiptStatus === 'CONFIRMED' ? '已确认' : item.receiptStatus === 'REJECTED' ? `已打回:${item.receiptRejectReason || '待补充'}` : '待确认' }}</el-tag>
|
||||
<template v-if="canEdit && !locked && !item.frozen">
|
||||
<el-button link type="primary" :disabled="mutating" @click="beginEditInbounds(item)"><Pencil :size="15" />改关联</el-button>
|
||||
<el-button link type="primary" :disabled="mutating" @click="chooseReplacement(item)"><Upload :size="15" />覆盖</el-button>
|
||||
@@ -319,6 +393,11 @@ function formatSize(bytes: number) {
|
||||
.customs-locked strong { color: var(--navy); font-size: 12px; }
|
||||
.customs-locked span { margin-top: 3px; font-size: 11px; }
|
||||
.customs-section { margin-top: 18px; }
|
||||
.clearance-result-list { display:grid; gap:10px; }
|
||||
.clearance-result-row { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:6px 14px; padding:12px 14px; border:1px solid var(--border); background:#fbfcfe; }
|
||||
.clearance-result-row strong,.clearance-result-row span { display:block; }
|
||||
.clearance-result-row span { margin-top:4px; color:var(--muted); font-size:11px; }
|
||||
.clearance-result-row p { grid-column:1 / -1; margin:0; color:var(--navy); font-size:12px; }
|
||||
.customs-section-title { display: flex; align-items: flex-start; justify-content: space-between; gap: 18px; margin-bottom: 12px; }
|
||||
.customs-section-title h3 { margin: 0; color: var(--navy); font-size: 14px; }
|
||||
.customs-section-title p { margin: 5px 0 0; color: var(--muted); font-size: 11px; line-height: 1.55; }
|
||||
|
||||
@@ -25,14 +25,13 @@ test('supports a standalone dialog and keeps the unpushed billing-weight state v
|
||||
})
|
||||
|
||||
test('shows the published merchant inbound, measurement details and customer confirmation entry', () => {
|
||||
for (const label of ['入仓码', '平台收货仓', '实际毛重', '实测体积', '托盘与库位', '换算与精度', '最终计费量', '对客单价', '基础费用', '附加费用', '费用合计', '平台备注']) {
|
||||
assert.match(source, new RegExp(label))
|
||||
for (const label of ['入仓码', '平台收货仓', '实际毛重', '实测体积', '托盘与库位', '换算与精度', '最终计费量', '单价', '基础费用', '附加费用', '费用合计', '平台备注']) {
|
||||
assert.ok(source.includes(label), `missing label: ${label}`)
|
||||
}
|
||||
assert.match(source, /formatInboundMoney\(props\.data\.latestFee\.totalAmount/)
|
||||
assert.match(source, /formatConversionPrecision\(/)
|
||||
assert.match(source, /确认费用/)
|
||||
assert.match(source, /二次确认费用/)
|
||||
assert.match(source, /确认后该费用将锁定,平台不能再修改/)
|
||||
assert.match(source, /确认后当前费用版本将锁定/)
|
||||
assert.match(source, /customerConfirmationStatus === 'CONFIRMED'/)
|
||||
assert.match(source, /confirmMerchantInboundFee/)
|
||||
assert.match(source, /measurementItems/)
|
||||
@@ -42,7 +41,19 @@ test('shows the published merchant inbound, measurement details and customer con
|
||||
assert.match(source, /latestFee\.rateCode/)
|
||||
assert.match(source, /latestFee\.chargeRuleVersion/)
|
||||
assert.match(source, /latestFee\.carrierRateVersion/)
|
||||
assert.doesNotMatch(source, /箱规规则|calculationRuleVersion/)
|
||||
assert.match(source, /箱规规则/)
|
||||
assert.match(source, /getCalculationRuleLabel\(row\.calculationRuleVersion\)/)
|
||||
})
|
||||
|
||||
test('matches the confirmed billing prototype summary, table, notice and footer', () => {
|
||||
assert.match(source, /width="min\(1180px, calc\(100vw - 40px\)\)"/)
|
||||
assert.match(source, /class="fee-dialog-summary" aria-label="计费汇总"/)
|
||||
for (const label of ['计算重', '单价', '总金额', '箱规长(cm)', '箱规宽(cm)', '箱规高(cm)', '每箱体积', '总体积', '总材积重']) assert.ok(source.includes(label), `missing prototype label: ${label}`)
|
||||
assert.match(source, /class="fee-confirm-notice"/)
|
||||
assert.match(source, /请核对计算重、单价和总金额。确认后当前费用版本将锁定。/)
|
||||
assert.match(source, /确认计费量和费用/)
|
||||
assert.match(source, /\.fee-dialog-summary \{ display:grid; grid-template-columns:repeat\(3,minmax\(0,1fr\)\)/)
|
||||
assert.match(source, /\.fee-dialog-table :deep\(\.el-table__body td\.el-table__cell\)/)
|
||||
})
|
||||
|
||||
test('stacks core information for 720 and 360 pixel layouts', () => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Clock3, PackageCheck, RefreshCw, TriangleAlert } from '@lucide/vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { CircleAlert, Clock3, PackageCheck, RefreshCw, TriangleAlert } from '@lucide/vue'
|
||||
import { confirmMerchantInboundFee } from '@/modules/first-leg/api/inboundBilling'
|
||||
import type { MerchantInboundBilling } from '@/modules/first-leg/types/inboundBilling'
|
||||
import {
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
formatInboundMeasure,
|
||||
formatInboundMoney,
|
||||
formatInboundNumber,
|
||||
getCalculationRuleLabel,
|
||||
getChargeModeLabel,
|
||||
getTaxModeLabel,
|
||||
} from '@/modules/first-leg/utils/inboundBilling'
|
||||
@@ -54,15 +55,6 @@ async function confirmFee() {
|
||||
ElMessage.info('当前费用已经确认,无需重复操作')
|
||||
return
|
||||
}
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`请再次确认费用单 ${fee.feeNo},合计 ${formatInboundMoney(fee.totalAmount, fee.currency)}。确认后该费用将锁定,平台不能再修改。`,
|
||||
'二次确认费用',
|
||||
{ type: 'warning', confirmButtonText: '确认费用无误', cancelButtonText: '返回核对' },
|
||||
)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
confirming.value = true
|
||||
try {
|
||||
await confirmMerchantInboundFee(orderId, fee.feeVersion)
|
||||
@@ -139,6 +131,7 @@ async function confirmFee() {
|
||||
<el-table-column label="每箱体积" min-width="105"><template #default="{ row }">{{ formatInboundMeasure(row.unitVolumeCbm, 'm³') }}</template></el-table-column>
|
||||
<el-table-column label="总体积" min-width="105"><template #default="{ row }">{{ formatInboundMeasure(row.totalVolumeCbm, 'm³') }}</template></el-table-column>
|
||||
<el-table-column label="总材积重" min-width="105"><template #default="{ row }">{{ formatInboundMeasure(row.volumeWeightKg, 'kg') }}</template></el-table-column>
|
||||
<el-table-column label="箱规规则" min-width="170"><template #default="{ row }">{{ getCalculationRuleLabel(row.calculationRuleVersion) }}</template></el-table-column>
|
||||
<el-table-column prop="boxSpecification" label="备注" min-width="130" />
|
||||
</el-table>
|
||||
</div>
|
||||
@@ -205,7 +198,7 @@ async function confirmFee() {
|
||||
|
||||
</section>
|
||||
|
||||
<el-dialog v-model="feeDialogOpen" title="确认测量与计费数据" width="min(980px, 94vw)" append-to-body @closed="emit('closed')">
|
||||
<el-dialog v-model="feeDialogOpen" class="billing-confirm-dialog" title="确认测量与计费数据" width="min(1180px, calc(100vw - 40px))" append-to-body @closed="emit('closed')">
|
||||
<div v-if="props.loading" class="inbound-billing-loading" aria-live="polite" aria-busy="true">
|
||||
<el-skeleton :rows="4" animated />
|
||||
<span class="sr-only">正在加载计费重</span>
|
||||
@@ -220,19 +213,12 @@ async function confirmFee() {
|
||||
<div><strong>{{ props.unavailableMessage }}</strong><span>平台推送计费重后,可在这里核对并确认。</span></div>
|
||||
</div>
|
||||
<template v-else>
|
||||
<dl class="inbound-measurements fee-dialog-summary">
|
||||
<div><dt>计费总箱数</dt><dd>{{ formatInboundNumber(props.data.measurementSummary.actualPackageCount, 0) }}</dd></div>
|
||||
<div><dt>计费总毛重</dt><dd>{{ formatInboundMeasure(props.data.measurementSummary.actualGrossWeightKg, 'kg') }}</dd></div>
|
||||
<div><dt>外箱规总体积</dt><dd>{{ formatInboundMeasure(props.data.measurementSummary.actualVolumeCbm, 'm³') }}</dd></div>
|
||||
<div><dt>外箱规总材积重</dt><dd>{{ formatInboundMeasure(props.data.measurementSummary.volumeWeightKg, 'kg') }}</dd></div>
|
||||
<div><dt>计费体积</dt><dd>{{ formatInboundMeasure(props.data.billingBasis.chargeableVolumeCbm, 'm³') }}</dd></div>
|
||||
<div><dt>计费重量</dt><dd>{{ formatInboundMeasure(props.data.billingBasis.chargeableWeightKg, 'kg') }}</dd></div>
|
||||
<div><dt>对客单价</dt><dd>{{ formatInboundMoney(props.data.latestFee.unitPrice, props.data.latestFee.currency) }} / {{ props.data.latestFee.chargeUnit }}</dd></div>
|
||||
<div><dt>基础费用</dt><dd>{{ formatInboundMoney(props.data.latestFee.subtotalAmount, props.data.latestFee.currency) }}</dd></div>
|
||||
<div><dt>附加费用</dt><dd>{{ formatInboundMoney(props.data.latestFee.additionalAmount, props.data.latestFee.currency) }}</dd></div>
|
||||
<div><dt>费用合计</dt><dd>{{ formatInboundMoney(props.data.latestFee.totalAmount, props.data.latestFee.currency) }}</dd></div>
|
||||
<dl class="fee-dialog-summary" aria-label="计费汇总">
|
||||
<div><dt>计算重</dt><dd>{{ formatInboundMeasure(props.data.billingBasis.chargeableWeightKg, 'kg') }}</dd></div>
|
||||
<div><dt>单价</dt><dd>{{ formatInboundMoney(props.data.latestFee.unitPrice, props.data.latestFee.currency) }} / {{ props.data.latestFee.chargeUnit.toUpperCase() }}</dd></div>
|
||||
<div><dt>总金额</dt><dd>{{ formatInboundMoney(props.data.latestFee.totalAmount, props.data.latestFee.currency) }}</dd></div>
|
||||
</dl>
|
||||
<el-table v-if="props.data?.measurementItems.length" class="measurement-detail-table" :data="props.data.measurementItems" border size="small">
|
||||
<el-table class="measurement-detail-table fee-dialog-table" :data="props.data.measurementItems" border empty-text="暂无箱规测量明细">
|
||||
<el-table-column type="index" label="#" width="54" />
|
||||
<el-table-column prop="lengthCm" label="箱规长(cm)" min-width="105" />
|
||||
<el-table-column prop="widthCm" label="箱规宽(cm)" min-width="105" />
|
||||
@@ -241,17 +227,14 @@ async function confirmFee() {
|
||||
<el-table-column label="每箱体积" min-width="105"><template #default="{ row }">{{ formatInboundMeasure(row.unitVolumeCbm, 'm³') }}</template></el-table-column>
|
||||
<el-table-column label="总体积" min-width="105"><template #default="{ row }">{{ formatInboundMeasure(row.totalVolumeCbm, 'm³') }}</template></el-table-column>
|
||||
<el-table-column label="总材积重" min-width="105"><template #default="{ row }">{{ formatInboundMeasure(row.volumeWeightKg, 'kg') }}</template></el-table-column>
|
||||
<el-table-column label="箱规规则" min-width="170"><template #default="{ row }">{{ getCalculationRuleLabel(row.calculationRuleVersion) }}</template></el-table-column>
|
||||
<el-table-column prop="boxSpecification" label="备注" min-width="130" />
|
||||
</el-table>
|
||||
<el-alert
|
||||
v-if="props.data.latestFee.customerConfirmationStatus === 'CONFIRMED'"
|
||||
class="fee-confirmed-alert"
|
||||
:title="`客户已于 ${formatInboundDateTime(props.data.latestFee.customerConfirmedAt)} 确认,费用已锁定,不允许修改。`"
|
||||
type="success"
|
||||
:closable="false"
|
||||
show-icon
|
||||
/>
|
||||
<el-alert v-else class="fee-confirmed-alert" title="请核对计费量、对客单价和费用合计。确认后当前费用版本将锁定。" type="warning" :closable="false" show-icon />
|
||||
<div class="fee-confirm-notice" :class="{ 'is-confirmed': props.data.latestFee.customerConfirmationStatus === 'CONFIRMED' }" role="status">
|
||||
<CircleAlert :size="21" aria-hidden="true" />
|
||||
<span v-if="props.data.latestFee.customerConfirmationStatus === 'CONFIRMED'">客户已于 {{ formatInboundDateTime(props.data.latestFee.customerConfirmedAt) }} 确认,费用已锁定,不允许修改。</span>
|
||||
<span v-else>请核对计算重、单价和总金额。确认后当前费用版本将锁定。</span>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<el-button @click="feeDialogOpen = false">关闭</el-button>
|
||||
@@ -287,8 +270,25 @@ async function confirmFee() {
|
||||
.inbound-measurements { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); margin: 10px 0 0; background: #f6f8f8; }
|
||||
.inbound-measurements > div { min-width: 0; padding: 12px; }
|
||||
.measurement-detail-table { margin-top: 12px; }
|
||||
.fee-dialog-summary { margin-top: 0; }
|
||||
.fee-confirmed-alert { margin-top: 14px; }
|
||||
.fee-dialog-summary { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:12px; margin:0; }
|
||||
.fee-dialog-summary > div { position:relative; min-width:0; overflow:hidden; padding:13px 17px; border:1px solid #e4e8f2; border-radius:11px; background:linear-gradient(145deg,#fff,#f8f9fc); box-shadow:0 5px 16px rgba(51,65,85,.04); }
|
||||
.fee-dialog-summary > div::before { position:absolute; inset:0 auto 0 0; width:4px; background:#a7b4ef; content:''; }
|
||||
.fee-dialog-summary > div:nth-child(2)::before { background:#7d91eb; }
|
||||
.fee-dialog-summary > div:nth-child(3) { border-color:#cdd7ff; background:linear-gradient(145deg,#f8faff,#eef2ff); box-shadow:0 8px 22px rgba(82,110,232,.09); }
|
||||
.fee-dialog-summary > div:nth-child(3)::before { background:#526ee8; }
|
||||
.fee-dialog-summary dt { margin:0 0 3px; color:#7b8494; font-size:13px; font-weight:500; }
|
||||
.fee-dialog-summary dd { margin:0; overflow-wrap:anywhere; color:#283246; font-size:20px; font-weight:700; font-variant-numeric:tabular-nums; letter-spacing:0; }
|
||||
.fee-dialog-summary > div:nth-child(3) dt { color:#6578c5; }
|
||||
.fee-dialog-summary > div:nth-child(3) dd { color:#405fd8; font-size:22px; }
|
||||
.fee-dialog-table { margin-top:16px; overflow:hidden; border-radius:10px; box-shadow:0 5px 18px rgba(51,65,85,.04); }
|
||||
.fee-dialog-table :deep(.el-table__header th.el-table__cell) { height:42px; padding:9px 0; background:linear-gradient(180deg,#f8f9fc,#f3f5f9); color:#697386; font-size:13px; font-weight:650; }
|
||||
.fee-dialog-table :deep(.el-table__body td.el-table__cell) { height:82px; padding:9px 0; color:#515a6c; font-size:14px; font-variant-numeric:tabular-nums; }
|
||||
.fee-dialog-table :deep(.el-table__body td:nth-child(7)), .fee-dialog-table :deep(.el-table__body td:nth-child(8)) { color:#34466b; font-weight:650; }
|
||||
.fee-dialog-table :deep(.cell) { line-height:1.6; }
|
||||
.fee-confirm-notice { display:flex; align-items:center; gap:10px; margin-top:14px; padding:10px 14px; border:1px solid #f5dfba; border-radius:9px; background:linear-gradient(90deg,#fff9ef,#fffaf2); color:#c6790c; font-size:14px; font-weight:500; }
|
||||
.fee-confirm-notice > svg { flex:0 0 auto; fill:#eda332; color:#fff; filter:drop-shadow(0 2px 3px rgba(233,154,37,.25)); }
|
||||
.fee-confirm-notice.is-confirmed { border-color:#b7dfc2; background:#f1faf3; color:#2d8a46; }
|
||||
.fee-confirm-notice.is-confirmed > svg { fill:#4eae67; }
|
||||
.pallet-location-list { margin-top: 9px; border-top: 1px solid var(--border); }
|
||||
.pallet-location-list article { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 11px; padding: 12px 0; border-bottom: 1px solid var(--border); }
|
||||
.pallet-location-list article > svg { color: var(--primary); }
|
||||
@@ -312,7 +312,26 @@ async function confirmFee() {
|
||||
.inbound-public-remark strong { color: var(--navy); font-size: 11px; }
|
||||
.inbound-public-remark p { margin: 5px 0 0; overflow-wrap: anywhere; color: var(--muted); font-size: 11px; line-height: 1.55; white-space: pre-wrap; }
|
||||
|
||||
:global(.billing-confirm-dialog) { max-height:calc(100vh - 40px); overflow:hidden; border-radius:14px; box-shadow:0 28px 90px rgba(17,24,39,.33); }
|
||||
:global(.billing-confirm-dialog .el-dialog__header) { display:flex; align-items:center; min-height:66px; margin:0; padding:16px 22px 14px; border-bottom:1px solid #edf0f5; }
|
||||
:global(.billing-confirm-dialog .el-dialog__title) { color:#252b38; font-size:22px; font-weight:700; line-height:1.4; letter-spacing:0; }
|
||||
:global(.billing-confirm-dialog .el-dialog__headerbtn) { top:14px; right:20px; width:36px; height:36px; border-radius:50%; background:#f5f6f9; }
|
||||
:global(.billing-confirm-dialog .el-dialog__headerbtn:hover) { background:#e9edfa; }
|
||||
:global(.billing-confirm-dialog .el-dialog__headerbtn .el-dialog__close) { color:#8b92a0; font-size:22px; }
|
||||
:global(.billing-confirm-dialog .el-dialog__body) { max-height:calc(100vh - 174px); overflow:auto; padding:18px 22px; }
|
||||
:global(.billing-confirm-dialog .el-dialog__footer) { padding:14px 22px 16px; border-top:1px solid #edf0f5; }
|
||||
:global(.billing-confirm-dialog .el-dialog__footer .el-button) { min-height:40px; padding:0 20px; border-radius:8px; font-size:14px; font-weight:650; }
|
||||
:global(.billing-confirm-dialog .el-dialog__footer .el-button--primary) { min-width:168px; border-color:#526ee8; background:linear-gradient(135deg,#647fec,#526ee8); box-shadow:0 7px 16px rgba(82,110,232,.17); }
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.fee-dialog-summary { grid-template-columns: 1fr; gap: 8px; }
|
||||
.fee-dialog-summary dd, .fee-dialog-summary > div:nth-child(3) dd { font-size: 18px; }
|
||||
:global(.billing-confirm-dialog) { width:calc(100vw - 16px) !important; max-height:calc(100vh - 16px); }
|
||||
:global(.billing-confirm-dialog .el-dialog__header) { padding:14px 16px 11px; }
|
||||
:global(.billing-confirm-dialog .el-dialog__title) { font-size:19px; }
|
||||
:global(.billing-confirm-dialog .el-dialog__body) { max-height:calc(100vh - 145px); padding:14px 16px; }
|
||||
:global(.billing-confirm-dialog .el-dialog__footer) { padding:12px 16px; }
|
||||
:global(.billing-confirm-dialog .el-dialog__footer .el-button) { min-height:38px; padding:0 14px; font-size:13px; }
|
||||
.inbound-billing-state { grid-template-columns: auto minmax(0, 1fr); }
|
||||
.inbound-billing-state .el-button { grid-column: 1 / -1; width: 100%; min-height: 44px; }
|
||||
.inbound-overview, .inbound-measurements, .fee-calculation-grid { grid-template-columns: 1fr; }
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { FileText, Ship } from '@lucide/vue'
|
||||
import { Calculator, FileText, Ship } from '@lucide/vue'
|
||||
import type { ClientModuleManifest } from '../types.ts'
|
||||
|
||||
export const firstLegModule = {
|
||||
@@ -44,5 +44,21 @@ export const firstLegModule = {
|
||||
release: 'active',
|
||||
meta: { title: '快速下单', description: '按渠道、目的仓和产品 SKU 快速生成订单草稿', permission: 'order:first-leg:quick-create' },
|
||||
},
|
||||
{
|
||||
path: 'first-leg/customs-documents',
|
||||
name: 'first-leg-customs-documents',
|
||||
component: () => import('./views/CustomsDocumentsView.vue'),
|
||||
release: 'active',
|
||||
meta: { title: '报关资料', description: '管理装柜后的退税报关资料与回执', permission: 'order:first-leg:customs-list' },
|
||||
navigation: { key: 'first-leg-customs-documents', title: '报关资料', icon: FileText },
|
||||
},
|
||||
{
|
||||
path: 'first-leg/sailing-prices',
|
||||
name: 'first-leg-sailing-prices',
|
||||
component: () => import('./views/SailingPricesView.vue'),
|
||||
release: 'active',
|
||||
meta: { title: '船期价格', description: '查询渠道、参考船期与预估运费', permission: 'order:first-leg:pricing-list' },
|
||||
navigation: { key: 'first-leg-sailing-prices', title: '船期价格', icon: Calculator },
|
||||
},
|
||||
],
|
||||
} satisfies ClientModuleManifest
|
||||
|
||||
@@ -1,18 +1,39 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
import { runInNewContext } from 'node:vm'
|
||||
import ts from 'typescript'
|
||||
|
||||
const storeSource = readFileSync(new URL('./firstLegOrders.ts', import.meta.url), 'utf8')
|
||||
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('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
|
||||
const map = runInNewContext(`${javascript}; businessSummary`)
|
||||
const transport = {
|
||||
vesselName: 'REASSIGNED', voyageNo: 'V02',
|
||||
estimatedSailingAt: '2026-10-07T08:00:00', actualSailingAt: '2026-10-08T09:00:00',
|
||||
effectiveSailingAt: '2026-10-08T09:00:00', effectiveSailingAtEstimated: false,
|
||||
estimatedPortArrivalAt: '2026-10-27T08:00:00', actualPortArrivalAt: null,
|
||||
}
|
||||
const mapped = map({ transport })
|
||||
assert.equal(mapped.estimatedSailingAt, transport.estimatedSailingAt)
|
||||
assert.equal(mapped.estimatedPortArrivalAt, transport.estimatedPortArrivalAt)
|
||||
assert.equal(mapped.vesselName, 'REASSIGNED')
|
||||
assert.equal(mapped.voyageNo, 'V02')
|
||||
assert.equal(map().estimatedSailingAt, '')
|
||||
assert.equal(map({ transport: null }).estimatedPortArrivalAt, '')
|
||||
})
|
||||
|
||||
test('maps the first-leg aggregate list fields without loading detail rows', () => {
|
||||
assert.match(apiSource, /interface MerchantFirstLegOrderRow/)
|
||||
assert.match(apiSource, /skuNos: string \| string\[\] \| null/)
|
||||
assert.match(apiSource, /cargoes\?: Array<\{ skuNo\?: string \| null \}>/)
|
||||
assert.match(apiSource, /apiRequest<PageResult<MerchantFirstLegOrderRow>>/)
|
||||
assert.match(storeSource, /function mapFirstLegOrderRow/)
|
||||
for (const field of ['inboundId', 'inboundCode', 'channelName', 'destinationWarehouseName', 'pickupMethod', 'packageCount', 'expressCompany', 'trackingNo', 'totalGrossWeightKg', 'totalNetWeightKg', 'totalVolumeCbm', 'totalQuantity', 'remark', 'shipmentId', 'referenceId', 'createdAt']) {
|
||||
for (const field of ['inboundId', 'inboundCode', 'channelName', 'destinationWarehouseName', 'pickupMethod', 'packageCount', 'expressCompany', 'trackingNo', 'totalGrossWeightKg', 'totalNetWeightKg', 'totalVolumeCbm', 'totalQuantity', 'remark', 'shipmentId', 'fulfillmentType', 'lxCode', 'referenceId', 'createdAt']) {
|
||||
assert.match(storeSource, new RegExp(`row\\.${field}`))
|
||||
}
|
||||
assert.match(storeSource, /row\.skuNos/)
|
||||
@@ -102,7 +123,9 @@ test('keeps unified order pagination valid when the store omits page and size',
|
||||
|
||||
test('keeps drafts on the legacy read path without assigning a unified main status', () => {
|
||||
assert.match(storeSource, /getMerchantFirstLegOrders\(legacyQuery\(query, 'DRAFT'\)\)/)
|
||||
assert.match(storeSource, /getById\(id\)\?\.status === 'DRAFT' \? loadLegacyDetail\(id\) : loadUnifiedDetail\(id\)/)
|
||||
assert.match(storeSource, /const listOrder = getById\(id\)/)
|
||||
assert.match(storeSource, /listOrder\?\.status === 'DRAFT' \? loadLegacyDetail\(id, listOrder\) : loadUnifiedDetail\(id\)/)
|
||||
assert.match(storeSource, /detail\.order\.lxCode == null && listOrder\?\.lxCode/)
|
||||
assert.match(storeSource, /return loadLegacyDetail\(String\(createdId\)\)/)
|
||||
assert.match(storeSource, /draftLoadError/)
|
||||
})
|
||||
|
||||
@@ -220,8 +220,8 @@ function businessSummary(summary?: MerchantUnifiedOrderRow): FirstLegOrder['busi
|
||||
cabinetNo: summary?.transport?.cabinetNo ?? '',
|
||||
vesselName: summary?.transport?.vesselName ?? '',
|
||||
voyageNo: summary?.transport?.voyageNo ?? '',
|
||||
sailingAt: summary?.transport?.sailingAt ?? '',
|
||||
portArrivalAt: summary?.transport?.portArrivalAt ?? '',
|
||||
estimatedSailingAt: summary?.transport?.estimatedSailingAt ?? '',
|
||||
estimatedPortArrivalAt: summary?.transport?.estimatedPortArrivalAt ?? '',
|
||||
inboundId: summary?.inboundId == null ? '' : String(summary.inboundId),
|
||||
inboundVersion: summary?.inboundVersion ?? null,
|
||||
inboundCode: summary?.inboundCode ?? '',
|
||||
@@ -359,7 +359,7 @@ function mapFirstLegOrderRow(row: MerchantFirstLegOrderRow): FirstLegOrder {
|
||||
const uniqueSkuNos = [...new Set(resolvedSkuNos)]
|
||||
return {
|
||||
id: String(row.orderId), orderNo: row.orderNo, referenceNo: row.referenceId ?? '', transportMode: null,
|
||||
businessType: 'EXPORT', origin: '', destination: row.destinationWarehouseName ?? '', receivingWarehouseId: '',
|
||||
businessType: 'EXPORT', fulfillmentType: row.fulfillmentType ?? 'STANDARD', lxCode: row.lxCode ?? '', origin: '', destination: row.destinationWarehouseName ?? '', receivingWarehouseId: '',
|
||||
receivingWarehouseName: '', receivingWarehouseSnapshot: null, expectedDepartureDate: '', etd: '', eta: '',
|
||||
shipperPartyId: '', shipperPartySnapshot: null, shipper: '', consigneePartyId: '', consigneePartySnapshot: null,
|
||||
consignee: row.receivingCompanyName ?? '', expressCompanyName: row.expressCompany ?? '', trackingNumber: row.trackingNo ?? '',
|
||||
@@ -542,9 +542,19 @@ export const useFirstLegOrdersStore = defineStore('client-first-leg-orders', ()
|
||||
return mapped
|
||||
}
|
||||
|
||||
async function loadLegacyDetail(id: string) {
|
||||
async function loadLegacyDetail(id: string, listOrder?: FirstLegOrder) {
|
||||
const detail = await getMerchantFirstLegOrder(id)
|
||||
return upsertOrder(mapOrder(detail.order, warehouses.value, detail))
|
||||
const mapped = mapOrder(detail.order, warehouses.value, detail)
|
||||
// Older detail responses may omit newly added dropship fields. Keep the
|
||||
// values already returned by the list row so editing a draft does not
|
||||
// clear the persisted LX code in the form.
|
||||
if (detail.order.fulfillmentType == null && listOrder?.fulfillmentType) {
|
||||
mapped.fulfillmentType = listOrder.fulfillmentType
|
||||
}
|
||||
if (detail.order.lxCode == null && listOrder?.lxCode) {
|
||||
mapped.lxCode = listOrder.lxCode
|
||||
}
|
||||
return upsertOrder(mapped)
|
||||
}
|
||||
|
||||
async function loadUnifiedDetail(id: string) {
|
||||
@@ -558,7 +568,8 @@ export const useFirstLegOrdersStore = defineStore('client-first-leg-orders', ()
|
||||
}
|
||||
|
||||
async function loadDetail(id: string) {
|
||||
return getById(id)?.status === 'DRAFT' ? loadLegacyDetail(id) : loadUnifiedDetail(id)
|
||||
const listOrder = getById(id)
|
||||
return listOrder?.status === 'DRAFT' ? loadLegacyDetail(id, listOrder) : loadUnifiedDetail(id)
|
||||
}
|
||||
|
||||
async function loadWarehouseDetail(id: string) {
|
||||
@@ -616,7 +627,7 @@ export const useFirstLegOrdersStore = defineStore('client-first-leg-orders', ()
|
||||
}
|
||||
|
||||
async function loadDraftDetail(id: string) {
|
||||
return loadLegacyDetail(id)
|
||||
return loadLegacyDetail(id, getById(id))
|
||||
}
|
||||
|
||||
return { orders, warehouses, loading, loadedFromApi, loadError, warehouseLoadError, draftLoadError, activeCount, pendingCount, getById, load, loadDetail, loadDraftDetail, loadWarehouseDetail, loadWarehouseType, saveDraft, saveDraftBatch, submitSaved, submit }
|
||||
|
||||
@@ -87,8 +87,8 @@ export interface FirstLegOrderBusinessSummary {
|
||||
cabinetNo: string
|
||||
vesselName: string
|
||||
voyageNo: string
|
||||
sailingAt: string
|
||||
portArrivalAt: string
|
||||
estimatedSailingAt: string
|
||||
estimatedPortArrivalAt: string
|
||||
inboundId: string
|
||||
inboundVersion: number | null
|
||||
inboundCode: string
|
||||
|
||||
@@ -19,6 +19,13 @@ test('translates backend rounding modes into customer-facing calculation labels'
|
||||
assert.match(source, /return value \? labels\[value\] \|\| value : '-'/)
|
||||
})
|
||||
|
||||
test('translates carton calculation rules into customer-facing labels', () => {
|
||||
assert.match(source, /export function getCalculationRuleLabel/)
|
||||
assert.match(source, /ACTUAL_DIMENSION_V1/)
|
||||
assert.match(source, /按实际箱规计算/)
|
||||
assert.match(source, /CUSTOMER_DIMENSION_\(SMALL\|MEDIUM\|LARGE\|EXTRA_LARGE\)_V/)
|
||||
})
|
||||
|
||||
test('renders conversion metadata as one complete label or a single empty marker', () => {
|
||||
assert.match(source, /export function formatConversionPrecision/)
|
||||
assert.match(source, /roundingScale === null \|\| roundingScale === undefined/)
|
||||
|
||||
@@ -46,6 +46,20 @@ export function getTaxModeLabel(value: string) {
|
||||
return value || '-'
|
||||
}
|
||||
|
||||
export function getCalculationRuleLabel(value: string | null | undefined) {
|
||||
if (!value) return '-'
|
||||
if (value === 'ACTUAL_DIMENSION_V1' || value === 'DIMENSION_ACTUAL_V1') return '按实际箱规计算'
|
||||
const customerRule = /^CUSTOMER_DIMENSION_(SMALL|MEDIUM|LARGE|EXTRA_LARGE)_V(\d+)$/.exec(value)
|
||||
if (customerRule) {
|
||||
const sizeLabel: Record<string, string> = {
|
||||
SMALL: '小号', MEDIUM: '中号', LARGE: '大号', EXTRA_LARGE: '超大号',
|
||||
}
|
||||
return `客户箱规规则(${sizeLabel[customerRule[1]]},V${customerRule[2]})`
|
||||
}
|
||||
const warehouseRule = /^WAREHOUSE_OUTER_OVERRIDE_(.+)$/.exec(value)
|
||||
return warehouseRule ? `仓库外箱规覆盖(${warehouseRule[1]})` : value
|
||||
}
|
||||
|
||||
export function getRoundingModeLabel(value: string | null | undefined) {
|
||||
const labels: Record<string, string> = {
|
||||
UP: '向上舍入',
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { FileText, RefreshCcw } from '@lucide/vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useFirstLegOrdersStore } from '../public'
|
||||
import { getMerchantCustomsForOrder, type MerchantCustomsContainer } from '../api/customs'
|
||||
import CustomsDocumentsDialog from '../components/CustomsDocumentsDialog.vue'
|
||||
import type { FirstLegOrder } from '../types/firstLegOrder'
|
||||
|
||||
const store = useFirstLegOrdersStore()
|
||||
const loading = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const keyword = ref('')
|
||||
const records = ref<Array<{ order: FirstLegOrder; detail: MerchantCustomsContainer }>>([])
|
||||
const dialogOpen = ref(false)
|
||||
const selectedOrderId = ref('')
|
||||
const selectedDetail = ref<MerchantCustomsContainer | null>(null)
|
||||
|
||||
const filteredRecords = computed(() => {
|
||||
const query = keyword.value.trim().toLowerCase()
|
||||
if (!query) return records.value
|
||||
return records.value.filter(({ order, detail }) => [order.orderNo, order.businessSummary.inboundCode, detail.containerNo, detail.cabinetNo].some((value) => String(value ?? '').toLowerCase().includes(query)))
|
||||
})
|
||||
|
||||
function statusLabel(detail: MerchantCustomsContainer) {
|
||||
return ({ WAITING_CUSTOMER_DOCUMENTS: '待上传资料', READY_TO_START: '待报关', DECLARING: '报关中', COMPLETED: '报关完成' } as Record<string, string>)[detail.status] ?? detail.status
|
||||
}
|
||||
function statusType(detail: MerchantCustomsContainer) {
|
||||
return detail.status === 'COMPLETED' ? 'success' : detail.status === 'DECLARING' ? 'primary' : 'warning'
|
||||
}
|
||||
async function load() {
|
||||
loading.value = true; errorMessage.value = ''
|
||||
try {
|
||||
await store.load()
|
||||
const candidates = store.orders.filter((order) => order.status === 'LOADED' && order.taxMode === 'TAX_REFUND')
|
||||
const results = await Promise.allSettled(candidates.map(async (order) => ({ order, detail: await getMerchantCustomsForOrder(order.id) })))
|
||||
records.value = results.flatMap((result) => result.status === 'fulfilled' && result.value.detail.inbounds.some((item) => item.orderId === result.value.order.id && item.taxMode === 'TAX_REFUND') ? [result.value] : [])
|
||||
} catch (error) {
|
||||
records.value = []
|
||||
errorMessage.value = error instanceof Error ? error.message : '报关资料加载失败,请稍后重试'
|
||||
} finally { loading.value = false }
|
||||
}
|
||||
function openDetail(item: unknown) {
|
||||
const value = item as { order: FirstLegOrder; detail: MerchantCustomsContainer }
|
||||
selectedOrderId.value = value.order.id; selectedDetail.value = value.detail; dialogOpen.value = true
|
||||
}
|
||||
function updateDetail(value: MerchantCustomsContainer) {
|
||||
const found = records.value.find((item) => item.order.id === selectedOrderId.value)
|
||||
if (found) found.detail = value
|
||||
}
|
||||
onMounted(() => { void load() })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="customs-page">
|
||||
<section class="customs-hero"><div><span class="eyebrow">CUSTOMS DOCUMENTS</span><h1>报关资料</h1><p>查看装柜后退税订单的资料清单、文件版本与报关行回执。</p></div><el-button :loading="loading" @click="load"><RefreshCcw :size="15" />刷新</el-button></section>
|
||||
<section class="customs-toolbar"><el-input v-model="keyword" clearable placeholder="搜索订单号、入仓码或柜号" /><span>共 {{ filteredRecords.length }} 个资料包</span></section>
|
||||
<section class="customs-card">
|
||||
<el-skeleton v-if="loading" :rows="5" animated />
|
||||
<div v-else-if="errorMessage" class="state-error" role="alert"><p>{{ errorMessage }}</p><el-button @click="load">重试</el-button></div>
|
||||
<el-empty v-else-if="!filteredRecords.length" description="暂无待处理的报关资料" />
|
||||
<el-table v-else :data="filteredRecords" row-key="order.id" stripe>
|
||||
<el-table-column label="操作" width="120" fixed="left"><template #default="{ row }"><el-button link type="primary" @click="openDetail(row)"><FileText :size="15" />查看资料</el-button></template></el-table-column>
|
||||
<el-table-column label="订单号" min-width="160"><template #default="{ row }">{{ row.order.orderNo || '—' }}</template></el-table-column>
|
||||
<el-table-column label="入仓码" min-width="150"><template #default="{ row }">{{ row.order.businessSummary.inboundCode || '—' }}</template></el-table-column>
|
||||
<el-table-column label="柜号" min-width="140"><template #default="{ row }">{{ row.detail.containerNo || row.detail.cabinetNo || '—' }}</template></el-table-column>
|
||||
<el-table-column label="实际箱数" width="100" prop="detail.actualBoxCount" />
|
||||
<el-table-column label="资料覆盖" width="110"><template #default="{ row }">{{ row.detail.coveredTaxRefundCount }} / {{ row.detail.taxRefundCount }}</template></el-table-column>
|
||||
<el-table-column label="状态" width="120"><template #default="{ row }"><el-tag :type="statusType(row.detail)">{{ statusLabel(row.detail) }}</el-tag></template></el-table-column>
|
||||
</el-table>
|
||||
</section>
|
||||
<CustomsDocumentsDialog v-model="dialogOpen" :order-id="selectedOrderId" :can-edit="selectedDetail?.status !== 'COMPLETED'" :initial-detail="selectedDetail" @updated="updateDetail" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.customs-page { display:flex; flex-direction:column; gap:16px; }.customs-hero { display:flex; justify-content:space-between; align-items:flex-start; padding:0 2px 4px; }.eyebrow { color:#5b82c9; font-size:11px; font-weight:750; letter-spacing:.14em; }.customs-hero h1 { margin:6px 0 5px; color:#1f2f46; font-size:26px; line-height:1.25; letter-spacing:-.025em; }.customs-hero p { margin:0; color:#7d8da2; font-size:13px; }.customs-hero .el-button { margin-top:3px; border-color:#d9e3ef; border-radius:9px; background:#fff; color:#61738b; box-shadow:0 3px 8px rgba(26,55,91,.04); }.customs-toolbar { display:flex; min-height:82px; align-items:center; gap:16px; padding:16px 18px; border:1px solid #dce4ef; border-radius:16px; background:#fff; box-shadow:0 5px 16px rgba(31,56,88,.045); }.customs-toolbar .el-input { width:min(520px, 100%); }.customs-toolbar :deep(.el-input__wrapper) { min-height:42px; border-radius:10px; box-shadow:0 0 0 1px #d9e3ef inset; }.customs-toolbar span { color:#7d8da2; font-size:12px; }.customs-card { min-height:320px; padding:4px; overflow:hidden; border:1px solid #dce4ef; border-radius:16px; background:#fff; box-shadow:0 5px 16px rgba(31,56,88,.045); }.customs-card :deep(.el-table) { --el-table-header-bg-color:#f4f7fb; --el-table-row-hover-bg-color:#eaf2ff; --el-table-border-color:#edf1f6; color:#53647a; }.customs-card :deep(.el-table th.el-table__cell) { padding:13px 16px; color:#607089; font-size:12px; font-weight:750; }.customs-card :deep(.el-table td.el-table__cell) { height:58px; padding:13px 16px; font-size:13px; }.customs-card :deep(.el-table__row:nth-child(even)) { background:#f8fbff; }.state-error { display:flex; min-height:260px; flex-direction:column; align-items:center; justify-content:center; gap:10px; color:#b42318; }.state-error p { margin:0; }
|
||||
</style>
|
||||
@@ -700,13 +700,13 @@ test('supports the frozen customs document workflow and customer-safe downloads'
|
||||
'/merchant/v1/customs/loading-plans/',
|
||||
'/merchant/v1/customs/documents/',
|
||||
'/inbounds',
|
||||
'/completion/download',
|
||||
'/receipt/download',
|
||||
]) assert.match(customsApiSource, new RegExp(path.replaceAll('/', '\\/')))
|
||||
assert.match(customsApiSource, /MAX_CUSTOMS_FILE_SIZE = 50 \* 1024 \* 1024/)
|
||||
assert.match(customsApiSource, /data: \{ inboundIds, expectedVersion: document\.source\.version \}/)
|
||||
assert.match(customsApiSource, /requestId: error\.requestId/)
|
||||
assert.match(customsDialogSource, /报关开始后仅可查看和下载,不提供退回或解锁/)
|
||||
assert.match(customsDialogSource, /detail\.status === 'COMPLETED' && item\.completion/)
|
||||
assert.match(customsDialogSource, /item\.finalDocument/)
|
||||
assert.match(customsDialogSource, /资料版本历史/)
|
||||
assert.match(customsDialogSource, /尚未上传退税报关资料/)
|
||||
assert.match(customsDialogSource, /退税报关资料加载失败/)
|
||||
@@ -729,7 +729,7 @@ test('shows authoritative warehouse, loading and sailing fields on desktop and n
|
||||
}
|
||||
assert.match(source, /businessSummary\.cabinetNo \|\| .*businessSummary\.containerNo/)
|
||||
assert.match(source, /businessSummary\.vesselName.*businessSummary\.voyageNo/s)
|
||||
assert.match(source, /businessSummary\.sailingAt/)
|
||||
assert.match(source, /businessSummary\.estimatedSailingAt/)
|
||||
})
|
||||
|
||||
test('loads complete order detail before editing and rejects an unavailable historical warehouse', () => {
|
||||
|
||||
@@ -144,7 +144,7 @@ const selectableOrderColumns: DataTableColumn[] = [
|
||||
{ key: 'expectedShipDate', label: '期望出运', width: 132, align: 'center', headerAlign: 'center', slotName: 'expectedShipDate' },
|
||||
{ key: 'cabinetNo', label: '柜号 / 柜次', width: 150, align: 'left', headerAlign: 'left', showOverflowTooltip: true, slotName: 'cabinetNo' },
|
||||
{ key: 'vesselVoyage', label: '船名 / 航次', width: 160, align: 'left', headerAlign: 'left', showOverflowTooltip: true, slotName: 'vesselVoyage' },
|
||||
{ key: 'sailingAt', label: '预计船期', width: 164, align: 'center', headerAlign: 'center', slotName: 'sailingAt' },
|
||||
{ key: 'estimatedSailingAt', label: '预计船期', width: 164, align: 'center', headerAlign: 'center', slotName: 'estimatedSailingAt' },
|
||||
{ key: 'createdAt', label: '创建时间', width: 164, align: 'center', headerAlign: 'center', slotName: 'createdAt' },
|
||||
{ key: 'updatedAt', label: '更新时间', width: 164, align: 'center', headerAlign: 'center', slotName: 'updatedAt' },
|
||||
]
|
||||
@@ -429,6 +429,12 @@ function quickOrderDraft() {
|
||||
} catch {
|
||||
// Keep the standard empty row when quick-order data is malformed.
|
||||
}
|
||||
const pricingTaxMode = String(route.query.pricingTaxMode ?? '')
|
||||
if (pricingTaxMode === 'TAX_INCLUDED' || pricingTaxMode === 'TAX_EXCLUDED') draft.pricingTaxMode = pricingTaxMode
|
||||
const grossWeightKg = Number(route.query.grossWeightKg)
|
||||
const volumeCbm = Number(route.query.volumeCbm)
|
||||
if (Number.isFinite(grossWeightKg) && grossWeightKg > 0) draft.items[0].grossWeightKg = grossWeightKg
|
||||
if (Number.isFinite(volumeCbm) && volumeCbm > 0) draft.items[0].volumeCbm = volumeCbm
|
||||
return draft
|
||||
}
|
||||
|
||||
@@ -566,7 +572,7 @@ const progressSummaryItems = computed(() => {
|
||||
const cabinetProgressItems = computed(() => {
|
||||
const summary = detailOrder.value?.businessSummary
|
||||
if (!summary || (!summary.cabinetNo && !summary.containerNo)) return []
|
||||
return [{ cabinetId: summary.cabinetNo || summary.containerNo, cabinetNo: summary.cabinetNo || summary.containerNo, status: summary.loadingStatus || '待更新', vesselName: summary.vesselName, voyageNo: summary.voyageNo, sailingAt: summary.sailingAt }]
|
||||
return [{ cabinetId: summary.cabinetNo || summary.containerNo, cabinetNo: summary.cabinetNo || summary.containerNo, status: summary.loadingStatus || '待更新', vesselName: summary.vesselName, voyageNo: summary.voyageNo, estimatedSailingAt: summary.estimatedSailingAt }]
|
||||
})
|
||||
const selectedWarehouse = computed(() => availableWarehouses.value.find((item) => item.id === form.receivingWarehouseId))
|
||||
const selectedDestinationWarehouse = computed(() => store.warehouses.find((item) => item.id === form.destinationWarehouseId && item.warehouseType === form.destinationType))
|
||||
@@ -971,6 +977,7 @@ function createEstimateRequest(): MerchantPriceEstimateRequest | null {
|
||||
destinationWarehouseId: form.destinationType === 'OVERSEAS' ? form.destinationWarehouseId : undefined,
|
||||
destinationName: form.destinationWarehouseName || undefined,
|
||||
pricingTaxMode: form.pricingTaxMode,
|
||||
chargeMode: form.pricingTaxMode === 'TAX_INCLUDED' ? 'KG' : 'CBM',
|
||||
actualWeightKg: summary.grossWeightKg,
|
||||
actualVolumeCbm: summary.volumeCbm,
|
||||
}
|
||||
@@ -1267,6 +1274,11 @@ async function autofillWarehouseSelections() {
|
||||
if (form.receivingWarehouseId) {
|
||||
await loadSelectedWarehouse(form.receivingWarehouseId)
|
||||
}
|
||||
if (form.destinationType !== 'PRIVATE' && !form.destinationWarehouseId && form.amazonWarehouseCode) {
|
||||
const destinationCode = form.amazonWarehouseCode.trim().toUpperCase()
|
||||
const matchedDestination = availableDestinationWarehouses.value.find((warehouse) => warehouse.code.trim().toUpperCase() === destinationCode)
|
||||
if (matchedDestination) await selectDestinationWarehouse(matchedDestination.id)
|
||||
}
|
||||
if (form.destinationType !== 'PRIVATE' && !form.destinationWarehouseId && availableDestinationWarehouses.value.length === 1) {
|
||||
await selectDestinationWarehouse(availableDestinationWarehouses.value[0].id)
|
||||
}
|
||||
@@ -2136,7 +2148,7 @@ async function searchOrders(model: QueryModel) {
|
||||
<template #expectedShipDate="{ row }">{{ (row as FirstLegOrder).expectedDepartureDate ? formatDateTime((row as FirstLegOrder).expectedDepartureDate) : '-' }}</template>
|
||||
<template #cabinetNo="{ row }"><span class="table-ellipsis table-code" :title="displayListValue((row as FirstLegOrder).businessSummary.cabinetNo || (row as FirstLegOrder).businessSummary.containerNo)">{{ displayListValue((row as FirstLegOrder).businessSummary.cabinetNo || (row as FirstLegOrder).businessSummary.containerNo) }}</span></template>
|
||||
<template #vesselVoyage="{ row }"><span class="table-ellipsis" :title="displayListValue([(row as FirstLegOrder).businessSummary.vesselName, (row as FirstLegOrder).businessSummary.voyageNo].filter(Boolean).join(' · '))">{{ displayListValue([(row as FirstLegOrder).businessSummary.vesselName, (row as FirstLegOrder).businessSummary.voyageNo].filter(Boolean).join(' · ')) }}</span></template>
|
||||
<template #sailingAt="{ row }">{{ (row as FirstLegOrder).businessSummary.sailingAt ? formatDateTime((row as FirstLegOrder).businessSummary.sailingAt) : '-' }}</template>
|
||||
<template #estimatedSailingAt="{ row }">{{ (row as FirstLegOrder).businessSummary.estimatedSailingAt ? formatDateTime((row as FirstLegOrder).businessSummary.estimatedSailingAt) : '-' }}</template>
|
||||
<template #createdAt="{ row }">{{ formatDateTime((row as FirstLegOrder).createdAt) }}</template>
|
||||
<template #updatedAt="{ row }">{{ formatDateTime((row as FirstLegOrder).updatedAt) }}</template>
|
||||
<template #empty>
|
||||
@@ -2205,8 +2217,8 @@ async function searchOrders(model: QueryModel) {
|
||||
<div class="address-book-control"><el-select v-model="form.privateDestinationAddressId" clearable filterable :loading="orderAddressesLoading" placeholder="选择企业保存的私人仓地址" @change="selectPrivateAddress"><el-option v-for="address in privateAddresses" :key="String(address.id)" :label="addressLabel(address)" :value="String(address.id)" /></el-select><el-button @click="savePrivateAddress">保存当前地址</el-button><el-button v-if="form.privateDestinationAddressId" link type="danger" @click="removeOrderAddress('PRIVATE_DESTINATION')">停用</el-button></div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item v-if="form.destinationType !== 'PRIVATE'" :label="form.destinationType === 'FBA' ? 'FBA 仓' : '海外仓'" prop="destinationWarehouseId" required :rules="requiredSelectRules('请选择平台维护的目的仓')" class="prototype-form-row"><el-select v-model="form.destinationWarehouseId" filterable :loading="destinationWarehousesLoading" placeholder="请选择当前可送达的目的仓" @change="selectDestinationWarehouse"><el-option v-for="warehouse in availableDestinationWarehouses" :key="warehouse.id" :label="`${warehouse.name} · ${warehouse.code}`" :value="warehouse.id" /></el-select><small v-if="!destinationWarehousesLoading && availableDestinationWarehouses.length === 0" class="field-guidance">平台暂未维护可用的{{ form.destinationType === 'FBA' ? ' FBA 仓' : '海外仓' }},请联系平台管理员。</small></el-form-item>
|
||||
<div v-if="form.destinationType !== 'PRIVATE' && selectedDestinationWarehouse" class="selected-destination"><MapPin :size="17" /><div><span>{{ selectedDestinationWarehouse.name }} · {{ selectedDestinationWarehouse.code }} · {{ selectedDestinationWarehouse.countryCode }}</span><small v-if="selectedDestinationWarehouse.address">仓库地址:{{ selectedDestinationWarehouse.province }}{{ selectedDestinationWarehouse.city }} {{ selectedDestinationWarehouse.address }}</small><small v-if="selectedDestinationWarehouse.contactName || selectedDestinationWarehouse.contactPhone">联系人:{{ selectedDestinationWarehouse.contactName || '-' }} {{ selectedDestinationWarehouse.contactPhone }}</small></div></div>
|
||||
<el-form-item v-if="form.destinationType !== 'PRIVATE'" :label="form.destinationType === 'FBA' ? 'FBA 仓' : '海外仓'" prop="destinationWarehouseId" required :rules="requiredSelectRules('请选择平台维护的目的仓')" class="prototype-form-row"><el-select v-model="form.destinationWarehouseId" filterable :loading="destinationWarehousesLoading" placeholder="请选择当前可送达的目的仓" @change="selectDestinationWarehouse"><el-option v-for="warehouse in availableDestinationWarehouses" :key="warehouse.id" :label="form.destinationType === 'FBA' ? warehouse.code : `${warehouse.name} · ${warehouse.code}`" :value="warehouse.id" /></el-select><small v-if="!destinationWarehousesLoading && availableDestinationWarehouses.length === 0" class="field-guidance">平台暂未维护可用的{{ form.destinationType === 'FBA' ? ' FBA 仓' : '海外仓' }},请联系平台管理员。</small></el-form-item>
|
||||
<div v-if="form.destinationType !== 'PRIVATE' && selectedDestinationWarehouse" class="selected-destination"><MapPin :size="17" /><div><span>{{ form.destinationType === 'FBA' ? selectedDestinationWarehouse.code : `${selectedDestinationWarehouse.name} · ${selectedDestinationWarehouse.code}` }} · {{ selectedDestinationWarehouse.countryCode }}</span><small v-if="selectedDestinationWarehouse.address">仓库地址:{{ selectedDestinationWarehouse.province }}{{ selectedDestinationWarehouse.city }} {{ selectedDestinationWarehouse.address }}</small><small v-if="selectedDestinationWarehouse.contactName || selectedDestinationWarehouse.contactPhone">联系人:{{ selectedDestinationWarehouse.contactName || '-' }} {{ selectedDestinationWarehouse.contactPhone }}</small></div></div>
|
||||
<template v-if="form.destinationType === 'PRIVATE' && form.consigneePartySnapshot">
|
||||
<el-form-item label="私人地址名称" prop="destinationWarehouseName" required class="prototype-form-row"><el-input v-model="form.destinationWarehouseName" maxlength="128" placeholder="例如:洛杉矶客户自有仓" @input="syncPrivateDestinationName" /></el-form-item>
|
||||
<el-form-item label="收件人姓名" prop="consigneePartySnapshot.contactName" required :rules="requiredTextRules('请输入收货联系人姓名')" class="prototype-form-row prototype-form-row--compact"><el-input v-model="form.consigneePartySnapshot.contactName" maxlength="50" placeholder="请输入收件人姓名" /></el-form-item>
|
||||
@@ -2384,7 +2396,7 @@ async function searchOrders(model: QueryModel) {
|
||||
<el-table v-if="cabinetProgressItems.length" :data="cabinetProgressItems" border size="small" class="cabinet-progress-table" row-key="cabinetId">
|
||||
<el-table-column prop="cabinetNo" label="柜号 / 柜次" min-width="150"><template #default="{ row }"><span class="table-code">{{ row.cabinetNo || '—' }}</span></template></el-table-column>
|
||||
<el-table-column label="船名 / 航次" min-width="180"><template #default="{ row }">{{ [row.vesselName, row.voyageNo].filter(Boolean).join(' · ') || '待更新' }}</template></el-table-column>
|
||||
<el-table-column label="预计船期" width="170"><template #default="{ row }">{{ row.sailingAt ? formatDateTime(row.sailingAt) : '待更新' }}</template></el-table-column>
|
||||
<el-table-column label="预计船期" width="170"><template #default="{ row }">{{ row.estimatedSailingAt ? formatDateTime(row.estimatedSailingAt) : '待更新' }}</template></el-table-column>
|
||||
<el-table-column prop="status" label="当前节点" width="120"><template #default="{ row }"><el-tag size="small" effect="plain">{{ row.status || '待更新' }}</el-tag></template></el-table-column>
|
||||
</el-table>
|
||||
<p v-else class="detail-inline-empty">尚未分配柜次,后续柜次节点将在这里同步。</p>
|
||||
|
||||
@@ -509,7 +509,7 @@ onMounted(loadChannels)
|
||||
</el-form-item>
|
||||
<el-form-item v-if="destinationForm.type !== 'PRIVATE'" :label="destinationForm.type === 'FBA' ? 'FBA 仓' : '海外仓'" required>
|
||||
<el-select v-model="destinationForm.warehouseId" filterable :loading="destinationLoading" placeholder="请选择当前可送达的目的仓">
|
||||
<el-option v-for="warehouse in availableDestinationWarehouses" :key="warehouse.id" :label="`${warehouse.name} · ${warehouse.code}`" :value="warehouse.id" />
|
||||
<el-option v-for="warehouse in availableDestinationWarehouses" :key="warehouse.id" :label="destinationForm.type === 'FBA' ? warehouse.code : `${warehouse.name} · ${warehouse.code}`" :value="warehouse.id" />
|
||||
</el-select>
|
||||
<small v-if="!destinationLoading && availableDestinationWarehouses.length === 0">平台暂未维护可用目的仓,请联系平台管理员。</small>
|
||||
</el-form-item>
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
||||
import { Calculator, Search } from '@lucide/vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { estimateMerchantFirstLegPrice, getMerchantPricingChannels, getMerchantWarehouses, type MerchantPriceEstimate, type MerchantPricingChannel, type MerchantWarehouse } from '../api/firstLeg'
|
||||
|
||||
const loading = ref(false); const querying = ref(false); const errorMessage = ref('')
|
||||
const router = useRouter()
|
||||
const warehouses = ref<MerchantWarehouse[]>([]); const destinationWarehouses = ref<MerchantWarehouse[]>([]); const channels = ref<MerchantPricingChannel[]>([]); const quotes = ref<MerchantPriceEstimate[]>([])
|
||||
const quoteDialogOpen = ref(false); const originWarehouseId = ref<string | number>(''); const destinationWarehouseId = ref<string | number>(''); const weight = ref<number | undefined>(); const volume = ref<number | undefined>(); const taxMode = ref<'TAX_INCLUDED' | 'TAX_EXCLUDED'>('TAX_INCLUDED')
|
||||
const selectedDestinationWarehouse = computed(() => destinationWarehouses.value.find((item) => String(item.id) === String(destinationWarehouseId.value)))
|
||||
const destinationCode = computed(() => selectedDestinationWarehouse.value?.warehouseCode || '')
|
||||
const hasMeasureInput = computed(() => Number(weight.value || 0) > 0 || Number(volume.value || 0) > 0)
|
||||
const hasInput = computed(() => Boolean(originWarehouseId.value && destinationWarehouseId.value && hasMeasureInput.value))
|
||||
// The pricing API accepts either dimension, but normalizing both here keeps
|
||||
// every channel on the same calculation path when one field is omitted.
|
||||
const normalizedWeightKg = computed(() => weight.value || (volume.value ? volume.value * 167 : 0))
|
||||
const normalizedVolumeCbm = computed(() => volume.value || (weight.value ? weight.value / 167 : 0))
|
||||
type QuoteCard = { quote: MerchantPriceEstimate; channel: MerchantPricingChannel }
|
||||
const allQuoteCards = computed<QuoteCard[]>(() => quotes.value.map((quote) => ({ quote, channel: channels.value.find((item) => item.channelCode === quote.channelCode) })).filter((item): item is QuoteCard => Boolean(item.channel)))
|
||||
const priceWinner = computed<QuoteCard | undefined>(() => allQuoteCards.value.reduce<QuoteCard | undefined>((winner, item) => !winner || item.quote.estimatedAmount < winner.quote.estimatedAmount ? item : winner, undefined))
|
||||
const timeWinner = computed<QuoteCard | undefined>(() => allQuoteCards.value.reduce<QuoteCard | undefined>((winner, item) => { const days = transitDays(item.channel.referenceTransit); if (days === null) return winner; return !winner || days < transitDays(winner.channel.referenceTransit)! ? item : winner }, undefined))
|
||||
const quoteCards = computed(() => {
|
||||
const prioritized: QuoteCard[] = []
|
||||
if (timeWinner.value) prioritized.push(timeWinner.value)
|
||||
if (priceWinner.value && priceWinner.value.quote.channelCode !== timeWinner.value?.quote.channelCode) prioritized.push(priceWinner.value)
|
||||
const prioritizedCodes = new Set(prioritized.map((item) => item.quote.channelCode))
|
||||
const remaining = [...allQuoteCards.value].filter((item) => !prioritizedCodes.has(item.quote.channelCode)).sort((a, b) => a.quote.estimatedAmount - b.quote.estimatedAmount)
|
||||
return [...prioritized, ...remaining]
|
||||
})
|
||||
const resultSummary = computed(() => {
|
||||
const quoteUnit = (volume.value || 0) >= (weight.value || 0) / 167 ? 'CBM' : 'KG'
|
||||
const quoteQuantity = quoteUnit === 'CBM' ? volume.value : weight.value
|
||||
return `${quoteQuantity || 0} ${quoteUnit} · ${destinationCode.value || '—'} · 匹配 ${quoteCards.value.length} 个渠道`
|
||||
})
|
||||
function transitDays(value: string | null) { const match = value?.match(/\d+/); return match ? Number(match[0]) : null }
|
||||
function formatMoney(item: MerchantPriceEstimate) { const currency = item.currency === 'CNY' ? '¥' : `${item.currency} `; return `${currency}${item.estimatedAmount.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}` }
|
||||
function formatUnitPrice(item: MerchantPriceEstimate) { const currency = item.currency === 'CNY' ? '¥' : `${item.currency} `; return `${currency}${item.unitPrice.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}/${item.chargeUnit || 'CBM'}` }
|
||||
function recommendation(item: QuoteCard) { if (timeWinner.value?.quote.channelCode === item.quote.channelCode) return { label: '时间优选', className: 'time' }; if (priceWinner.value?.quote.channelCode === item.quote.channelCode) return { label: '价格优选', className: 'price' }; return undefined }
|
||||
async function loadBase() { loading.value = true; errorMessage.value = ''; try { const [originResult, destinationResult] = await Promise.all([getMerchantWarehouses({ warehouseType: 'DOMESTIC' }), getMerchantWarehouses({ warehouseType: 'FBA' })]); warehouses.value = originResult.records; destinationWarehouses.value = destinationResult.records; if (originResult.records[0]) originWarehouseId.value = originResult.records[0].id; if (destinationResult.records[0]) destinationWarehouseId.value = destinationResult.records[0].id; await loadChannels() } catch (error) { errorMessage.value = error instanceof Error ? error.message : '船期价格加载失败' } finally { loading.value = false } }
|
||||
async function loadChannels() { if (!originWarehouseId.value) return; try { channels.value = await getMerchantPricingChannels(originWarehouseId.value) } catch (error) { channels.value = []; errorMessage.value = error instanceof Error ? error.message : '船期渠道加载失败' } }
|
||||
async function query() { const destination = selectedDestinationWarehouse.value; if (!hasInput.value || !destination) { ElMessage.warning('请选择目的 FBA 仓,并填写重量或体积(填写一项即可)'); return }; if (!channels.value.length) { ElMessage.warning('暂无可查询的船期渠道'); return }; querying.value = true; quotes.value = []; try { const results = await Promise.allSettled(channels.value.map((channel) => estimateMerchantFirstLegPrice({ originWarehouseId: originWarehouseId.value, channelCode: channel.channelCode, destinationCode: destination.warehouseCode, destinationType: 'FBA', destinationWarehouseId: destination.id, destinationName: destination.warehouseName, pricingTaxMode: taxMode.value, chargeMode: taxMode.value === 'TAX_INCLUDED' ? 'KG' : 'CBM', actualWeightKg: normalizedWeightKg.value, actualVolumeCbm: normalizedVolumeCbm.value }))); quotes.value = results.flatMap((item) => item.status === 'fulfilled' ? [item.value] : []); if (!quotes.value.length) { const failure = results.find((item): item is PromiseRejectedResult => item.status === 'rejected'); const message = failure?.reason instanceof Error ? failure.reason.message : '当前条件暂无匹配报价'; ElMessage.warning(message); return }; quoteDialogOpen.value = true } finally { querying.value = false } }
|
||||
async function placeOrder(channelCode: string, channelName: string) {
|
||||
if (!quotes.value.some((quote) => quote.channelCode === channelCode)) {
|
||||
ElMessage.warning('请先查询该渠道价格')
|
||||
return
|
||||
}
|
||||
// Release Element Plus dialog scroll locking before changing routes. If the
|
||||
// route is replaced while the close transition is still active, the dialog
|
||||
// can otherwise leave body overflow hidden on the order page.
|
||||
quoteDialogOpen.value = false
|
||||
await nextTick()
|
||||
router.push({
|
||||
name: 'first-leg-order-create',
|
||||
query: {
|
||||
quick: '1',
|
||||
receivingWarehouseId: String(originWarehouseId.value),
|
||||
channelCode,
|
||||
channelName,
|
||||
destinationType: 'FBA',
|
||||
destinationWarehouseId: String(selectedDestinationWarehouse.value!.id),
|
||||
destinationWarehouseName: selectedDestinationWarehouse.value!.warehouseName,
|
||||
amazonWarehouseCode: selectedDestinationWarehouse.value!.warehouseCode,
|
||||
destinationCountryCode: selectedDestinationWarehouse.value!.countryCode || 'US',
|
||||
pricingTaxMode: taxMode.value,
|
||||
grossWeightKg: String(normalizedWeightKg.value),
|
||||
volumeCbm: String(normalizedVolumeCbm.value),
|
||||
cargo: JSON.stringify([{ sku: '', packageCount: 1 }]),
|
||||
},
|
||||
})
|
||||
}
|
||||
watch(originWarehouseId, () => { void loadChannels() }); onMounted(() => { void loadBase() })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="pricing-page"><section class="pricing-inquiry"><div class="inquiry-copy"><span>SAILING PRICE INQUIRY</span><h2>船期价格查询</h2><p>重量或体积填写一项即可,系统会按计价规则自动换算</p></div><el-form class="pricing-form" label-position="top"><el-form-item label="起运仓"><el-select v-model="originWarehouseId" filterable :loading="loading" placeholder="选择起运仓"><el-option v-for="item in warehouses" :key="String(item.id)" :label="`${item.warehouseName} · ${item.warehouseCode}`" :value="item.id" /></el-select></el-form-item><el-form-item label="货物重量"><div class="unit-input"><el-input v-model.number="weight" type="number" min="0" placeholder="可选,输入重量" /><span>KG</span></div></el-form-item><el-form-item label="货物立方数"><div class="unit-input"><el-input v-model.number="volume" type="number" min="0" placeholder="可选,输入体积" /><span>CBM</span></div></el-form-item><el-form-item label="目的 FBA 仓"><el-select v-model="destinationWarehouseId" filterable :loading="loading" placeholder="输入或选择仓库代码"><el-option v-for="item in destinationWarehouses" :key="String(item.id)" :label="item.warehouseCode" :value="item.id" /></el-select></el-form-item><el-form-item label="报价方式"><el-radio-group v-model="taxMode"><el-radio-button value="TAX_INCLUDED" label="包税" /><el-radio-button value="TAX_EXCLUDED" label="不包税" /></el-radio-group></el-form-item><el-form-item class="query-item"><el-button type="primary" :loading="querying" @click="query"><Search :size="16" />立即查询</el-button></el-form-item></el-form>
|
||||
<el-dialog v-model="quoteDialogOpen" class="quote-dialog" :show-close="false" :lock-scroll="false" width="min(1080px, calc(100vw - 48px))" top="6vh"><template #header><div class="quote-dialog-head"><div><h3>查询结果</h3><span>{{ resultSummary }}</span></div><button type="button" class="quote-close" aria-label="关闭" @click="quoteDialogOpen = false">×</button></div></template><div class="quote-grid"><article v-for="item in quoteCards" :key="item.quote.channelCode" class="quote-card" :class="recommendation(item)?.className"><div v-if="recommendation(item)" class="quote-ribbon">{{ recommendation(item)?.label }}</div><div class="quote-card-title"><strong>{{ item.channel.channelName }}</strong><span>{{ item.quote.pricingTaxMode === 'TAX_INCLUDED' ? '包税' : '不包税' }} / {{ item.quote.chargeUnit || 'CBM' }}</span></div><div class="quote-price"><b>{{ formatMoney(item.quote) }}</b><small>预估运费</small></div><div class="quote-meta"><div><span>单价</span><strong>{{ formatUnitPrice(item.quote) }}</strong></div><div><span>参考时效</span><strong>{{ item.channel.referenceTransit || '待更新' }}</strong></div><div><span>最晚进仓</span><strong>{{ item.channel.latestWarehouseEntry || '—' }}</strong></div><div><span>船期</span><strong>{{ item.channel.estimatedDepartureText || item.channel.estimatedDepartureDate || '待更新' }}</strong></div></div><el-button type="primary" plain class="quote-order" @click="placeOrder(item.quote.channelCode, item.channel.channelName)">下单</el-button></article></div></el-dialog></section>
|
||||
<section class="pricing-results"><div class="section-title"><div><h2>匹配渠道</h2><span>{{ channels.length }} 个渠道 · 参考价格以服务端估价为准</span></div><Calculator :size="20" /></div><el-skeleton v-if="loading || querying" :rows="4" animated /><el-alert v-else-if="errorMessage" :title="errorMessage" type="error" show-icon /><el-empty v-else-if="!channels.length" description="暂无可用船期价格" /><el-table v-else :data="channels" stripe><el-table-column label="渠道" min-width="160"><template #default="{ row }"><strong>{{ row.channelName }}</strong><small>{{ row.channelCode }}</small></template></el-table-column><el-table-column label="参考船期" min-width="150"><template #default="{ row }">{{ row.estimatedDepartureText || row.estimatedDepartureDate || '待更新' }}</template></el-table-column><el-table-column label="最晚进仓" min-width="130"><template #default="{ row }">{{ row.latestWarehouseEntry || '—' }}</template></el-table-column><el-table-column label="参考时效" width="120"><template #default="{ row }">{{ row.referenceTransit || '—' }}</template></el-table-column><el-table-column label="预估运费" width="150"><template #default="{ row }"><span v-if="quotes.find((quote) => quote.channelCode === row.channelCode)">{{ formatMoney(quotes.find((quote) => quote.channelCode === row.channelCode)!) }}</span><span v-else class="muted">查询后显示</span></template></el-table-column><el-table-column label="附加费说明" min-width="180" prop="surchargeDescription" /><el-table-column label="操作" width="100" fixed="right"><template #default="{ row }"><el-button link type="primary" :disabled="!quotes.some((quote) => quote.channelCode === row.channelCode)" @click="placeOrder(row.channelCode, row.channelName)">下单</el-button></template></el-table-column></el-table></section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.pricing-page { display:flex; flex-direction:column; gap:16px; }.pricing-heading { display:flex; justify-content:space-between; align-items:flex-start; padding:0 2px; }.eyebrow { color:#5b82c9; font-size:11px; font-weight:750; letter-spacing:.14em; }.pricing-heading h1 { margin:6px 0 5px; color:#1f2f46; font-size:26px; line-height:1.25; letter-spacing:-.025em; }.pricing-heading p { margin:0; color:#7d8da2; font-size:13px; }.refresh-button { margin-top:3px; border-color:#d9e3ef; border-radius:9px; background:#fff; color:#61738b; box-shadow:0 3px 8px rgba(26,55,91,.04); }.pricing-inquiry { position:relative; overflow:hidden; padding:24px; border:1px solid rgba(99,135,255,.36); border-radius:18px; background:linear-gradient(112deg,#152754 0%,#203d8f 42%,#315bd9 75%,#4c70f5 100%); box-shadow:0 14px 36px rgba(31,61,140,.2); }.pricing-inquiry::before { content:''; position:absolute; right:-75px; top:-165px; width:410px; height:410px; border:48px solid rgba(255,255,255,.07); border-radius:50%; background:radial-gradient(circle,rgba(255,255,255,.07),transparent 64%); pointer-events:none; }.inquiry-copy { position:relative; z-index:1; margin-bottom:19px; }.inquiry-copy > span { color:rgba(255,255,255,.7); font-size:10px; font-weight:850; letter-spacing:.18em; }.inquiry-copy h2 { margin:5px 0 4px; color:#fff; font-size:22px; font-weight:850; }.inquiry-copy p { margin:0; color:rgba(255,255,255,.7); font-size:13px; }.pricing-form { position:relative; z-index:1; display:grid; grid-template-columns:minmax(130px,.9fr) minmax(120px,.75fr) minmax(120px,.75fr) minmax(170px,1.1fr) minmax(125px,.8fr) 128px; align-items:end; gap:10px; }.pricing-form :deep(.el-form-item) { margin-bottom:0; }.pricing-form :deep(.el-form-item__label) { height:auto; margin-bottom:8px; color:rgba(255,255,255,.82); font-size:12px; font-weight:750; line-height:1.2; }.pricing-form :deep(.el-input__wrapper), .pricing-form :deep(.el-select__wrapper) { min-height:48px; border:1px solid #d4e0ed; border-radius:11px; background:#fff; box-shadow:0 4px 13px rgba(45,72,105,.04); }.pricing-form :deep(.el-input__inner), .pricing-form :deep(.el-select__selected-item) { color:#24354d; font-size:14px; font-weight:600; }.unit-input { display:flex; min-height:48px; overflow:hidden; border:1px solid #d4e0ed; border-radius:11px; background:#fff; }.unit-input :deep(.el-input__wrapper) { min-height:46px; border:0; box-shadow:none; }.unit-input span { display:flex; align-items:center; padding:0 13px 0 4px; color:#7690ad; font-size:12px; font-weight:750; }.pricing-form :deep(.el-radio-group) { display:flex; min-height:48px; align-items:center; padding:0 4px; border-radius:11px; background:#fff; }.pricing-form :deep(.el-radio-button__inner) { height:40px; padding:12px 12px; border:0; border-radius:8px; color:#61738b; box-shadow:none; }.pricing-form :deep(.el-radio-button__original-radio:checked + .el-radio-button__inner) { border-color:#3d70e8; background:#3d70e8; color:#fff; box-shadow:0 5px 12px rgba(37,99,235,.2); }.query-item :deep(.el-form-item__content) { display:block; }.query-item :deep(.el-button) { width:100%; min-height:48px; border:0; border-radius:11px; background:linear-gradient(135deg,#ffca4b,#ffb82e); color:#48350b; box-shadow:0 9px 20px rgba(255,185,46,.25); font-size:14px; font-weight:850; }.query-item :deep(.el-button:hover) { background:linear-gradient(135deg,#ffd363,#ffc13e); color:#48350b; transform:translateY(-1px); }.pricing-results { padding:4px; overflow:hidden; border:1px solid #dce4ef; border-radius:16px; background:#fff; box-shadow:0 5px 16px rgba(31,56,88,.045); }.section-title { display:flex; align-items:center; justify-content:space-between; padding:17px 18px 13px; }.section-title h2 { margin:0 0 4px; color:#20324a; font-size:16px; }.section-title span { color:#7d8da2; font-size:12px; }.section-title svg { color:#3d70e8; }.pricing-results small { display:block; margin-top:3px; color:#7d8da2; font-size:11px; }.pricing-results :deep(.el-table) { --el-table-header-bg-color:#f4f7fb; --el-table-row-hover-bg-color:#eaf2ff; --el-table-border-color:#edf1f6; color:#53647a; }.pricing-results :deep(.el-table th.el-table__cell) { padding:13px 16px; color:#607089; font-size:12px; font-weight:750; }.pricing-results :deep(.el-table td.el-table__cell) { height:58px; padding:13px 16px; font-size:13px; }.pricing-results :deep(.el-table__row:nth-child(even)) { background:#f8fbff; }.pricing-results :deep(.el-button--link) { font-weight:750; }.muted { color:#94a0af; }
|
||||
.quote-dialog { --el-dialog-bg-color:transparent; }.quote-dialog :deep(.el-dialog) { overflow:hidden; border-radius:18px; background:linear-gradient(125deg,#182e69 0%,#274cbd 55%,#416bf1 100%); box-shadow:0 18px 52px rgba(11,31,89,.3); }.quote-dialog :deep(.el-dialog__header) { margin:0; padding:0; }.quote-dialog :deep(.el-dialog__body) { padding:0 14px 16px; }.quote-dialog-head { display:flex; align-items:flex-start; justify-content:space-between; padding:16px 18px 12px; color:#fff; }.quote-dialog-head h3 { margin:0 0 2px; font-size:19px; }.quote-dialog-head span { color:rgba(255,255,255,.72); font-size:12px; }.quote-close { width:36px; height:36px; border:1px solid #cfddf2; border-radius:10px; background:#fff; color:#8392a8; font-size:26px; line-height:29px; cursor:pointer; }.quote-grid { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:12px; align-items:stretch; }.quote-card { position:relative; display:flex; height:100%; min-height:240px; box-sizing:border-box; flex-direction:column; overflow:hidden; padding:18px 17px 14px; border:1px solid #d5deee; border-radius:15px; background:#f8f9fd; color:#263a55; box-shadow:0 6px 16px rgba(22,48,117,.1); }.quote-card.price { border:2px solid #ffae3c; }.quote-card.time { border:2px solid #27c28c; }.quote-ribbon { position:absolute; right:-43px; top:13px; width:130px; padding:5px 0; transform:rotate(43deg); background:#ff8d14; color:#fff; text-align:center; font-size:10px; font-weight:850; }.quote-card.time .quote-ribbon { background:#0db886; }.quote-card-title { display:flex; min-width:0; align-items:flex-start; justify-content:space-between; gap:6px; }.quote-card.price .quote-card-title, .quote-card.time .quote-card-title { padding-right:38px; }.quote-card-title strong { min-width:0; overflow-wrap:anywhere; font-size:16px; line-height:1.25; word-break:break-word; }.quote-card-title span { flex:none; padding:5px 7px; border-radius:15px; background:#edf5ff; color:#367ae0; font-size:10px; font-weight:750; white-space:nowrap; }.quote-price { display:flex; flex-shrink:0; align-items:baseline; gap:6px; margin:17px 0 15px; }.quote-price b { color:#ed5a2a; font-size:26px; line-height:1; white-space:nowrap; }.quote-price small { flex-shrink:0; color:#96a4b8; font-size:12px; }.quote-meta { display:grid; min-width:0; flex:1; grid-template-columns:minmax(0,1fr) minmax(0,1fr); align-content:start; gap:11px 10px; }.quote-meta > div { min-width:0; }.quote-meta span { display:block; margin-bottom:3px; color:#93a0b2; font-size:10px; }.quote-meta strong { display:block; min-width:0; overflow-wrap:anywhere; color:#344961; font-size:12px; line-height:1.35; word-break:break-word; }.quote-order { width:100%; min-height:32px; flex-shrink:0; margin-top:16px; border-radius:7px; font-size:12px; }.quote-card:not(.price):not(.time) .quote-order { background:#fff; }
|
||||
@media (max-width:1200px) { .pricing-form { grid-template-columns:repeat(3,minmax(0,1fr)); }.quote-grid { grid-template-columns:repeat(2,minmax(0,1fr)); } }
|
||||
@media (max-width:720px) { .quote-grid { grid-template-columns:1fr; }.pricing-form { grid-template-columns:1fr; } }
|
||||
:global(.quote-dialog.el-dialog) { overflow:hidden; border-radius:18px; background:linear-gradient(125deg,#182e69 0%,#274cbd 55%,#416bf1 100%); box-shadow:0 18px 52px rgba(11,31,89,.3); }
|
||||
:global(.quote-dialog.el-dialog .el-dialog__header) { margin:0; padding:0; }
|
||||
:global(.quote-dialog.el-dialog .el-dialog__body) { padding:0 14px 16px; }
|
||||
</style>
|
||||
@@ -92,7 +92,7 @@ test('submits the complete create contract with stable retry idempotency and rec
|
||||
for (const model of ['dispatchNo', 'plannedDeliveryAt', 'deliveryMethod', 'pickupMethod', 'loadingType', 'waybillNo', 'carrierName', 'vehicleType', 'vehicleSize', 'driverName', 'driverPhone', 'vehiclePlate', 'appointmentAccount', 'sellerName', 'remark']) {
|
||||
assert.match(createSource, new RegExp(`v-model="form\\.${model}"`))
|
||||
}
|
||||
assert.match(createSource, /if \(!pendingIdempotencyKey\) pendingIdempotencyKey = crypto\.randomUUID\(\)/)
|
||||
assert.match(createSource, /if \(!pendingIdempotencyKey\) pendingIdempotencyKey = createIdempotencyKey\(\)/)
|
||||
assert.match(createSource, /createDeliveryOrder\(requestPayload\(\)\)/)
|
||||
assert.match(createSource, /fees: form\.fees\.map/)
|
||||
assert.match(createSource, /if \(form\.fees\.length === 0\)/)
|
||||
|
||||
@@ -5,6 +5,7 @@ import { ArrowLeft, Plus, Trash2 } from '@lucide/vue'
|
||||
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
|
||||
import { useAuthStore } from '@/modules/auth/public'
|
||||
import { useUnsavedWorkRegistration } from '@/navigation/workspace-tabs/unsavedWork'
|
||||
import { createIdempotencyKey } from '@/utils/idempotencyKey'
|
||||
import { createDeliveryOrder, type DeliveryOrderFee, type DeliveryTransferPayload } from '../api/deliveryOrders'
|
||||
|
||||
type DeliveryCreateForm = Omit<DeliveryTransferPayload, 'idempotencyKey'>
|
||||
@@ -112,7 +113,7 @@ async function submit() {
|
||||
submitError.value = ''
|
||||
const valid = await formRef.value?.validate().catch(() => false)
|
||||
if (!valid || !validateFees()) return
|
||||
if (!pendingIdempotencyKey) pendingIdempotencyKey = crypto.randomUUID()
|
||||
if (!pendingIdempotencyKey) pendingIdempotencyKey = createIdempotencyKey()
|
||||
submitting.value = true
|
||||
try {
|
||||
const created = await createDeliveryOrder(requestPayload())
|
||||
|
||||
@@ -2,6 +2,7 @@ import { overseasApiRequest, overseasBlobRequest } from '@/api/overseasHttp'
|
||||
import type { PageResult } from './warehouseServices'
|
||||
|
||||
export interface ReturnOrderSummary {
|
||||
relabelApplications?: Array<{ relabelNo: string; status: string; quantity: number; createdAt: string }>
|
||||
returnNo: string; overseasWarehouseId: string; warehouseContactName?: string; warehouseContactPhone?: string; customerReferenceNo?: string; status: string
|
||||
skuTypes: number; expectedQuantity: number; receivedQuantity: number; needRelabel: boolean; relabelNo?: string; relabelStatus?: string; createdAt: string
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { overseasApiRequest, overseasBlobRequest } from '@/api/overseasHttp'
|
||||
import { createIdempotencyKey } from '@/utils/idempotencyKey'
|
||||
|
||||
export type RelabelDeliveryMethod = 'TRUCK' | 'EXPRESS' | 'TRUCK_PICKUP' | 'EXPRESS_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 }
|
||||
export interface TransferSummary { id: string; transferNo: string; inboundCode: string; lastlegOrderId: string; lastlegOrderNo: string; operatingWarehouseId: string; fbaWarehouseCode?: string; packageCount: number; deliveryMethod: string; needRelabel: boolean; relabelNo?: string; status: string; estimatedArrivalAt: string; estimatedShipAt?: string; actualShipAt?: string; createdAt: string }
|
||||
export interface RelabelSummary { id: string; relabelNo: string; lastlegOrderId: string; lastlegOrderNo: string; customerOrderNo: string; overseasWarehouseId: string; status: string; itemCount: number; totalPackageCount: number; notifiedPackageCount: number; skuCodes: string; createdAt: string; totalFileId?: string; totalFileName?: string; totalFileUrl?: string; cartonTotalFileId?: string; cartonTotalFileName?: string; cartonTotalFileUrl?: string; skuTotalFileId?: string; skuTotalFileName?: string; skuTotalFileUrl?: string; deliveryMethod?: RelabelDeliveryMethod; printShippingLabel?: boolean; shippingLabelFileId?: string; shippingLabelFileName?: string; shippingLabelFileUrl?: string; fulfillmentStatus?: string; outboundOrderNo?: string; customerConfirmedAt?: string }
|
||||
export interface HistoricalOrderSummary { id: string | null; orderNo: string; sourceOrderNo?: string; outboundOrderNo?: string; relabelOrderNo?: string; orderType?: string; status?: string; packageCount?: number }
|
||||
export interface TransferSummary { id: string; transferNo: string; inboundCode: string; lastlegOrderId: string; lastlegOrderNo: string; operatingWarehouseId: string; fbaWarehouseCode?: string; packageCount: number; deliveryMethod: string; needRelabel: boolean; relabelNo?: string; relabelStatus?: string; relabelFulfillmentStatus?: string; status: string; estimatedArrivalAt: string; estimatedShipAt?: string; actualShipAt?: string; createdAt: string }
|
||||
export interface RelabelSummary { id: string; relabelNo: string; lastlegOrderId: string; lastlegOrderNo: string; customerOrderNo: string; overseasWarehouseId: string; status: string; itemCount: number; totalPackageCount: number; notifiedPackageCount: number; skuCodes: string; createdAt: string; totalFileId?: string; totalFileName?: string; totalFileUrl?: string; cartonTotalFileId?: string; cartonTotalFileName?: string; cartonTotalFileUrl?: string; skuTotalFileId?: string; skuTotalFileName?: string; skuTotalFileUrl?: string; deliveryMethod?: RelabelDeliveryMethod; printShippingLabel?: boolean; shippingLabelFileId?: string; shippingLabelFileName?: string; shippingLabelFileUrl?: string; fulfillmentStatus?: string; outboundOrderNo?: string; customerConfirmedAt?: string; reviewChannel?: 'TAIL_ORDER' | 'RELABEL_ORDER'; reviewRemark?: string }
|
||||
export interface RelabelItemDetail {
|
||||
id: string; skuCode?: string; productNameZh: string; totalPackageCount: number; notifiedPackageCount: number
|
||||
shipmentNo: string; trackingCode?: string; newSkuCode?: string; labelRequirement?: string; remark?: string
|
||||
@@ -32,11 +34,11 @@ export interface RelabelInventoryCandidate {
|
||||
availablePackages: number | string; version: number
|
||||
positions: Array<{ positionId: string; palletNo?: string; batchNo?: string; warehouseLocationCode?: string; availablePackages: number }>
|
||||
sourceType?: 'INBOUND' | 'RETURN_CARTON'; returnCartonId?: string; cartonMark?: string
|
||||
skuItems?: Array<{ skuCode: string; newSkuCode?: string; quantity: number; remark?: string }>
|
||||
skuItems?: Array<{ skuCode: string; newSkuCode?: string; quantity: number; remark?: string }>; relabelledPackages?: number
|
||||
}
|
||||
|
||||
export interface TransferCreate {
|
||||
operatingWarehouseId: string; fbaWarehouseCode?: string; destinationType?: 'NONE' | 'FBA' | 'PRIVATE'
|
||||
inboundCode?: string; sourceLastlegOrderIds?: number[]; 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
|
||||
@@ -52,8 +54,8 @@ export interface RelabelCreate {
|
||||
cartonMark: string; fbaCartonMark?: string; destinationFbaWarehouseCode?: string; operationType: 'CARTON_LABEL' | 'SKU_LABEL' | 'CARTON_AND_SKU'; skuItems?: Array<{ skuCode: string; newSkuCode: string; quantity: number; remark?: string }>; remark?: string }>
|
||||
}
|
||||
|
||||
export function listLastlegOrders(inboundCode: string) {
|
||||
return overseasApiRequest<PageResult<LastlegOrderSummary>>({ method: 'GET', url: '/merchant/v1/last-leg/orders', params: { page: 1, size: 200, inboundCode } })
|
||||
export function listRelabelHistoricalOrders(operationWarehouseId: string, keyword?: string) {
|
||||
return overseasApiRequest<HistoricalOrderSummary[]>({ method: 'GET', url: '/merchant/v1/last-leg/relabel-applications/historical-orders', params: { operationWarehouseId, keyword: keyword || undefined } })
|
||||
}
|
||||
export function listTransfers(params: Record<string, unknown>) {
|
||||
return overseasApiRequest<PageResult<TransferSummary>>({ method: 'GET', url: '/merchant/v1/last-leg/warehouse-transfers', params })
|
||||
@@ -77,7 +79,7 @@ export function getRelabel(relabelNo: string) {
|
||||
return overseasApiRequest<RelabelDetail>({ method: 'GET', url: `/merchant/v1/last-leg/relabel-applications/${encodeURIComponent(relabelNo)}` })
|
||||
}
|
||||
export function confirmTransferMeasurement(transferNo: string, remark?: string) {
|
||||
return overseasApiRequest<Record<string, unknown>>({ method: 'POST', url: `/merchant/v1/last-leg/warehouse-transfers/${encodeURIComponent(transferNo)}/confirm-measurement`, data: { idempotencyKey: crypto.randomUUID(), remark } })
|
||||
return overseasApiRequest<Record<string, unknown>>({ method: 'POST', url: `/merchant/v1/last-leg/warehouse-transfers/${encodeURIComponent(transferNo)}/confirm-measurement`, data: { idempotencyKey: createIdempotencyKey(), remark } })
|
||||
}
|
||||
export interface TransferCartonLabel { transferNo: string; cartons: Array<{ cartonMark: string; customerCartonMark?: string; cartonIndex: number; totalCartons: number; status: string }>; printableHtml: string }
|
||||
export function getTransferCartonLabel(transferNo: string) {
|
||||
@@ -96,6 +98,9 @@ export function listRelabelInventoryCandidates(operationWarehouseId: string, inb
|
||||
export function createRelabel(payload: RelabelCreate) {
|
||||
return overseasApiRequest<RelabelDetail>({ method: 'POST', url: '/merchant/v1/last-leg/relabel-applications', data: payload })
|
||||
}
|
||||
export function resubmitRelabel(relabelNo: string, payload: { labelDeadline: string; contactName: string; contactPhone: string; remark?: string; idempotencyKey: string }) {
|
||||
return overseasApiRequest<RelabelDetail>({ method: 'PUT', url: `/merchant/v1/last-leg/relabel-applications/${encodeURIComponent(relabelNo)}/resubmit`, data: payload })
|
||||
}
|
||||
export function uploadRelabelShippingLabel(relabelNo: string, file: File) {
|
||||
const data = new FormData(); data.append('file', file)
|
||||
return overseasApiRequest<RelabelDetail>({ method: 'POST', url: `/merchant/v1/last-leg/relabel-applications/${encodeURIComponent(relabelNo)}/shipping-label`, data, headers: { 'Content-Type': 'multipart/form-data' } })
|
||||
@@ -105,7 +110,7 @@ export function uploadRelabelFile(relabelNo: string, scope: 'CARTON_TOTAL' | 'SK
|
||||
return overseasApiRequest<RelabelDetail>({ method: 'POST', url: `/merchant/v1/last-leg/relabel-applications/${encodeURIComponent(relabelNo)}/files`, params: { scope, itemId }, data, headers: { 'Content-Type': 'multipart/form-data' } })
|
||||
}
|
||||
export function confirmRelabelCompletion(relabelNo: string, remark?: string) {
|
||||
return overseasApiRequest<RelabelDetail>({ method: 'POST', url: `/merchant/v1/last-leg/relabel-applications/${encodeURIComponent(relabelNo)}/confirm-completion`, data: { idempotencyKey: crypto.randomUUID(), remark } })
|
||||
return overseasApiRequest<RelabelDetail>({ method: 'POST', url: `/merchant/v1/last-leg/relabel-applications/${encodeURIComponent(relabelNo)}/confirm-completion`, data: { idempotencyKey: createIdempotencyKey(), remark } })
|
||||
}
|
||||
export function downloadRelabelCompletionPhoto(relabelNo: string, fileId: string) {
|
||||
return overseasBlobRequest({ method: 'GET', url: `/merchant/v1/last-leg/relabel-applications/${encodeURIComponent(relabelNo)}/completion-photos/${encodeURIComponent(fileId)}/download` })
|
||||
|
||||
@@ -32,7 +32,7 @@ test('opens warehouse service and return receipt details as dedicated protected
|
||||
assert.match(routes, /path: 'overseas-warehouse\/return-relabel\/:returnNo\/detail'[\s\S]*?name: 'overseas-return-relabel-detail'[\s\S]*?props: \{ detailPage: true \}[\s\S]*?permission: 'merchant:overseas-warehouse:return-order:list'/)
|
||||
assert.match(routes, /path: 'overseas-warehouse\/storage-transfer\/:detailNo\/detail'[\s\S]*?name: 'overseas-storage-transfer-detail'[\s\S]*?props: \{ service: 'transfer', detailPage: true \}/)
|
||||
assert.match(routes, /path: 'overseas-warehouse\/cargo-disposal\/:detailNo\/detail'[\s\S]*?name: 'overseas-cargo-disposal-detail'[\s\S]*?props: \{ service: 'disposal', detailPage: true \}/)
|
||||
assert.match(services, /name: 'overseas-labeling-detail'/)
|
||||
assert.match(services, /labeling: 'overseas-labeling-detail'/)
|
||||
assert.match(services, /transfer: 'overseas-storage-transfer-detail'/)
|
||||
assert.match(services, /disposal: 'overseas-cargo-disposal-detail'/)
|
||||
assert.match(services, /class="warehouse-detail-error"/)
|
||||
@@ -40,6 +40,11 @@ test('opens warehouse service and return receipt details as dedicated protected
|
||||
assert.doesNotMatch(returns, /title="退货签收详情"/)
|
||||
})
|
||||
|
||||
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: 'overseas-labeling' \}\)/)
|
||||
assert.match(services, /uploadReturnRelabelFile\([\s\S]*?router\.push\(\{ name: 'overseas-labeling' \}\)/)
|
||||
})
|
||||
|
||||
test('keeps overseas warehouse writes behind operation-specific button permissions', () => {
|
||||
for (const permission of [
|
||||
'merchant:overseas-warehouse:relabel:create',
|
||||
|
||||
@@ -35,6 +35,7 @@ test('新建退货预报使用独立路由页面,编辑仍保留加宽弹框',
|
||||
assert.match(view, /<h2>新建退货预报<\/h2>/)
|
||||
assert.match(view, /:title="editingReturnNo \? '编辑退货预报' : '新建退货预报'" width="1120px"/)
|
||||
assert.match(view, /return-create-form \{ display:grid; grid-template-columns:repeat\(2,minmax\(0,1fr\)\)/)
|
||||
assert.match(view, /class="return-create-actions"[\s\S]*?<el-button type="primary" :loading="creating" @click="submit">提交预报<\/el-button>/)
|
||||
})
|
||||
|
||||
test('退货列表和详情使用客户可读仓库、状态与时间', () => {
|
||||
@@ -42,7 +43,7 @@ test('退货列表和详情使用客户可读仓库、状态与时间', () => {
|
||||
assert.match(view, /return warehouse \? warehouseLabel\(warehouse\) : '未关联'/)
|
||||
assert.match(view, /PENDING_RECEIPT: '待签收'/)
|
||||
assert.match(view, /PARTIALLY_RECEIVED: '部分签收'/)
|
||||
assert.match(view, /return needRelabel \? '已完成入库\/待换标' : '已完成入库'/)
|
||||
assert.match(view, /if \(status === 'COMPLETED'\) return '已完成入库'/)
|
||||
assert.match(view, /function formatDateTime\(value\?: string \| null\)/)
|
||||
assert.match(view, /\{\{ operationWarehouseLabel\(row\.overseasWarehouseId\) \}\}/)
|
||||
assert.match(view, /\{\{ statusLabel\(row\.status, row\.needRelabel\) \}\}/)
|
||||
@@ -76,9 +77,9 @@ test('退货签收详情使用独立路由和紧凑连续页面', () => {
|
||||
test('待审核退货可编辑,完成入库后从独立新增页发起贴标', () => {
|
||||
assert.match(view, /row\.status === 'PENDING_REVIEW'.*key: 'edit'/)
|
||||
assert.match(view, /await updateReturnOrder\(editingReturnNo\.value, payload\)/)
|
||||
assert.match(view, /order\.status === 'COMPLETED' && order\.needRelabel === true && !order\.relabelNo/)
|
||||
assert.match(view, /order\.status === 'COMPLETED' && order\.needRelabel === true/)
|
||||
assert.match(view, /name: 'overseas-labeling-create'/)
|
||||
assert.match(view, /query: \{ returnNo: order\.returnNo, warehouseId: String\(order\.overseasWarehouseId\) \}/)
|
||||
assert.match(view, /prop: 'relabelNo', label: '换标单号'/)
|
||||
assert.match(view, /label: '换标单状态'/)
|
||||
assert.match(view, /v-for="application in row\.relabelApplications \|\| \[\]"/)
|
||||
assert.match(view, /relabelStatusLabel\(application\.status\)/)
|
||||
})
|
||||
|
||||
@@ -8,6 +8,7 @@ import DataTable, { type DataTableColumn } from '@/components/common/DataTable.v
|
||||
import type { TableAction } from '@/components/common/tableActions'
|
||||
import { useAuthStore } from '@/modules/auth/public'
|
||||
import { getMerchantWarehouses, type MerchantWarehouse } from '@/modules/first-leg/public'
|
||||
import { createIdempotencyKey } from '@/utils/idempotencyKey'
|
||||
import { createReturnOrder, downloadReturnPhoto, getReturnOrder, listReturnOrders, updateReturnOrder, type ReturnOrderDetail, type ReturnOrderSummary, type ReturnReceipt } from '../api/returnOrders'
|
||||
|
||||
const props = withDefaults(defineProps<{ createPage?: boolean; detailPage?: boolean; returnNo?: string }>(), { createPage: false, detailPage: false, returnNo: '' })
|
||||
@@ -49,7 +50,7 @@ const tableColumns: DataTableColumn[] = [
|
||||
{ key: 'warehouse', label: '操作仓', slotName: 'warehouse', minWidth: 200 }, { key: 'relabel', label: '换标', slotName: 'relabel', width: 100 },
|
||||
{ prop: 'skuTypes', label: 'SKU 种类', width: 105 }, { prop: 'expectedQuantity', label: '预报数量', width: 110 },
|
||||
{ prop: 'receivedQuantity', label: '实收数量', width: 110 }, { key: 'status', label: '状态', slotName: 'status', width: 150 },
|
||||
{ prop: 'relabelNo', label: '换标单号', minWidth: 190 }, { key: 'relabelStatus', label: '换标单状态', slotName: 'relabelStatus', width: 130 },
|
||||
{ key: 'relabelApplications', label: '换标子单', slotName: 'relabelApplications', minWidth: 340 },
|
||||
{ key: 'createdAt', label: '创建时间', slotName: 'createdAt', width: 180 },
|
||||
]
|
||||
const itemColumns: DataTableColumn[] = [
|
||||
@@ -185,21 +186,21 @@ async function submit() {
|
||||
if (duplicateSkuIds.value.size) { ElMessage.warning('同一退货单不能重复填写相同 SKU'); return }
|
||||
creating.value = true
|
||||
try {
|
||||
const payload = { overseasWarehouseId: form.overseasWarehouseId, warehouseContactName: form.warehouseContactName.trim(), warehouseContactPhone: form.warehouseContactPhone.trim(), customerReferenceNo: form.customerReferenceNo || undefined, remark: form.remark || undefined, needRelabel: form.needRelabel, idempotencyKey: crypto.randomUUID(), items: items.map(item => ({ ...item, productNameZh: item.productNameZh || undefined })) }
|
||||
const payload = { overseasWarehouseId: form.overseasWarehouseId, warehouseContactName: form.warehouseContactName.trim(), warehouseContactPhone: form.warehouseContactPhone.trim(), customerReferenceNo: form.customerReferenceNo || undefined, remark: form.remark || undefined, needRelabel: form.needRelabel, idempotencyKey: createIdempotencyKey(), items: items.map(item => ({ ...item, productNameZh: item.productNameZh || undefined })) }
|
||||
const detail = editingReturnNo.value ? await updateReturnOrder(editingReturnNo.value, payload) : await createReturnOrder(payload)
|
||||
ElMessage.success(editingReturnNo.value ? '退货预报已更新' : '退货预报已创建'); showCreate.value = false; editingReturnNo.value = ''; resetForm(); await router.push({ name: 'overseas-return-relabel-detail', params: { returnNo: detail.order.returnNo } })
|
||||
} finally { creating.value = false }
|
||||
}
|
||||
function statusType(status: string, needRelabel = false) {
|
||||
if (status === 'COMPLETED') return needRelabel ? 'warning' : 'success'
|
||||
if (status === 'COMPLETED') return 'success'
|
||||
return ({ PENDING_REVIEW: 'warning', PENDING_RECEIPT: 'primary', PARTIALLY_RECEIVED: 'warning', REJECTED: 'danger', CANCELLED: 'info' } as Record<string, 'primary' | 'success' | 'warning' | 'danger' | 'info'>)[status] || 'info'
|
||||
}
|
||||
function statusClass(status: string, needRelabel = false) {
|
||||
if (status === 'COMPLETED') return needRelabel ? 'return-status--relabel' : 'return-status--completed'
|
||||
if (status === 'COMPLETED') return 'return-status--completed'
|
||||
return `return-status--${status.toLowerCase().replaceAll('_', '-')}`
|
||||
}
|
||||
function statusLabel(status: string, needRelabel = false) {
|
||||
if (status === 'COMPLETED') return needRelabel ? '已完成入库/待换标' : '已完成入库'
|
||||
if (status === 'COMPLETED') return '已完成入库'
|
||||
return ({ PENDING_REVIEW: '待审核', PENDING_RECEIPT: '待签收', PARTIALLY_RECEIVED: '部分签收', REJECTED: '已驳回', CANCELLED: '已取消' } as Record<string, string>)[status] ?? (status || '未知状态')
|
||||
}
|
||||
function relabelStatusType(status?: string) {
|
||||
@@ -253,7 +254,7 @@ onBeforeUnmount(clearPhotoUrls)
|
||||
<el-table-column label="预报数量" width="180"><template #default="{ row }"><el-input-number v-model="row.expectedQuantity" :min="1" controls-position="right" placeholder="数量" /></template></el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
<footer class="return-create-actions"><el-button @click="cancelCreate">取消</el-button><el-button v-if="canCreate" type="primary" :loading="creating" @click="submit">提交预报</el-button></footer>
|
||||
<footer class="return-create-actions"><el-button @click="cancelCreate">取消</el-button><el-button type="primary" :loading="creating" @click="submit">提交预报</el-button></footer>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
@@ -265,7 +266,7 @@ onBeforeUnmount(clearPhotoUrls)
|
||||
<template #warehouse="{ row }">{{ operationWarehouseLabel(row.overseasWarehouseId) }}</template>
|
||||
<template #relabel="{ row }"><el-tag v-if="row.needRelabel" type="warning" effect="light">需要换标</el-tag><span v-else>否</span></template>
|
||||
<template #status="{ row }"><el-tag :type="statusType(row.status, row.needRelabel)" :class="statusClass(row.status, row.needRelabel)" effect="light">{{ statusLabel(row.status, row.needRelabel) }}</el-tag></template>
|
||||
<template #relabelStatus="{ row }"><el-tag v-if="row.relabelStatus" :type="relabelStatusType(row.relabelStatus)" effect="light">{{ relabelStatusLabel(row.relabelStatus) }}</el-tag><span v-else>—</span></template>
|
||||
<template #relabelApplications="{ row }"><div v-for="application in row.relabelApplications || []" :key="application.relabelNo" class="relabel-suborder"><span>{{ application.relabelNo }}</span><el-tag :type="relabelStatusType(application.status)" effect="light">{{ relabelStatusLabel(application.status) }}</el-tag><span>{{ application.quantity }} 件</span></div><span v-if="!row.relabelApplications?.length">—</span></template>
|
||||
<template #createdAt="{ row }">{{ formatDateTime(row.createdAt) }}</template>
|
||||
</DataTable>
|
||||
</div>
|
||||
@@ -304,8 +305,6 @@ onBeforeUnmount(clearPhotoUrls)
|
||||
<div><span>实收 / 预报</span><strong>{{ selected.order.receivedQuantity }} / {{ selected.order.expectedQuantity }}</strong></div>
|
||||
<div><span>SKU 种类</span><strong>{{ selected.order.skuTypes }}</strong></div>
|
||||
<div><span>是否换标</span><strong>{{ selected.order.needRelabel ? '需要换标' : '否' }}</strong></div>
|
||||
<div><span>换标单号</span><strong>{{ selected.order.relabelNo || '—' }}</strong></div>
|
||||
<div><span>换标单状态</span><strong>{{ relabelStatusLabel(selected.order.relabelStatus) }}</strong></div>
|
||||
<div><span>客户参考号</span><strong>{{ selected.order.customerReferenceNo || '—' }}</strong></div>
|
||||
<div><span>海外仓联系人</span><strong>{{ selected.order.warehouseContactName || '—' }}</strong></div>
|
||||
<div><span>联系电话</span><strong>{{ selected.order.warehouseContactPhone || '—' }}</strong></div>
|
||||
@@ -313,6 +312,14 @@ onBeforeUnmount(clearPhotoUrls)
|
||||
</div>
|
||||
<div v-if="selected.remark" class="return-detail-remark"><span>备注</span><strong>{{ selected.remark }}</strong></div>
|
||||
</section>
|
||||
<section class="return-detail-section">
|
||||
<h3>换标子单({{ selected.order.relabelApplications?.length || 0 }})</h3>
|
||||
<el-table :data="selected.order.relabelApplications || []" empty-text="暂无换标申请">
|
||||
<el-table-column prop="relabelNo" label="换标单号" min-width="220" />
|
||||
<el-table-column label="状态" min-width="130"><template #default="{ row }"><el-tag :type="relabelStatusType(row.status)">{{ relabelStatusLabel(row.status) }}</el-tag></template></el-table-column>
|
||||
<el-table-column prop="quantity" label="本次换标数量(件)" min-width="160" />
|
||||
</el-table>
|
||||
</section>
|
||||
<section class="return-detail-section">
|
||||
<h3>预报与实收</h3>
|
||||
<DataTable :data="selected.items" :columns="itemColumns" row-key="skuCode" empty-text="暂无 SKU 明细" />
|
||||
@@ -356,6 +363,7 @@ onBeforeUnmount(clearPhotoUrls)
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.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; } .items-hint { margin-bottom:10px; color:var(--el-text-color-secondary); font-size:12px; } 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 { overflow:hidden; border:1px solid var(--client-border); border-radius:var(--client-radius); background:#fff; }
|
||||
.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); }
|
||||
|
||||
@@ -8,11 +8,11 @@ import DataTable, { type DataTableColumn } from '@/components/common/DataTable.v
|
||||
import type { TableAction } from '@/components/common/tableActions'
|
||||
import { useAuthStore } from '@/modules/auth/public'
|
||||
import {
|
||||
createDiscard, createRelabel, createTransfer, getDiscard, getRelabel, getTransfer,
|
||||
createDiscard, createRelabel, createTransfer, getDiscard, getRelabel, getTransfer, resubmitRelabel,
|
||||
getTransferCartonLabel, submitTransfer, updateTransfer,
|
||||
confirmRelabelCompletion, confirmTransferMeasurement, downloadRelabelCompletionPhoto, downloadTransferMeasurementPhoto, uploadRelabelShippingLabel,
|
||||
uploadRelabelFile,
|
||||
listDiscardCandidates, listDiscards, listRelabelInventoryCandidates, listRelabels, listTransfers, cancelDiscard,
|
||||
listDiscards, listRelabelInventoryCandidates, listRelabelHistoricalOrders, listRelabels, listTransfers, cancelDiscard,
|
||||
type RelabelInventoryCandidate,
|
||||
type RelabelCreate,
|
||||
type RelabelDetail,
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
type MerchantWarehouseDetail,
|
||||
} from '@/modules/first-leg/api/firstLeg'
|
||||
import { loadAllWarehouseRecords } from '@/modules/first-leg/utils/warehousePagination'
|
||||
import { createIdempotencyKey } from '@/utils/idempotencyKey'
|
||||
import { createReturnRelabel, uploadReturnRelabelFile } from '../api/returnOrders'
|
||||
|
||||
type ServiceType = 'labeling' | 'transfer' | 'disposal' | 'relabel'
|
||||
@@ -55,7 +56,7 @@ const canConfirmTransfer = computed(() => auth.hasPermission('merchant:overseas-
|
||||
const canCancelDiscard = computed(() => auth.hasPermission('merchant:overseas-warehouse:discard:cancel'))
|
||||
const configs: Record<ServiceType, ServiceConfig> = {
|
||||
labeling: { title: '贴标申请', createTitle: '新增贴标申请', numberLabel: '贴标申请单号', emptyText: '暂无贴标申请', columns: ['操作', '贴标申请单号', '客户单号', '海外操作仓', '贴标要求', 'SKU 数', '总箱数', '状态', '创建时间'] },
|
||||
transfer: { title: '仓储转运单', createTitle: '新增仓储转运单', numberLabel: '转运单号', emptyText: '暂无仓储转运订单', columns: ['操作', '转运单号', '入库箱数', '海外仓', 'FBA 目的仓', '入仓方式', '是否换标', '状态', '创建时间'] },
|
||||
transfer: { title: '仓储转运单', createTitle: '新增仓储转运单', numberLabel: '转运单号', emptyText: '暂无仓储转运订单', columns: ['操作', '转运单号', '箱数', '海外仓', 'FBA 目的仓', '入仓方式', '是否换标', '换标单号', '换标状态', '状态', '创建时间'] },
|
||||
disposal: { title: '货物废弃', createTitle: '新增货物废弃申请', numberLabel: '废弃申请单号', emptyText: '暂无货物废弃申请', columns: ['操作', '废弃申请单号', '进仓编号', '操作仓', 'SKU 汇总', '废弃数量', '预估费用', '状态', '创建时间'] },
|
||||
relabel: { title: '退货换标', createTitle: '新建退货换标单', numberLabel: '退货换标单号', emptyText: '暂无退货换标订单', columns: ['操作', '退货换标单号', '来源 FBA 仓', '目的 FBA 仓', 'SKU 汇总', '退件总数', '是否换标', '换标状态', '尾程状态', '创建时间'] },
|
||||
}
|
||||
@@ -70,7 +71,7 @@ const listQueryModel = computed<QueryModel>({
|
||||
}),
|
||||
})
|
||||
const statusOptions = computed(() => props.service === 'labeling'
|
||||
? [{ label: '待处理', value: 'PENDING_PROCESSING' }, { label: '处理中', value: 'PROCESSING' }, { label: '换标完成', value: 'COMPLETED' }]
|
||||
? [{ label: '待审核', value: 'PENDING_REVIEW' }, { label: '待处理', value: 'PENDING_PROCESSING' }, { label: '处理中', value: 'PROCESSING' }, { label: '换标完成', value: 'COMPLETED' }, { label: '已驳回', value: 'REJECTED' }]
|
||||
: props.service === 'disposal'
|
||||
? [{ label: '待审核', value: 'PENDING_REVIEW' }, { label: '待 PDA 废弃', value: 'APPROVED' }, { label: '待仓库确认完成', value: 'PDA_COMPLETED' }, { label: '已完成', value: 'COMPLETED' }, { label: '已拒绝', value: 'REJECTED' }, { label: '已取消', value: 'CANCELLED' }]
|
||||
: props.service === 'transfer'
|
||||
@@ -111,13 +112,14 @@ interface TransferPallet { id?: string | number; sequenceNo?: number; palletNo?:
|
||||
interface TransferMeasurementPhoto { fileId: string | number; fileName?: string; contentType?: string; sizeBytes?: number; uploadedAt?: string }
|
||||
interface ServiceDetail extends Record<string, unknown> {
|
||||
transferNo?: string; status?: string; operatingWarehouseId?: string; fbaWarehouseCode?: string
|
||||
destinationType?: 'FBA' | 'PRIVATE'; destinationWarehouseCode?: string; destinationWarehouseName?: string
|
||||
destinationType?: 'NONE' | 'FBA' | 'PRIVATE'; destinationWarehouseCode?: string; destinationWarehouseName?: string
|
||||
privateDestinationAddress?: string; privateDestinationPostalCode?: string; privateDestinationContactName?: string; privateDestinationContactPhone?: string
|
||||
deliveryAddress?: string; pickupContactName?: string; pickupContactPhone?: string
|
||||
packageCount?: number; volumeCbm?: number; deliveryMethod?: string; estimatedArrivalAt?: string
|
||||
estimatedShipAt?: string; actualShipAt?: string; fbaShipmentNo?: string; trackingNo?: string
|
||||
remark?: string; needRelabel?: boolean; labelDeadline?: string; relabelContactName?: string
|
||||
relabelContactPhone?: string; relabelDeliveryMethod?: string
|
||||
relabelContactPhone?: string; relabelDeliveryMethod?: string; relabelNo?: string
|
||||
relabelStatus?: string; relabelFulfillmentStatus?: string; createdAt?: string
|
||||
items?: Array<{ skuCode?: string; productNameZh?: string; packageCount?: number; shippingMark?: string }>
|
||||
relabelBindings?: Array<{ operationType?: string; customerCartonMark?: string; originalSku?: string; newSku?: string; quantity?: number; fbaCartonMark?: string; destinationWarehouseCode?: string; remark?: string }>
|
||||
cartons?: TransferCarton[]; pallets?: TransferPallet[]; measurementPhotos?: TransferMeasurementPhoto[]; customerConfirmedAt?: string
|
||||
@@ -158,13 +160,17 @@ const listQueryFields = computed<QueryField[]>(() => {
|
||||
})
|
||||
const listColumns = computed<DataTableColumn[]>(() => config.value.columns.map((label, index) => index === 0
|
||||
? { key: 'actions', label, slotName: 'actions', width: props.service === 'transfer' ? 210 : 160, fixed: 'left' }
|
||||
: { key: String(index - 1), prop: String(index - 1), label, minWidth: index === 1 ? 190 : 130 }))
|
||||
: { key: String(index - 1), prop: String(index - 1), label, minWidth: index === 1 || label === '换标单号' ? 190 : 130,
|
||||
slotName: props.service === 'transfer' && label === '换标状态' ? 'transferRelabelStatus' : props.service === 'transfer' && label === '状态' ? 'transferStatus' : undefined }))
|
||||
const discardCandidates = ref<import('../api/warehouseServices').DiscardCandidate[]>([])
|
||||
const selectedDiscardOrderId = ref('')
|
||||
const discardCandidatesLoading = ref(false)
|
||||
const relabelCandidates = ref<RelabelInventoryCandidate[]>([])
|
||||
const selectedRelabelInboundOrderId = ref('')
|
||||
const relabelCandidatesLoading = ref(false)
|
||||
const transferInventoryCandidates = ref<RelabelInventoryCandidate[]>([])
|
||||
const selectedTransferInventory = ref('')
|
||||
const selectedTransferSourceOrderIds = ref<number[]>([])
|
||||
const returnRelabelSkuRows = ref<ReturnRelabelSkuRow[]>([])
|
||||
const isReturnRelabelSource = computed(() => props.service === 'labeling' && selectedRelabelInboundOrderId.value.startsWith('RETURN_ORDER:'))
|
||||
const selectedReturnOrderNo = computed(() => isReturnRelabelSource.value ? selectedRelabelInboundOrderId.value.slice('RETURN_ORDER:'.length) : '')
|
||||
@@ -174,17 +180,21 @@ function inventoryQuantity(value: number | string | null | undefined) {
|
||||
return Number.isFinite(quantity) && quantity > 0 ? quantity : 0
|
||||
}
|
||||
const relabelInboundOptions = computed(() => {
|
||||
const grouped = new Map<string, { value: string; inboundOrderNo: string; inboundCode: string; availablePackages: number; quantityUnit: '箱' | '件' }>()
|
||||
const grouped = new Map<string, { value: string; inboundOrderNo: string; inboundCode: string; availablePackages: number; relabelledPackages: number; quantityUnit: '箱' | '件' }>()
|
||||
for (const candidate of relabelCandidates.value) {
|
||||
const returnOrderNo = candidate.inboundOrderNo || String(candidate.returnCartonId || '')
|
||||
const value = candidate.sourceType === 'RETURN_CARTON' ? `RETURN_ORDER:${returnOrderNo}` : String(candidate.inboundOrderId)
|
||||
const current = grouped.get(value)
|
||||
if (current) current.availablePackages += inventoryQuantity(candidate.availablePackages)
|
||||
if (current) {
|
||||
current.availablePackages += inventoryQuantity(candidate.availablePackages)
|
||||
current.relabelledPackages += inventoryQuantity(candidate.relabelledPackages)
|
||||
}
|
||||
else grouped.set(value, {
|
||||
value,
|
||||
inboundOrderNo: candidate.sourceType === 'RETURN_CARTON' ? `退货单 ${candidate.inboundOrderNo || '—'}` : (candidate.inboundOrderNo || '—'),
|
||||
inboundCode: candidate.sourceType === 'RETURN_CARTON' ? '已签收退货库存' : (candidate.inboundCode || '—'),
|
||||
availablePackages: inventoryQuantity(candidate.availablePackages),
|
||||
relabelledPackages: inventoryQuantity(candidate.relabelledPackages),
|
||||
quantityUnit: candidate.sourceType === 'RETURN_CARTON' ? '件' : '箱',
|
||||
})
|
||||
}
|
||||
@@ -222,6 +232,13 @@ watch(() => form.relabel, (value) => {
|
||||
if (!transferRelabelRows.value.length) addTransferRelabelRow()
|
||||
}
|
||||
})
|
||||
watch(() => cargoRows.value.map((row) => row.sku), (skus) => {
|
||||
if (props.service !== 'transfer' || !form.relabel) return
|
||||
transferRelabelRows.value.forEach((row, index) => {
|
||||
const sourceSku = skus[Math.min(index, Math.max(0, skus.length - 1))]?.trim() || ''
|
||||
if (sourceSku) row.sku = sourceSku
|
||||
})
|
||||
})
|
||||
watch(labelingMode, (value) => { if (value !== 'carton' && !skuRelabelRows.value.length) addSkuRelabelRow() })
|
||||
watch(() => form.destination, (value) => {
|
||||
if (props.service !== 'labeling') return
|
||||
@@ -262,6 +279,9 @@ function resetForm() {
|
||||
discardCandidates.value = []
|
||||
selectedDiscardOrderId.value = ''
|
||||
relabelCandidates.value = []
|
||||
transferInventoryCandidates.value = []
|
||||
selectedTransferInventory.value = ''
|
||||
selectedTransferSourceOrderIds.value = []
|
||||
selectedRelabelInboundOrderId.value = ''
|
||||
cartonTotalFile.value = null
|
||||
skuTotalFile.value = null
|
||||
@@ -354,8 +374,23 @@ async function selectDiscardWarehouse() {
|
||||
if (!form.warehouse) return
|
||||
discardCandidatesLoading.value = true
|
||||
try {
|
||||
const candidates = await listDiscardCandidates()
|
||||
discardCandidates.value = candidates.filter((candidate) => String(candidate.overseasWarehouseId) === form.warehouse)
|
||||
const inventory = await listRelabelInventoryCandidates(form.warehouse)
|
||||
const grouped = new Map<string, import('../api/warehouseServices').DiscardCandidate>()
|
||||
for (const candidate of inventory) {
|
||||
const orderId = String(candidate.inboundOrderId || '')
|
||||
if (!orderId) continue
|
||||
const current = grouped.get(orderId) || { orderId, inboundCode: candidate.inboundCode || '', inboundOrderNo: candidate.inboundOrderNo || '', overseasWarehouseId: String(candidate.overseasWarehouseId || form.warehouse), overseasWarehouseName: candidate.overseasWarehouseName || '', availablePackages: 0, skus: [] }
|
||||
const packages = inventoryQuantity(candidate.availablePackages)
|
||||
current.availablePackages += packages
|
||||
const skuCode = String(candidate.skuCode || '').trim()
|
||||
if (skuCode) {
|
||||
const sku = current.skus.find((item) => item.skuCode === skuCode)
|
||||
if (sku) sku.availablePackages += packages
|
||||
else current.skus.push({ skuCode, productName: candidate.productNameZh || '', availablePackages: packages })
|
||||
}
|
||||
grouped.set(orderId, current)
|
||||
}
|
||||
discardCandidates.value = [...grouped.values()].filter((candidate) => String(candidate.overseasWarehouseId) === form.warehouse)
|
||||
} catch (error) { ElMessage.error(error instanceof Error ? error.message : '可废弃入库单加载失败') }
|
||||
finally { discardCandidatesLoading.value = false }
|
||||
}
|
||||
@@ -391,7 +426,10 @@ function printTransferDraftCartonMarks() {
|
||||
printWindow.document.write(`<!doctype html><html><head><meta charset="utf-8"><title>仓储转运客户箱唛</title><style>@page{size:100mm 100mm;margin:8mm}body{margin:0;font-family:Arial,'Microsoft YaHei',sans-serif;color:#111}.label{height:84mm;display:flex;flex-direction:column;align-items:center;justify-content:center;page-break-after:always;border:1px solid #222;box-sizing:border-box;padding:8mm}.title{font-size:18px;font-weight:700}.mark{margin:11mm 0 6mm;font-size:26px;font-weight:700;letter-spacing:1px;word-break:break-all;text-align:center}.sku{font-size:17px;font-weight:650}.meta{font-size:12px;margin-top:4mm}</style></head><body>${sections}</body></html>`)
|
||||
printWindow.document.close(); printWindow.focus(); printWindow.print()
|
||||
}
|
||||
function addTransferRelabelRow() { transferRelabelRows.value.push(createCargoRow()) }
|
||||
function addTransferRelabelRow() {
|
||||
const source = cargoRows.value[Math.min(transferRelabelRows.value.length, Math.max(0, cargoRows.value.length - 1))]
|
||||
transferRelabelRows.value.push({ ...createCargoRow(), sku: source?.sku || '' })
|
||||
}
|
||||
function clearTransferRelabelRows() {
|
||||
transferRelabelRows.value = []
|
||||
}
|
||||
@@ -722,9 +760,12 @@ function warehouseDisplay(value?: string) {
|
||||
}
|
||||
function statusLabel(value: string) {
|
||||
if (props.service === 'labeling') {
|
||||
if (value === 'PROCESSING') return '处理中'
|
||||
if (value === 'CUSTOMER_CONFIRM_PENDING') return '待客户确认'
|
||||
return ['READY_FOR_OUTBOUND', 'COMPLETED'].includes(value) ? '换标完成' : '待处理'
|
||||
const relabelLabels: Record<string, string> = {
|
||||
WAIT_APPROVAL: '待审核', PENDING_REVIEW: '待审核', PENDING_PROCESSING: '待仓库处理', PROCESSING: '处理中',
|
||||
CUSTOMER_CONFIRM_PENDING: '待客户确认', READY_FOR_OUTBOUND: '换标完成', COMPLETED: '已完成',
|
||||
REJECTED: '已驳回', CANCELLED: '已取消',
|
||||
}
|
||||
return relabelLabels[value] || value || '—'
|
||||
}
|
||||
if (props.service === 'transfer') {
|
||||
const transferLabels: Record<string, string> = {
|
||||
@@ -749,6 +790,55 @@ function statusLabel(value: string) {
|
||||
}
|
||||
return labels[value] || value || '—'
|
||||
}
|
||||
type StatusTagType = 'primary' | 'success' | 'warning' | 'danger' | 'info'
|
||||
function relabelStatusTagType(status: string): StatusTagType {
|
||||
if (['READY_FOR_OUTBOUND', 'COMPLETED'].includes(status)) return 'success'
|
||||
if (['REJECTED', 'CANCELLED'].includes(status)) return 'danger'
|
||||
if (['WAIT_APPROVAL', 'PENDING_REVIEW', 'PENDING_PROCESSING', 'CUSTOMER_CONFIRM_PENDING'].includes(status)) return 'warning'
|
||||
return 'primary'
|
||||
}
|
||||
function relabelFulfillmentLabel(value?: string) {
|
||||
const labels: Record<string, string> = {
|
||||
WAIT_APPROVAL: '待审核', WAIT_PROCESSING: '待仓库处理',
|
||||
WAIT_RELABEL: '待换标', WAIT_PRE_DISPATCH: '待排车', READY_FOR_DISPATCH: '待排车',
|
||||
OUTBOUND_CREATED: '换标出库单已创建', OUTBOUNDED: '已出库', COMPLETED: '已完成',
|
||||
}
|
||||
return labels[value || ''] || value || '—'
|
||||
}
|
||||
function relabelFulfillmentTagType(status?: string): StatusTagType {
|
||||
if (['OUTBOUNDED', 'COMPLETED'].includes(status || '')) return 'success'
|
||||
if (['WAIT_APPROVAL', 'WAIT_PROCESSING', 'WAIT_RELABEL', 'WAIT_PRE_DISPATCH', 'READY_FOR_DISPATCH'].includes(status || '')) return 'warning'
|
||||
return 'primary'
|
||||
}
|
||||
function transferStatusTagType(status: string): StatusTagType {
|
||||
if (['RECEIVED', 'COMPLETED'].includes(status)) return 'success'
|
||||
if (['REJECTED', 'CANCELLED'].includes(status)) return 'danger'
|
||||
if (['DRAFT'].includes(status)) return 'info'
|
||||
if (['PENDING_REVIEW', 'WAIT_CUSTOMER_CONFIRMATION', 'WAIT_RELABEL', 'WAIT_FBA_DESTINATION', 'READY_FOR_DISPATCH', 'WAIT_OUTBOUND', 'PARTIALLY_RECEIVED'].includes(status)) return 'warning'
|
||||
return 'primary'
|
||||
}
|
||||
function transferRelabelStatus(item: { needRelabel?: boolean; relabelNo?: string; relabelStatus?: string; relabelFulfillmentStatus?: string }) {
|
||||
if (!item.needRelabel) return { code: 'NOT_REQUIRED', label: '无需换标' }
|
||||
if (!item.relabelNo) return { code: 'WAIT_RELABEL', label: '待换标' }
|
||||
const code = item.relabelStatus || 'PENDING_PROCESSING'
|
||||
const labels: Record<string, string> = {
|
||||
PENDING_REVIEW: '待审核', PENDING_PROCESSING: '待处理', PROCESSING: '换标中',
|
||||
CUSTOMER_CONFIRM_PENDING: '待客户确认', READY_FOR_OUTBOUND: '已换标', COMPLETED: '已换标',
|
||||
REJECTED: '已驳回', CANCELLED: '已取消',
|
||||
}
|
||||
const fulfillmentLabels: Record<string, string> = {
|
||||
WAIT_RELABEL: '待换标', WAIT_CUSTOMER_CONFIRMATION: '待客户确认', READY_FOR_DISPATCH: '已换标',
|
||||
OUTBOUND_CREATED: '已换标', OUTBOUNDED: '已换标', NOT_REQUIRED: '无需换标',
|
||||
}
|
||||
return { code, label: labels[code] || fulfillmentLabels[item.relabelFulfillmentStatus || ''] || statusLabel(code) }
|
||||
}
|
||||
function transferRelabelStatusTagType(status: string): StatusTagType {
|
||||
if (['READY_FOR_OUTBOUND', 'COMPLETED'].includes(status)) return 'success'
|
||||
if (['REJECTED', 'CANCELLED'].includes(status)) return 'danger'
|
||||
if (status === 'NOT_REQUIRED') return 'info'
|
||||
if (['WAIT_RELABEL', 'PENDING_REVIEW', 'CUSTOMER_CONFIRM_PENDING'].includes(status)) return 'warning'
|
||||
return 'primary'
|
||||
}
|
||||
function deliveryMethodLabel(value: string) {
|
||||
const labels: Record<string, string> = {
|
||||
TRUCK: '卡车派送', EXPRESS: '快递派送', TRUCK_PICKUP: '卡车自提', EXPRESS_PICKUP: '快递自提',
|
||||
@@ -758,6 +848,11 @@ function deliveryMethodLabel(value: string) {
|
||||
}
|
||||
return labels[value] || value || '—'
|
||||
}
|
||||
function destinationTypeLabel(value?: string, fbaWarehouseCode?: string) {
|
||||
if (value === 'PRIVATE') return '私人仓'
|
||||
if (value === 'FBA' || fbaWarehouseCode) return 'FBA 仓'
|
||||
return '暂未确定'
|
||||
}
|
||||
function formatDateTime(value?: string | null) {
|
||||
return value ? value.replace('T', ' ').slice(0, 19) : '—'
|
||||
}
|
||||
@@ -780,8 +875,9 @@ async function load() {
|
||||
total.value = Number.isFinite(Number(result.total)) ? Number(result.total) : result.records.length
|
||||
submittedRows.value = result.records.map((item) => {
|
||||
const operationWarehouse = warehouseDisplay(item.operatingWarehouseId)
|
||||
const relabelStatus = transferRelabelStatus(item)
|
||||
return props.service === 'transfer'
|
||||
? { ...row([item.transferNo, item.packageCount, operationWarehouse, item.fbaWarehouseCode, deliveryMethodLabel(item.deliveryMethod), item.needRelabel ? '是 · 待绑定换标单' : '否', statusLabel(item.status), formatDateTime(item.createdAt)]), __status: item.status }
|
||||
? { ...row([item.transferNo, item.packageCount, operationWarehouse, item.fbaWarehouseCode, deliveryMethodLabel(item.deliveryMethod), item.needRelabel ? '是' : '否', item.relabelNo, relabelStatus.label, statusLabel(item.status), formatDateTime(item.createdAt)]), __status: item.status, __statusLabel: statusLabel(item.status), __relabelStatus: relabelStatus.code, __relabelStatusLabel: relabelStatus.label }
|
||||
: row([item.transferNo, '—', item.fbaWarehouseCode, '—', item.packageCount, '是', item.relabelNo ? '已生成' : '待生成', statusLabel(item.status), formatDateTime(item.createdAt)])
|
||||
})
|
||||
}
|
||||
@@ -819,7 +915,7 @@ async function submitReturnRelabel() {
|
||||
destinationFbaWarehouseId: String(destination.id),
|
||||
destinationFbaWarehouseCode: destination.warehouseCode,
|
||||
cartonRemark: form.remark.trim() || undefined,
|
||||
idempotencyKey: crypto.randomUUID(),
|
||||
idempotencyKey: createIdempotencyKey(),
|
||||
})
|
||||
await uploadReturnRelabelFile(created.relabelNo, fileScope, selectedFile)
|
||||
ElMessage.success(`贴标申请 ${created.relabelNo} 已提交,等待审核`)
|
||||
@@ -884,6 +980,9 @@ async function submit(action: 'draft' | 'submit' = 'submit') {
|
||||
if (props.service === 'transfer' && transferDestinationType.value === 'FBA' && !form.destination) {
|
||||
ElMessage.warning('请选择 FBA 目的仓'); return
|
||||
}
|
||||
if (props.service === 'transfer' && transferDestinationType.value === 'NONE') {
|
||||
ElMessage.warning('请选择 FBA 仓或私人仓'); return
|
||||
}
|
||||
if (props.service === 'transfer' && transferDestinationType.value === 'PRIVATE' && (!form.privateDestinationAddress || !form.privateDestinationPostalCode || !form.privateDestinationContact || !form.privateDestinationPhone)) {
|
||||
ElMessage.warning('请完整填写私人仓地址、邮编、联系人和电话'); return
|
||||
}
|
||||
@@ -904,7 +1003,7 @@ async function submit(action: 'draft' | 'submit' = 'submit') {
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
const idempotencyKey = crypto.randomUUID()
|
||||
const idempotencyKey = createIdempotencyKey()
|
||||
if (props.service === 'labeling') {
|
||||
const sourceInboundOrders = [...new Set(cargoRows.value.map((item) => item.sourceInboundOrderNo || item.sourceInboundCode).filter((value): value is string => Boolean(value)))].join('、').slice(0, 128)
|
||||
const items = labelingMode.value === 'carton'
|
||||
@@ -918,14 +1017,14 @@ async function submit(action: 'draft' | 'submit' = 'submit') {
|
||||
if (skuTotalFile.value) await uploadRelabelFile(created.application.relabelNo, 'SKU_TOTAL', skuTotalFile.value)
|
||||
if (shippingLabelFile.value) await uploadRelabelShippingLabel(created.application.relabelNo, shippingLabelFile.value)
|
||||
} catch (error) {
|
||||
// Creation already committed: leave the creation form even if the detail reload fails.
|
||||
if (props.createPage) await router.push({ name: 'overseas-labeling-detail', params: { detailNo: created.application.relabelNo } })
|
||||
// Creation already committed: return to the list even when an attachment still needs retrying.
|
||||
if (props.createPage) await router.push({ name: 'overseas-labeling' })
|
||||
else { mode.value = 'list'; await load(); await openDetail({ '0': created.application.relabelNo }) }
|
||||
await ElMessageBox.alert(`申请 ${created.application.relabelNo} 已创建,部分附件上传失败:${error instanceof Error ? error.message : '上传失败'}。请在原单详情补传失败附件,不要重复下单。`, '申请已创建,附件待补传', { confirmButtonText: '知道了' })
|
||||
return
|
||||
}
|
||||
ElMessage.success(`${config.value.title}提交成功`)
|
||||
await router.push({ name: 'overseas-labeling-detail', params: { detailNo: created.application.relabelNo } })
|
||||
await router.push({ name: 'overseas-labeling' })
|
||||
return
|
||||
} else if (props.service === 'disposal') {
|
||||
const items = cargoRows.value.filter((item) => item.quantity > 0).map((item) => {
|
||||
@@ -942,6 +1041,10 @@ async function submit(action: 'draft' | 'submit' = 'submit') {
|
||||
const operationType = labelingMode.value === 'carton' ? 'CARTON_LABEL' : labelingMode.value === 'sku' ? 'SKU_LABEL' : 'CARTON_AND_SKU'
|
||||
const selectedFba = fbaWarehouses.value.find((warehouse) => warehouse.warehouseCode === form.destination)
|
||||
const payload: TransferCreate = { operatingWarehouseId: primaryWarehouse, destinationType: transferDestinationType.value, fbaWarehouseCode: transferDestinationType.value === 'FBA' ? form.destination || undefined : undefined, destinationWarehouseId: transferDestinationType.value === 'FBA' ? String(selectedFba?.id || '') || undefined : undefined, destinationWarehouseCode: transferDestinationType.value === 'FBA' ? form.destination || undefined : undefined, destinationWarehouseName: transferDestinationType.value === 'FBA' ? selectedFba?.warehouseName : undefined, privateDestinationAddress: transferDestinationType.value === 'PRIVATE' ? form.privateDestinationAddress : undefined, privateDestinationPostalCode: transferDestinationType.value === 'PRIVATE' ? form.privateDestinationPostalCode : undefined, privateDestinationContactName: transferDestinationType.value === 'PRIVATE' ? form.privateDestinationContact : undefined, privateDestinationContactPhone: transferDestinationType.value === 'PRIVATE' ? form.privateDestinationPhone : undefined, deliveryAddress: form.address, packageCount, volumeCbm: form.volumeCbm, deliveryMethod: form.deliveryMethod, estimatedArrivalAt: form.estimatedArrivalAt, estimatedShipAt: form.estimatedShipAt || undefined, fbaShipmentNo: transferDestinationType.value === 'FBA' ? form.fbaShipmentNo || undefined : undefined, trackingNo: form.deliveryMethod === 'CUSTOMER_DELIVERY' ? form.trackingNo || undefined : undefined, fbaBoxNos: customerCartonMarks.length ? customerCartonMarks.join(',') : undefined, remark: transferRemark || undefined, needRelabel: props.service === 'relabel' || form.relabel, labelDeadline: form.relabel ? form.labelDeadline : undefined, pickupContactName: form.deliveryMethod === 'WAREHOUSE_PICKUP' ? form.pickupContact : undefined, pickupContactPhone: form.deliveryMethod === 'WAREHOUSE_PICKUP' ? form.pickupPhone : undefined, relabelContactName: form.relabel ? form.relabelContact : undefined, relabelContactPhone: form.relabel ? form.relabelPhone : undefined, relabelDeliveryMethod: form.relabel && labelingMode.value !== 'sku' ? form.relabelDeliveryMethod as 'TRUCK' | 'EXPRESS' | 'TRUCK_PICKUP' | 'EXPRESS_PICKUP' : undefined, idempotencyKey, items: cargoRows.value.map((item) => ({ skuCode: item.sku, productNameZh: item.name, packageCount: item.quantity, shippingMark: transferCustomerMarks(item).join(',') || undefined })), relabelItems: form.relabel ? transferRelabelRows.value.map((item) => ({ operationType, customerCartonMarks: transferCustomerMarks(item), originalSku: item.sku, newSku: item.newSkuCode || undefined, quantity: item.quantity, fbaCartonMark: item.fbaCartonMark || undefined, destinationWarehouseCode: transferRelabelDefaultDestination.value || undefined, remark: item.remark || undefined })) : undefined }
|
||||
if (props.service === 'transfer' && selectedTransferInventory.value) {
|
||||
;(payload as TransferCreate).inboundCode = selectedTransferInventory.value
|
||||
;(payload as TransferCreate).sourceLastlegOrderIds = selectedTransferSourceOrderIds.value
|
||||
}
|
||||
const created = editingTransferNo.value
|
||||
? await updateTransfer(editingTransferNo.value, payload)
|
||||
: await createTransfer(payload)
|
||||
@@ -951,8 +1054,13 @@ async function submit(action: 'draft' | 'submit' = 'submit') {
|
||||
const submitted = await submitTransfer(transferNo)
|
||||
if (form.relabel) {
|
||||
const relabelNo = String(submitted.relabelNo || created.relabelNo || '')
|
||||
if (!relabelNo) {
|
||||
ElMessage.success('转运单已提交审核;PDA 入库确认后将自动生成关联换标单')
|
||||
await ElMessageBox.alert(`转运单 ${transferNo} 已提交。关联换标单会在 PDA 完成入库、客户确认实测库存后自动生成;届时请进入关联换标单详情上传贴标总文件。`, '转运单已提交', { confirmButtonText: '知道了' })
|
||||
await openDetail({ '0': transferNo })
|
||||
return
|
||||
}
|
||||
try {
|
||||
if (!relabelNo) throw new Error('转运单已提交,但未返回关联换标单号')
|
||||
if (cartonTotalFile.value) await uploadRelabelFile(relabelNo, 'CARTON_TOTAL', cartonTotalFile.value)
|
||||
if (skuTotalFile.value) await uploadRelabelFile(relabelNo, 'SKU_TOTAL', skuTotalFile.value)
|
||||
} catch (error) {
|
||||
@@ -990,8 +1098,8 @@ async function openDetail(value: Record<string, string>) {
|
||||
} catch (error) { selectedDetail.value = null; selectedRelabelDetail.value = null; detailError.value = error instanceof Error ? error.message : '详情加载失败'; mode.value = 'detail'; ElMessage.error(detailError.value) }
|
||||
}
|
||||
async function loadDetailPage() {
|
||||
await loadWarehouses()
|
||||
await openDetail({ '0': detailNumber.value })
|
||||
await loadWarehouses()
|
||||
}
|
||||
async function confirmTransferMeasurements() {
|
||||
const transferNo = String(selectedDetail.value?.transferNo || selectedDetail.value?.['0'] || '')
|
||||
@@ -1127,10 +1235,79 @@ async function selectTransferWarehouse() {
|
||||
form.relabelContact = detail.warehouse.contactName || ''
|
||||
form.relabelPhone = detail.warehouse.contactPhone || ''
|
||||
if (form.deliveryMethod === 'CUSTOMER_DELIVERY') form.address = operationWarehouseAddress.value
|
||||
transferInventoryCandidates.value = await listRelabelInventoryCandidates(form.warehouse)
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '中转仓联系信息加载失败')
|
||||
} finally { operationWarehouseDetailLoading.value = false }
|
||||
}
|
||||
async function confirmSelectedRelabel() {
|
||||
const relabelNo = selectedRelabelDetail.value?.application.relabelNo
|
||||
if (!relabelNo) return
|
||||
try {
|
||||
await ElMessageBox.confirm('确认仓库上传的换标结果无误并继续出库流程吗?', '换标完成确认', { confirmButtonText: '确认并继续', cancelButtonText: '取消' })
|
||||
await confirmRelabelCompletion(relabelNo)
|
||||
ElMessage.success('已确认换标完成,订单已进入后续出库流程')
|
||||
await openDetail({ '0': relabelNo })
|
||||
} catch (error) { if (error !== 'cancel') ElMessage.error(error instanceof Error ? error.message : '确认失败') }
|
||||
}
|
||||
async function resubmitRejectedRelabel() {
|
||||
const application = selectedRelabelDetail.value?.application
|
||||
if (!application || application.status !== 'REJECTED') return
|
||||
try {
|
||||
const { value } = await ElMessageBox.prompt('可先在下方替换贴标文件,再补充本次修改说明。', '修改后重新提交审核', {
|
||||
inputType: 'textarea', inputValue: selectedRelabelDetail.value?.remark || '',
|
||||
inputPlaceholder: '修改说明(选填)', confirmButtonText: '重新提交', cancelButtonText: '取消',
|
||||
})
|
||||
selectedRelabelDetail.value = await resubmitRelabel(application.relabelNo, {
|
||||
labelDeadline: application.labelDeadline || '', contactName: application.contactName || '',
|
||||
contactPhone: application.contactPhone || '', remark: value?.trim() || undefined,
|
||||
idempotencyKey: createIdempotencyKey(),
|
||||
})
|
||||
ElMessage.success('贴标单已重新提交审核')
|
||||
} catch (error) {
|
||||
if (error !== 'cancel' && error !== 'close') ElMessage.error(error instanceof Error ? error.message : '重新提交失败')
|
||||
}
|
||||
}
|
||||
const transferInventoryOptions = computed(() => {
|
||||
const grouped = new Map<string, { value: string; label: string; packages: number }>()
|
||||
for (const item of transferInventoryCandidates.value) {
|
||||
const value = String(item.inboundCode || '')
|
||||
if (!value) continue
|
||||
const packages = inventoryQuantity(item.availablePackages)
|
||||
const current = grouped.get(value)
|
||||
if (current) current.packages += packages
|
||||
else grouped.set(value, { value, label: `${item.inboundOrderNo || value}|${value}`, packages })
|
||||
}
|
||||
return [...grouped.values()]
|
||||
})
|
||||
async function selectTransferInventory(value: string) {
|
||||
selectedTransferInventory.value = value || ''
|
||||
selectedTransferSourceOrderIds.value = []
|
||||
if (!value) return
|
||||
form.inboundNo = value
|
||||
// The inventory-candidates response already contains the merchant-visible
|
||||
// SKU/name/package data. Do not call the platform-only last-leg orders API
|
||||
// here: merchants are not authorized for that endpoint (A0403), and the
|
||||
// transfer service can derive source orders from inboundCode when IDs are
|
||||
// omitted. Aggregate candidates for the selected inbound into cargo rows.
|
||||
const candidates = transferInventoryCandidates.value.filter((item) => String(item.inboundCode || '') === value)
|
||||
const grouped = new Map<string, CargoRow>()
|
||||
for (const candidate of candidates) {
|
||||
const sku = String(candidate.skuCode || '')
|
||||
const current = grouped.get(sku)
|
||||
const quantity = inventoryQuantity(candidate.availablePackages)
|
||||
if (current) current.quantity += quantity
|
||||
else grouped.set(sku, { ...createCargoRow(), sku, name: candidate.productNameZh || '', quantity, availableQuantity: quantity })
|
||||
}
|
||||
cargoRows.value = [...grouped.values()]
|
||||
try {
|
||||
const historical = await listRelabelHistoricalOrders(form.warehouse, value)
|
||||
const sourceIds = historical.map((item) => Number(item.id)).filter((id) => Number.isInteger(id) && id > 0)
|
||||
selectedTransferSourceOrderIds.value = sourceIds
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '库存来源订单加载失败')
|
||||
}
|
||||
}
|
||||
function selectTransferInboundMethod(value: string | number | boolean | undefined) {
|
||||
form.address = value === 'CUSTOMER_DELIVERY' ? operationWarehouseAddress.value : ''
|
||||
}
|
||||
@@ -1174,7 +1351,14 @@ function selectRelabelInventory(inboundOrderId: string) {
|
||||
const current = grouped.get(key)
|
||||
const quantity = inventoryQuantity(item.quantity)
|
||||
if (current) current.availableQuantity += quantity
|
||||
else grouped.set(key, { skuCode, productNameZh: candidate.skuCode?.trim().toUpperCase() === key ? (candidate.productNameZh || '') : '', availableQuantity: quantity, selected: false, quantity })
|
||||
else grouped.set(key, {
|
||||
skuCode,
|
||||
productNameZh: candidate.skuCode?.trim().toUpperCase() === key ? (candidate.productNameZh || '') : '',
|
||||
availableQuantity: quantity,
|
||||
selected: false,
|
||||
// 可贴库存和本次申请数量是两个概念;重复换标时默认不应自动带入上次/首个箱子的数量。
|
||||
quantity: 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
returnRelabelSkuRows.value = [...grouped.values()]
|
||||
@@ -1210,6 +1394,8 @@ function selectRelabelInventory(inboundOrderId: string) {
|
||||
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="submittedRows" :columns="listColumns" :loading="loading" :total="total" :page-sizes="[15, 30, 50, 100]" :row-actions="listRowActions" :empty-text="config.emptyText">
|
||||
<template #toolbar-actions><el-button v-if="canCreate" type="primary" @click="openCreate"><Plus :size="16" />{{ props.service === 'relabel' ? '新建退货换标单' : props.service === 'transfer' ? '新增订单' : `新增${config.title}` }}</el-button></template>
|
||||
<template v-if="props.service === 'transfer' && submittedRows.some((item) => item.__status === 'WAIT_CUSTOMER_CONFIRMATION')" #before-table><div class="warehouse-list-notice">部分入库完成订单已有 PDA 实测数据,请在对应行确认测量数据。</div></template>
|
||||
<template #transferRelabelStatus="{ row }"><el-tag :type="transferRelabelStatusTagType(row.__relabelStatus)" effect="light">{{ row.__relabelStatusLabel }}</el-tag></template>
|
||||
<template #transferStatus="{ row }"><el-tag :type="transferStatusTagType(row.__status)" effect="light">{{ row.__statusLabel }}</el-tag></template>
|
||||
</DataTable>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1225,7 +1411,8 @@ function selectRelabelInventory(inboundOrderId: string) {
|
||||
<label v-if="props.service === 'relabel'"><span><i>*</i>来源 FBA 仓库</span><el-select v-model="form.sourceWarehouse" :loading="fbaWarehousesLoading" :disabled="Boolean(fbaWarehousesError)" placeholder="请选择平台维护的 FBA 仓"><el-option v-for="warehouse in fbaWarehouses" :key="warehouse.id" :label="warehouseLabel(warehouse)" :value="warehouse.warehouseCode" /></el-select></label>
|
||||
<label v-else-if="props.service === 'disposal'"><span>进仓编号</span><el-input v-model="form.inboundNo" readonly /></label>
|
||||
<label v-else><span><i>*</i>{{ props.service === 'labeling' ? '海外操作仓(中转仓)' : props.service === 'transfer' ? '入库海外仓' : '操作仓' }}</span><el-select v-model="form.warehouse" :loading="fbaWarehousesLoading || operationWarehouseDetailLoading" :disabled="Boolean(operationWarehousesError)" clearable :placeholder="props.service === 'transfer' ? '请选择海外仓' : '请选择国外中转仓'" @change="props.service === 'labeling' ? selectOperationWarehouse() : props.service === 'transfer' && selectTransferWarehouse()"><el-option v-for="warehouse in operationWarehouses" :key="warehouse.id" :label="warehouseLabel(warehouse)" :value="String(warehouse.id)" /></el-select><small v-if="operationWarehousesError" class="warehouse-hint">{{ operationWarehousesError }}</small></label>
|
||||
<label v-if="props.service === 'labeling'" class="warehouse-span-2"><span><i>*</i>选择有库存的入仓单或退货单</span><el-select v-model="selectedRelabelInboundOrderId" filterable clearable :loading="relabelCandidatesLoading" :disabled="!form.warehouse" no-data-text="当前中转仓暂无可用库存" placeholder="请选择当前仍有可用库存的入仓单或退货单" @change="selectRelabelInventory"><el-option v-for="option in relabelInboundOptions" :key="option.value" :label="`${option.inboundOrderNo}|${option.inboundCode}|可用 ${option.availablePackages} ${option.quantityUnit}`" :value="option.value" /></el-select></label>
|
||||
<label v-if="props.service === 'labeling'" class="warehouse-span-2"><span><i>*</i>选择有库存的入仓单或退货单</span><el-select v-model="selectedRelabelInboundOrderId" filterable clearable :loading="relabelCandidatesLoading" :disabled="!form.warehouse" no-data-text="当前中转仓暂无可用库存" placeholder="请选择当前仍有可用库存的入仓单或退货单" @change="selectRelabelInventory"><el-option v-for="option in relabelInboundOptions" :key="option.value" :label="`${option.inboundOrderNo}|${option.inboundCode}|可用 ${option.availablePackages} ${option.quantityUnit}${option.relabelledPackages > 0 ? `|已换标 ${option.relabelledPackages} ${option.quantityUnit}` : ''}`" :value="option.value" /></el-select></label>
|
||||
<label v-if="props.service === 'transfer'"><span>库存转运来源(可选)</span><el-select v-model="selectedTransferInventory" filterable clearable :loading="relabelCandidatesLoading" :disabled="!form.warehouse" no-data-text="当前海外仓暂无可用库存" placeholder="不选则按新货转运;选择后免 PDA 测量" @change="selectTransferInventory"><el-option v-for="option in transferInventoryOptions" :key="option.value" :label="`${option.label}|可用 ${option.packages} 箱`" :value="option.value" /></el-select><small class="warehouse-hint">库存转运可直接进入目的仓、换标和排车流程,PDA 可拆托、重打或同目的仓合托。</small></label>
|
||||
<label v-if="props.service === 'labeling' && (isReturnRelabelSource || labelingMode !== 'sku')"><span><i>*</i>FBA 目的仓</span><el-select v-model="form.destination" :loading="fbaWarehousesLoading" :disabled="Boolean(fbaWarehousesError)" filterable placeholder="请选择本张申请统一的 FBA 目的仓"><el-option v-for="warehouse in fbaWarehouses" :key="warehouse.id" :label="warehouseLabel(warehouse)" :value="warehouse.warehouseCode" /></el-select></label>
|
||||
<div v-if="props.service === 'transfer'" class="warehouse-choice-field warehouse-span-2"><span><i>*</i>入仓方式</span><el-radio-group v-model="form.deliveryMethod" @change="selectTransferInboundMethod"><el-radio-button value="WAREHOUSE_PICKUP">仓库上门提货</el-radio-button><el-radio-button value="CUSTOMER_DELIVERY">客户送货到仓</el-radio-button></el-radio-group></div>
|
||||
<label v-if="props.service === 'transfer' && form.deliveryMethod === 'WAREHOUSE_PICKUP'" class="pickup-address-field"><span><i>*</i>提货地址</span><el-input v-model="form.address" placeholder="请输入仓库上门提货的详细地址" /></label>
|
||||
@@ -1249,11 +1436,11 @@ function selectRelabelInventory(inboundOrderId: string) {
|
||||
<label v-if="props.service === 'relabel'" class="warehouse-span-2"><span><i>*</i>详细派送地址</span><el-input v-model="form.address" placeholder="请输入派送地址" /></label>
|
||||
<label v-if="props.service === 'transfer' || props.service === 'relabel'"><span><i>*</i>{{ props.service === 'transfer' ? '预估体积(CBM)' : '预估总体积(CBM)' }}</span><el-input-number v-model="form.volumeCbm" :min="0" :precision="4" :step="0.0001" controls-position="right" placeholder="请输入预估总体积" /></label>
|
||||
<label v-if="props.service === 'transfer' || props.service === 'relabel'"><span><i>*</i>{{ props.service === 'transfer' ? '预计入仓时间' : '预计到达时间' }}</span><el-date-picker v-model="form.estimatedArrivalAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" :placeholder="props.service === 'transfer' ? '请选择预计入仓时间' : '请选择预计到达时间'" /></label>
|
||||
<label v-if="props.service === 'transfer'"><span>预计发货时间</span><el-date-picker v-model="form.estimatedShipAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="选填" /></label>
|
||||
<label v-if="props.service === 'transfer'"><span>期望发货时间</span><el-date-picker v-model="form.estimatedShipAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="选填" /></label>
|
||||
<div v-if="props.service === 'transfer'" class="warehouse-choice-field warehouse-span-2"><span><i>*</i>目的去向</span><el-radio-group v-model="transferDestinationType" @change="selectTransferDestinationType"><el-radio-button value="NONE">暂未确定</el-radio-button><el-radio-button value="FBA">FBA 仓</el-radio-button><el-radio-button value="PRIVATE">私人仓</el-radio-button></el-radio-group></div>
|
||||
<label v-if="props.service === 'transfer' && transferDestinationType === 'FBA'"><span><i>*</i>FBA 目的仓</span><el-select v-model="form.destination" :loading="fbaWarehousesLoading" :disabled="Boolean(fbaWarehousesError)" filterable placeholder="请选择 FBA 仓"><el-option v-for="warehouse in fbaWarehouses" :key="warehouse.id" :label="warehouseLabel(warehouse)" :value="warehouse.warehouseCode" /></el-select></label>
|
||||
<label v-if="props.service === 'transfer' && transferDestinationType === 'FBA'"><span>FBA 货件编号</span><el-input v-model="form.fbaShipmentNo" placeholder="选填,可后续补充" /></label>
|
||||
<label v-if="props.service === 'transfer' && transferDestinationType === 'PRIVATE'" class="warehouse-span-2"><span><i>*</i>私人仓地址</span><el-input v-model="form.privateDestinationAddress" maxlength="1000" placeholder="请输入私人仓完整地址" /></label>
|
||||
<label v-if="props.service === 'transfer' && transferDestinationType === 'PRIVATE'"><span><i>*</i>私人仓地址</span><el-input v-model="form.privateDestinationAddress" maxlength="1000" placeholder="请输入私人仓完整地址" /></label>
|
||||
<label v-if="props.service === 'transfer' && transferDestinationType === 'PRIVATE'"><span><i>*</i>邮编</span><el-input v-model="form.privateDestinationPostalCode" maxlength="32" placeholder="请输入邮编" /></label>
|
||||
<label v-if="props.service === 'transfer' && transferDestinationType === 'PRIVATE'"><span><i>*</i>私人仓联系人</span><el-input v-model="form.privateDestinationContact" maxlength="128" placeholder="请输入联系人" /></label>
|
||||
<label v-if="props.service === 'transfer' && transferDestinationType === 'PRIVATE'"><span><i>*</i>联系电话</span><el-input v-model="form.privateDestinationPhone" maxlength="64" placeholder="请输入联系电话" /></label>
|
||||
@@ -1261,7 +1448,7 @@ function selectRelabelInventory(inboundOrderId: string) {
|
||||
<label v-if="props.service === 'labeling'"><span>贴标要求</span><el-input v-model="form.requirement" placeholder="请输入贴标要求" /></label>
|
||||
<label v-else-if="props.service === 'relabel' || (props.service === 'transfer' && form.deliveryMethod === 'CUSTOMER_DELIVERY')"><span>物流跟踪号</span><el-input v-model="form.trackingNo" placeholder="请输入物流跟踪号(选填)" /></label>
|
||||
<label class="warehouse-span-2"><span>备注</span><el-input v-model="form.remark" type="textarea" :rows="2" placeholder="选填" /></label>
|
||||
<div v-if="props.service === 'transfer'" class="warehouse-choice-field warehouse-span-2"><span><i>*</i>换标服务</span><el-radio-group v-model="form.relabel"><el-radio-button :value="false">不需要</el-radio-button><el-radio-button :value="true">需要</el-radio-button></el-radio-group></div>
|
||||
<div v-if="props.service === 'transfer'" class="warehouse-choice-field warehouse-choice-field--relabel warehouse-span-2"><span><i>*</i>是否贴标</span><el-radio-group v-model="form.relabel"><el-radio-button :value="false">不需要贴标</el-radio-button><el-radio-button :value="true">需要贴标</el-radio-button></el-radio-group><small>{{ form.relabel ? '已开启贴标,请在右侧填写贴标资料' : 'FBA 仓和私人仓均可选择是否贴标' }}</small></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="warehouse-card warehouse-form-section warehouse-transfer-cargo-card">
|
||||
@@ -1273,9 +1460,9 @@ function selectRelabelInventory(inboundOrderId: string) {
|
||||
<section v-if="canManageRelabelFiles" class="warehouse-file-group warehouse-file-group--primary">
|
||||
<div class="warehouse-file-group__heading"><div><strong><span>1</span>贴标总文件</strong></div><em>{{ requiresBothTotalFiles ? '必传 · 两份文件' : '必传 · 二选一' }}</em></div>
|
||||
<div class="warehouse-file-options">
|
||||
<div class="warehouse-file-option" :class="{ 'is-selected': Boolean(cartonTotalFile) }"><div class="warehouse-file-option__name"><strong>箱唛总文件</strong></div><el-button :type="cartonTotalFile ? 'primary' : 'default'" plain @click="chooseCreateTotalFile('CARTON_TOTAL')"><Upload :size="15" />{{ cartonTotalFile ? '重新上传' : '选择文件' }}</el-button><div class="warehouse-file-option__status" :class="{ 'is-empty': !cartonTotalFile }"><span>{{ cartonTotalFile?.name || '尚未选择文件' }}</span><button v-if="cartonTotalFile" type="button" class="warehouse-file-clear" title="清除箱唛总文件" aria-label="清除箱唛总文件" :disabled="saving" @click="clearCreateTotalFile('CARTON_TOTAL')"><X :size="14" /></button></div></div>
|
||||
<div class="warehouse-file-option" :class="{ 'is-selected': Boolean(cartonTotalFile) }"><div class="warehouse-file-option__name"><strong>箱唛总文件</strong></div><el-button native-type="button" :type="cartonTotalFile ? 'primary' : 'default'" plain @click="chooseCreateTotalFile('CARTON_TOTAL')"><Upload :size="15" />{{ cartonTotalFile ? '重新上传' : '选择文件' }}</el-button><div class="warehouse-file-option__status" :class="{ 'is-empty': !cartonTotalFile }"><span>{{ cartonTotalFile?.name || '尚未选择文件' }}</span><button v-if="cartonTotalFile" type="button" class="warehouse-file-clear" title="清除箱唛总文件" aria-label="清除箱唛总文件" :disabled="saving" @click="clearCreateTotalFile('CARTON_TOTAL')"><X :size="14" /></button></div></div>
|
||||
<div class="warehouse-file-choice-or" :class="{ 'is-both': requiresBothTotalFiles }">{{ requiresBothTotalFiles ? '和' : '或' }}</div>
|
||||
<div class="warehouse-file-option" :class="{ 'is-selected': Boolean(skuTotalFile) }"><div class="warehouse-file-option__name"><strong>SKU 总文件</strong></div><el-button :type="skuTotalFile ? 'primary' : 'default'" plain @click="chooseCreateTotalFile('SKU_TOTAL')"><Upload :size="15" />{{ skuTotalFile ? '重新上传' : '选择文件' }}</el-button><div class="warehouse-file-option__status" :class="{ 'is-empty': !skuTotalFile }"><span>{{ skuTotalFile?.name || '尚未选择文件' }}</span><button v-if="skuTotalFile" type="button" class="warehouse-file-clear" title="清除 SKU 总文件" aria-label="清除 SKU 总文件" :disabled="saving" @click="clearCreateTotalFile('SKU_TOTAL')"><X :size="14" /></button></div></div>
|
||||
<div class="warehouse-file-option" :class="{ 'is-selected': Boolean(skuTotalFile) }"><div class="warehouse-file-option__name"><strong>SKU 总文件</strong></div><el-button native-type="button" :type="skuTotalFile ? 'primary' : 'default'" plain @click="chooseCreateTotalFile('SKU_TOTAL')"><Upload :size="15" />{{ skuTotalFile ? '重新上传' : '选择文件' }}</el-button><div class="warehouse-file-option__status" :class="{ 'is-empty': !skuTotalFile }"><span>{{ skuTotalFile?.name || '尚未选择文件' }}</span><button v-if="skuTotalFile" type="button" class="warehouse-file-clear" title="清除 SKU 总文件" aria-label="清除 SKU 总文件" :disabled="saving" @click="clearCreateTotalFile('SKU_TOTAL')"><X :size="14" /></button></div></div>
|
||||
</div>
|
||||
</section>
|
||||
<section v-if="!isReturnRelabelSource" class="warehouse-file-group">
|
||||
@@ -1291,7 +1478,7 @@ function selectRelabelInventory(inboundOrderId: string) {
|
||||
<div v-if="isReturnRelabelSource" class="warehouse-table-scroll">
|
||||
<table class="warehouse-edit-table warehouse-return-relabel-table">
|
||||
<thead><tr><th>选择</th><th>SKU</th><th>中文品名</th><th>库存总数</th><th>本次贴标数量</th></tr></thead>
|
||||
<tbody><tr v-for="row in returnRelabelSkuRows" :key="row.skuCode"><td><el-checkbox v-model="row.selected" /></td><td><strong>{{ row.skuCode }}</strong></td><td>{{ row.productNameZh || '—' }}</td><td><strong>{{ row.availableQuantity }}</strong></td><td><el-input-number v-model="row.quantity" :disabled="!row.selected" :min="1" :max="row.availableQuantity" :precision="0" controls-position="right" placeholder="请输入贴标数量" /></td></tr><tr v-if="returnRelabelSkuRows.length === 0"><td colspan="5"><div class="warehouse-empty">该退货单暂无可申请贴标的 SKU 库存</div></td></tr></tbody>
|
||||
<tbody><tr v-for="row in returnRelabelSkuRows" :key="row.skuCode"><td><el-checkbox :model-value="row.selected" @change="row.selected = Boolean($event)" /></td><td><strong>{{ row.skuCode }}</strong></td><td>{{ row.productNameZh || '—' }}</td><td><strong>{{ row.availableQuantity }}</strong></td><td><el-input-number v-model="row.quantity" :disabled="!row.selected" :min="1" :max="row.availableQuantity" :precision="0" controls-position="right" placeholder="请输入贴标数量" /></td></tr><tr v-if="returnRelabelSkuRows.length === 0"><td colspan="5"><div class="warehouse-empty">该退货单暂无可申请贴标的 SKU 库存</div></td></tr></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div v-if="!isReturnRelabelSource" class="warehouse-labeling-mode">
|
||||
@@ -1327,9 +1514,9 @@ function selectRelabelInventory(inboundOrderId: string) {
|
||||
<section v-if="canManageRelabelFiles" class="warehouse-file-group warehouse-transfer-total-files">
|
||||
<div class="warehouse-file-group__heading"><div><strong>贴标总文件</strong></div><em>{{ requiresBothTotalFiles ? '必传 · 两份文件' : '必传 · 二选一' }}</em></div>
|
||||
<div class="warehouse-file-options">
|
||||
<div class="warehouse-file-option" :class="{ 'is-selected': Boolean(cartonTotalFile) }"><div class="warehouse-file-option__name"><strong>箱唛总文件</strong></div><el-button :type="cartonTotalFile ? 'primary' : 'default'" plain @click="chooseCreateTotalFile('CARTON_TOTAL')"><Upload :size="15" />{{ cartonTotalFile ? '重新上传' : '选择文件' }}</el-button><div class="warehouse-file-option__status" :class="{ 'is-empty': !cartonTotalFile }"><span>{{ cartonTotalFile?.name || '尚未选择文件' }}</span><button v-if="cartonTotalFile" type="button" class="warehouse-file-clear" title="清除箱唛总文件" aria-label="清除箱唛总文件" :disabled="saving" @click="clearCreateTotalFile('CARTON_TOTAL')"><X :size="14" /></button></div></div>
|
||||
<div class="warehouse-file-option" :class="{ 'is-selected': Boolean(cartonTotalFile) }"><div class="warehouse-file-option__name"><strong>箱唛总文件</strong></div><el-button native-type="button" :type="cartonTotalFile ? 'primary' : 'default'" plain @click="chooseCreateTotalFile('CARTON_TOTAL')"><Upload :size="15" />{{ cartonTotalFile ? '重新上传' : '选择文件' }}</el-button><div class="warehouse-file-option__status" :class="{ 'is-empty': !cartonTotalFile }"><span>{{ cartonTotalFile?.name || '尚未选择文件' }}</span><button v-if="cartonTotalFile" type="button" class="warehouse-file-clear" title="清除箱唛总文件" aria-label="清除箱唛总文件" :disabled="saving" @click="clearCreateTotalFile('CARTON_TOTAL')"><X :size="14" /></button></div></div>
|
||||
<div class="warehouse-file-choice-or" :class="{ 'is-both': requiresBothTotalFiles }">{{ requiresBothTotalFiles ? '和' : '或' }}</div>
|
||||
<div class="warehouse-file-option" :class="{ 'is-selected': Boolean(skuTotalFile) }"><div class="warehouse-file-option__name"><strong>SKU 总文件</strong></div><el-button :type="skuTotalFile ? 'primary' : 'default'" plain @click="chooseCreateTotalFile('SKU_TOTAL')"><Upload :size="15" />{{ skuTotalFile ? '重新上传' : '选择文件' }}</el-button><div class="warehouse-file-option__status" :class="{ 'is-empty': !skuTotalFile }"><span>{{ skuTotalFile?.name || '尚未选择文件' }}</span><button v-if="skuTotalFile" type="button" class="warehouse-file-clear" title="清除 SKU 总文件" aria-label="清除 SKU 总文件" :disabled="saving" @click="clearCreateTotalFile('SKU_TOTAL')"><X :size="14" /></button></div></div>
|
||||
<div class="warehouse-file-option" :class="{ 'is-selected': Boolean(skuTotalFile) }"><div class="warehouse-file-option__name"><strong>SKU 总文件</strong></div><el-button native-type="button" :type="skuTotalFile ? 'primary' : 'default'" plain @click="chooseCreateTotalFile('SKU_TOTAL')"><Upload :size="15" />{{ skuTotalFile ? '重新上传' : '选择文件' }}</el-button><div class="warehouse-file-option__status" :class="{ 'is-empty': !skuTotalFile }"><span>{{ skuTotalFile?.name || '尚未选择文件' }}</span><button v-if="skuTotalFile" type="button" class="warehouse-file-clear" title="清除 SKU 总文件" aria-label="清除 SKU 总文件" :disabled="saving" @click="clearCreateTotalFile('SKU_TOTAL')"><X :size="14" /></button></div></div>
|
||||
</div>
|
||||
<input ref="cartonTotalCreateInput" class="warehouse-hidden-input" type="file" accept="application/pdf,.pdf" @change="selectCartonTotalFile"><input ref="skuTotalCreateInput" class="warehouse-hidden-input" type="file" accept="application/pdf,.pdf" @change="selectSkuTotalFile">
|
||||
</section>
|
||||
@@ -1352,10 +1539,17 @@ function selectRelabelInventory(inboundOrderId: string) {
|
||||
<header class="warehouse-detail-header">
|
||||
<button type="button" class="warehouse-detail-back" @click="returnToList"><ArrowLeft :size="16" />返回列表</button>
|
||||
<div class="warehouse-detail-title"><strong>{{ props.service === 'labeling' ? selectedRelabelDetail?.application.relabelNo : selectedDetail?.transferNo || selectedDetail?.['0'] || `${config.title}详情` }}</strong><span>{{ config.title }}详情</span></div>
|
||||
<div class="warehouse-detail-actions"><el-button v-if="canCancelDiscard && props.service === 'disposal' && selectedDetail?.status === 'PENDING_REVIEW'" type="danger" @click="cancelSelectedDiscard">取消申请</el-button><el-button v-if="canCreate && props.service === 'transfer' && selectedDetail?.status === 'DRAFT'" type="primary" @click="openEdit(String(selectedDetail?.transferNo || selectedDetail?.['0'] || ''))">编辑</el-button><el-button v-if="canConfirmTransfer && props.service === 'transfer' && selectedDetail?.status === 'WAIT_CUSTOMER_CONFIRMATION'" type="primary" @click="confirmTransferMeasurements">确认测量数据</el-button><el-tag effect="light">{{ statusLabel(props.service === 'labeling' ? selectedRelabelDetail?.application.status || '' : String(selectedDetail?.status || '')) }}</el-tag></div>
|
||||
<div class="warehouse-detail-actions"><el-button v-if="canCreate && props.service === 'labeling' && selectedRelabelDetail?.application.status === 'REJECTED'" type="primary" @click="resubmitRejectedRelabel">修改后重新提交</el-button><el-button v-if="canConfirmRelabel && props.service === 'labeling' && selectedRelabelDetail?.application.status === 'CUSTOMER_CONFIRM_PENDING' && !selectedRelabelDetail?.application.customerConfirmedAt" type="primary" @click="confirmSelectedRelabel">确认换标完成</el-button><el-button v-if="canCancelDiscard && props.service === 'disposal' && selectedDetail?.status === 'PENDING_REVIEW'" type="danger" @click="cancelSelectedDiscard">取消申请</el-button><el-button v-if="canCreate && props.service === 'transfer' && selectedDetail?.status === 'DRAFT'" type="primary" @click="openEdit(String(selectedDetail?.transferNo || selectedDetail?.['0'] || ''))">编辑</el-button><el-button v-if="canConfirmTransfer && props.service === 'transfer' && selectedDetail?.status === 'WAIT_CUSTOMER_CONFIRMATION'" type="primary" @click="confirmTransferMeasurements">确认测量数据</el-button><el-tag :type="props.service === 'labeling' ? relabelStatusTagType(selectedRelabelDetail?.application.status || '') : undefined" effect="light">{{ statusLabel(props.service === 'labeling' ? selectedRelabelDetail?.application.status || '' : String(selectedDetail?.status || '')) }}</el-tag></div>
|
||||
</header>
|
||||
<div v-if="detailError" class="warehouse-detail-error" role="alert"><strong>{{ detailError }}</strong><el-button @click="loadDetailPage">重新加载</el-button></div>
|
||||
<div v-else class="warehouse-card warehouse-form-section"><h3>订单概览</h3><div v-if="props.service === 'labeling'" class="warehouse-detail-grid"><div><span>操作仓</span><strong>{{ warehouseDisplay(selectedRelabelDetail?.application.overseasWarehouseId) }}</strong></div><div><span>客户参考号</span><strong>{{ selectedRelabelDetail?.application.customerOrderNo || '—' }}</strong></div><div><span>SKU 数</span><strong>{{ selectedRelabelDetail?.application.itemCount ?? '—' }}</strong></div><div><span>总箱数</span><strong>{{ selectedRelabelDetail?.application.totalPackageCount ?? '—' }}</strong></div><div><span>贴标截止时间</span><strong>{{ formatDateTime(selectedRelabelDetail?.application.labelDeadline) }}</strong></div><div><span>仓库联系人</span><strong>{{ selectedRelabelDetail?.application.contactName || '—' }}</strong></div><div><span>联系电话</span><strong>{{ selectedRelabelDetail?.application.contactPhone || '—' }}</strong></div><div><span>创建时间</span><strong>{{ formatDateTime(selectedRelabelDetail?.application.createdAt) }}</strong></div></div><div v-else class="warehouse-detail-grid"><div v-for="(column, index) in config.columns.slice(1)" :key="column"><span>{{ column }}</span><strong>{{ selectedDetail?.[String(index)] || '—' }}</strong></div></div></div>
|
||||
<el-alert v-else-if="props.service === 'labeling' && selectedRelabelDetail?.application.status === 'REJECTED'" class="warehouse-rejected-alert" type="error" :closable="false" show-icon title="贴标单审核已驳回" :description="selectedRelabelDetail.application.reviewRemark || '平台未填写驳回备注'" />
|
||||
<div v-else class="warehouse-card warehouse-form-section"><h3>订单概览</h3><div v-if="props.service === 'labeling'" class="warehouse-detail-grid"><div><span>操作仓</span><strong>{{ warehouseDisplay(selectedRelabelDetail?.application.overseasWarehouseId) }}</strong></div><div><span>客户参考号</span><strong>{{ selectedRelabelDetail?.application.customerOrderNo || '—' }}</strong></div><div><span>SKU 数</span><strong>{{ selectedRelabelDetail?.application.itemCount ?? '—' }}</strong></div><div><span>总箱数</span><strong>{{ selectedRelabelDetail?.application.totalPackageCount ?? '—' }}</strong></div><div><span>贴标截止时间</span><strong>{{ formatDateTime(selectedRelabelDetail?.application.labelDeadline) }}</strong></div><div><span>仓库联系人</span><strong>{{ selectedRelabelDetail?.application.contactName || '—' }}</strong></div><div><span>联系电话</span><strong>{{ selectedRelabelDetail?.application.contactPhone || '—' }}</strong></div><div><span>创建时间</span><strong>{{ formatDateTime(selectedRelabelDetail?.application.createdAt) }}</strong></div></div><div v-else-if="props.service === 'transfer'" class="warehouse-detail-grid warehouse-transfer-overview-grid"><div><span>状态</span><el-tag :type="transferStatusTagType(selectedDetail?.status || '')" effect="light">{{ statusLabel(selectedDetail?.status || '') }}</el-tag></div><div><span>目的去向</span><strong>{{ destinationTypeLabel(selectedDetail?.destinationType, selectedDetail?.fbaWarehouseCode) }}</strong></div><div v-if="selectedDetail?.destinationType !== 'PRIVATE'"><span>FBA 目的仓</span><strong>{{ selectedDetail?.destinationWarehouseName || warehouseDisplay(selectedDetail?.destinationWarehouseCode || selectedDetail?.fbaWarehouseCode) }}</strong></div><div v-if="selectedDetail?.destinationType !== 'PRIVATE'"><span>FBA 货件号</span><strong>{{ selectedDetail?.fbaShipmentNo || '—' }}</strong></div><div v-if="selectedDetail?.destinationType === 'PRIVATE'" class="warehouse-overview-wide"><span>私人仓地址</span><strong>{{ selectedDetail?.privateDestinationAddress || '—' }}</strong></div><div v-if="selectedDetail?.destinationType === 'PRIVATE'"><span>邮编</span><strong>{{ selectedDetail?.privateDestinationPostalCode || '—' }}</strong></div><div v-if="selectedDetail?.destinationType === 'PRIVATE'"><span>私人仓联系人</span><strong>{{ selectedDetail?.privateDestinationContactName || '—' }}</strong></div><div v-if="selectedDetail?.destinationType === 'PRIVATE'"><span>联系电话</span><strong>{{ selectedDetail?.privateDestinationContactPhone || '—' }}</strong></div><div><span>海外仓</span><strong>{{ warehouseDisplay(selectedDetail?.operatingWarehouseId) }}</strong></div><div><span>箱数</span><strong>{{ selectedDetail?.packageCount ?? '—' }}</strong></div><div><span>入仓方式</span><strong>{{ deliveryMethodLabel(selectedDetail?.deliveryMethod || '') }}</strong></div><div><span>期望发货时间</span><strong>{{ formatDateTime(selectedDetail?.estimatedShipAt) }}</strong></div><div><span>实际发货时间</span><strong>{{ formatDateTime(selectedDetail?.actualShipAt) }}</strong></div><div><span>是否换标</span><strong>{{ selectedDetail?.needRelabel ? '是' : '否' }}</strong></div><div><span>换标状态</span><el-tag :type="transferRelabelStatusTagType(transferRelabelStatus(selectedDetail || {}).code)" effect="light">{{ transferRelabelStatus(selectedDetail || {}).label }}</el-tag></div><div><span>换标单号</span><strong>{{ selectedDetail?.relabelNo || '—' }}</strong></div><div><span>创建时间</span><strong>{{ formatDateTime(selectedDetail?.createdAt) }}</strong></div><div><span>转运单号</span><strong>{{ selectedDetail?.transferNo || '—' }}</strong></div></div><div v-else class="warehouse-detail-grid"><div v-for="(column, index) in config.columns.slice(1)" :key="column"><span>{{ column }}</span><strong>{{ selectedDetail?.[String(index)] || '—' }}</strong></div></div></div>
|
||||
<div v-if="props.service === 'transfer'" class="warehouse-card warehouse-form-section warehouse-transfer-measurement-card">
|
||||
<div class="warehouse-section-heading"><h3>PDA 打托实测数据</h3><span class="warehouse-hint">PDA 作业完成即显示入库完成,客户可确认仓库实测数据</span></div>
|
||||
<div v-if="selectedDetail?.pallets?.length" class="warehouse-table-scroll"><table><thead><tr><th>托盘号</th><th>库位</th><th>箱数</th><th>长 × 宽 × 高(cm)</th><th>毛重(kg)</th><th>体积(m³)</th><th>状态</th><th>完成时间</th></tr></thead><tbody><tr v-for="pallet in selectedDetail.pallets" :key="String(pallet.id || pallet.sequenceNo)"><td>{{ pallet.palletNo || `第${pallet.sequenceNo || '—'}托` }}</td><td>{{ pallet.warehouseLocationCode || '—' }}</td><td>{{ pallet.cartonCount ?? '—' }}</td><td>{{ [pallet.lengthCm, pallet.widthCm, pallet.heightCm].every((value) => value != null) ? `${pallet.lengthCm} × ${pallet.widthCm} × ${pallet.heightCm}` : '—' }}</td><td>{{ pallet.grossWeightKg ?? '—' }}</td><td>{{ pallet.volumeCbm ?? '—' }}</td><td>{{ statusLabel(pallet.status || '') }}</td><td>{{ formatDateTime(pallet.completedAt) }}</td></tr></tbody></table></div>
|
||||
<div v-else class="warehouse-empty">暂无 PDA 打托测量数据</div>
|
||||
<div class="warehouse-evidence warehouse-measurement-evidence"><span>测量打托现场照片</span><div v-if="selectedDetail?.measurementPhotos?.length" class="warehouse-photo-grid warehouse-measurement-photo-grid"><div v-for="(photo, index) in selectedDetail.measurementPhotos" :key="photo.fileId" class="warehouse-photo-item warehouse-measurement-photo-item" :title="photo.fileName || `测量照片 ${index + 1}`"><el-image v-if="measurementPhotoUrls[photo.fileId]" :src="measurementPhotoUrls[photo.fileId]" :preview-src-list="measurementPhotoPreviewUrls" :initial-index="measurementPhotoPreviewUrls.indexOf(measurementPhotoUrls[photo.fileId])" :alt="photo.fileName || `测量照片 ${index + 1}`" fit="cover" preview-teleported /><div v-else-if="measurementPhotoLoading[photo.fileId]" class="warehouse-photo-placeholder">照片 {{ index + 1 }} 加载中…</div><button v-else class="warehouse-photo-placeholder warehouse-photo-retry" type="button" @click="loadMeasurementPhoto(photo.fileId)">{{ measurementPhotoErrors[photo.fileId] ? `照片 ${index + 1} 加载失败,点击重试` : `加载照片 ${index + 1}` }}</button></div></div><strong v-else>仓库尚未上传测量打托照片</strong></div>
|
||||
</div>
|
||||
<div v-if="props.service === 'transfer'" class="warehouse-card warehouse-form-section warehouse-transfer-cargo-card">
|
||||
<div class="warehouse-section-heading"><h3>货物明细</h3><el-button v-if="selectedDetail?.status !== 'DRAFT'" native-type="button" type="primary" @click="printTransferCartons(String(selectedDetail?.transferNo || selectedDetail?.['0'] || ''))"><Printer :size="16" />打印箱唛</el-button></div>
|
||||
<div class="warehouse-table-scroll"><table class="warehouse-edit-table"><thead><tr><th>商品 SKU</th><th>中文品名</th><th>预计箱数</th><th>客户箱唛号</th></tr></thead><tbody><tr v-for="(item, index) in selectedDetail?.items || []" :key="`${item.skuCode}-${index}`"><td>{{ item.skuCode || '—' }}</td><td>{{ item.productNameZh || '—' }}</td><td>{{ item.packageCount ?? '—' }}</td><td>{{ item.shippingMark || '—' }}</td></tr><tr v-if="!selectedDetail?.items?.length"><td colspan="4"><div class="warehouse-empty">暂无货物明细</div></td></tr></tbody></table></div>
|
||||
@@ -1364,12 +1558,11 @@ function selectRelabelInventory(inboundOrderId: string) {
|
||||
<h3>换标箱级去向</h3>
|
||||
<div class="warehouse-table-scroll"><table><thead><tr><th>换标类型</th><th>客户箱唛号</th><th>旧 SKU</th><th>新 SKU</th><th>新 FBA 箱唛</th><th>实际 FBA 目的仓</th><th>数量</th><th>备注</th></tr></thead><tbody><tr v-for="(binding, index) in selectedDetail?.relabelBindings || []" :key="`${binding.customerCartonMark}-${index}`"><td>{{ binding.operationType === 'CARTON_AND_SKU' ? 'SKU 和箱唛都换' : binding.operationType === 'SKU_LABEL' ? '只换 SKU' : '只换箱唛' }}</td><td>{{ binding.customerCartonMark || '—' }}</td><td>{{ binding.originalSku || '—' }}</td><td>{{ binding.newSku || '—' }}</td><td>{{ binding.fbaCartonMark || '—' }}</td><td>{{ binding.operationType === 'SKU_LABEL' ? '沿用原箱去向' : warehouseDisplay(binding.destinationWarehouseCode) }}</td><td>{{ binding.quantity ?? '—' }}</td><td>{{ binding.remark || '—' }}</td></tr><tr v-if="!selectedDetail?.relabelBindings?.length"><td colspan="8"><div class="warehouse-empty">暂无换标绑定明细</div></td></tr></tbody></table></div>
|
||||
</div>
|
||||
<div v-if="props.service === 'transfer'" class="warehouse-card warehouse-form-section warehouse-transfer-times"><h3>发货时间</h3><div class="warehouse-detail-grid"><div><span>预计发货时间</span><strong>{{ formatDateTime(selectedDetail?.estimatedShipAt) }}</strong></div><div><span>实际发货时间</span><strong>{{ formatDateTime(selectedDetail?.actualShipAt) }}</strong></div></div><p>实际发货时间由仓库出库或 PDA 完成后回填,客户不能手动修改。</p></div>
|
||||
<div v-if="props.service === 'labeling' && canManageRelabelFiles" class="warehouse-card warehouse-form-section">
|
||||
<div class="warehouse-section-heading"><h3>换标完成与出库</h3></div>
|
||||
<div class="warehouse-detail-grid warehouse-fulfillment-grid">
|
||||
<div><span>派送方式</span><strong>{{ deliveryMethodLabel(selectedRelabelDetail?.application.deliveryMethod || '') || '无需出库' }}</strong></div>
|
||||
<div><span>履约状态</span><strong>{{ statusLabel(selectedRelabelDetail?.application.fulfillmentStatus || '') }}</strong></div>
|
||||
<div><span>履约状态</span><el-tag :type="relabelFulfillmentTagType(selectedRelabelDetail?.application.fulfillmentStatus)" effect="light">{{ relabelFulfillmentLabel(selectedRelabelDetail?.application.fulfillmentStatus) }}</el-tag></div>
|
||||
<div><span>客户确认时间</span><strong>{{ formatDateTime(selectedRelabelDetail?.application.customerConfirmedAt) }}</strong></div>
|
||||
</div>
|
||||
<div class="warehouse-evidence"><span>仓库完工照片</span><div v-if="selectedRelabelDetail?.completionEvidence?.photoFileIds?.length" class="warehouse-photo-grid"><div v-for="(fileId, index) in selectedRelabelDetail.completionEvidence.photoFileIds" :key="fileId" class="warehouse-photo-item"><el-image v-if="completionPhotoUrls[fileId]" :src="completionPhotoUrls[fileId]" :preview-src-list="completionPhotoPreviewUrls" :initial-index="completionPhotoPreviewUrls.indexOf(completionPhotoUrls[fileId])" fit="cover" preview-teleported /><div v-else-if="completionPhotoLoading[fileId]" class="warehouse-photo-placeholder">照片 {{ index + 1 }} 加载中…</div><button v-else class="warehouse-photo-placeholder warehouse-photo-retry" type="button" @click="loadCompletionPhoto(fileId)">{{ completionPhotoErrors[fileId] ? `照片 ${index + 1} 加载失败,点击重试` : `加载照片 ${index + 1}` }}</button></div></div><strong v-else>仓库尚未上传完工照片</strong></div>
|
||||
@@ -1383,12 +1576,6 @@ function selectRelabelInventory(inboundOrderId: string) {
|
||||
<tr v-if="!selectedRelabelDetail?.items?.length"><td colspan="5"><div class="warehouse-empty">暂无贴标明细</div></td></tr>
|
||||
</tbody></table></div>
|
||||
</div>
|
||||
<div v-if="props.service === 'transfer'" class="warehouse-card warehouse-form-section">
|
||||
<div class="warehouse-section-heading"><h3>PDA 打托实测数据</h3><span class="warehouse-hint">PDA 作业完成即显示入库完成,客户可确认仓库实测数据</span></div>
|
||||
<div v-if="selectedDetail?.pallets?.length" class="warehouse-table-scroll"><table><thead><tr><th>托盘号</th><th>库位</th><th>箱数</th><th>长 × 宽 × 高(cm)</th><th>毛重(kg)</th><th>体积(m³)</th><th>状态</th><th>完成时间</th></tr></thead><tbody><tr v-for="pallet in selectedDetail.pallets" :key="String(pallet.id || pallet.sequenceNo)"><td>{{ pallet.palletNo || `第${pallet.sequenceNo || '—'}托` }}</td><td>{{ pallet.warehouseLocationCode || '—' }}</td><td>{{ pallet.cartonCount ?? '—' }}</td><td>{{ [pallet.lengthCm, pallet.widthCm, pallet.heightCm].every((value) => value != null) ? `${pallet.lengthCm} × ${pallet.widthCm} × ${pallet.heightCm}` : '—' }}</td><td>{{ pallet.grossWeightKg ?? '—' }}</td><td>{{ pallet.volumeCbm ?? '—' }}</td><td>{{ statusLabel(pallet.status || '') }}</td><td>{{ formatDateTime(pallet.completedAt) }}</td></tr></tbody></table></div>
|
||||
<div v-else class="warehouse-empty">暂无 PDA 打托测量数据</div>
|
||||
<div class="warehouse-evidence warehouse-measurement-evidence"><span>测量打托现场照片</span><div v-if="selectedDetail?.measurementPhotos?.length" class="warehouse-photo-grid"><div v-for="(photo, index) in selectedDetail.measurementPhotos" :key="photo.fileId" class="warehouse-photo-item" :title="photo.fileName || `测量照片 ${index + 1}`"><el-image v-if="measurementPhotoUrls[photo.fileId]" :src="measurementPhotoUrls[photo.fileId]" :preview-src-list="measurementPhotoPreviewUrls" :initial-index="measurementPhotoPreviewUrls.indexOf(measurementPhotoUrls[photo.fileId])" :alt="photo.fileName || `测量照片 ${index + 1}`" fit="cover" preview-teleported /><div v-else-if="measurementPhotoLoading[photo.fileId]" class="warehouse-photo-placeholder">照片 {{ index + 1 }} 加载中…</div><button v-else class="warehouse-photo-placeholder warehouse-photo-retry" type="button" @click="loadMeasurementPhoto(photo.fileId)">{{ measurementPhotoErrors[photo.fileId] ? `照片 ${index + 1} 加载失败,点击重试` : `加载照片 ${index + 1}` }}</button></div></div><strong v-else>仓库尚未上传测量打托照片</strong></div>
|
||||
</div>
|
||||
<div v-if="props.service === 'labeling'" class="warehouse-card warehouse-form-section">
|
||||
<h3>换标任务明细</h3>
|
||||
<div class="warehouse-table-scroll"><table class="warehouse-relabel-detail-table"><thead><tr><th>换标模式</th><th>我方箱唛</th><th>新 FBA 箱唛</th><th>FBA 目的仓</th><th>任务箱数</th><th>商品 SKU</th><th>SKU 换标明细</th><th>FBA 货件编号</th><th>贴标要求</th><th>备注</th></tr></thead><tbody>
|
||||
@@ -1419,12 +1606,21 @@ function selectRelabelInventory(inboundOrderId: string) {
|
||||
.warehouse-service-page--return-relabel .warehouse-table-scroll:has(> .warehouse-return-relabel-table)~.warehouse-table-scroll{display:none}
|
||||
.warehouse-native-file{min-height:32px;padding:5px 8px;border:1px solid #dcdfe6;border-radius:4px;background:#fff;color:#606266}.warehouse-fulfillment-grid{margin-bottom:18px}.warehouse-evidence{display:flex;align-items:flex-start;gap:16px;padding:14px;border:1px solid #e7ebf1;border-radius:4px;background:#f8fafc}.warehouse-evidence>span{padding-top:6px;color:#71809a;font-size:12px;white-space:nowrap}.warehouse-evidence>strong{padding-top:6px;color:#8a95a5;font-size:12px;font-weight:400}.warehouse-photo-grid{display:grid;grid-template-columns:repeat(4,140px);gap:12px}.warehouse-photo-item{width:140px;height:105px;overflow:hidden;border:1px solid #dce3ed;border-radius:5px;background:#fff}.warehouse-photo-item :deep(.el-image){display:block;width:100%;height:100%;cursor:zoom-in}.warehouse-photo-placeholder{display:flex;width:100%;height:100%;align-items:center;justify-content:center;padding:10px;border:0;background:#f4f7fb;color:#8792a2;font-size:12px;text-align:center}.warehouse-photo-retry{cursor:pointer}.warehouse-completion-remark{margin:10px 0 0;color:#596579;font-size:12px}
|
||||
.warehouse-measurement-evidence{margin-top:16px}
|
||||
.warehouse-measurement-photo-grid{flex:1;grid-template-columns:repeat(auto-fill,minmax(240px,1fr))}
|
||||
.warehouse-measurement-photo-item{width:100%;height:180px}
|
||||
.warehouse-template-actions{flex-wrap:wrap;justify-content:flex-end}.warehouse-import-preview{margin:0 0 16px;padding:14px;border:1px solid #cfe0f5;border-radius:5px;background:#f8fbff}.warehouse-import-preview .warehouse-section-heading{margin-bottom:10px}.warehouse-import-preview .warehouse-section-heading>div{display:flex;align-items:center;gap:10px}.warehouse-import-preview p{margin:0;color:#7d8797;font-size:12px}.warehouse-binding-preview-table{min-width:980px;background:#fff}
|
||||
.warehouse-transfer-relabel .warehouse-labeling-heading>div:first-child{min-width:0}.warehouse-transfer-relabel .warehouse-labeling-heading h3{margin-bottom:6px}.warehouse-transfer-relabel .warehouse-labeling-heading p{margin:0 0 16px;color:#7d8797;font-size:12px}.warehouse-transfer-relabel-fields{margin-bottom:18px}.warehouse-transfer-file-groups{display:grid;grid-template-columns:minmax(0,1.15fr) minmax(360px,.85fr);align-items:stretch;gap:14px;margin-bottom:14px}.warehouse-transfer-total-files,.warehouse-transfer-template-group{height:100%;margin:0}.warehouse-transfer-mark-table{min-width:1420px}.warehouse-transfer-mark-table td{vertical-align:top}.warehouse-transfer-mark-table :deep(.el-textarea){min-width:220px}.warehouse-transfer-mark-table :deep(.el-input-number){width:150px}.warehouse-count-error{color:#d64545}
|
||||
.warehouse-transfer-form{display:grid;grid-template-columns:minmax(0,1fr);gap:12px}.warehouse-transfer-form--relabel{grid-template-columns:minmax(0,1fr) minmax(0,1.35fr);align-items:start}.warehouse-transfer-form--relabel .warehouse-transfer-main-card{grid-column:1;grid-row:1}.warehouse-transfer-form--relabel .warehouse-transfer-cargo-card{grid-column:1;grid-row:2}.warehouse-transfer-form--relabel .warehouse-transfer-relabel{grid-column:2;grid-row:1 / span 2;margin-bottom:0}.warehouse-transfer-form--relabel .warehouse-transfer-submit-bar{grid-column:1 / -1}.warehouse-transfer-form :deep(.el-input__wrapper),.warehouse-transfer-form :deep(.el-textarea__inner),.warehouse-transfer-form :deep(.el-select__wrapper),.warehouse-transfer-form :deep(.el-date-editor.el-input__wrapper){min-height:32px}.warehouse-transfer-form .warehouse-form-section{padding:16px}.warehouse-transfer-form .warehouse-form-section h3{margin-bottom:12px;padding-bottom:9px;font-size:14px}.warehouse-transfer-form .warehouse-form-grid{gap:12px 16px}.warehouse-transfer-form .warehouse-choice-field{min-height:34px;padding:8px 10px;grid-template-columns:118px minmax(0,1fr);gap:10px}.warehouse-transfer-form .warehouse-choice-field>span{font-size:12px}.warehouse-transfer-form .warehouse-receiving-card{gap:12px;padding:10px 12px}.warehouse-transfer-form .warehouse-section-heading{margin-bottom:8px}.warehouse-transfer-form .warehouse-edit-table{font-size:11px}.warehouse-transfer-form .warehouse-edit-table th{height:36px;padding:0 9px}.warehouse-transfer-form .warehouse-edit-table td{height:42px;padding:6px 7px}.warehouse-transfer-form .warehouse-transfer-mark-table :deep(.el-textarea){min-width:150px}.warehouse-transfer-form .warehouse-transfer-mark-table :deep(.el-input-number){width:112px}.warehouse-transfer-form .warehouse-hint{margin-top:5px;font-size:11px}.warehouse-transfer-form .warehouse-labeling-mode{gap:9px;margin-bottom:10px;padding:9px 10px}.warehouse-transfer-form .warehouse-labeling-mode>span{font-size:12px}.warehouse-transfer-form .warehouse-labeling-mode :deep(.el-radio-button__inner){padding:6px 9px}.warehouse-transfer-form .warehouse-template-actions{display:flex;flex-wrap:wrap;justify-content:flex-end;gap:5px;padding-bottom:8px}.warehouse-transfer-form .warehouse-template-actions :deep(.el-button){padding:6px 8px;font-size:11px}.warehouse-transfer-form .warehouse-submit-bar{padding:4px 0 18px}.warehouse-transfer-form .warehouse-submit-bar :deep(.el-button){min-height:32px;padding:7px 14px}.warehouse-transfer-form .warehouse-transfer-relabel{overflow:hidden}.warehouse-transfer-form .warehouse-transfer-relabel-fields{margin-bottom:10px}.warehouse-step-heading{align-items:flex-start}.warehouse-step-heading h3{display:flex;align-items:center;gap:9px}.warehouse-step-number{display:inline-grid;flex:0 0 auto;width:24px;height:24px;border-radius:50%;background:#3478c8;color:#fff;font-size:12px;place-items:center}.warehouse-heading-actions{display:flex;flex-wrap:wrap;justify-content:flex-end;gap:8px}.warehouse-transfer-template-group{margin:0 0 14px;padding:12px 14px;border:1px solid #dde5f0;border-radius:7px;background:#fbfcfe}.warehouse-binding-heading{margin-top:14px;padding:12px 14px;border:1px solid #dde5f0;border-radius:7px;background:#f8faff}.warehouse-binding-heading strong{display:flex;align-items:center;gap:8px;color:#34445c;font-size:13px}
|
||||
.warehouse-transfer-form .warehouse-choice-field--relabel{grid-template-columns:150px auto minmax(240px,1fr)}.warehouse-choice-field--relabel :deep(.el-radio-group){display:flex;flex-wrap:nowrap}.warehouse-choice-field--relabel>small{min-width:0;margin:0;line-height:1.5}
|
||||
@media(max-width:1250px){.warehouse-transfer-form--relabel{grid-template-columns:minmax(0,1fr) minmax(0,1fr)}.warehouse-transfer-form .warehouse-template-actions{justify-content:flex-start}.warehouse-transfer-form .warehouse-labeling-heading{align-items:flex-start;flex-direction:column}}
|
||||
@media(max-width:1180px){.warehouse-transfer-file-groups{grid-template-columns:minmax(0,1fr)}}
|
||||
.warehouse-transfer-form .pickup-address-field{grid-column:span 1}
|
||||
.warehouse-transfer-main-card .warehouse-form-grid{grid-template-columns:repeat(2,minmax(280px,640px))}.warehouse-transfer-cargo-card .warehouse-section-heading,.warehouse-transfer-cargo-card .warehouse-table-scroll{width:100%;max-width:1296px}
|
||||
.warehouse-service-page--detail .warehouse-transfer-cargo-card .warehouse-section-heading,.warehouse-service-page--detail .warehouse-transfer-cargo-card .warehouse-table-scroll{max-width:none}
|
||||
.warehouse-service-page--detail .warehouse-transfer-overview-grid{grid-template-columns:repeat(4,minmax(180px,1fr));gap:6px 28px;padding:10px 16px 12px}.warehouse-service-page--detail .warehouse-transfer-overview-grid>div{min-height:26px;padding:2px 0;border:0}.warehouse-service-page--detail .warehouse-transfer-overview-grid span{flex:0 0 auto;min-width:76px}.warehouse-service-page--detail .warehouse-transfer-overview-grid :deep(.el-tag){height:24px}
|
||||
.warehouse-service-page--detail>.warehouse-detail-header+.warehouse-card .warehouse-detail-grid{grid-template-columns:repeat(4,minmax(180px,1fr));gap:6px 28px;padding:10px 16px 12px}.warehouse-service-page--detail>.warehouse-detail-header+.warehouse-card .warehouse-detail-grid>div{min-height:26px;padding:2px 0;border:0}.warehouse-service-page--detail>.warehouse-detail-header+.warehouse-card .warehouse-detail-grid span{flex:0 0 auto;min-width:76px}
|
||||
.warehouse-service-page--detail .warehouse-transfer-overview-grid>.warehouse-overview-wide{grid-column:span 2}
|
||||
@media(max-width:1100px){.warehouse-service-page--detail .warehouse-transfer-overview-grid{grid-template-columns:repeat(2,minmax(180px,1fr))}}@media(max-width:720px){.warehouse-service-page--detail .warehouse-transfer-overview-grid{grid-template-columns:1fr;gap:4px;padding:10px 14px}}
|
||||
/* Keep the count and controls visible together on narrow list pages. */
|
||||
.warehouse-pagination { justify-content: flex-end; }
|
||||
.warehouse-pagination-total { color: #526176; font-size: 14px; }
|
||||
@@ -1434,5 +1630,8 @@ function selectRelabelInventory(inboundOrderId: string) {
|
||||
.warehouse-list-actions { display: flex; align-items: center; gap: 8px; white-space: nowrap; }
|
||||
@media (max-width: 720px) {
|
||||
.warehouse-pagination :deep(.el-pagination) { width: 100%; justify-content: flex-end; }
|
||||
.warehouse-transfer-main-card .warehouse-form-grid { grid-template-columns: minmax(0, 1fr); }
|
||||
.warehouse-transfer-cargo-card .warehouse-section-heading, .warehouse-transfer-cargo-card .warehouse-table-scroll { max-width: none; }
|
||||
.warehouse-transfer-form .warehouse-choice-field--relabel { grid-template-columns: 1fr; align-items: start; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -208,10 +208,11 @@ test('贴标详情展示箱唛、SKU 子明细和独立FBA目的仓', () => {
|
||||
assert.match(view, /item\.skuItems\?\.length/)
|
||||
})
|
||||
|
||||
test('客户在换标列表确认完工且照片使用二进制下载', () => {
|
||||
test('客户可在换标列表或详情确认完工且照片使用二进制下载', () => {
|
||||
assert.match(view, /item\.__status === 'CUSTOMER_CONFIRM_PENDING'/)
|
||||
assert.match(view, /confirmRelabelFromList\(item\['0'\]\)/)
|
||||
assert.doesNotMatch(view, /selectedRelabelDetail\?\.application\.status === 'CUSTOMER_CONFIRM_PENDING'/)
|
||||
assert.match(view, /selectedRelabelDetail\?\.application\.status === 'CUSTOMER_CONFIRM_PENDING'/)
|
||||
assert.match(view, /@click="confirmSelectedRelabel">确认换标完成/)
|
||||
assert.match(api, /overseasBlobRequest/)
|
||||
})
|
||||
|
||||
@@ -224,6 +225,14 @@ test('客户详情进入页面后直接回显完工照片并支持大图预览',
|
||||
})
|
||||
|
||||
test('进入复核后客户订单回显换标完成状态', () => {
|
||||
assert.match(view, /value === 'CUSTOMER_CONFIRM_PENDING'\) return '待客户确认'/)
|
||||
assert.match(view, /\['READY_FOR_OUTBOUND', 'COMPLETED'\]\.includes\(value\) \? '换标完成'/)
|
||||
assert.match(view, /CUSTOMER_CONFIRM_PENDING: '待客户确认'/)
|
||||
assert.match(view, /READY_FOR_OUTBOUND: '换标完成'/)
|
||||
})
|
||||
|
||||
test('审核驳回后展示原因并允许替换资料后重新提交', () => {
|
||||
assert.match(api, /export function resubmitRelabel\(/)
|
||||
assert.match(view, /贴标单审核已驳回/)
|
||||
assert.match(view, /application\.reviewRemark/)
|
||||
assert.match(view, /修改后重新提交/)
|
||||
assert.match(view, /await resubmitRelabel\(/)
|
||||
})
|
||||
|
||||
@@ -7,6 +7,6 @@ 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 = \[\].*listDiscardCandidates\(\).*candidate\.overseasWarehouseId.*form\.warehouse/s)
|
||||
assert.match(view, /async function selectDiscardWarehouse\(\).*selectedDiscardOrderId\.value = ''.*cargoRows\.value = \[\].*listRelabelInventoryCandidates\(form\.warehouse\).*grouped/s)
|
||||
assert.match(view, /\|\| !primaryWarehouse \|\| invalidCargo/)
|
||||
})
|
||||
|
||||
@@ -25,9 +25,14 @@ test('仓储转运支持无目的地、FBA 仓和私人仓并显式选择是否
|
||||
assert.match(view, /value="FBA">FBA 仓/)
|
||||
assert.match(view, /value="PRIVATE">私人仓/)
|
||||
assert.match(view, /transferDestinationType === 'PRIVATE'.*私人仓地址.*邮编.*私人仓联系人.*联系电话/s)
|
||||
assert.match(view, />换标服务</)
|
||||
assert.match(view, /:value="false">不需要/)
|
||||
assert.match(view, /:value="true">需要/)
|
||||
assert.match(view, />是否贴标</)
|
||||
assert.match(view, /:value="false">不需要贴标/)
|
||||
assert.match(view, /:value="true">需要贴标/)
|
||||
assert.match(view, /FBA 仓和私人仓均可选择是否贴标/)
|
||||
assert.doesNotMatch(view, /v-model="form\.relabel" disabled/)
|
||||
assert.doesNotMatch(view, /value === 'FBA'\) form\.relabel = true/)
|
||||
assert.doesNotMatch(view, /value === 'PRIVATE'.*form\.relabel = false/)
|
||||
assert.match(view, /v-if="props\.service === 'transfer' && form\.relabel" class="warehouse-card warehouse-form-section warehouse-transfer-relabel"/)
|
||||
assert.match(api, /fbaWarehouseCode\?: string/)
|
||||
assert.match(api, /privateDestinationAddress\?: string; privateDestinationPostalCode\?: string/)
|
||||
assert.match(api, /fbaShipmentNo\?: string/)
|
||||
@@ -35,6 +40,17 @@ test('仓储转运支持无目的地、FBA 仓和私人仓并显式选择是否
|
||||
assert.doesNotMatch(view, /relabelChargeAmount:/)
|
||||
})
|
||||
|
||||
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 \}\)/)
|
||||
})
|
||||
|
||||
test('仓储转运换标绑定明细自动同步只读的旧 SKU', () => {
|
||||
assert.match(view, /watch\(\(\) => cargoRows\.value\.map\(\(row\) => row\.sku\)/)
|
||||
assert.match(view, /if \(sourceSku\) row\.sku = sourceSku/)
|
||||
assert.match(view, /transferRelabelRows\.value\.push\(\{ \.\.\.createCargoRow\(\), sku: source\?\.sku \|\| '' \}\)/)
|
||||
})
|
||||
|
||||
test('仓储转运换标整票只允许一个目的仓', () => {
|
||||
assert.match(view, /props\.service === 'transfer' && transferDestinationType === 'FBA'[^\n]+FBA 目的仓/)
|
||||
assert.match(view, /FBA 货件编号<\/span><el-input v-model="form\.fbaShipmentNo" placeholder="选填,可后续补充"/)
|
||||
@@ -115,6 +131,8 @@ test('仓储转运需要换标时上传总文件并挂到关联换标单', () =>
|
||||
assert.match(view, /uploadRelabelFile\(relabelNo, 'CARTON_TOTAL', cartonTotalFile\.value\)/)
|
||||
assert.match(view, /uploadRelabelFile\(relabelNo, 'SKU_TOTAL', skuTotalFile\.value\)/)
|
||||
assert.match(view, /转运单已提交,附件待补传/)
|
||||
assert.match(transferRelabelSection, /native-type="button"[^>]+chooseCreateTotalFile\('CARTON_TOTAL'\)/)
|
||||
assert.match(transferRelabelSection, /native-type="button"[^>]+chooseCreateTotalFile\('SKU_TOTAL'\)/)
|
||||
})
|
||||
|
||||
test('仓储转运换标总文件与明细模板在桌面端左右排列', () => {
|
||||
@@ -150,13 +168,17 @@ test('仓储转运列表操作仓使用海外仓接口数据', () => {
|
||||
assert.doesNotMatch(view, /美西洛杉矶仓/)
|
||||
})
|
||||
|
||||
test('仓储转运预计发货时间可选并只读展示实际发货时间', () => {
|
||||
assert.match(view, /预计发货时间/)
|
||||
test('仓储转运期望发货时间可选并在订单概览回填发货时间', () => {
|
||||
assert.doesNotMatch(view, /预计发货时间/)
|
||||
assert.match(view, /期望发货时间/)
|
||||
assert.match(view, /v-model="form\.estimatedShipAt"/)
|
||||
assert.match(view, /v-model="form\.estimatedShipAt"[^>]+placeholder="选填"/)
|
||||
assert.doesNotMatch(view, /!form\.estimatedShipAt && '预计发货时间'/)
|
||||
assert.doesNotMatch(view, /!form\.estimatedShipAt/)
|
||||
assert.match(view, /estimatedShipAt: form\.estimatedShipAt/)
|
||||
assert.match(view, /实际发货时间由仓库出库或 PDA 完成后回填/)
|
||||
assert.match(view, /<span>期望发货时间<\/span><strong>\{\{ formatDateTime\(selectedDetail\?\.estimatedShipAt\) \}\}<\/strong>/)
|
||||
assert.match(view, /<span>实际发货时间<\/span><strong>\{\{ formatDateTime\(selectedDetail\?\.actualShipAt\) \}\}<\/strong>/)
|
||||
assert.doesNotMatch(view, /class="warehouse-card warehouse-form-section warehouse-transfer-times"/)
|
||||
assert.match(view, /estimatedArrivalAt: detail\.estimatedArrivalAt \|\| '', estimatedShipAt: detail\.estimatedShipAt \|\| ''/)
|
||||
assert.match(api, /estimatedShipAt\?: string/)
|
||||
})
|
||||
|
||||
@@ -171,6 +193,78 @@ test('客户仓储转运展示每个实际流转状态的中文名称', () => {
|
||||
assert.match(view, /OUTBOUNDED: '已出库'/)
|
||||
})
|
||||
|
||||
test('仓储转运列表展示换标单号、换标状态并为两类状态着色', () => {
|
||||
const transferConfig = view.match(/transfer:\s*\{[^\n]+/)?.[0] || ''
|
||||
assert.match(transferConfig, /'是否换标', '换标单号', '换标状态', '状态'/)
|
||||
assert.match(api, /relabelNo\?: string; relabelStatus\?: string; relabelFulfillmentStatus\?: string/)
|
||||
assert.match(view, /item\.needRelabel \? '是' : '否', item\.relabelNo, relabelStatus\.label/)
|
||||
assert.match(view, /if \(!item\.relabelNo\) return \{ code: 'WAIT_RELABEL', label: '待换标' \}/)
|
||||
assert.match(view, /READY_FOR_OUTBOUND: '已换标', COMPLETED: '已换标'/)
|
||||
assert.match(view, /#transferRelabelStatus=.*transferRelabelStatusTagType/)
|
||||
assert.match(view, /#transferStatus=.*transferStatusTagType/)
|
||||
assert.doesNotMatch(view, /是 · 待绑定换标单/)
|
||||
})
|
||||
|
||||
test('仓储转运详情订单概览使用详情接口命名字段回填', () => {
|
||||
assert.match(view, /v-else-if="props\.service === 'transfer'" class="warehouse-detail-grid warehouse-transfer-overview-grid"/)
|
||||
assert.doesNotMatch(view, /入库箱数/)
|
||||
assert.match(view, /'转运单号', '箱数', '海外仓'/)
|
||||
for (const field of ['transferNo', 'packageCount', 'operatingWarehouseId', 'deliveryMethod', 'needRelabel', 'relabelNo', 'status', 'createdAt']) {
|
||||
assert.match(view, new RegExp(`selectedDetail\\?\\.${field}`))
|
||||
}
|
||||
assert.match(view, /selectedDetail\?\.destinationWarehouseCode \|\| selectedDetail\?\.fbaWarehouseCode/)
|
||||
assert.match(view, /transferRelabelStatus\(selectedDetail \|\| \{\}\)/)
|
||||
assert.match(view, /transferStatusTagType\(selectedDetail\?\.status \|\| ''\)/)
|
||||
})
|
||||
|
||||
test('仓储转运和贴标申请详情先展示主数据再串行补充仓库名称', () => {
|
||||
const loader = view.match(/async function loadDetailPage\(\) \{[\s\S]*?\n\}/)?.[0] || ''
|
||||
assert.match(loader, /await openDetail\(\{ '0': detailNumber\.value \}\)/)
|
||||
assert.match(loader, /await loadWarehouses\(\)/)
|
||||
assert.ok(loader.indexOf('await openDetail') < loader.indexOf('await loadWarehouses'))
|
||||
assert.match(view, /props\.service === 'labeling' \? await getRelabel\(value\['0'\]\)/)
|
||||
assert.match(view, /: props\.service === 'disposal' \? await getDiscard\(value\['0'\]\) : await getTransfer\(value\['0'\]\)/)
|
||||
})
|
||||
|
||||
test('仓储转运详情展示目的去向和对应目的仓资料并让货物明细铺满', () => {
|
||||
assert.match(view, />目的去向<\/span><strong>\{\{ destinationTypeLabel\(selectedDetail\?\.destinationType, selectedDetail\?\.fbaWarehouseCode\) \}\}/)
|
||||
assert.match(view, />FBA 目的仓<\/span>.*selectedDetail\?\.destinationWarehouseCode \|\| selectedDetail\?\.fbaWarehouseCode/)
|
||||
assert.match(view, />FBA 货件号<\/span><strong>\{\{ selectedDetail\?\.fbaShipmentNo \|\| '—' \}\}/)
|
||||
for (const field of ['privateDestinationAddress', 'privateDestinationPostalCode', 'privateDestinationContactName', 'privateDestinationContactPhone']) {
|
||||
assert.match(view, new RegExp(`selectedDetail\\?\\.${field}`))
|
||||
}
|
||||
assert.match(view, /warehouse-service-page--detail \.warehouse-transfer-cargo-card \.warehouse-section-heading,[^}]+\.warehouse-table-scroll\{max-width:none\}/)
|
||||
})
|
||||
|
||||
test('仓储转运订单概览使用紧凑左右信息布局', () => {
|
||||
assert.match(view, /warehouse-detail-grid warehouse-transfer-overview-grid/)
|
||||
assert.match(view, /warehouse-transfer-overview-grid"><div><span>状态<\/span>.*<span>目的去向<\/span>/)
|
||||
assert.match(view, /class="warehouse-overview-wide"><span>私人仓地址/)
|
||||
assert.match(view, /warehouse-transfer-overview-grid>\.warehouse-overview-wide\{grid-column:span 2\}/)
|
||||
assert.match(view, /warehouse-transfer-overview-grid>div\{min-height:26px;padding:2px 0;border:0\}/)
|
||||
assert.match(view, /warehouse-transfer-overview-grid span\{flex:0 0 auto;min-width:76px\}/)
|
||||
assert.match(view, /warehouse-transfer-overview-grid\{grid-template-columns:1fr;gap:4px/)
|
||||
})
|
||||
|
||||
test('贴标申请详情使用紧凑订单概览并中文展示换标状态', () => {
|
||||
assert.match(view, /WAIT_APPROVAL: '待审核'/)
|
||||
assert.match(view, /WAIT_PROCESSING: '待仓库处理'/)
|
||||
assert.match(view, /relabelFulfillmentLabel\(selectedRelabelDetail\?\.application\.fulfillmentStatus\)/)
|
||||
assert.match(view, />\.warehouse-detail-header\+\.warehouse-card \.warehouse-detail-grid\{grid-template-columns:repeat\(4,minmax\(180px,1fr\)\)/)
|
||||
})
|
||||
|
||||
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.match(overview, /selectedDetail\?\.packageCount \?\? '—'/)
|
||||
assert.match(overview, /warehouseDisplay\(selectedDetail\?\.operatingWarehouseId\)/)
|
||||
assert.match(overview, /selectedDetail\?\.destinationWarehouseName \|\| warehouseDisplay\(selectedDetail\?\.destinationWarehouseCode \|\| selectedDetail\?\.fbaWarehouseCode\)/)
|
||||
assert.match(overview, /deliveryMethodLabel\(selectedDetail\?\.deliveryMethod \|\| ''\)/)
|
||||
assert.match(overview, /statusLabel\(selectedDetail\?\.status \|\| ''\)/)
|
||||
assert.match(overview, /formatDateTime\(selectedDetail\?\.createdAt\)/)
|
||||
})
|
||||
|
||||
test('PDA 打托完成后客户可从列表或详情确认并查看实测数据', () => {
|
||||
assert.match(view, /item\.__status === 'WAIT_CUSTOMER_CONFIRMATION'/)
|
||||
assert.match(view, /confirmTransferFromList\(item\['0'\]\)/)
|
||||
@@ -184,6 +278,20 @@ test('PDA 打托完成后客户可从列表或详情确认并查看实测数据'
|
||||
assert.doesNotMatch(view, /pallet\.items/)
|
||||
})
|
||||
|
||||
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>')
|
||||
assert.ok(overviewIndex >= 0 && overviewIndex < measurementIndex)
|
||||
assert.ok(measurementIndex < cargoIndex)
|
||||
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\}/)
|
||||
})
|
||||
|
||||
test('仓储转运详情自动加载 PDA 测量照片并释放 Blob URL', () => {
|
||||
assert.match(api, /downloadTransferMeasurementPhoto/)
|
||||
assert.match(api, /measurement-photos\/files\/\$\{encodeURIComponent\(fileId\)\}\/download/)
|
||||
@@ -224,6 +332,12 @@ test('仓储转运可在新建页直接提交,也可保存草稿后编辑提
|
||||
assert.doesNotMatch(view, /<h3>箱唛明细<\/h3>/)
|
||||
})
|
||||
|
||||
test('转运贴标单在PDA确认库存前不误报附件上传失败', () => {
|
||||
assert.match(view, /if \(!relabelNo\) \{[\s\S]*PDA 入库确认后将自动生成关联换标单/)
|
||||
assert.match(view, /届时请进入关联换标单详情上传贴标总文件/)
|
||||
assert.doesNotMatch(view, /if \(!relabelNo\) throw new Error\('转运单已提交,但未返回关联换标单号'\)/)
|
||||
})
|
||||
|
||||
test('仓储转运基础货物不强制 SKU 和中文品名,只换箱唛不强制旧 SKU', () => {
|
||||
assert.match(view, /商品 SKU\{\{ props\.service === 'transfer' \? '' : ' \*' \}\}/)
|
||||
assert.match(view, /中文品名\{\{ props\.service === 'transfer' \? '' : ' \*' \}\}/)
|
||||
|
||||
@@ -49,6 +49,8 @@ export const TENANT_PERMISSION_GROUPS: readonly TenantPermissionGroup[] = [
|
||||
label: '头程订单',
|
||||
items: [
|
||||
{ code: 'order:first-leg:list', label: '查看订单', description: '查看本企业头程订单列表和详情' },
|
||||
{ code: 'order:first-leg:customs-list', label: '查看报关资料', description: '查看本企业装柜后的退税报关资料、文件版本和报关行回执' },
|
||||
{ code: 'order:first-leg:pricing-list', label: '查看船期价格', description: '查看本企业可用渠道、参考船期和服务端预估价格' },
|
||||
{ code: 'order:first-leg:create', label: '创建订单', description: '新建并保存订单草稿' },
|
||||
{ code: 'order:first-leg:quick-create', label: '快速下单', description: '使用快速下单页面生成订单草稿,不包含提交订单权限' },
|
||||
{ code: 'order:first-leg:edit', label: '编辑订单', description: '编辑本企业尚未提交的订单草稿' },
|
||||
@@ -134,6 +136,8 @@ export const FRONTEND_TO_MERCHANT_PERMISSION_CODES: Readonly<Record<string, read
|
||||
'merchant:finance:account:view': ['merchant:finance:account:view'],
|
||||
'merchant:finance:recharge:create': ['merchant:finance:recharge:create'],
|
||||
'order:first-leg:list': ['merchant:order:list', 'merchant:order:view'],
|
||||
'order:first-leg:customs-list': ['merchant:order:list', 'merchant:order:view'],
|
||||
'order:first-leg:pricing-list': ['merchant:order:list', 'merchant:order:view'],
|
||||
'order:first-leg:create': ['merchant:order:create'],
|
||||
'order:first-leg:quick-create': ['merchant:order:quick-create', 'merchant:order:create'],
|
||||
'order:first-leg:edit': ['merchant:order:edit'],
|
||||
|
||||
@@ -42,7 +42,7 @@ test('keeps account and notification routes as fixed-header utilities', () => {
|
||||
)
|
||||
})
|
||||
|
||||
test('organises customer-relevant business pages under five task-oriented sidebar entries', () => {
|
||||
test('keeps standalone business pages at the client sidebar root', () => {
|
||||
assert.deepEqual(
|
||||
clientNavigation.filter((node) => !node.hidden).map((node) => ({
|
||||
title: node.title,
|
||||
@@ -54,6 +54,8 @@ test('organises customer-relevant business pages under five task-oriented sideba
|
||||
title: '订单中心',
|
||||
children: ['头程订单', '海外采购', '美国本土卡', '美本土散板'],
|
||||
},
|
||||
{ title: '报关资料', children: undefined },
|
||||
{ title: '船期价格', children: undefined },
|
||||
{
|
||||
title: '海外仓',
|
||||
children: ['贴标申请', '仓储转运', '货物废弃', '退货换标'],
|
||||
@@ -89,6 +91,8 @@ test('flattens visible leaves with unique paths', () => {
|
||||
'/cross-border/procurement',
|
||||
'/cross-border/domestic-trucking',
|
||||
'/cross-border/pallet-shipping',
|
||||
'/first-leg/customs-documents',
|
||||
'/first-leg/sailing-prices',
|
||||
'/overseas-warehouse/labeling',
|
||||
'/overseas-warehouse/storage-transfer',
|
||||
'/overseas-warehouse/cargo-disposal',
|
||||
@@ -107,10 +111,10 @@ test('flattens visible leaves with unique paths', () => {
|
||||
})
|
||||
|
||||
test('filters inaccessible leaves and empty navigation groups', () => {
|
||||
const visible = filterNavigationByPermissions(clientNavigation, ['account:profile:view', 'order:first-leg:list'])
|
||||
const visible = filterNavigationByPermissions(clientNavigation, ['account:profile:view', 'order:first-leg:list', 'order:first-leg:customs-list', 'order:first-leg:pricing-list'])
|
||||
|
||||
assert.deepEqual(getNavigationLeaves(visible).map((item) => item.path), ['/orders/first-leg'])
|
||||
assert.deepEqual(visible.map((item) => item.title), ['订单中心'])
|
||||
assert.deepEqual(getNavigationLeaves(visible).map((item) => item.path), ['/orders/first-leg', '/first-leg/customs-documents', '/first-leg/sailing-prices'])
|
||||
assert.deepEqual(visible.map((item) => item.title), ['订单中心', '报关资料', '船期价格'])
|
||||
assert.equal(getFirstAccessiblePath(['account:profile:view']), '/account/profile')
|
||||
assert.equal(getFirstAccessiblePath([]), '/forbidden')
|
||||
})
|
||||
|
||||
@@ -57,6 +57,8 @@ export const clientNavigation: ClientNavigationNode[] = [
|
||||
requireLeaf('cross-border-pallet-shipping'),
|
||||
],
|
||||
},
|
||||
requireLeaf('first-leg-customs-documents', '报关资料'),
|
||||
requireLeaf('first-leg-sailing-prices', '船期价格'),
|
||||
{
|
||||
key: 'overseas-warehouse-center',
|
||||
title: '海外仓',
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
export function createIdempotencyKey(prefix = 'client') {
|
||||
const nativeUuid = globalThis.crypto?.randomUUID
|
||||
if (typeof nativeUuid === 'function') return nativeUuid.call(globalThis.crypto)
|
||||
|
||||
const bytes = new Uint8Array(16)
|
||||
if (typeof globalThis.crypto?.getRandomValues === 'function') {
|
||||
globalThis.crypto.getRandomValues(bytes)
|
||||
} else {
|
||||
for (let index = 0; index < bytes.length; index += 1) {
|
||||
bytes[index] = Math.floor(Math.random() * 256)
|
||||
}
|
||||
}
|
||||
bytes[6] = (bytes[6] & 0x0f) | 0x40
|
||||
bytes[8] = (bytes[8] & 0x3f) | 0x80
|
||||
const hex = Array.from(bytes, value => value.toString(16).padStart(2, '0')).join('')
|
||||
const uuid = `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`
|
||||
return prefix ? `${prefix}-${uuid}` : uuid
|
||||
}
|
||||
Reference in New Issue
Block a user