优化
This commit is contained in:
@@ -49,7 +49,7 @@ describe('AdminSidebarMenu', () => {
|
||||
expect(groupTitles).toContain('系统管理')
|
||||
expect(groupTitles).not.toContain('财务管理')
|
||||
expect(paths).toContain('/dashboard')
|
||||
expect(paths).toContain('/inbounds')
|
||||
expect(paths).toContain('/orders')
|
||||
expect(paths).toContain('/master-data/express-companies')
|
||||
expect(paths).not.toContain('/orders/first-leg')
|
||||
expect(paths).not.toContain('/first-leg-pricing/customer-rules')
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { downloadRoutes } from '@/modules/downloads/routes'
|
||||
import { firstLegOrderRoutes } from '@/modules/first-leg-orders/routes'
|
||||
import { inboundRoutes } from '@/modules/inbounds/routes'
|
||||
import { unifiedOrderRoutes } from '@/modules/unified-orders/routes'
|
||||
import { fbaPricingRoutes } from '@/modules/fba-pricing/routes'
|
||||
import { iamRoutes } from '@/modules/iam/routes'
|
||||
import { masterDataRoutes } from '@/modules/master-data/routes'
|
||||
@@ -43,6 +44,7 @@ const router = createRouter({
|
||||
...prototypeModuleRoutes,
|
||||
...customerRoutes,
|
||||
...warehouseRoutes,
|
||||
...unifiedOrderRoutes,
|
||||
...firstLegOrderRoutes,
|
||||
...inboundRoutes,
|
||||
...fbaPricingRoutes,
|
||||
|
||||
@@ -29,8 +29,8 @@ describe('adminNavigation', () => {
|
||||
'海外仓阶梯费用',
|
||||
])
|
||||
expect(adminNavigation.find((item) => item.key === 'domestic-warehouse')?.children?.map((item) => item.title)).toEqual([
|
||||
'入库单列表',
|
||||
'入库单测量',
|
||||
'订单列表',
|
||||
'订单测量',
|
||||
'测量数据审核',
|
||||
'库位管理',
|
||||
'托盘管理',
|
||||
@@ -73,7 +73,9 @@ describe('adminNavigation', () => {
|
||||
const permissions = leaves.flatMap((item) => item.permission ? [item.permission] : [])
|
||||
|
||||
expect(paths).toContain('/dashboard')
|
||||
expect(paths).toContain('/orders')
|
||||
expect(paths).not.toContain('/orders/first-leg')
|
||||
expect(paths).not.toContain('/inbounds')
|
||||
expect(paths).not.toContain('/first-leg-pricing/customer-rules')
|
||||
expect(paths).toContain('/downloads')
|
||||
expect(paths).toContain('/prototype/finance/profit-report')
|
||||
@@ -110,7 +112,7 @@ describe('adminNavigation', () => {
|
||||
expect(filtered.map((item) => item.title)).toEqual(['首页数据看板', '国内仓', '供应商中心', '通用'])
|
||||
expect(paths).toEqual([
|
||||
'/dashboard',
|
||||
'/inbounds',
|
||||
'/orders',
|
||||
'/master-data/express-companies',
|
||||
'/master-data/counterparties',
|
||||
'/warehouses',
|
||||
|
||||
@@ -53,8 +53,8 @@ export const adminNavigation: AdminNavigationNode[] = [
|
||||
icon: WarehouseIcon,
|
||||
iconColor: '#52c41a',
|
||||
children: [
|
||||
{ key: 'inbound-list', title: '入库单列表', icon: ClipboardList, path: '/inbounds', permission: 'platform:firstleg:inbound:list' },
|
||||
{ key: 'inbound-dedup', title: '入库单测量', icon: ClipboardList, path: '/prototype/domestic-warehouse/inbound-dedup', hidden: true },
|
||||
{ key: 'order-list', title: '订单列表', icon: ClipboardList, path: '/orders', permission: 'platform:order:list', permissionAnyOf: ['platform:firstleg:inbound:list'] },
|
||||
{ key: 'inbound-dedup', title: '订单测量', icon: ClipboardList, path: '/prototype/domestic-warehouse/inbound-dedup', hidden: true },
|
||||
{ key: 'inbound-measure', title: '测量数据审核', icon: FileCheck2, path: '/prototype/domestic-warehouse/inbound-measure', hidden: true },
|
||||
{ key: 'locations', title: '库位管理', icon: WarehouseIcon, path: '/master-data/locations', permission: 'platform:location:list' },
|
||||
{ key: 'pallets', title: '托盘管理', icon: WarehouseIcon, path: '/master-data/pallets', permission: 'platform:pallet:list' },
|
||||
|
||||
@@ -275,7 +275,7 @@ async function loadCustomerMasterData(customerId: string) {
|
||||
masterDataLoading.value = true
|
||||
try {
|
||||
const [warehouseResult, partnerResult] = await Promise.all([
|
||||
listPlatformWarehouses(customerId, 1, 200),
|
||||
listPlatformWarehouses(1, 200),
|
||||
listPlatformPartners(customerId, { page: 1, size: 200, type: 'CARRIER', status: 'ACTIVE' }),
|
||||
])
|
||||
if (sequence !== masterDataSequence) return false
|
||||
|
||||
@@ -4,7 +4,7 @@ export const firstLegOrderRoutes = [
|
||||
{
|
||||
path: 'orders/first-leg',
|
||||
name: 'first-leg-orders',
|
||||
component: () => import('@/modules/first-leg-orders/pages/FirstLegOrdersView.vue'),
|
||||
meta: { title: '头程订单', description: '创建、受理和跟进客户提交的头程运输订单', permission: 'platform:order:list' },
|
||||
redirect: '/orders',
|
||||
meta: { title: '订单列表', description: '旧头程订单入口已合并到统一订单列表', permission: 'platform:order:list' },
|
||||
},
|
||||
] satisfies RouteRecordRaw[]
|
||||
|
||||
@@ -17,12 +17,12 @@ describe('InboundWorkflowWorkspace', () => {
|
||||
expect(source).toContain('label="基本信息"')
|
||||
expect(source).toContain('label="货物与测量"')
|
||||
expect(source).toContain('label="测量审核"')
|
||||
expect(source).toContain('label="费用快照"')
|
||||
expect(source).toContain('label="托盘与记录"')
|
||||
expect(source).toContain('label="费用信息"')
|
||||
expect(source).toContain('label="状态与其他"')
|
||||
})
|
||||
|
||||
it('uses the established first-leg detail density without nested framed surfaces', () => {
|
||||
expect(source).toContain('padding: 20px 24px')
|
||||
expect(source).toContain('padding: 18px 24px 16px')
|
||||
expect(source).toContain('border-bottom: 1px solid var(--cc-border)')
|
||||
expect(source).not.toContain('border-radius: 10px')
|
||||
expect(source).not.toContain('<el-tag')
|
||||
@@ -54,7 +54,7 @@ describe('InboundWorkflowWorkspace', () => {
|
||||
|
||||
it('keeps pallet and location results read-only in the overview', () => {
|
||||
expect(source).toContain(':pallet-locations="detail.palletLocations"')
|
||||
expect(source).toContain('label="托盘与记录"')
|
||||
expect(source).toContain('label="状态与其他"')
|
||||
expect(source).not.toContain('reserveLocation')
|
||||
expect(source).not.toContain('allocatePallet')
|
||||
})
|
||||
|
||||
@@ -106,7 +106,7 @@ onBeforeUnmount(() => {
|
||||
<header class="workspace-header">
|
||||
<div>
|
||||
<div class="workspace-title-row">
|
||||
<h2>入仓码:{{ title }}</h2>
|
||||
<h2>入库码:{{ title }}</h2>
|
||||
<StatusBadge v-if="detail" :status="statusLabel" />
|
||||
</div>
|
||||
<p>按基本信息、货物测量、审核、费用和托盘库位顺序处理,所有结果以服务端最新状态为准。</p>
|
||||
@@ -134,7 +134,7 @@ onBeforeUnmount(() => {
|
||||
|
||||
<el-tabs v-else v-model="activeTab" class="workflow-tabs">
|
||||
<el-tab-pane label="基本信息" name="overview">
|
||||
<InboundSummaryPanel :summary="detail.summary" />
|
||||
<InboundSummaryPanel :summary="detail.summary" :public-remark="detail.publicRemark" />
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="货物与测量" name="measurement" lazy>
|
||||
<div class="measurement-workflow">
|
||||
@@ -169,7 +169,7 @@ onBeforeUnmount(() => {
|
||||
show-icon
|
||||
/>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="费用快照" name="fee" lazy>
|
||||
<el-tab-pane label="费用信息" name="fee" lazy>
|
||||
<InboundFeePanel
|
||||
:key="`fee-${revision}`"
|
||||
:tenant-id="customerId"
|
||||
@@ -177,13 +177,13 @@ onBeforeUnmount(() => {
|
||||
@changed="handleDomainChanged"
|
||||
/>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="托盘与记录" name="records" lazy>
|
||||
<el-tab-pane label="状态与其他" name="records" lazy>
|
||||
<InboundHistoryPanel
|
||||
:pallet-locations="detail.palletLocations"
|
||||
:files="detail.files"
|
||||
:internal-notes="detail.internalNotes"
|
||||
:status-history="detail.statusHistory"
|
||||
:public-remark="detail.publicRemark"
|
||||
:public-remark="null"
|
||||
/>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
@@ -192,15 +192,18 @@ onBeforeUnmount(() => {
|
||||
|
||||
<style scoped>
|
||||
.inbound-workspace { min-width: 0; }
|
||||
.workspace-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; padding: 20px 24px; border-bottom: 1px solid var(--cc-border); }
|
||||
.workspace-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; padding: 18px 24px 16px; border-bottom: 1px solid var(--cc-border); }
|
||||
.workspace-title-row { display: flex; min-width: 0; align-items: center; flex-wrap: wrap; gap: 10px; }
|
||||
.workspace-title-row h2 { margin: 0; overflow-wrap: anywhere; color: var(--cc-text); font-size: 18px; line-height: 1.35; }
|
||||
.workspace-title-row h2 { margin: 0; overflow-wrap: anywhere; color: #172033; font-size: 16px; line-height: 1.35; }
|
||||
.workspace-header p { margin: 5px 0 0; color: var(--cc-muted); font-size: 12px; line-height: 1.5; }
|
||||
.workspace-state { margin: 24px; }
|
||||
.workspace-skeleton { padding: 0; }
|
||||
.workflow-tabs { min-width: 0; padding: 0 24px 24px; }
|
||||
.workflow-tabs :deep(.el-tabs__header) { margin-bottom: 16px; }
|
||||
.workflow-tabs :deep(.el-tabs__item) { min-height: 48px; }
|
||||
.workflow-tabs :deep(.el-tabs__header) { margin-bottom: 20px; }
|
||||
.workflow-tabs :deep(.el-tabs__nav-wrap::after) { height: 1px; background: var(--cc-border); }
|
||||
.workflow-tabs :deep(.el-tabs__active-bar) { height: 2px; background: var(--cc-primary); }
|
||||
.workflow-tabs :deep(.el-tabs__item) { min-height: 44px; padding: 0 18px; color: #475467; font-size: 13px; font-weight: 500; }
|
||||
.workflow-tabs :deep(.el-tabs__item.is-active) { color: var(--cc-primary); font-weight: 650; }
|
||||
.measurement-workflow { display: grid; min-width: 0; gap: 28px; }
|
||||
@media (max-width: 720px) {
|
||||
.workspace-header { align-items: stretch; flex-direction: column; padding: 16px; }
|
||||
|
||||
@@ -51,26 +51,27 @@ function date(value: string | null) {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.history-panel { display: grid; gap: 22px; min-width: 0; }
|
||||
.history-section { min-width: 0; }
|
||||
.section-heading { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin-bottom: 10px; }
|
||||
.section-heading h3 { margin: 0; color: var(--cc-text); font-size: 14px; }
|
||||
.history-panel { display: grid; gap: 24px; min-width: 0; }
|
||||
.history-section { min-width: 0; padding-top: 16px; border-top: 1px solid var(--cc-border); }
|
||||
.history-section:first-child { padding-top: 0; border-top: 0; }
|
||||
.section-heading { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin-bottom: 12px; }
|
||||
.section-heading h3 { margin: 0; color: #172033; font-size: 13px; }
|
||||
.section-heading span { color: var(--cc-muted); font-size: 12px; }
|
||||
.allocation-list, .simple-list { overflow: hidden; border: 1px solid var(--cc-border); border-radius: 8px; }
|
||||
.allocation-row { display: grid; grid-template-columns: 1fr auto minmax(100px, 0.8fr); gap: 12px; align-items: center; padding: 11px 12px; border-bottom: 1px solid var(--cc-border); font-size: 12px; }
|
||||
.allocation-list, .simple-list { overflow: hidden; border: 1px solid var(--cc-border); border-radius: 6px; background: #fff; }
|
||||
.allocation-row { display: grid; grid-template-columns: 1fr auto minmax(100px, 0.8fr); gap: 12px; align-items: center; padding: 10px 12px; border-bottom: 1px solid #edf0f4; font-size: 12px; }
|
||||
.allocation-row:last-child, .simple-list > div:last-child { border-bottom: 0; }
|
||||
.allocation-row > div, .simple-list > div { min-width: 0; }
|
||||
.allocation-row span, .simple-list span { display: block; margin-top: 3px; color: var(--cc-muted); }
|
||||
.allocation-location { overflow-wrap: anywhere; color: var(--cc-primary); text-align: right; }
|
||||
.simple-list > div { padding: 11px 12px; border-bottom: 1px solid var(--cc-border); font-size: 12px; }
|
||||
.simple-list > div { padding: 10px 12px; border-bottom: 1px solid #edf0f4; font-size: 12px; }
|
||||
.simple-list strong, .simple-list span { overflow-wrap: anywhere; }
|
||||
.muted-empty { margin: 0; padding: 18px 12px; border: 1px dashed var(--cc-border); border-radius: 8px; color: var(--cc-muted); font-size: 12px; text-align: center; }
|
||||
.muted-empty { margin: 0; padding: 18px 12px; border: 1px dashed var(--cc-border); border-radius: 6px; background: #f8fafc; color: var(--cc-muted); font-size: 12px; text-align: center; }
|
||||
.timeline { display: grid; gap: 0; margin: 0; padding: 0; list-style: none; }
|
||||
.timeline li { display: grid; grid-template-columns: 16px 1fr; gap: 10px; min-width: 0; padding-bottom: 16px; }
|
||||
.timeline-dot { width: 8px; height: 8px; margin-top: 5px; border: 2px solid var(--cc-primary); border-radius: 50%; background: #fff; box-shadow: 0 0 0 3px color-mix(in srgb, var(--cc-primary) 12%, transparent); }
|
||||
.timeline li > div { min-width: 0; padding-bottom: 12px; border-bottom: 1px solid var(--cc-border); }
|
||||
.timeline li > div { min-width: 0; padding-bottom: 12px; border-bottom: 1px solid #edf0f4; }
|
||||
.timeline strong, .timeline span, .timeline small { display: block; overflow-wrap: anywhere; }
|
||||
.timeline span, .timeline small { margin-top: 3px; color: var(--cc-muted); font-size: 12px; line-height: 1.5; }
|
||||
.remark-section p { margin: 0; padding: 12px; border-radius: 8px; background: #f8fafc; color: var(--cc-text); font-size: 13px; line-height: 1.6; white-space: pre-wrap; }
|
||||
.remark-section p { margin: 0; padding: 12px; border-radius: 6px; background: #f8fafc; color: var(--cc-text); font-size: 13px; line-height: 1.6; white-space: pre-wrap; }
|
||||
@media (max-width: 480px) { .allocation-row { grid-template-columns: 1fr auto; } .allocation-location { grid-column: 1 / -1; text-align: left; } }
|
||||
</style>
|
||||
|
||||
@@ -14,6 +14,9 @@ describe('InboundSummaryPanel', () => {
|
||||
expect(source).toContain('summary.chargeableWeightKg')
|
||||
expect(source).toContain('summary.locationCodes')
|
||||
expect(source).toContain('summary.notificationStatus')
|
||||
expect(source).toContain('publicRemark')
|
||||
expect(source).toContain('买单 / 退税')
|
||||
expect(source).toContain('计费毛重')
|
||||
})
|
||||
|
||||
it('does not restore excluded receiving review or customer fee confirmation actions', () => {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { Box, MapPin, Scale, Warehouse } from '@lucide/vue'
|
||||
import type { InboundOrderSummary } from './types'
|
||||
|
||||
defineProps<{ summary: InboundOrderSummary }>()
|
||||
withDefaults(defineProps<{ summary: InboundOrderSummary; publicRemark?: string | null }>(), { publicRemark: null })
|
||||
|
||||
const processLabels: Record<string, string> = {
|
||||
WAIT_MEASURE: '待测量', MEASURING: '测量中', MEASURE_PENDING: '待审核', MEASURE_REJECTED: '测量驳回',
|
||||
@@ -33,8 +33,9 @@ function label(value: string | null, labels: Record<string, string>) { return va
|
||||
<template>
|
||||
<section class="summary-panel" aria-label="入库单概览">
|
||||
<div class="summary-heading">
|
||||
<div>
|
||||
<h3>{{ summary.inboundCode || `入库单 ${summary.id}` }}</h3>
|
||||
<span class="summary-icon" aria-hidden="true"><Box :size="20" /></span>
|
||||
<div class="summary-title">
|
||||
<h3>入库码:{{ summary.inboundCode || `入库单 ${summary.id}` }}</h3>
|
||||
<p class="summary-subtitle">订单 {{ value(summary.orderNo) }} · {{ value(summary.customerName) }}</p>
|
||||
</div>
|
||||
<el-tag :type="processType(summary.processStatus)" effect="light">{{ processLabel(summary.processStatus) }}</el-tag>
|
||||
@@ -50,9 +51,10 @@ function label(value: string | null, labels: Record<string, string>) { return va
|
||||
<div><dt>公司</dt><dd>{{ value(summary.companyName) }}</dd></div>
|
||||
<div><dt>业务类型</dt><dd>{{ value(summary.businessType) }}</dd></div>
|
||||
<div><dt>渠道</dt><dd>{{ value(summary.channelName || summary.channelCode) }}</dd></div>
|
||||
<div><dt>计税模式</dt><dd>{{ value(summary.taxMode) }}</dd></div>
|
||||
<div><dt>装载模式</dt><dd>{{ value(summary.containerMode) }}</dd></div>
|
||||
<div><dt>买单 / 退税</dt><dd>{{ value(summary.taxMode) }}</dd></div>
|
||||
<div><dt>是否整柜</dt><dd>{{ value(summary.containerMode) }}</dd></div>
|
||||
<div><dt>商检验</dt><dd>{{ yesNo(summary.customsInspection) }}</dd></div>
|
||||
<div><dt>备注</dt><dd>{{ value(publicRemark) }}</dd></div>
|
||||
<div><dt>创建时间</dt><dd>{{ date(summary.createdAt) }}</dd></div>
|
||||
</dl>
|
||||
</section>
|
||||
@@ -79,11 +81,11 @@ function label(value: string | null, labels: Record<string, string>) { return va
|
||||
<h4 id="inbound-measure-heading">测量与计费</h4>
|
||||
<dl class="summary-grid">
|
||||
<div><dt><Box :size="14" />总箱数</dt><dd>{{ value(summary.totalPackageCount) }} 箱</dd></div>
|
||||
<div><dt><Scale :size="14" />总毛重</dt><dd>{{ number(summary.actualGrossWeightKg) }} kg</dd></div>
|
||||
<div><dt>总净重</dt><dd>{{ number(summary.actualNetWeightKg) }} kg</dd></div>
|
||||
<div><dt><Scale :size="14" />总毛重 (T.K.GW)</dt><dd>{{ number(summary.actualGrossWeightKg) }} kg</dd></div>
|
||||
<div><dt>总净重 (T.K.NW)</dt><dd>{{ number(summary.actualNetWeightKg) }} kg</dd></div>
|
||||
<div><dt>总体积</dt><dd>{{ number(summary.actualVolumeCbm) }} CBM</dd></div>
|
||||
<div><dt>总材积重</dt><dd>{{ number(summary.volumeWeightKg) }} kg</dd></div>
|
||||
<div><dt>计费重量</dt><dd>{{ number(summary.chargeableWeightKg) }} kg</dd></div>
|
||||
<div><dt>计费毛重</dt><dd>{{ number(summary.chargeableWeightKg) }} kg</dd></div>
|
||||
<div><dt>计费体积</dt><dd>{{ number(summary.chargeableVolumeCbm) }} CBM</dd></div>
|
||||
<div><dt>测量审核</dt><dd>{{ label(summary.measureReviewStatus, measureReviewLabels) }}</dd></div>
|
||||
<div><dt>费用状态</dt><dd>{{ label(summary.feeStatus, feeLabels) }}</dd></div>
|
||||
@@ -108,17 +110,21 @@ function label(value: string | null, labels: Record<string, string>) { return va
|
||||
|
||||
<style scoped>
|
||||
.summary-panel { min-width: 0; }
|
||||
.summary-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; padding-bottom: 18px; border-bottom: 1px solid var(--cc-border); }
|
||||
h3 { margin: 0; overflow-wrap: anywhere; color: var(--cc-text); font-size: 18px; line-height: 1.3; }
|
||||
.summary-heading { display: grid; grid-template-columns: 36px minmax(0, 1fr) auto; align-items: center; gap: 10px; padding-bottom: 18px; border-bottom: 1px solid var(--cc-border); }
|
||||
.summary-icon { display: grid; width: 36px; height: 36px; place-items: center; border-radius: 8px; background: #172033; color: #fff; }
|
||||
.summary-title { min-width: 0; }
|
||||
h3 { margin: 0; overflow-wrap: anywhere; color: #172033; font-size: 16px; line-height: 1.35; }
|
||||
.summary-subtitle { margin: 6px 0 0; color: var(--cc-muted); font-size: 12px; }
|
||||
.summary-sections { display: grid; min-width: 0; gap: 22px; margin-top: 18px; }
|
||||
.summary-sections section { min-width: 0; }
|
||||
h4 { margin: 0 0 9px; color: var(--cc-text); font-size: 14px; line-height: 1.4; }
|
||||
.summary-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); margin: 0; border-top: 1px solid var(--cc-border); border-left: 1px solid var(--cc-border); }
|
||||
.summary-grid > div { min-width: 0; min-height: 66px; padding: 11px 12px; border-right: 1px solid var(--cc-border); border-bottom: 1px solid var(--cc-border); }
|
||||
dt { display: flex; align-items: center; gap: 6px; color: var(--cc-muted); font-size: 12px; }
|
||||
dd { margin: 5px 0 0; overflow-wrap: anywhere; color: var(--cc-text); font-size: 13px; line-height: 1.5; }
|
||||
.hold-note { margin: 16px 0 0; padding: 10px 12px; border: 1px solid #f5c26b; border-radius: 8px; background: #fffaeb; color: #93370d; font-size: 12px; line-height: 1.5; }
|
||||
.summary-sections { display: grid; min-width: 0; gap: 24px; margin-top: 18px; }
|
||||
.summary-sections section { min-width: 0; padding-top: 16px; border-top: 1px solid var(--cc-border); }
|
||||
.summary-sections section:first-child { padding-top: 0; border-top: 0; }
|
||||
h4 { margin: 0 0 12px; color: #172033; font-size: 13px; line-height: 1.4; }
|
||||
.summary-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px 24px; margin: 0; color: #475467; }
|
||||
.summary-grid > div { display: grid; min-width: 0; grid-template-columns: max-content minmax(0, 1fr); align-items: baseline; gap: 4px; min-height: 26px; line-height: 1.7; }
|
||||
dt { display: flex; align-items: center; gap: 6px; color: var(--cc-muted); font-size: 12px; white-space: nowrap; }
|
||||
dt::after { content: ":"; }
|
||||
dd { margin: 0; overflow-wrap: anywhere; color: #475467; font-size: 13px; line-height: 1.7; }
|
||||
.hold-note { margin: 16px 0 0; padding: 10px 12px; border: 1px solid var(--cc-border); border-radius: 8px; background: #fffaeb; color: #b54708; font-size: 12px; line-height: 1.5; }
|
||||
@media (max-width: 900px) { .summary-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
||||
@media (max-width: 480px) { .summary-heading { align-items: stretch; flex-direction: column; } .summary-grid { grid-template-columns: 1fr; } }
|
||||
@media (max-width: 480px) { .summary-heading { grid-template-columns: 36px minmax(0, 1fr); align-items: start; } .summary-heading :deep(.el-tag) { grid-column: 2; justify-self: start; } .summary-grid { grid-template-columns: 1fr; } .summary-grid > div { grid-template-columns: 1fr; gap: 1px; } dt::after { content: ""; } }
|
||||
</style>
|
||||
|
||||
@@ -14,8 +14,8 @@ describe('InboundOrderDetailView', () => {
|
||||
expect(source).toContain('缺少所属企业信息,请从入库单列表重新打开')
|
||||
expect(source).not.toContain('PlatformTenantSelector')
|
||||
expect(source).not.toContain('租户')
|
||||
expect(source).toContain('<h1>入库详情</h1>')
|
||||
expect(source).toContain('货物测量、审核、费用及托盘库位记录')
|
||||
expect(source).toContain('<h1>入库单详情</h1>')
|
||||
expect(source).toContain('货品与测量、费用信息、状态历史及其他记录')
|
||||
expect(source).toContain('class="surface-panel detail-page"')
|
||||
expect(source).toContain('class="detail-page-actions"')
|
||||
expect(source).toContain('返回入库单列表')
|
||||
|
||||
@@ -25,8 +25,8 @@ function backToList() {
|
||||
<section class="surface-panel detail-page">
|
||||
<header class="detail-page-header">
|
||||
<div class="detail-page-heading">
|
||||
<h1>入库详情</h1>
|
||||
<p>查看基本信息、货物测量、审核、费用及托盘库位记录。</p>
|
||||
<h1>入库单详情</h1>
|
||||
<p>查看基本信息、货品与测量、费用信息、状态历史及其他记录。</p>
|
||||
</div>
|
||||
<div class="detail-page-actions">
|
||||
<el-button @click="backToList"><ArrowLeft :size="16" />返回入库单列表</el-button>
|
||||
|
||||
@@ -101,7 +101,7 @@ describe('InboundOrdersView', () => {
|
||||
|
||||
await selectCustomer(wrapper)
|
||||
expect(listPlatformInbounds).toHaveBeenCalledWith('10001', expect.objectContaining({ page: 1, size: 20 }))
|
||||
expect(listPlatformWarehouses).toHaveBeenCalledWith('10001', 1, 200)
|
||||
expect(listPlatformWarehouses).toHaveBeenCalledWith(1, 200)
|
||||
expect(listPlatformTenants).toHaveBeenCalledWith({ page: 1, size: 200 })
|
||||
expect(document.body.textContent).toContain('RK-001')
|
||||
expect(document.body.textContent).toContain('查看')
|
||||
@@ -185,8 +185,10 @@ describe('InboundOrdersView', () => {
|
||||
expect(tabButtons).toHaveLength(5)
|
||||
const toolbarRows = wrapper.find('.table-toolbar').element.children
|
||||
expect(toolbarRows[0].classList.contains('table-tabs-row')).toBe(true)
|
||||
expect(toolbarRows[1].classList.contains('table-action-row')).toBe(true)
|
||||
expect(wrapper.find('.table-action-row').text()).toContain('共 1 条 · 所属企业 全部客户')
|
||||
expect(toolbarRows[1].classList.contains('inbound-stats-row')).toBe(true)
|
||||
expect(toolbarRows[2].classList.contains('table-action-row')).toBe(true)
|
||||
expect(wrapper.find('.inbound-stats-row').text()).toContain('当前显示 1 条 · 共 1 条')
|
||||
expect(wrapper.find('.table-action-row').text()).toContain('所属企业 全部客户')
|
||||
expect(wrapper.find('.table-action-buttons').text()).toContain('刷新')
|
||||
expect(wrapper.text()).not.toContain('全部入库单')
|
||||
expect(tabButtons[0].attributes('aria-selected')).toBe('true')
|
||||
@@ -214,7 +216,7 @@ describe('InboundOrdersView', () => {
|
||||
toolbar.vm.$emit('update:modelValue', { ...toolbar.props('modelValue'), customerId: '10002' })
|
||||
await flushPromises()
|
||||
expect(listPlatformInbounds).toHaveBeenLastCalledWith('10002', expect.objectContaining({ page: 1 }))
|
||||
expect(listPlatformWarehouses).toHaveBeenLastCalledWith('10002', 1, 200)
|
||||
expect(listPlatformWarehouses).toHaveBeenLastCalledWith(1, 200)
|
||||
})
|
||||
|
||||
it('resets to all customers without sending an empty backend tenant ID', async () => {
|
||||
|
||||
@@ -116,7 +116,7 @@ const columns: DataTableColumn[] = [
|
||||
{ key: 'orderStatus', label: '订单 / 流程状态', minWidth: 156, slotName: 'orderStatus' },
|
||||
{ key: 'measureReviewStatus', label: '测量数据审核状态', minWidth: 142, slotName: 'measureReviewStatus' },
|
||||
{ key: 'expectedArrivalAt', label: '预计到达时间', minWidth: 164, slotName: 'expectedArrivalAt' },
|
||||
{ key: 'feeStatus', label: '费用状态', minWidth: 124, slotName: 'feeStatus' },
|
||||
{ key: 'feeStatus', label: '费用审核状态', minWidth: 132, slotName: 'feeStatus' },
|
||||
{ key: 'customerName', label: '客户', minWidth: 142, slotName: 'customerName' },
|
||||
{ key: 'warehouseName', label: '仓库', minWidth: 140, slotName: 'warehouseName' },
|
||||
{ key: 'deliveryWarehouseName', label: '交货仓', minWidth: 140, slotName: 'deliveryWarehouseName' },
|
||||
@@ -128,20 +128,20 @@ const columns: DataTableColumn[] = [
|
||||
{ key: 'companyName', label: '公司', minWidth: 160, slotName: 'companyName' },
|
||||
{ key: 'businessType', label: '业务类型', minWidth: 112, slotName: 'businessType' },
|
||||
{ key: 'channelName', label: '渠道', minWidth: 138, slotName: 'channelName' },
|
||||
{ key: 'taxMode', label: '计税模式', minWidth: 108, slotName: 'taxMode' },
|
||||
{ key: 'containerMode', label: '装载模式', minWidth: 108, slotName: 'containerMode' },
|
||||
{ key: 'taxMode', label: '买单/退税', minWidth: 108, slotName: 'taxMode' },
|
||||
{ key: 'containerMode', label: '是否整柜', minWidth: 108, slotName: 'containerMode' },
|
||||
{ key: 'customsInspection', label: '商检验', width: 92, slotName: 'customsInspection' },
|
||||
{ key: 'sailingAt', label: '开船时间', minWidth: 164, slotName: 'sailingAt' },
|
||||
{ key: 'portArrivalAt', label: '到港时间', minWidth: 164, slotName: 'portArrivalAt' },
|
||||
{ key: 'totalPackageCount', label: '总箱数', width: 90, slotName: 'totalPackageCount' },
|
||||
{ key: 'actualGrossWeightKg', label: '总毛重(kg)', minWidth: 118, slotName: 'actualGrossWeightKg' },
|
||||
{ key: 'chargeableWeightKg', label: '计费重量(kg)', minWidth: 126, slotName: 'chargeableWeightKg' },
|
||||
{ key: 'actualVolumeCbm', label: '总体积(CBM)', minWidth: 122, slotName: 'actualVolumeCbm' },
|
||||
{ key: 'chargeableVolumeCbm', label: '计费体积(CBM)', minWidth: 132, slotName: 'chargeableVolumeCbm' },
|
||||
{ key: 'actualGrossWeightKg', label: '总毛重(T.K.GW)', minWidth: 136, slotName: 'actualGrossWeightKg' },
|
||||
{ key: 'chargeableWeightKg', label: '计费毛重', minWidth: 112, slotName: 'chargeableWeightKg' },
|
||||
{ key: 'actualVolumeCbm', label: '总体积(m³)', minWidth: 118, slotName: 'actualVolumeCbm' },
|
||||
{ key: 'chargeableVolumeCbm', label: '计费体积(m³)', minWidth: 128, slotName: 'chargeableVolumeCbm' },
|
||||
{ key: 'palletCount', label: '总托盘数', width: 100, slotName: 'palletCount' },
|
||||
{ key: 'locationCodes', label: '库位位置', minWidth: 124, slotName: 'locationCodes' },
|
||||
{ key: 'volumeWeightKg', label: '总材积重(kg)', minWidth: 126, slotName: 'volumeWeightKg' },
|
||||
{ key: 'actualNetWeightKg', label: '总净重(kg)', minWidth: 118, slotName: 'actualNetWeightKg' },
|
||||
{ key: 'volumeWeightKg', label: '总材积重', minWidth: 110, slotName: 'volumeWeightKg' },
|
||||
{ key: 'actualNetWeightKg', label: '总净重(T.K.NW)', minWidth: 136, slotName: 'actualNetWeightKg' },
|
||||
{ key: 'destinationType', label: '目的仓类型', minWidth: 112, slotName: 'destinationType' },
|
||||
{ key: 'destinationName', label: '目的仓', minWidth: 132, slotName: 'destinationName' },
|
||||
{ key: 'measureCompletedAt', label: '测量完成时间', minWidth: 164, slotName: 'measureCompletedAt' },
|
||||
@@ -156,6 +156,16 @@ const selectedCustomerName = computed(() => {
|
||||
return customer?.legalName?.trim() || customer?.tenantName?.trim() || '全部客户'
|
||||
})
|
||||
const listEmptyText = '暂无符合条件的入库单'
|
||||
const currentPageCount = computed(() => result.value.records.length)
|
||||
const aggregateStats = computed(() => {
|
||||
const records = result.value.records
|
||||
return {
|
||||
packages: records.reduce((total, item) => total + (item.totalPackageCount || 0), 0),
|
||||
grossWeight: records.reduce((total, item) => total + (item.actualGrossWeightKg || 0), 0),
|
||||
chargeableWeight: records.reduce((total, item) => total + (item.chargeableWeightKg || 0), 0),
|
||||
volume: records.reduce((total, item) => total + (item.actualVolumeCbm || 0), 0),
|
||||
}
|
||||
})
|
||||
|
||||
function queryValue(value: string) { return value.trim() || undefined }
|
||||
function handleTabKeydown(event: KeyboardEvent, currentIndex: number) {
|
||||
@@ -186,7 +196,7 @@ async function loadWarehouseOptions() {
|
||||
}
|
||||
warehousesLoading.value = true
|
||||
try {
|
||||
const response = await listPlatformWarehouses(selectedCustomerId.value, 1, 200)
|
||||
const response = await listPlatformWarehouses(1, 200)
|
||||
if (disposed || requestId !== warehouseRequestId) return
|
||||
warehouseOptions.value = response.records.map((warehouse) => ({
|
||||
label: `${warehouse.warehouseName} · ${warehouse.warehouseCode}`,
|
||||
@@ -297,6 +307,10 @@ function openWorkspace(row: InboundOrderSummary) {
|
||||
void router.push({ name: 'inbound-order-detail', params: { inboundId: row.id }, query: { tenantId: row.tenantId } })
|
||||
}
|
||||
function formatNumber(value: number | null, digits = 3) { return value === null ? '—' : value.toFixed(digits) }
|
||||
function formatAggregate(value: number, digits = 3) {
|
||||
if (!Number.isFinite(value)) return '0'
|
||||
return value.toLocaleString('zh-CN', { maximumFractionDigits: digits })
|
||||
}
|
||||
function formatDate(value: string | null) {
|
||||
if (!value) return '—'
|
||||
const date = new Date(value)
|
||||
@@ -390,8 +404,20 @@ onBeforeUnmount(() => { disposed = true; loadRequestId += 1; detailRequestId +=
|
||||
<button v-for="(item, index) in tabs" :key="item.value || 'all'" type="button" role="tab" class="inbound-tab" :class="{ 'is-active': tab === item.value }" :aria-selected="tab === item.value" :tabindex="tab === item.value ? 0 : -1" :title="item.description" @click="tab = item.value" @keydown="handleTabKeydown($event, index)">{{ item.label }}</button>
|
||||
</nav>
|
||||
</div>
|
||||
<div class="inbound-stats-row" aria-label="入库单统计">
|
||||
<span class="selected-info">当前显示 {{ currentPageCount }} 条 · 共 {{ result.total }} 条</span>
|
||||
<span class="total-info">
|
||||
<b>总箱数 {{ formatAggregate(aggregateStats.packages, 0) }}</b>
|
||||
<i aria-hidden="true" />
|
||||
<b>总毛重 {{ formatAggregate(aggregateStats.grossWeight) }} kg</b>
|
||||
<i aria-hidden="true" />
|
||||
<b>计费重量 {{ formatAggregate(aggregateStats.chargeableWeight) }} kg</b>
|
||||
<i aria-hidden="true" />
|
||||
<b>总体积 {{ formatAggregate(aggregateStats.volume) }} m³</b>
|
||||
</span>
|
||||
</div>
|
||||
<div class="table-action-row">
|
||||
<span class="result-summary">共 {{ result.total }} 条 · 所属企业 {{ selectedCustomerName }}</span>
|
||||
<span class="result-summary">所属企业 {{ selectedCustomerName }}</span>
|
||||
<div class="table-action-buttons">
|
||||
<el-button :loading="loading" @click="loadInbounds"><RefreshCw :size="16" />刷新</el-button>
|
||||
</div>
|
||||
@@ -442,37 +468,50 @@ onBeforeUnmount(() => { disposed = true; loadRequestId += 1; detailRequestId +=
|
||||
|
||||
<AppDrawer v-model="detailOpen" :title="detail ? `入库单详情 · ${detail.summary.inboundCode || detail.summary.id}` : '入库单详情'" size="min(720px, 94vw)" :loading="detailLoading" :show-footer="false" destroy-on-close @closed="closeDetail">
|
||||
<section v-if="detailError" class="detail-error" role="alert"><strong>入库单详情暂时无法加载</strong><p>{{ detailError.message }}</p><ApiErrorMeta :details="detailError" /><el-button type="primary" :disabled="!detailTarget" @click="detailTarget && openDetail(detailTarget)">重新加载</el-button></section>
|
||||
<div v-else-if="detail" class="detail-content"><InboundSummaryPanel :summary="detail.summary" /><InboundHistoryPanel :pallet-locations="detail.palletLocations" :files="detail.files" :internal-notes="detail.internalNotes" :status-history="detail.statusHistory" :public-remark="detail.publicRemark" /></div>
|
||||
<div v-else-if="detail" class="detail-content"><InboundSummaryPanel :summary="detail.summary" :public-remark="detail.publicRemark" /><InboundHistoryPanel :pallet-locations="detail.palletLocations" :files="detail.files" :internal-notes="detail.internalNotes" :status-history="detail.statusHistory" :public-remark="null" /></div>
|
||||
</AppDrawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.inbound-orders-page { min-width: 0; }
|
||||
.inbound-tabs { display: flex; width: max-content; max-width: 100%; min-width: 0; overflow-x: auto; padding: 4px; border: 1px solid var(--cc-border); border-radius: 8px; background: #f7f9fc; scrollbar-width: none; }
|
||||
.inbound-query-toolbar { align-items: stretch; border-radius: 8px; background: #f8fafc; }
|
||||
.inbound-query-toolbar :deep(.query-toolbar__filters) { display: grid; flex: 1 1 auto; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px 16px; }
|
||||
.inbound-query-toolbar :deep(.query-toolbar__filters .el-input),
|
||||
.inbound-query-toolbar :deep(.query-toolbar__filters .el-select),
|
||||
.inbound-query-toolbar :deep(.query-toolbar__filters .el-date-editor) { width: 100%; }
|
||||
.inbound-query-toolbar :deep(.query-toolbar__actions) { align-content: flex-end; align-items: flex-end; }
|
||||
.inbound-tabs { display: flex; width: max-content; max-width: 100%; min-width: 0; overflow-x: auto; border-bottom: 2px solid var(--cc-border); scrollbar-width: none; }
|
||||
.inbound-tabs::-webkit-scrollbar { display: none; }
|
||||
.inbound-tab { min-height: 44px; flex: 0 0 auto; padding: 8px 18px; border: 1px solid transparent; border-radius: 6px; background: transparent; color: var(--cc-muted); cursor: pointer; font-size: 13px; line-height: 1.4; white-space: nowrap; transition: background-color .16s ease, border-color .16s ease, color .16s ease, box-shadow .16s ease; }
|
||||
.inbound-tab:hover { background: #fff; color: var(--cc-primary); }
|
||||
.inbound-tab { min-height: 42px; flex: 0 0 auto; padding: 10px 22px; border: 0; border-bottom: 2px solid transparent; margin-bottom: -2px; background: transparent; color: #475467; cursor: pointer; font-size: 13px; line-height: 1.4; white-space: nowrap; transition: border-color .16s ease, color .16s ease, background-color .16s ease; }
|
||||
.inbound-tab:hover { color: var(--cc-primary); background: #f8fbff; }
|
||||
.inbound-tab:focus-visible { outline: 3px solid rgba(102, 126, 234, .2); outline-offset: 1px; }
|
||||
.inbound-tab.is-active { border-color: #d8e0f0; background: #fff; color: var(--cc-primary); box-shadow: 0 1px 3px rgba(16, 24, 40, .08); font-weight: 650; }
|
||||
.inbound-tab.is-active { border-bottom-color: var(--cc-primary); background: #fff; color: var(--cc-primary); font-weight: 650; }
|
||||
.inbound-load-error { margin-top: 12px; }
|
||||
.inbound-query-toolbar { border-radius: 8px; }
|
||||
.row-actions { display: flex; align-items: center; gap: 2px; white-space: nowrap; }
|
||||
.row-actions :deep(.el-button) { margin-left: 0; }
|
||||
.table-toolbar { display: grid; min-width: 0; border-bottom: 1px solid var(--cc-border); }
|
||||
.table-tabs-row { min-width: 0; padding: 10px 14px; }
|
||||
.table-action-row { display: flex; min-width: 0; min-height: 56px; align-items: center; justify-content: space-between; gap: 16px; padding: 8px 14px; border-top: 1px solid var(--cc-border); background: #fbfcfe; }
|
||||
.table-tabs-row { min-width: 0; padding: 0 14px; background: #fff; }
|
||||
.inbound-stats-row { display: flex; min-width: 0; align-items: center; justify-content: space-between; gap: 16px; padding: 10px 14px; background: var(--cc-primary); color: #fff; }
|
||||
.inbound-stats-row .selected-info, .inbound-stats-row .total-info { min-width: 0; font-size: 12px; font-weight: 650; line-height: 1.5; }
|
||||
.inbound-stats-row .total-info { display: flex; align-items: center; flex-wrap: wrap; justify-content: flex-end; gap: 8px; }
|
||||
.inbound-stats-row b { font-weight: 650; }
|
||||
.inbound-stats-row i { width: 1px; height: 12px; background: rgba(255, 255, 255, .38); }
|
||||
.table-action-row { display: flex; min-width: 0; min-height: 48px; align-items: center; justify-content: space-between; gap: 16px; padding: 7px 14px; border-top: 1px solid #dfe6f0; background: #fbfcfe; }
|
||||
.table-action-buttons { display: flex; min-width: 0; flex-wrap: wrap; justify-content: flex-end; gap: 8px; }
|
||||
.result-summary { color: var(--cc-muted); font-size: 12px; white-space: nowrap; }
|
||||
.table-action-buttons :deep(.el-button) { min-height: 40px; margin-left: 0; }
|
||||
.table-action-buttons :deep(.el-button) { min-height: 34px; margin-left: 0; }
|
||||
.table-panel :deep(.el-table th.el-table__cell) { border-right: 1px solid #edf0f4; }
|
||||
.table-panel :deep(.el-table td.el-table__cell) { border-right: 1px solid #edf0f4; }
|
||||
.primary-cell, .status-stack { display: grid; min-width: 0; justify-items: start; gap: 4px; }
|
||||
.primary-cell strong, .primary-cell span, .status-stack span, .status-stack small { overflow-wrap: anywhere; }
|
||||
.primary-cell span, .status-stack span, .status-stack small { color: var(--cc-muted); font-size: 12px; }
|
||||
.detail-content { display: grid; gap: 24px; min-width: 0; }
|
||||
.detail-error { display: flex; min-height: 240px; align-items: flex-start; justify-content: center; flex-direction: column; }
|
||||
.detail-error strong { color: var(--cc-danger); font-size: 15px; }
|
||||
.detail-error p { margin: 7px 0; color: var(--cc-text); font-size: 13px; }
|
||||
.detail-error strong { color: #b42318; font-size: 16px; }
|
||||
.detail-error p { margin: 7px 0; color: #172033; font-size: 13px; }
|
||||
.detail-error :deep(.el-button) { margin-top: 14px; }
|
||||
@media (max-width: 900px) { .inbound-tabs { width: 100%; } }
|
||||
@media (max-width: 720px) { .inbound-tab { padding-inline: 14px; } .table-tabs-row { padding: 10px; } .table-action-row { min-height: 0; align-items: stretch; flex-direction: column; gap: 10px; padding: 10px; } .table-action-buttons { justify-content: flex-start; } .result-summary { min-width: 0; overflow: hidden; text-overflow: ellipsis; } .table-action-buttons :deep(.el-button) { min-height: 44px; } }
|
||||
@media (max-width: 1180px) { .inbound-query-toolbar :deep(.query-toolbar__filters) { grid-template-columns: repeat(3, minmax(0, 1fr)); } }
|
||||
@media (max-width: 900px) { .inbound-tabs { width: 100%; } .inbound-query-toolbar :deep(.query-toolbar__filters) { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
||||
@media (max-width: 720px) { .inbound-query-toolbar :deep(.query-toolbar__filters) { grid-template-columns: 1fr; } .inbound-tab { padding-inline: 14px; } .table-tabs-row { padding: 0 10px; } .inbound-stats-row, .table-action-row { min-height: 0; align-items: stretch; flex-direction: column; gap: 10px; padding: 10px; } .inbound-stats-row .total-info { justify-content: flex-start; } .table-action-buttons { justify-content: flex-start; } .result-summary { min-width: 0; overflow: hidden; text-overflow: ellipsis; } .table-action-buttons :deep(.el-button) { min-height: 44px; } }
|
||||
</style>
|
||||
|
||||
@@ -4,10 +4,10 @@ export const inboundRoutes = [
|
||||
{
|
||||
path: 'inbounds',
|
||||
name: 'inbound-orders',
|
||||
component: () => import('./pages/InboundOrdersView.vue'),
|
||||
redirect: '/orders',
|
||||
meta: {
|
||||
title: '入库单',
|
||||
description: '查询头程入库单并跟进测量、审核、费用和入库进度',
|
||||
title: '订单列表',
|
||||
description: '旧入库列表入口已合并到统一订单列表',
|
||||
permission: 'platform:firstleg:inbound:list',
|
||||
},
|
||||
},
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { http } from '@/shared/api/http'
|
||||
import { getPlatformUnifiedOrder, listPlatformUnifiedOrders } from './unifiedOrders'
|
||||
|
||||
vi.mock('@/shared/api/http', () => ({
|
||||
http: { get: vi.fn() },
|
||||
unwrapApiResponse: <T>(response: { data: T }) => response.data,
|
||||
}))
|
||||
|
||||
describe('unified orders API contract', () => {
|
||||
beforeEach(() => vi.mocked(http.get).mockReset())
|
||||
|
||||
it('uses the C02 platform orders endpoint and contract filters', async () => {
|
||||
vi.mocked(http.get).mockResolvedValue({ data: { records: [], page: '2', size: '20', total: '0', pages: '0' } })
|
||||
await expect(listPlatformUnifiedOrders({
|
||||
keyword: 'CC', orderNo: 'CC-1', mainStatus: 'WAIT_SPLIT', customerId: '30001',
|
||||
receivingWarehouseId: '11', destinationWarehouseId: '22', page: 2, size: 20,
|
||||
})).resolves.toMatchObject({ page: 2, size: 20, total: 0, pages: 0 })
|
||||
expect(http.get).toHaveBeenCalledWith('/platform/v1/orders', {
|
||||
params: {
|
||||
keyword: 'CC', orderNo: 'CC-1', mainStatus: 'WAIT_SPLIT', customerId: '30001',
|
||||
receivingWarehouseId: '11', destinationWarehouseId: '22', page: 2, size: 20,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('normalizes C02 row IDs and keeps inboundId for measurement routing', async () => {
|
||||
vi.mocked(http.get).mockResolvedValue({ data: {
|
||||
records: [{ orderId: '90001', tenantId: '208', inboundId: '77', inboundVersion: 3, mainStatus: 'WAIT_MEASURE', measurement: null, preload: null, loading: null, delivery: null, transport: null }],
|
||||
page: 1, size: 20, total: 1, pages: 1,
|
||||
} })
|
||||
await expect(listPlatformUnifiedOrders({ page: 1, size: 20 })).resolves.toMatchObject({
|
||||
records: [{ orderId: '90001', tenantId: '208', inboundId: '77', inboundVersion: 3 }],
|
||||
})
|
||||
})
|
||||
|
||||
it('loads the C02 detail shape and passes tenantId only on the detail boundary', async () => {
|
||||
vi.mocked(http.get).mockResolvedValue({ data: {
|
||||
summary: { orderId: '90001', tenantId: '208', mainStatus: 'WAIT_SPLIT' },
|
||||
order: { order: {}, cargoes: [], parties: [], internalNotes: [], statusHistory: [] },
|
||||
timeline: [],
|
||||
} })
|
||||
await expect(getPlatformUnifiedOrder('208', '90001')).resolves.toMatchObject({
|
||||
summary: { orderId: '90001' }, order: { internalNotes: [] }, timeline: [],
|
||||
})
|
||||
expect(http.get).toHaveBeenCalledWith('/platform/v1/orders/90001', { params: { tenantId: '208' } })
|
||||
})
|
||||
|
||||
it('does not expose a guessed generic action endpoint', async () => {
|
||||
expect(http).not.toHaveProperty('post')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,264 @@
|
||||
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
|
||||
export const unifiedOrdersApiPath = '/platform/v1/orders'
|
||||
|
||||
export type UnifiedOrderMainStatus =
|
||||
| 'PENDING_REVIEW'
|
||||
| 'WAIT_MEASURE'
|
||||
| 'INBOUNDED'
|
||||
| 'WAIT_SPLIT'
|
||||
| 'LOADING'
|
||||
| 'LOADED'
|
||||
| 'DELIVERED'
|
||||
|
||||
export const unifiedOrderStatusOptions: Array<{ value: UnifiedOrderMainStatus; label: string }> = [
|
||||
{ value: 'PENDING_REVIEW', label: '待审核' },
|
||||
{ value: 'WAIT_MEASURE', label: '待测量数据' },
|
||||
{ value: 'INBOUNDED', label: '已入库' },
|
||||
{ value: 'WAIT_SPLIT', label: '待分柜' },
|
||||
{ value: 'LOADING', label: '装柜中' },
|
||||
{ value: 'LOADED', label: '装柜完成' },
|
||||
{ value: 'DELIVERED', label: '派送完成' },
|
||||
]
|
||||
|
||||
export interface UnifiedOrderMeasurementSummary {
|
||||
reviewStatus: string | null
|
||||
actualGrossWeightKg: number | null
|
||||
actualVolumeCbm: number | null
|
||||
chargeableWeightKg: number | null
|
||||
completedAt: string | null
|
||||
}
|
||||
|
||||
export interface UnifiedOrderPreloadSummary {
|
||||
status: string | null
|
||||
version: number | null
|
||||
itemCount: number | null
|
||||
updatedAt: string | null
|
||||
}
|
||||
|
||||
export interface UnifiedOrderLoadingSummary {
|
||||
status: string | null
|
||||
loadingVersion: number | null
|
||||
pickingVersion: number | null
|
||||
expectedCount: number | null
|
||||
pickedCount: number | null
|
||||
evidenceCount: number | null
|
||||
sealNo: string | null
|
||||
updatedAt: string | null
|
||||
}
|
||||
|
||||
export interface UnifiedOrderDeliverySummary {
|
||||
status: string | null
|
||||
completedAt: string | null
|
||||
trackingNo: string | null
|
||||
}
|
||||
|
||||
export interface UnifiedOrderTransportSummary {
|
||||
loadingStatus: string | null
|
||||
containerNo: string | null
|
||||
cabinetNo: string | null
|
||||
vesselName: string | null
|
||||
voyageNo: string | null
|
||||
sailingAt: string | null
|
||||
portArrivalAt: string | null
|
||||
}
|
||||
|
||||
export interface UnifiedOrderResource {
|
||||
orderId: number | string
|
||||
tenantId: number | string
|
||||
orderNo: string | null
|
||||
mainStatus: UnifiedOrderMainStatus
|
||||
nextAction: string | null
|
||||
internalOrderStatus: string | null
|
||||
customerId: number | string | null
|
||||
customerName: string | null
|
||||
channelName: string | null
|
||||
receivingWarehouseId: number | string | null
|
||||
receivingWarehouseName: string | null
|
||||
destinationWarehouseId: number | string | null
|
||||
destinationWarehouseName: string | null
|
||||
inboundId: number | string | null
|
||||
inboundVersion: number | null
|
||||
inboundCode: string | null
|
||||
expectedShipDate: string | null
|
||||
totalPackageCount: number | null
|
||||
measurement: UnifiedOrderMeasurementSummary | null
|
||||
preload: UnifiedOrderPreloadSummary | null
|
||||
loading: UnifiedOrderLoadingSummary | null
|
||||
delivery: UnifiedOrderDeliverySummary | null
|
||||
transport: UnifiedOrderTransportSummary | null
|
||||
createdAt: string | null
|
||||
updatedAt: string | null
|
||||
}
|
||||
|
||||
export interface UnifiedOrderTimelineEvent {
|
||||
source: string | null
|
||||
eventType: string | null
|
||||
mainStatus: UnifiedOrderMainStatus | null
|
||||
action: string | null
|
||||
remark: string | null
|
||||
operatorId: number | string | null
|
||||
operatorName: string | null
|
||||
occurredAt: string | null
|
||||
}
|
||||
|
||||
export interface UnifiedOrderDetailOrderSnapshot {
|
||||
id: number | string | null
|
||||
tenantId: number | string | null
|
||||
orderNo: string | null
|
||||
customerId: number | string | null
|
||||
customerName: string | null
|
||||
transportMode: string | null
|
||||
destinationCountryCode: string | null
|
||||
destinationWarehouseId: number | string | null
|
||||
destinationWarehouseName: string | null
|
||||
expectedShipDate: string | null
|
||||
receivingWarehouseId: number | string | null
|
||||
receivingWarehouseName: string | null
|
||||
channelName: string | null
|
||||
createdAt: string | null
|
||||
updatedAt: string | null
|
||||
}
|
||||
|
||||
export interface UnifiedOrderDetailOrderResource {
|
||||
order: UnifiedOrderDetailOrderSnapshot | null
|
||||
cargoes: Array<Record<string, unknown>>
|
||||
parties: Array<Record<string, unknown>>
|
||||
internalNotes: UnifiedOrderInternalNoteResource[]
|
||||
statusHistory: UnifiedOrderStatusHistoryResource[]
|
||||
}
|
||||
|
||||
export interface UnifiedOrderInternalNoteResource {
|
||||
id: number | string
|
||||
content: string
|
||||
createdBy: number | string | null
|
||||
createdAt: string | null
|
||||
}
|
||||
|
||||
export interface UnifiedOrderStatusHistoryResource {
|
||||
id: number | string
|
||||
fromStatus: string | null
|
||||
toStatus: string | null
|
||||
action: string | null
|
||||
reason: string | null
|
||||
operatorId: number | string | null
|
||||
operatorName: string | null
|
||||
occurredAt: string | null
|
||||
}
|
||||
|
||||
export interface UnifiedOrderDetailResource {
|
||||
summary: UnifiedOrderResource
|
||||
order: UnifiedOrderDetailOrderResource
|
||||
timeline: UnifiedOrderTimelineEvent[]
|
||||
}
|
||||
|
||||
export interface UnifiedOrderPageResource {
|
||||
records: UnifiedOrderResource[]
|
||||
page: number
|
||||
size: number
|
||||
total: number
|
||||
pages: number
|
||||
}
|
||||
|
||||
export interface UnifiedOrderQuery {
|
||||
tenantId?: string
|
||||
keyword?: string
|
||||
orderNo?: string
|
||||
mainStatus?: UnifiedOrderMainStatus
|
||||
customerId?: string
|
||||
receivingWarehouseId?: string
|
||||
destinationWarehouseId?: string
|
||||
createdFrom?: string
|
||||
createdTo?: string
|
||||
page: number
|
||||
size: number
|
||||
}
|
||||
|
||||
type PagePayload = Omit<UnifiedOrderPageResource, 'page' | 'size' | 'total' | 'pages'> & {
|
||||
page: number | string
|
||||
size: number | string
|
||||
total: number | string
|
||||
pages: number | string
|
||||
}
|
||||
|
||||
function integer(value: number | string | null | undefined, fallback: number) {
|
||||
const parsed = Number(value)
|
||||
return Number.isFinite(parsed) && parsed >= 0 ? Math.trunc(parsed) : fallback
|
||||
}
|
||||
|
||||
function nullableInteger(value: number | string | null | undefined) {
|
||||
if (value === null || value === undefined || value === '') return null
|
||||
return integer(value, 0)
|
||||
}
|
||||
|
||||
function numberValue(value: number | string | null | undefined) {
|
||||
if (value === null || value === undefined || value === '') return null
|
||||
const parsed = Number(value)
|
||||
return Number.isFinite(parsed) ? parsed : null
|
||||
}
|
||||
|
||||
function id(value: number | string | null | undefined) {
|
||||
return value === null || value === undefined || value === '' ? null : String(value)
|
||||
}
|
||||
|
||||
function queryParams(query: UnifiedOrderQuery) {
|
||||
return Object.fromEntries(Object.entries(query).filter(([, value]) => value !== undefined && value !== ''))
|
||||
}
|
||||
|
||||
function normalizeRow(raw: UnifiedOrderResource): UnifiedOrderResource {
|
||||
return {
|
||||
...raw,
|
||||
orderId: String(raw.orderId),
|
||||
tenantId: String(raw.tenantId),
|
||||
customerId: id(raw.customerId),
|
||||
receivingWarehouseId: id(raw.receivingWarehouseId),
|
||||
destinationWarehouseId: id(raw.destinationWarehouseId),
|
||||
inboundId: id(raw.inboundId),
|
||||
inboundVersion: nullableInteger(raw.inboundVersion),
|
||||
totalPackageCount: nullableInteger(raw.totalPackageCount),
|
||||
measurement: raw.measurement ? {
|
||||
...raw.measurement,
|
||||
actualGrossWeightKg: numberValue(raw.measurement.actualGrossWeightKg),
|
||||
actualVolumeCbm: numberValue(raw.measurement.actualVolumeCbm),
|
||||
chargeableWeightKg: numberValue(raw.measurement.chargeableWeightKg),
|
||||
} : null,
|
||||
preload: raw.preload ? { ...raw.preload, version: nullableInteger(raw.preload.version), itemCount: nullableInteger(raw.preload.itemCount) } : null,
|
||||
loading: raw.loading ? {
|
||||
...raw.loading,
|
||||
loadingVersion: nullableInteger(raw.loading.loadingVersion),
|
||||
pickingVersion: nullableInteger(raw.loading.pickingVersion),
|
||||
expectedCount: nullableInteger(raw.loading.expectedCount),
|
||||
pickedCount: nullableInteger(raw.loading.pickedCount),
|
||||
evidenceCount: nullableInteger(raw.loading.evidenceCount),
|
||||
} : null,
|
||||
transport: raw.transport ? { ...raw.transport } : null,
|
||||
}
|
||||
}
|
||||
|
||||
export async function listPlatformUnifiedOrders(query: UnifiedOrderQuery) {
|
||||
const response = await http.get<ApiEnvelope<PagePayload>, ApiEnvelope<PagePayload>>(unifiedOrdersApiPath, {
|
||||
params: queryParams(query),
|
||||
})
|
||||
const result = unwrapApiResponse(response)
|
||||
return {
|
||||
...result,
|
||||
records: (result.records || []).map(normalizeRow),
|
||||
page: integer(result.page, query.page),
|
||||
size: integer(result.size, query.size),
|
||||
total: integer(result.total, 0),
|
||||
pages: integer(result.pages, 0),
|
||||
} satisfies UnifiedOrderPageResource
|
||||
}
|
||||
|
||||
export async function getPlatformUnifiedOrder(tenantId: string, orderId: string) {
|
||||
const response = await http.get<ApiEnvelope<UnifiedOrderDetailResource>, ApiEnvelope<UnifiedOrderDetailResource>>(
|
||||
`${unifiedOrdersApiPath}/${orderId}`,
|
||||
{ params: { tenantId } },
|
||||
)
|
||||
const result = unwrapApiResponse(response)
|
||||
return { ...result, summary: normalizeRow(result.summary) }
|
||||
}
|
||||
|
||||
export function unifiedOrderStatusLabel(status: UnifiedOrderMainStatus) {
|
||||
return unifiedOrderStatusOptions.find((item) => item.value === status)?.label || status
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { unifiedOrderStatusOptions } from '../api/unifiedOrders'
|
||||
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/modules/unified-orders/pages/UnifiedOrdersView.vue'), 'utf8')
|
||||
|
||||
describe('UnifiedOrdersView contract', () => {
|
||||
it('only exposes the seven product main statuses', () => {
|
||||
expect(unifiedOrderStatusOptions.map((item) => item.label)).toEqual([
|
||||
'待审核', '待测量数据', '已入库', '待分柜', '装柜中', '装柜完成', '派送完成',
|
||||
])
|
||||
for (const legacyLabel of ['待客户确认', '差异处理中', '派送中', '进入分柜']) {
|
||||
expect(source).not.toContain(legacyLabel)
|
||||
}
|
||||
})
|
||||
|
||||
it('covers real list states and keeps operation column first and left-fixed', () => {
|
||||
expect(source).toContain("{ key: 'actions', label: '操作', width: 180, fixed: 'left'")
|
||||
expect(source).toContain('title="订单列表暂时无法加载"')
|
||||
expect(source).toContain('empty-text="暂无符合条件的订单"')
|
||||
expect(source).toContain(':loading="loading"')
|
||||
expect(source).toContain('v-model:page="page"')
|
||||
expect(source).toContain('v-model:page-size="pageSize"')
|
||||
})
|
||||
|
||||
it('keeps business actions as explicit C01/C03/C04/C06 boundaries', () => {
|
||||
expect(source).toContain('nextActionBoundary')
|
||||
expect(source).toContain('待接入:${label}')
|
||||
expect(source).toContain('CONFIRM_PRELOAD')
|
||||
expect(source).toContain("can('platform:firstleg:inbound:view')")
|
||||
expect(source).toContain('openMeasurement')
|
||||
expect(source).not.toContain('updateMainStatus')
|
||||
expect(source).not.toContain('人工进入分柜')
|
||||
expect(source).not.toContain('performPlatformUnifiedOrderAction')
|
||||
expect(source).not.toContain('/actions/')
|
||||
})
|
||||
|
||||
it('provides unified detail, history and internal note states', () => {
|
||||
expect(source).toContain('getPlatformUnifiedOrder')
|
||||
expect(source).toContain('aria-label="状态历史"')
|
||||
expect(source).toContain('aria-label="内部备注"')
|
||||
expect(source).toContain('订单详情暂时无法加载')
|
||||
})
|
||||
|
||||
it('exposes the C06 workflow entry only for inbound-backed downstream states', () => {
|
||||
expect(source).toContain("name: 'unified-order-workflow'")
|
||||
expect(source).toContain("['WAIT_SPLIT', 'LOADING', 'LOADED'].includes(row.mainStatus)")
|
||||
expect(source).toContain('后半程作业')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,327 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { ClipboardList, Eye, RefreshCw } from '@lucide/vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import {
|
||||
getPlatformUnifiedOrder,
|
||||
listPlatformUnifiedOrders,
|
||||
unifiedOrderStatusOptions,
|
||||
type UnifiedOrderDetailResource,
|
||||
type UnifiedOrderMainStatus,
|
||||
type UnifiedOrderResource,
|
||||
} from '@/modules/unified-orders/api/unifiedOrders'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import AppDrawer from '@/shared/components/AppDrawer.vue'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||
import StatusBadge from '@/shared/components/StatusBadge.vue'
|
||||
|
||||
type FilterModel = QueryModel & {
|
||||
tenantId: string
|
||||
keyword: string
|
||||
mainStatus: UnifiedOrderMainStatus | ''
|
||||
createdRange: string[]
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const access = useAccessPreviewStore()
|
||||
const records = ref<UnifiedOrderResource[]>([])
|
||||
const customers = ref<PlatformTenantResource[]>([])
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
const loadError = ref<ApiErrorDetails | null>(null)
|
||||
const customerLoading = ref(false)
|
||||
const customerLoadError = ref<ApiErrorDetails | null>(null)
|
||||
const detail = ref<UnifiedOrderDetailResource | null>(null)
|
||||
const detailLoading = ref(false)
|
||||
const detailError = ref<ApiErrorDetails | null>(null)
|
||||
const detailOpen = ref(false)
|
||||
const filters = ref<FilterModel>({ tenantId: '', keyword: '', mainStatus: '', createdRange: [] })
|
||||
|
||||
const orderId = computed(() => typeof route.params.orderId === 'string' ? route.params.orderId : '')
|
||||
|
||||
const queryFields = computed<QueryField[]>(() => [
|
||||
{
|
||||
key: 'tenantId',
|
||||
type: 'select',
|
||||
width: 220,
|
||||
placeholder: customerLoadError.value ? '客户列表加载失败' : customerLoading.value ? '正在加载客户' : '全部客户',
|
||||
options: customers.value.map((customer) => ({
|
||||
value: String(customer.id),
|
||||
label: `${customer.legalName?.trim() || customer.tenantName}(${customer.tenantCode || '无客户编码'})`,
|
||||
})),
|
||||
props: { 'aria-label': '客户', filterable: true, loading: customerLoading.value, disabled: Boolean(customerLoadError.value) },
|
||||
},
|
||||
{
|
||||
key: 'mainStatus',
|
||||
type: 'select',
|
||||
width: 160,
|
||||
placeholder: '全部主状态',
|
||||
options: unifiedOrderStatusOptions,
|
||||
props: { 'aria-label': '订单主状态' },
|
||||
},
|
||||
{ key: 'keyword', type: 'input', width: 260, placeholder: '订单号、入仓码或客户名称', props: { 'aria-label': '订单关键词' } },
|
||||
{ key: 'createdRange', type: 'daterange', width: 300, placeholder: '创建日期范围', props: { 'aria-label': '创建日期范围' } },
|
||||
])
|
||||
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 180, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'order', label: '订单信息', minWidth: 220, slotName: 'order' },
|
||||
{ key: 'mainStatus', label: '主状态', width: 132, slotName: 'mainStatus' },
|
||||
{ key: 'customer', label: '客户', minWidth: 180, slotName: 'customer' },
|
||||
{ key: 'receivingWarehouseName', label: '平台收货仓', minWidth: 160, slotName: 'receivingWarehouseName' },
|
||||
{ key: 'destinationWarehouseName', label: '目的仓', minWidth: 160, slotName: 'destinationWarehouseName' },
|
||||
{ key: 'transport', label: '运输/装柜', minWidth: 180, slotName: 'transport' },
|
||||
{ key: 'cargo', label: '货物数据', minWidth: 180, slotName: 'cargo' },
|
||||
{ key: 'expectedShipDate', label: '预计出运', minWidth: 150, slotName: 'expectedShipDate' },
|
||||
{ key: 'updatedAt', label: '最近更新', minWidth: 150, slotName: 'updatedAt' },
|
||||
]
|
||||
|
||||
function can(permission: string) {
|
||||
return access.can(permission)
|
||||
}
|
||||
|
||||
function customerName(row: UnifiedOrderResource) {
|
||||
return row.customerName?.trim() || customers.value.find((item) => String(item.id) === String(row.tenantId))?.tenantName || `客户 ${row.tenantId}`
|
||||
}
|
||||
|
||||
function formatDate(value: string | null | undefined) {
|
||||
return value ? value.replace('T', ' ').slice(0, 16) : '—'
|
||||
}
|
||||
|
||||
function formatNumber(value: number | null | undefined, digits = 2) {
|
||||
return value === null || value === undefined ? '—' : value.toLocaleString('zh-CN', { maximumFractionDigits: digits })
|
||||
}
|
||||
|
||||
function statusLabel(value: UnifiedOrderMainStatus | string | null) {
|
||||
return unifiedOrderStatusOptions.find((item) => item.value === value)?.label || value || '状态更新'
|
||||
}
|
||||
|
||||
const nextActionLabels: Record<string, string> = {
|
||||
APPROVE_ORDER: '审核通过',
|
||||
CONFIRM_MEASUREMENT: '完成测量',
|
||||
SMART_SPLIT: '智能分柜',
|
||||
CONFIRM_PRELOAD: '确认预装清单',
|
||||
COMPLETE_LOADING: '确认装柜完成',
|
||||
COMPLETE_DELIVERY: '确认派送完成',
|
||||
}
|
||||
|
||||
function nextActionBoundary(row: UnifiedOrderResource) {
|
||||
const label = row.nextAction ? nextActionLabels[row.nextAction] || row.nextAction : ''
|
||||
return label ? `待接入:${label}` : ''
|
||||
}
|
||||
|
||||
async function loadCustomers() {
|
||||
customerLoading.value = true
|
||||
customerLoadError.value = null
|
||||
try {
|
||||
const records: PlatformTenantResource[] = []
|
||||
let currentPage = 1
|
||||
let pages = 1
|
||||
do {
|
||||
const result = await listPlatformTenants({ page: currentPage, size: 200 })
|
||||
records.push(...result.records)
|
||||
pages = Math.max(1, result.pages)
|
||||
currentPage += 1
|
||||
} while (currentPage <= pages)
|
||||
customers.value = records
|
||||
} catch (error) {
|
||||
customers.value = []
|
||||
customerLoadError.value = getApiErrorDetails(error, '客户列表加载失败,暂时无法按客户筛选')
|
||||
} finally {
|
||||
customerLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadOrders() {
|
||||
loading.value = true
|
||||
loadError.value = null
|
||||
try {
|
||||
const range = filters.value.createdRange
|
||||
const result = await listPlatformUnifiedOrders({
|
||||
tenantId: filters.value.tenantId || undefined,
|
||||
keyword: filters.value.keyword.trim() || undefined,
|
||||
mainStatus: filters.value.mainStatus || undefined,
|
||||
createdFrom: range.length === 2 ? `${range[0]}T00:00:00` : undefined,
|
||||
createdTo: range.length === 2 ? `${range[1]}T23:59:59` : undefined,
|
||||
page: page.value,
|
||||
size: pageSize.value,
|
||||
})
|
||||
records.value = result.records
|
||||
total.value = result.total
|
||||
} catch (error) {
|
||||
records.value = []
|
||||
total.value = 0
|
||||
loadError.value = getApiErrorDetails(error, '统一订单列表加载失败,请检查 C02 统一查询接口和订单权限')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function search() {
|
||||
if (page.value !== 1) {
|
||||
page.value = 1
|
||||
return
|
||||
}
|
||||
void loadOrders()
|
||||
}
|
||||
|
||||
function reset() {
|
||||
filters.value = { tenantId: '', keyword: '', mainStatus: '', createdRange: [] }
|
||||
page.value = 1
|
||||
void loadOrders()
|
||||
}
|
||||
|
||||
function openDetail(row: UnifiedOrderResource) {
|
||||
void router.push({ name: 'unified-order-detail', params: { orderId: String(row.orderId) }, query: { tenantId: String(row.tenantId) } })
|
||||
}
|
||||
|
||||
function openMeasurement(row: UnifiedOrderResource) {
|
||||
if (!row.inboundId || !can('platform:firstleg:inbound:view')) return
|
||||
void router.push({ name: 'inbound-order-detail', params: { inboundId: String(row.inboundId) }, query: { tenantId: String(row.tenantId) } })
|
||||
}
|
||||
|
||||
function workflowEligible(row: UnifiedOrderResource) {
|
||||
return Boolean(row.inboundId && ['WAIT_SPLIT', 'LOADING', 'LOADED'].includes(row.mainStatus))
|
||||
}
|
||||
|
||||
function openWorkflow(row: UnifiedOrderResource) {
|
||||
if (!workflowEligible(row) || !can('platform:firstleg:inbound:view')) return
|
||||
void router.push({
|
||||
name: 'unified-order-workflow',
|
||||
params: { orderId: String(row.orderId) },
|
||||
query: { tenantId: String(row.tenantId) },
|
||||
})
|
||||
}
|
||||
|
||||
function closeDetail() {
|
||||
detailOpen.value = false
|
||||
detail.value = null
|
||||
detailError.value = null
|
||||
void router.push({ name: 'unified-orders' })
|
||||
}
|
||||
|
||||
async function loadDetail() {
|
||||
if (!orderId.value) return
|
||||
const tenantId = typeof route.query.tenantId === 'string' ? route.query.tenantId : ''
|
||||
if (!tenantId) {
|
||||
detailError.value = { message: '订单详情缺少客户标识,请从订单列表重新打开' }
|
||||
detailOpen.value = true
|
||||
return
|
||||
}
|
||||
detailLoading.value = true
|
||||
detailError.value = null
|
||||
detailOpen.value = true
|
||||
try {
|
||||
detail.value = await getPlatformUnifiedOrder(tenantId, orderId.value)
|
||||
} catch (error) {
|
||||
detail.value = null
|
||||
detailError.value = getApiErrorDetails(error, '统一订单详情加载失败,请检查 C02 统一详情接口和订单权限')
|
||||
} finally {
|
||||
detailLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch([page, pageSize], () => { void loadOrders() })
|
||||
watch(orderId, (value) => {
|
||||
if (value) void loadDetail()
|
||||
else detailOpen.value = false
|
||||
}, { immediate: true })
|
||||
onMounted(() => {
|
||||
void loadCustomers()
|
||||
void loadOrders()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="unified-orders-page">
|
||||
<div class="page-intro">
|
||||
<div>
|
||||
<h1>订单列表</h1>
|
||||
<p>平台围绕同一订单主线跟进审核、测量、分柜、装柜和派送。</p>
|
||||
</div>
|
||||
<el-button :loading="loading" @click="loadOrders"><RefreshCw :size="16" />刷新</el-button>
|
||||
</div>
|
||||
|
||||
<QueryToolbar v-model="filters" ariaLabel="订单列表筛选" :fields="queryFields" :search-on-reset="false" @search="search" @reset="reset" />
|
||||
<LoadErrorState v-if="loadError" class="orders-load-error" title="订单列表暂时无法加载" :message="loadError.message" :loading="loading" @retry="loadOrders">
|
||||
<template #details><ApiErrorMeta :details="loadError" /></template>
|
||||
</LoadErrorState>
|
||||
|
||||
<div v-else class="table-card">
|
||||
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="records" :columns="columns" :loading="loading" :total="total" :page-sizes="[20, 50, 100]" row-key="orderId" empty-text="暂无符合条件的订单" aria-label="统一订单列表">
|
||||
<template #actions="{ row }">
|
||||
<div class="row-actions">
|
||||
<el-button link type="primary" @click="openDetail(row as UnifiedOrderResource)"><Eye :size="15" />详情</el-button>
|
||||
<el-button v-if="(row as UnifiedOrderResource).mainStatus === 'WAIT_MEASURE' && (row as UnifiedOrderResource).inboundId && can('platform:firstleg:inbound:view')" link type="primary" @click="openMeasurement(row as UnifiedOrderResource)">测量作业</el-button>
|
||||
<el-button v-if="workflowEligible(row as UnifiedOrderResource) && can('platform:firstleg:inbound:view')" link type="primary" @click="openWorkflow(row as UnifiedOrderResource)"><ClipboardList :size="15" />后半程作业</el-button>
|
||||
<span v-if="nextActionBoundary(row as UnifiedOrderResource)" class="action-boundary">{{ nextActionBoundary(row as UnifiedOrderResource) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<template #order="{ row }"><div class="primary-cell"><strong>{{ (row as UnifiedOrderResource).orderNo || `订单 ${(row as UnifiedOrderResource).orderId}` }}</strong><span v-if="(row as UnifiedOrderResource).inboundCode">入仓码 {{ (row as UnifiedOrderResource).inboundCode }}</span></div></template>
|
||||
<template #mainStatus="{ row }"><StatusBadge :status="statusLabel((row as UnifiedOrderResource).mainStatus)" /></template>
|
||||
<template #customer="{ row }"><div class="primary-cell"><strong>{{ customerName(row as UnifiedOrderResource) }}</strong><span>客户 ID {{ (row as UnifiedOrderResource).tenantId }}</span></div></template>
|
||||
<template #receivingWarehouseName="{ row }">{{ (row as UnifiedOrderResource).receivingWarehouseName || '—' }}</template>
|
||||
<template #destinationWarehouseName="{ row }">{{ (row as UnifiedOrderResource).destinationWarehouseName || '—' }}</template>
|
||||
<template #transport="{ row }"><div class="primary-cell"><strong>{{ (row as UnifiedOrderResource).transport?.containerNo || (row as UnifiedOrderResource).transport?.loadingStatus || '—' }}</strong><span v-if="(row as UnifiedOrderResource).transport?.vesselName">{{ (row as UnifiedOrderResource).transport?.vesselName }} · {{ (row as UnifiedOrderResource).transport?.voyageNo || '—' }}</span></div></template>
|
||||
<template #cargo="{ row }"><span>{{ (row as UnifiedOrderResource).totalPackageCount ?? '—' }} 箱</span><span v-if="(row as UnifiedOrderResource).measurement?.actualGrossWeightKg !== null && (row as UnifiedOrderResource).measurement?.actualGrossWeightKg !== undefined"> · {{ formatNumber((row as UnifiedOrderResource).measurement?.actualGrossWeightKg) }} kg</span><span v-if="(row as UnifiedOrderResource).measurement?.actualVolumeCbm !== null && (row as UnifiedOrderResource).measurement?.actualVolumeCbm !== undefined"> · {{ formatNumber((row as UnifiedOrderResource).measurement?.actualVolumeCbm, 3) }} m³</span></template>
|
||||
<template #expectedShipDate="{ row }">{{ formatDate((row as UnifiedOrderResource).expectedShipDate) }}</template>
|
||||
<template #updatedAt="{ row }">{{ formatDate((row as UnifiedOrderResource).updatedAt) }}</template>
|
||||
</DataTable>
|
||||
</div>
|
||||
|
||||
<AppDrawer v-model="detailOpen" :title="detail ? `订单详情 · ${detail.summary.orderNo || detail.summary.orderId}` : '订单详情'" size="min(760px, 94vw)" :loading="detailLoading" :show-footer="false" destroy-on-close @closed="closeDetail">
|
||||
<LoadErrorState v-if="detailError" title="订单详情暂时无法加载" :message="detailError.message" :loading="detailLoading" @retry="loadDetail"><template #details><ApiErrorMeta :details="detailError" /></template></LoadErrorState>
|
||||
<div v-else-if="detail" class="detail-content">
|
||||
<section class="detail-summary" aria-label="订单概览">
|
||||
<div class="detail-summary__header"><div><span class="eyebrow">订单主状态</span><h2>{{ detail.summary.orderNo || `订单 ${detail.summary.orderId}` }}</h2></div><div class="detail-summary__actions"><StatusBadge :status="statusLabel(detail.summary.mainStatus)" /><el-button v-if="workflowEligible(detail.summary) && can('platform:firstleg:inbound:view')" type="primary" plain @click="openWorkflow(detail.summary)"><ClipboardList :size="15" />后半程作业</el-button></div></div>
|
||||
<dl class="detail-grid"><div><dt>客户</dt><dd>{{ customerName(detail.summary) }}</dd></div><div><dt>入仓码</dt><dd>{{ detail.summary.inboundCode || '—' }}</dd></div><div><dt>平台收货仓</dt><dd>{{ detail.summary.receivingWarehouseName || '—' }}</dd></div><div><dt>目的仓</dt><dd>{{ detail.summary.destinationWarehouseName || '—' }}</dd></div><div><dt>下一步</dt><dd>{{ nextActionBoundary(detail.summary) || '—' }}</dd></div><div><dt>最近更新</dt><dd>{{ formatDate(detail.summary.updatedAt) }}</dd></div></dl>
|
||||
</section>
|
||||
<section class="detail-section" aria-label="状态历史"><h3>状态历史</h3><el-empty v-if="detail.timeline.length === 0" description="暂无状态历史" :image-size="56" /><ol v-else class="history-list"><li v-for="(item, index) in detail.timeline" :key="`${item.source || 'event'}-${item.occurredAt || index}`"><StatusBadge :status="statusLabel(item.mainStatus)" /><div><strong>{{ item.action || item.eventType || '状态更新' }}</strong><p>{{ item.operatorName || '系统' }} · {{ formatDate(item.occurredAt) }}</p><p v-if="item.remark">{{ item.remark }}</p></div></li></ol></section>
|
||||
<section class="detail-section" aria-label="内部备注"><h3>内部备注</h3><el-empty v-if="detail.order.internalNotes.length === 0" description="暂无内部备注" :image-size="56" /><ul v-else class="notes-list"><li v-for="item in detail.order.internalNotes" :key="String(item.id)"><p>{{ item.content }}</p><span>操作人 {{ item.createdBy || '系统' }} · {{ formatDate(item.createdAt) }}</span></li></ul></section>
|
||||
</div>
|
||||
</AppDrawer>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.unified-orders-page { min-width: 0; }
|
||||
.page-intro { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 16px; }
|
||||
.page-intro h1 { margin: 0; color: var(--cc-text); font-size: 22px; line-height: 1.35; }
|
||||
.page-intro p { margin: 5px 0 0; color: var(--cc-muted); font-size: 13px; line-height: 1.6; }
|
||||
.page-intro :deep(.el-button) { flex: 0 0 auto; }
|
||||
.orders-load-error { margin-top: 12px; }
|
||||
.table-card { margin-top: 16px; overflow: hidden; border: 1px solid var(--cc-border); border-radius: 8px; background: #fff; }
|
||||
.row-actions { display: flex; align-items: center; gap: 4px; white-space: nowrap; }
|
||||
.row-actions :deep(.el-button) { margin-left: 0; }
|
||||
.primary-cell { display: grid; min-width: 0; gap: 4px; }
|
||||
.primary-cell strong { overflow-wrap: anywhere; }
|
||||
.primary-cell span { color: var(--cc-muted); font-size: 12px; overflow-wrap: anywhere; }
|
||||
.detail-content { display: grid; gap: 22px; min-width: 0; }
|
||||
.detail-summary, .detail-section { padding: 16px; border: 1px solid var(--cc-border); border-radius: 8px; background: #fff; }
|
||||
.detail-summary__header { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
|
||||
.detail-summary__actions { display: flex; align-items: center; flex-wrap: wrap; justify-content: flex-end; gap: 8px; }
|
||||
.eyebrow { color: var(--cc-muted); font-size: 12px; }
|
||||
.detail-summary h2 { margin: 4px 0 0; color: var(--cc-text); font-size: 18px; }
|
||||
.detail-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px 20px; margin: 20px 0 0; }
|
||||
.detail-grid div { min-width: 0; }
|
||||
.detail-grid dt { color: var(--cc-muted); font-size: 12px; }
|
||||
.detail-grid dd { margin: 4px 0 0; color: var(--cc-text); overflow-wrap: anywhere; }
|
||||
.detail-section h3 { margin: 0 0 12px; color: var(--cc-text); font-size: 15px; }
|
||||
.history-list, .notes-list { display: grid; gap: 12px; margin: 0; padding: 0; list-style: none; }
|
||||
.history-list li { display: flex; align-items: flex-start; gap: 10px; padding-bottom: 12px; border-bottom: 1px solid #edf0f4; }
|
||||
.history-list li:last-child, .notes-list li:last-child { padding-bottom: 0; border-bottom: 0; }
|
||||
.history-list strong { font-size: 13px; }
|
||||
.history-list p, .notes-list p { margin: 3px 0 0; color: var(--cc-muted); font-size: 12px; line-height: 1.5; overflow-wrap: anywhere; }
|
||||
.notes-list li { padding-bottom: 12px; border-bottom: 1px solid #edf0f4; }
|
||||
.notes-list li > p { margin: 0; color: var(--cc-text); font-size: 13px; }
|
||||
.notes-list span { display: block; margin-top: 5px; color: var(--cc-muted); font-size: 12px; }
|
||||
@media (max-width: 720px) { .page-intro { align-items: stretch; flex-direction: column; } .page-intro :deep(.el-button) { width: 100%; min-height: 44px; } .detail-grid { grid-template-columns: 1fr; } .detail-summary__header { flex-direction: column; } .detail-summary__actions { justify-content: flex-start; } .row-actions { flex-wrap: wrap; } }
|
||||
</style>
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
|
||||
export const unifiedOrderRoutes = [
|
||||
{
|
||||
path: 'orders',
|
||||
name: 'unified-orders',
|
||||
component: () => import('./pages/UnifiedOrdersView.vue'),
|
||||
meta: {
|
||||
title: '订单列表',
|
||||
description: '围绕同一订单主线跟进审核、测量、分柜、装柜和派送',
|
||||
permission: 'platform:order:list',
|
||||
permissionAnyOf: ['platform:firstleg:inbound:list'],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'orders/:orderId/workflow',
|
||||
name: 'unified-order-workflow',
|
||||
component: () => import('./workflows/pages/UnifiedOrderWorkflowView.vue'),
|
||||
meta: {
|
||||
title: '订单后半程作业',
|
||||
description: '处理分柜、预装、装柜和派送作业',
|
||||
permission: 'platform:firstleg:inbound:view',
|
||||
permissionAnyOf: ['platform:firstleg:inbound:list', 'platform:order:list'],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'orders/:orderId',
|
||||
name: 'unified-order-detail',
|
||||
component: () => import('./pages/UnifiedOrdersView.vue'),
|
||||
meta: {
|
||||
title: '订单详情',
|
||||
description: '查看订单主状态、状态历史和平台备注',
|
||||
permission: 'platform:order:view',
|
||||
permissionAnyOf: ['platform:order:list', 'platform:firstleg:inbound:view'],
|
||||
},
|
||||
},
|
||||
] satisfies RouteRecordRaw[]
|
||||
@@ -0,0 +1,16 @@
|
||||
# C06 Admin 后半程作业
|
||||
|
||||
本目录只承载统一订单详情的后半程作业接入点,供 C05 统一订单列表在详情抽屉或作业页中装配。
|
||||
|
||||
真实接口均使用 `/api/platform/v1/inbounds/{inboundId}`:
|
||||
|
||||
- `POST /split/smart`:智能分柜/保存预装清单
|
||||
- `GET /preload`、`POST /preload/confirming`、`POST /preload/confirm`:预装清单查询与确认
|
||||
- `GET/POST /loading/picking`、`/loading/picking/start`、`/loading/picking/complete`:拣货
|
||||
- `GET/POST /loading/evidence`:四步照片与封签证据元数据
|
||||
- `POST /loading/complete`:完成装柜
|
||||
- `POST /delivery/complete`:确认派送完成
|
||||
|
||||
所有写请求带 `Idempotency-Key` 和服务端要求的 `expectedVersion`。`loading/complete` 与 `delivery/complete` 使用入库单版本;拣货使用拣货任务版本。当前没有 File 服务上传接口,证据组件不会把本地路径伪装成已上传文件。
|
||||
|
||||
权限码、状态流转和请求字段以 Firstleg Controller/DTO 为准;本模块不新增“进入分柜”操作,也不定义通用 `/actions/{ACTION}` 接口。
|
||||
@@ -0,0 +1,70 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { http } from '@/shared/api/http'
|
||||
import {
|
||||
completeDelivery,
|
||||
completeLoading,
|
||||
confirmingPreload,
|
||||
getPicking,
|
||||
getPreload,
|
||||
smartSplit,
|
||||
} from './api'
|
||||
|
||||
vi.mock('@/shared/api/http', () => ({
|
||||
http: { get: vi.fn(), post: vi.fn() },
|
||||
unwrapApiResponse: <T>(response: { data: T }) => response.data,
|
||||
}))
|
||||
|
||||
describe('unified order workflow API contract', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(http.get).mockReset()
|
||||
vi.mocked(http.post).mockReset()
|
||||
})
|
||||
|
||||
it('uses the C03 preload endpoints and expected version', async () => {
|
||||
vi.mocked(http.get).mockResolvedValue({ data: { id: 1, inboundId: 8, tenantId: 2, status: 'PENDING', version: 3, items: [] } })
|
||||
vi.mocked(http.post).mockResolvedValue({ data: { mainStatus: 'WAIT_SPLIT', preloadPlan: null } })
|
||||
await getPreload('2', '8')
|
||||
await smartSplit('2', '8', { expectedVersion: 3, items: [] })
|
||||
expect(http.get).toHaveBeenCalledWith('/platform/v1/inbounds/8/preload', { params: { tenantId: '2' } })
|
||||
expect(http.post).toHaveBeenCalledWith(
|
||||
'/platform/v1/inbounds/8/split/smart', { expectedVersion: 3, items: [] },
|
||||
expect.objectContaining({ params: { tenantId: '2' }, headers: { 'Idempotency-Key': expect.any(String) } }),
|
||||
)
|
||||
})
|
||||
|
||||
it('uses C04 picking and loading paths without a generic action endpoint', async () => {
|
||||
vi.mocked(http.get).mockResolvedValue({ data: { id: 1, inboundId: 8, status: 'PICKING', version: 4 } })
|
||||
vi.mocked(http.post).mockResolvedValue({ data: { mainStatus: 'LOADED', evidences: null } })
|
||||
await getPicking('2', '8')
|
||||
await completeLoading('2', '8', { expectedVersion: 7 })
|
||||
expect(http.get).toHaveBeenCalledWith('/platform/v1/inbounds/8/loading/picking', { params: { tenantId: '2' } })
|
||||
expect(http.post).toHaveBeenCalledWith(
|
||||
'/platform/v1/inbounds/8/loading/complete', { expectedVersion: 7 },
|
||||
expect.objectContaining({ params: { tenantId: '2' }, headers: { 'Idempotency-Key': expect.any(String) } }),
|
||||
)
|
||||
expect(vi.mocked(http.post).mock.calls.some(([url]) => String(url).includes('/actions/'))).toBe(false)
|
||||
})
|
||||
|
||||
it('uses the inbound version for delivery completion', async () => {
|
||||
vi.mocked(http.post).mockResolvedValue({ data: { mainStatus: 'DELIVERED', evidences: null } })
|
||||
await completeDelivery('2', '8', { expectedVersion: 9, remark: '已确认派送' })
|
||||
expect(http.post).toHaveBeenCalledWith(
|
||||
'/platform/v1/inbounds/8/delivery/complete', { expectedVersion: 9, remark: '已确认派送' },
|
||||
expect.objectContaining({ params: { tenantId: '2' }, headers: { 'Idempotency-Key': expect.any(String) } }),
|
||||
)
|
||||
})
|
||||
|
||||
it('does not swallow non-success responses', async () => {
|
||||
vi.mocked(http.get).mockRejectedValue(new Error('无权限'))
|
||||
await expect(getPreload('2', '8')).rejects.toThrow('无权限')
|
||||
})
|
||||
|
||||
it('supports the intermediate confirming preload action', async () => {
|
||||
vi.mocked(http.post).mockResolvedValue({ data: { mainStatus: 'WAIT_SPLIT', preloadPlan: null } })
|
||||
await confirmingPreload('2', '8', { expectedVersion: 1 })
|
||||
expect(http.post).toHaveBeenCalledWith(
|
||||
'/platform/v1/inbounds/8/preload/confirming', { expectedVersion: 1 },
|
||||
expect.objectContaining({ params: { tenantId: '2' } }),
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,229 @@
|
||||
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
|
||||
const inboundBasePath = '/platform/v1/inbounds'
|
||||
|
||||
export type PreloadStatus = 'PENDING' | 'CONFIRMING' | 'CONFIRMED' | string
|
||||
export type EvidenceType = 'PHOTO_STEP_1' | 'PHOTO_STEP_2' | 'PHOTO_STEP_3' | 'PHOTO_STEP_4' | 'SEAL'
|
||||
|
||||
export interface PreloadItem {
|
||||
containerCode: string | null
|
||||
cargoReference: string | null
|
||||
packageCount: number | null
|
||||
volumeCbm: number | null
|
||||
remark: string | null
|
||||
}
|
||||
|
||||
export interface PreloadPlan {
|
||||
id: number | string
|
||||
inboundId: number | string
|
||||
tenantId: number | string
|
||||
status: PreloadStatus
|
||||
version: number
|
||||
items: PreloadItem[]
|
||||
remark: string | null
|
||||
createdAt: string | null
|
||||
updatedAt: string | null
|
||||
}
|
||||
|
||||
export interface SplitActionResponse {
|
||||
mainStatus: string
|
||||
preloadPlan: PreloadPlan | null
|
||||
}
|
||||
|
||||
export interface PickingTask {
|
||||
id: number | string
|
||||
inboundId: number | string
|
||||
status: string
|
||||
version: number
|
||||
expectedCount: number | null
|
||||
pickedCount: number | null
|
||||
remark: string | null
|
||||
}
|
||||
|
||||
export interface Evidence {
|
||||
id: number | string
|
||||
inboundId: number | string
|
||||
tenantId: number | string
|
||||
evidenceType: EvidenceType
|
||||
fileId: number | string | null
|
||||
fileName: string | null
|
||||
sealNo: string | null
|
||||
remark: string | null
|
||||
version: number
|
||||
createdAt: string | null
|
||||
updatedAt: string | null
|
||||
}
|
||||
|
||||
export interface EvidenceList {
|
||||
inboundId: number | string
|
||||
evidences: Evidence[]
|
||||
}
|
||||
|
||||
export interface LoadingActionResponse {
|
||||
mainStatus: string
|
||||
evidences: EvidenceList | null
|
||||
}
|
||||
|
||||
export interface PickingRequest {
|
||||
expectedVersion: number
|
||||
expectedCount: number
|
||||
pickedCount: number
|
||||
remark?: string
|
||||
}
|
||||
|
||||
export interface SmartSplitRequest {
|
||||
expectedVersion: number
|
||||
items: PreloadItem[]
|
||||
remark?: string
|
||||
}
|
||||
|
||||
export interface ConfirmPreloadRequest {
|
||||
expectedVersion: number
|
||||
}
|
||||
|
||||
export interface UpsertEvidenceRequest {
|
||||
expectedVersion: number
|
||||
evidenceType: EvidenceType
|
||||
fileId?: number | string
|
||||
fileName?: string
|
||||
sealNo?: string
|
||||
remark?: string
|
||||
}
|
||||
|
||||
export interface CompleteLoadingRequest {
|
||||
expectedVersion: number
|
||||
remark?: string
|
||||
}
|
||||
|
||||
export interface CompleteDeliveryRequest {
|
||||
expectedVersion: number
|
||||
remark?: string
|
||||
}
|
||||
|
||||
function query(tenantId: string | number) {
|
||||
return { tenantId: String(tenantId) }
|
||||
}
|
||||
|
||||
function idempotencyKey(operation: string, inboundId: string | number) {
|
||||
return `admin-${operation}-${inboundId}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
||||
}
|
||||
|
||||
function writeOptions(operation: string, inboundId: string | number, tenantId: string | number) {
|
||||
return { params: query(tenantId), headers: { 'Idempotency-Key': idempotencyKey(operation, inboundId) } }
|
||||
}
|
||||
|
||||
export async function getPreload(tenantId: string | number, inboundId: string | number) {
|
||||
const response = await http.get<ApiEnvelope<PreloadPlan>, ApiEnvelope<PreloadPlan>>(
|
||||
`${inboundBasePath}/${inboundId}/preload`, { params: query(tenantId) },
|
||||
)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function smartSplit(
|
||||
tenantId: string | number,
|
||||
inboundId: string | number,
|
||||
request: SmartSplitRequest,
|
||||
) {
|
||||
const response = await http.post<ApiEnvelope<SplitActionResponse>, ApiEnvelope<SplitActionResponse>>(
|
||||
`${inboundBasePath}/${inboundId}/split/smart`, request, writeOptions('smart-split', inboundId, tenantId),
|
||||
)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function confirmingPreload(
|
||||
tenantId: string | number,
|
||||
inboundId: string | number,
|
||||
request: ConfirmPreloadRequest,
|
||||
) {
|
||||
const response = await http.post<ApiEnvelope<SplitActionResponse>, ApiEnvelope<SplitActionResponse>>(
|
||||
`${inboundBasePath}/${inboundId}/preload/confirming`, request,
|
||||
writeOptions('preload-confirming', inboundId, tenantId),
|
||||
)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function confirmPreload(
|
||||
tenantId: string | number,
|
||||
inboundId: string | number,
|
||||
request: ConfirmPreloadRequest,
|
||||
) {
|
||||
const response = await http.post<ApiEnvelope<SplitActionResponse>, ApiEnvelope<SplitActionResponse>>(
|
||||
`${inboundBasePath}/${inboundId}/preload/confirm`, request,
|
||||
writeOptions('preload-confirm', inboundId, tenantId),
|
||||
)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function getPicking(tenantId: string | number, inboundId: string | number) {
|
||||
const response = await http.get<ApiEnvelope<PickingTask>, ApiEnvelope<PickingTask>>(
|
||||
`${inboundBasePath}/${inboundId}/loading/picking`, { params: query(tenantId) },
|
||||
)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function startPicking(
|
||||
tenantId: string | number,
|
||||
inboundId: string | number,
|
||||
request: PickingRequest,
|
||||
) {
|
||||
const response = await http.post<ApiEnvelope<PickingTask>, ApiEnvelope<PickingTask>>(
|
||||
`${inboundBasePath}/${inboundId}/loading/picking/start`, request,
|
||||
writeOptions('picking-start', inboundId, tenantId),
|
||||
)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function completePicking(
|
||||
tenantId: string | number,
|
||||
inboundId: string | number,
|
||||
request: PickingRequest,
|
||||
) {
|
||||
const response = await http.post<ApiEnvelope<PickingTask>, ApiEnvelope<PickingTask>>(
|
||||
`${inboundBasePath}/${inboundId}/loading/picking/complete`, request,
|
||||
writeOptions('picking-complete', inboundId, tenantId),
|
||||
)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function getEvidence(tenantId: string | number, inboundId: string | number) {
|
||||
const response = await http.get<ApiEnvelope<EvidenceList>, ApiEnvelope<EvidenceList>>(
|
||||
`${inboundBasePath}/${inboundId}/loading/evidence`, { params: query(tenantId) },
|
||||
)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function upsertEvidence(
|
||||
tenantId: string | number,
|
||||
inboundId: string | number,
|
||||
request: UpsertEvidenceRequest,
|
||||
) {
|
||||
const response = await http.post<ApiEnvelope<LoadingActionResponse>, ApiEnvelope<LoadingActionResponse>>(
|
||||
`${inboundBasePath}/${inboundId}/loading/evidence`, request,
|
||||
writeOptions(`evidence-${request.evidenceType}`, inboundId, tenantId),
|
||||
)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function completeLoading(
|
||||
tenantId: string | number,
|
||||
inboundId: string | number,
|
||||
request: CompleteLoadingRequest,
|
||||
) {
|
||||
const response = await http.post<ApiEnvelope<LoadingActionResponse>, ApiEnvelope<LoadingActionResponse>>(
|
||||
`${inboundBasePath}/${inboundId}/loading/complete`, request,
|
||||
writeOptions('loading-complete', inboundId, tenantId),
|
||||
)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function completeDelivery(
|
||||
tenantId: string | number,
|
||||
inboundId: string | number,
|
||||
request: CompleteDeliveryRequest,
|
||||
) {
|
||||
const response = await http.post<ApiEnvelope<LoadingActionResponse>, ApiEnvelope<LoadingActionResponse>>(
|
||||
`${inboundBasePath}/${inboundId}/delivery/complete`, request,
|
||||
writeOptions('delivery-complete', inboundId, tenantId),
|
||||
)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
@@ -0,0 +1,388 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { RefreshCw, Save, Check, Play, Camera, Truck } from '@lucide/vue'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { ApiResponseError } from '@/shared/api/http'
|
||||
import {
|
||||
completeDelivery,
|
||||
completeLoading,
|
||||
completePicking,
|
||||
confirmingPreload,
|
||||
confirmPreload,
|
||||
getEvidence,
|
||||
getPicking,
|
||||
getPreload,
|
||||
smartSplit,
|
||||
startPicking,
|
||||
upsertEvidence,
|
||||
type Evidence,
|
||||
type EvidenceType,
|
||||
type PickingTask,
|
||||
type PreloadItem,
|
||||
type PreloadPlan,
|
||||
} from '@/modules/unified-orders/workflows/api'
|
||||
import type { UnifiedOrderMainStatus } from '@/modules/unified-orders/api/unifiedOrders'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
tenantId: string
|
||||
inboundId: string | number
|
||||
mainStatus: UnifiedOrderMainStatus
|
||||
inboundVersion?: number | null
|
||||
pickingVersion?: number | null
|
||||
deliveryTrackingNo?: string | null
|
||||
}>(), {
|
||||
inboundVersion: null,
|
||||
pickingVersion: null,
|
||||
deliveryTrackingNo: null,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{ updated: [] }>()
|
||||
const access = useAccessPreviewStore()
|
||||
const preload = ref<PreloadPlan | null>(null)
|
||||
const preloadItems = ref<PreloadItem[]>([])
|
||||
const picking = ref<PickingTask | null>(null)
|
||||
const evidences = ref<Evidence[]>([])
|
||||
const deliveryRemark = ref('')
|
||||
const loadingRemark = ref('')
|
||||
const splitRemark = ref('')
|
||||
const busy = ref('')
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const notice = ref('')
|
||||
|
||||
const evidenceTypes: Array<{ type: EvidenceType; label: string }> = [
|
||||
{ type: 'PHOTO_STEP_1', label: '装柜照片 1' },
|
||||
{ type: 'PHOTO_STEP_2', label: '装柜照片 2' },
|
||||
{ type: 'PHOTO_STEP_3', label: '装柜照片 3' },
|
||||
{ type: 'PHOTO_STEP_4', label: '装柜照片 4' },
|
||||
{ type: 'SEAL', label: '封签' },
|
||||
]
|
||||
|
||||
const isSplit = computed(() => props.mainStatus === 'WAIT_SPLIT')
|
||||
const isLoading = computed(() => props.mainStatus === 'LOADING')
|
||||
const isLoaded = computed(() => props.mainStatus === 'LOADED')
|
||||
const preloadVersion = computed(() => preload.value?.version ?? 0)
|
||||
const pickingExpectedVersion = computed(() => picking.value?.version ?? props.pickingVersion ?? null)
|
||||
const inboundExpectedVersion = computed(() => props.inboundVersion)
|
||||
const canSplit = computed(() => access.can('firstleg:inbound:split'))
|
||||
const canPreload = computed(() => access.can('firstleg:inbound:preload'))
|
||||
const canPreloadView = computed(() => access.can('firstleg:inbound:preload-view'))
|
||||
const canPicking = computed(() => access.can('platform:firstleg:inbound:picking:edit'))
|
||||
const canPickingView = computed(() => access.can('platform:firstleg:inbound:picking:view'))
|
||||
const canEvidence = computed(() => access.can('platform:firstleg:inbound:loading:edit'))
|
||||
const canEvidenceView = computed(() => access.can('platform:firstleg:inbound:loading:view'))
|
||||
const canLoadingComplete = computed(() => access.can('platform:firstleg:inbound:loading:complete'))
|
||||
const canDeliveryComplete = computed(() => access.can('platform:firstleg:inbound:delivery:complete'))
|
||||
|
||||
function itemTemplate(): PreloadItem {
|
||||
return { containerCode: '', cargoReference: '', packageCount: null, volumeCbm: 0, remark: '' }
|
||||
}
|
||||
|
||||
function addItem() {
|
||||
preloadItems.value.push(itemTemplate())
|
||||
}
|
||||
|
||||
function removeItem(index: number) {
|
||||
preloadItems.value.splice(index, 1)
|
||||
}
|
||||
|
||||
function evidence(type: EvidenceType) {
|
||||
return evidences.value.find((item) => item.evidenceType === type)
|
||||
}
|
||||
|
||||
function idempotentMessage(action: string) {
|
||||
return busy.value === action ? '正在提交,请勿重复点击' : ''
|
||||
}
|
||||
|
||||
async function run<T>(action: string, operation: () => Promise<T>, message: string) {
|
||||
busy.value = action
|
||||
error.value = ''
|
||||
notice.value = ''
|
||||
try {
|
||||
const result = await operation()
|
||||
notice.value = message
|
||||
emit('updated')
|
||||
return result
|
||||
} catch (cause) {
|
||||
error.value = cause instanceof Error ? cause.message : '操作失败,请重试'
|
||||
return null
|
||||
} finally {
|
||||
busy.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPreload() {
|
||||
if (!canPreloadView.value || !props.inboundId) return
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
preload.value = await getPreload(props.tenantId, props.inboundId)
|
||||
preloadItems.value = preload.value.items?.map((item) => ({ ...item })) || []
|
||||
} catch (cause) {
|
||||
preload.value = null
|
||||
preloadItems.value = []
|
||||
const missing = cause instanceof ApiResponseError
|
||||
&& (cause.status === 404 || cause.code === 'A0404' || cause.code === 'NOT_FOUND')
|
||||
error.value = missing ? '' : cause instanceof Error ? cause.message : '预装清单加载失败,请重试'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadLoading() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
const messages: string[] = []
|
||||
if (canPickingView.value) {
|
||||
try {
|
||||
picking.value = await getPicking(props.tenantId, props.inboundId)
|
||||
} catch (cause) {
|
||||
picking.value = null
|
||||
messages.push(cause instanceof Error ? cause.message : '拣货任务加载失败')
|
||||
}
|
||||
}
|
||||
if (canEvidenceView.value) {
|
||||
try {
|
||||
evidences.value = (await getEvidence(props.tenantId, props.inboundId)).evidences || []
|
||||
} catch (cause) {
|
||||
evidences.value = []
|
||||
messages.push(cause instanceof Error ? cause.message : '装柜证据加载失败')
|
||||
}
|
||||
}
|
||||
error.value = messages.join(';')
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
if (isSplit.value) await loadPreload()
|
||||
if (isLoading.value) await loadLoading()
|
||||
}
|
||||
|
||||
async function runSmartSplit() {
|
||||
const result = await run('smart-split', () => smartSplit(props.tenantId, props.inboundId, {
|
||||
expectedVersion: preloadVersion.value,
|
||||
items: preloadItems.value,
|
||||
remark: splitRemark.value || undefined,
|
||||
}), '智能分柜已提交')
|
||||
if (result) {
|
||||
preload.value = result.preloadPlan
|
||||
preloadItems.value = result.preloadPlan?.items?.map((item) => ({ ...item })) || []
|
||||
}
|
||||
}
|
||||
|
||||
async function startPreloadConfirmation() {
|
||||
const result = await run('preload-confirming', () => confirmingPreload(props.tenantId, props.inboundId, {
|
||||
expectedVersion: preloadVersion.value,
|
||||
}), '预装清单已进入确认中')
|
||||
if (result?.preloadPlan) preload.value = result.preloadPlan
|
||||
}
|
||||
|
||||
async function finishPreloadConfirmation() {
|
||||
const result = await run('preload-confirm', () => confirmPreload(props.tenantId, props.inboundId, {
|
||||
expectedVersion: preloadVersion.value,
|
||||
}), '预装清单已确认,订单进入装柜中')
|
||||
if (result?.preloadPlan) preload.value = result.preloadPlan
|
||||
}
|
||||
|
||||
function pickingRequest(): { expectedVersion: number; expectedCount: number; pickedCount: number; remark?: string } | null {
|
||||
if (pickingExpectedVersion.value === null) {
|
||||
error.value = '拣货版本缺失,请刷新订单详情后重试'
|
||||
return null
|
||||
}
|
||||
return {
|
||||
expectedVersion: pickingExpectedVersion.value,
|
||||
expectedCount: picking.value?.expectedCount ?? 0,
|
||||
pickedCount: picking.value?.pickedCount ?? 0,
|
||||
remark: loadingRemark.value || undefined,
|
||||
}
|
||||
}
|
||||
|
||||
async function startPickingTask() {
|
||||
const request = pickingRequest()
|
||||
if (!request) return
|
||||
const result = await run('picking-start', () => startPicking(props.tenantId, props.inboundId, request), '拣货任务已开始')
|
||||
if (result) picking.value = result
|
||||
}
|
||||
|
||||
async function completePickingTask() {
|
||||
const request = pickingRequest()
|
||||
if (!request) return
|
||||
const result = await run('picking-complete', () => completePicking(props.tenantId, props.inboundId, request), '拣货已完成')
|
||||
if (result) picking.value = result
|
||||
}
|
||||
|
||||
async function saveEvidence(type: EvidenceType) {
|
||||
const current = evidence(type)
|
||||
const result = await run(`evidence-${type}`, () => upsertEvidence(props.tenantId, props.inboundId, {
|
||||
expectedVersion: current?.version ?? 0,
|
||||
evidenceType: type,
|
||||
fileId: current?.fileId || undefined,
|
||||
fileName: current?.fileName || undefined,
|
||||
sealNo: current?.sealNo || undefined,
|
||||
remark: current?.remark || undefined,
|
||||
}), `${type === 'SEAL' ? '封签' : '证据'}已保存`)
|
||||
if (result?.evidences) evidences.value = result.evidences.evidences || []
|
||||
}
|
||||
|
||||
async function finishLoading() {
|
||||
const expectedVersion = inboundExpectedVersion.value
|
||||
if (expectedVersion === null) {
|
||||
error.value = '入库单版本缺失,刷新订单详情后才能确认装柜完成'
|
||||
return
|
||||
}
|
||||
const result = await run('loading-complete', () => completeLoading(props.tenantId, props.inboundId, {
|
||||
expectedVersion,
|
||||
remark: loadingRemark.value || undefined,
|
||||
}), '装柜完成已提交')
|
||||
if (result) await refresh()
|
||||
}
|
||||
|
||||
async function finishDelivery() {
|
||||
const expectedVersion = inboundExpectedVersion.value
|
||||
if (expectedVersion === null) {
|
||||
error.value = '入库单版本缺失,刷新订单详情后才能确认派送完成'
|
||||
return
|
||||
}
|
||||
await run('delivery-complete', () => completeDelivery(props.tenantId, props.inboundId, {
|
||||
expectedVersion,
|
||||
remark: deliveryRemark.value || undefined,
|
||||
}), '派送完成已提交')
|
||||
}
|
||||
|
||||
function updateEvidenceField(type: EvidenceType, field: 'fileName' | 'sealNo' | 'remark', value: string) {
|
||||
const current = evidence(type)
|
||||
if (current) {
|
||||
current[field] = value || null
|
||||
return
|
||||
}
|
||||
evidences.value.push({
|
||||
id: `draft-${type}`,
|
||||
inboundId: props.inboundId,
|
||||
tenantId: props.tenantId,
|
||||
evidenceType: type,
|
||||
fileId: null,
|
||||
fileName: field === 'fileName' ? value : null,
|
||||
sealNo: field === 'sealNo' ? value : null,
|
||||
remark: field === 'remark' ? value : null,
|
||||
version: 0,
|
||||
createdAt: null,
|
||||
updatedAt: null,
|
||||
})
|
||||
}
|
||||
|
||||
watch(() => props.mainStatus, () => { void refresh() })
|
||||
onMounted(() => { void refresh() })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="workflow-panel" aria-label="订单后半程作业">
|
||||
<div class="workflow-heading">
|
||||
<div>
|
||||
<h2>后半程作业</h2>
|
||||
<p>订单按待分柜、装柜中、装柜完成和派送完成推进;资料可修改,客户确认不阻断。</p>
|
||||
</div>
|
||||
<el-button text :loading="loading" aria-label="刷新作业数据" @click="refresh"><RefreshCw :size="15" />刷新</el-button>
|
||||
</div>
|
||||
|
||||
<el-alert v-if="error" class="workflow-alert" type="error" :closable="false" show-icon>
|
||||
<template #title>{{ error }}</template>
|
||||
<el-button link type="danger" @click="refresh">重试</el-button>
|
||||
</el-alert>
|
||||
<el-alert v-if="notice" class="workflow-alert" type="success" :closable="false" show-icon>{{ notice }}</el-alert>
|
||||
<el-alert v-if="busy" class="workflow-alert" type="info" :closable="false">{{ idempotentMessage(busy) }}</el-alert>
|
||||
|
||||
<template v-if="isSplit">
|
||||
<el-card shadow="never" class="workflow-card">
|
||||
<template #header><div class="card-title"><span>预装清单</span><el-tag>{{ preload?.status || '待确认' }}</el-tag></div></template>
|
||||
<el-empty v-if="!preload && !loading" description="暂无预装清单,可直接生成分柜方案" />
|
||||
<div class="preload-items" role="group" aria-label="预装清单明细">
|
||||
<div v-for="(item, index) in preloadItems" :key="index" class="preload-row">
|
||||
<el-input v-model="item.containerCode" aria-label="柜号" placeholder="柜号" />
|
||||
<el-input v-model="item.cargoReference" aria-label="货物引用" placeholder="货物引用" />
|
||||
<el-input-number v-model="item.packageCount" aria-label="件数" :min="1" controls-position="right" />
|
||||
<el-input-number v-model="item.volumeCbm" aria-label="体积" :min="0" :precision="3" controls-position="right" />
|
||||
<el-button text type="danger" aria-label="删除预装项" @click="removeItem(index)">删除</el-button>
|
||||
</div>
|
||||
<el-button plain @click="addItem">新增预装项</el-button>
|
||||
<el-input v-model="splitRemark" class="workflow-remark" type="textarea" :rows="2" aria-label="分柜备注" placeholder="分柜备注(可选)" />
|
||||
</div>
|
||||
<div class="workflow-actions">
|
||||
<el-button :disabled="!canSplit || Boolean(busy)" :loading="busy === 'smart-split'" @click="runSmartSplit"><Save :size="15" />智能分柜/保存调整</el-button>
|
||||
<el-button :disabled="!canPreload || !preload || Boolean(busy)" :loading="busy === 'preload-confirming'" @click="startPreloadConfirmation"><Play :size="15" />提交确认</el-button>
|
||||
<el-button type="primary" :disabled="!canPreload || !preload || preload?.status !== 'CONFIRMING' || Boolean(busy)" :loading="busy === 'preload-confirm'" @click="finishPreloadConfirmation"><Check :size="15" />确认预装清单</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<template v-if="isLoading">
|
||||
<el-card shadow="never" class="workflow-card">
|
||||
<template #header><div class="card-title"><span>拣货与装柜</span><el-tag type="warning">装柜中</el-tag></div></template>
|
||||
<el-descriptions v-if="picking" :column="2" border>
|
||||
<el-descriptions-item label="拣货状态">{{ picking.status || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="拣货版本">{{ picking.version }}</el-descriptions-item>
|
||||
<el-descriptions-item label="应拣件数">{{ picking.expectedCount ?? '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="已拣件数">{{ picking.pickedCount ?? '—' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<el-empty v-else-if="!loading" description="暂无拣货任务" />
|
||||
<div class="workflow-actions">
|
||||
<el-button :disabled="!canPicking || !picking || Boolean(busy)" :loading="busy === 'picking-start'" @click="startPickingTask"><Play :size="15" />开始拣货</el-button>
|
||||
<el-button :disabled="!canPicking || !picking || Boolean(busy)" :loading="busy === 'picking-complete'" @click="completePickingTask"><Check :size="15" />完成拣货</el-button>
|
||||
</div>
|
||||
<el-input v-model="loadingRemark" class="workflow-remark" type="textarea" :rows="2" aria-label="装柜备注" placeholder="装柜备注(可选)" />
|
||||
</el-card>
|
||||
|
||||
<el-card shadow="never" class="workflow-card">
|
||||
<template #header><div class="card-title"><span>装柜证据与封签</span><el-tag type="info">四步照片</el-tag></div></template>
|
||||
<div class="evidence-grid">
|
||||
<div v-for="entry in evidenceTypes" :key="entry.type" class="evidence-item">
|
||||
<strong><Camera :size="15" />{{ entry.label }}</strong>
|
||||
<el-input v-if="entry.type === 'SEAL'" :model-value="evidence(entry.type)?.sealNo || ''" aria-label="封签号" placeholder="封签号" @update:model-value="updateEvidenceField(entry.type, 'sealNo', $event)" />
|
||||
<el-input v-else :model-value="evidence(entry.type)?.fileName || ''" aria-label="照片文件名" placeholder="文件上传完成后填写文件名" @update:model-value="updateEvidenceField(entry.type, 'fileName', $event)" />
|
||||
<el-button plain :disabled="!canEvidence || Boolean(busy)" :loading="busy === `evidence-${entry.type}`" @click="saveEvidence(entry.type)">保存</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="workflow-hint">当前只保存证据元数据;真实文件上传需接 File 服务契约,不会把本地路径伪装成已上传文件。</p>
|
||||
<div class="workflow-actions">
|
||||
<el-button type="primary" :disabled="!canLoadingComplete || inboundExpectedVersion === null || Boolean(busy)" :loading="busy === 'loading-complete'" @click="finishLoading"><Check :size="15" />完成装柜</el-button>
|
||||
<span v-if="inboundExpectedVersion === null" class="workflow-hint">缺少入库版本,刷新订单详情后可提交</span>
|
||||
</div>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<el-card v-if="isLoaded" shadow="never" class="workflow-card">
|
||||
<template #header><div class="card-title"><span>派送</span><el-tag type="success">装柜完成</el-tag></div></template>
|
||||
<el-descriptions v-if="props.deliveryTrackingNo" :column="1" border>
|
||||
<el-descriptions-item label="派送单号">{{ props.deliveryTrackingNo }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<el-input v-model="deliveryRemark" class="workflow-remark" type="textarea" :rows="3" aria-label="派送备注" placeholder="派送备注(可选)" />
|
||||
<div class="workflow-actions">
|
||||
<el-button type="primary" :disabled="!canDeliveryComplete || inboundExpectedVersion === null || Boolean(busy)" :loading="busy === 'delivery-complete'" @click="finishDelivery"><Truck :size="15" />确认派送完成</el-button>
|
||||
<span v-if="inboundExpectedVersion === null" class="workflow-hint">缺少入库版本,刷新订单详情后可提交</span>
|
||||
</div>
|
||||
</el-card>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.workflow-panel { display: grid; gap: 14px; }
|
||||
.workflow-heading, .card-title, .workflow-actions { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
||||
.workflow-heading { justify-content: space-between; }
|
||||
.workflow-heading h2 { margin: 0; font-size: 17px; }
|
||||
.workflow-heading p { margin: 4px 0 0; color: var(--cc-text-secondary, #64748b); font-size: 13px; }
|
||||
.workflow-card { border-radius: 12px; }
|
||||
.card-title { justify-content: space-between; }
|
||||
.preload-items { display: grid; gap: 10px; }
|
||||
.preload-row { display: grid; grid-template-columns: 1fr 1fr 120px 120px auto; gap: 8px; align-items: center; }
|
||||
.workflow-actions { margin-top: 14px; }
|
||||
.workflow-remark { margin-top: 10px; }
|
||||
.evidence-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
|
||||
.evidence-item { display: grid; grid-template-columns: minmax(100px, auto) 1fr auto; gap: 8px; align-items: center; }
|
||||
.evidence-item strong { display: inline-flex; align-items: center; gap: 5px; white-space: nowrap; }
|
||||
.workflow-alert { margin-bottom: 0; }
|
||||
.workflow-hint { color: var(--cc-text-secondary, #64748b); font-size: 12px; }
|
||||
@media (max-width: 640px) {
|
||||
.preload-row, .evidence-grid { grid-template-columns: 1fr; }
|
||||
.evidence-item { grid-template-columns: 1fr; }
|
||||
.workflow-actions > .el-button { flex: 1 1 150px; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,3 @@
|
||||
export { default as UnifiedOrderWorkflowPanel } from './components/UnifiedOrderWorkflowPanel.vue'
|
||||
export { default as UnifiedOrderWorkflowView } from './pages/UnifiedOrderWorkflowView.vue'
|
||||
export * from './api'
|
||||
@@ -0,0 +1,61 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { getPlatformUnifiedOrder, type UnifiedOrderDetailResource } from '@/modules/unified-orders/api/unifiedOrders'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import UnifiedOrderWorkflowPanel from '@/modules/unified-orders/workflows/components/UnifiedOrderWorkflowPanel.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const detail = ref<UnifiedOrderDetailResource | null>(null)
|
||||
const loading = ref(false)
|
||||
const error = ref<ApiErrorDetails | null>(null)
|
||||
|
||||
const orderId = computed(() => String(route.params.orderId || ''))
|
||||
const tenantId = computed(() => typeof route.query.tenantId === 'string' ? route.query.tenantId : '')
|
||||
const summary = computed(() => detail.value?.summary || null)
|
||||
|
||||
async function load() {
|
||||
if (!orderId.value || !tenantId.value) {
|
||||
error.value = { message: '订单作业上下文缺少订单号或客户标识' }
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
detail.value = await getPlatformUnifiedOrder(tenantId.value, orderId.value)
|
||||
} catch (cause) {
|
||||
detail.value = null
|
||||
error.value = getApiErrorDetails(cause, '订单作业上下文加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => { void load() })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="workflow-page">
|
||||
<LoadErrorState v-if="error" title="后半程作业暂时无法加载" :message="error.message" :loading="loading" @retry="load">
|
||||
<template #details><ApiErrorMeta :details="error" /></template>
|
||||
</LoadErrorState>
|
||||
<UnifiedOrderWorkflowPanel
|
||||
v-else-if="summary && summary.inboundId"
|
||||
:tenant-id="tenantId"
|
||||
:inbound-id="summary.inboundId"
|
||||
:main-status="summary.mainStatus"
|
||||
:inbound-version="summary.inboundVersion"
|
||||
:picking-version="summary.loading?.pickingVersion"
|
||||
:delivery-tracking-no="summary.delivery?.trackingNo"
|
||||
@updated="load"
|
||||
/>
|
||||
<el-empty v-else-if="!loading" description="该订单尚未生成入库作业,暂无后半程操作" />
|
||||
<el-skeleton v-else :rows="5" animated />
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.workflow-page { display: grid; gap: 16px; }
|
||||
</style>
|
||||
@@ -9,13 +9,11 @@ import {
|
||||
listPlatformLocations,
|
||||
type PlatformLocationResource,
|
||||
} from '../api/platformLocations'
|
||||
import { listPlatformTenants, type PlatformTenantResource } from '../../../iam/api/platformIam'
|
||||
import { listPlatformWarehouses, type PlatformWarehouseResource } from '../../../warehouses/api/platformWarehouses'
|
||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||
import DataTable from '@/shared/components/DataTable.vue'
|
||||
import LocationsView from './LocationsView.vue'
|
||||
|
||||
vi.mock('../../../iam/api/platformIam', () => ({ listPlatformTenants: vi.fn() }))
|
||||
vi.mock('../../../warehouses/api/platformWarehouses', () => ({ listPlatformWarehouses: vi.fn() }))
|
||||
vi.mock('../api/platformLocations', () => ({
|
||||
listPlatformLocations: vi.fn(),
|
||||
@@ -44,32 +42,7 @@ function createMemoryStorage(): Storage {
|
||||
}
|
||||
}
|
||||
|
||||
const tenant = (id: number | string): PlatformTenantResource => ({
|
||||
id,
|
||||
tenantCode: String(id),
|
||||
tenantName: '雪花企业',
|
||||
status: 'ACTIVE',
|
||||
legalName: '雪花企业',
|
||||
legalRepresentative: null,
|
||||
unifiedSocialCreditCode: null,
|
||||
contactName: null,
|
||||
contactPhone: null,
|
||||
contactEmail: null,
|
||||
servicePackage: null,
|
||||
contactWechat: null,
|
||||
salesOwnerName: null,
|
||||
paymentTermDays: null,
|
||||
enterpriseType: null,
|
||||
establishedDate: null,
|
||||
registeredCapital: null,
|
||||
registeredCapitalCurrency: null,
|
||||
registeredAddress: null,
|
||||
internalRemark: null,
|
||||
createdAt: null,
|
||||
approvedAt: null,
|
||||
})
|
||||
|
||||
const tenantId = '9223372036854775806'
|
||||
const tenantId = '0'
|
||||
const warehouseId = '9223372036854775805'
|
||||
const warehouse: PlatformWarehouseResource = {
|
||||
id: warehouseId,
|
||||
@@ -151,9 +124,6 @@ describe('LocationsView', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
|
||||
document.body.innerHTML = ''
|
||||
vi.mocked(listPlatformTenants).mockReset().mockResolvedValue({
|
||||
records: [tenant(tenantId)], page: 1, size: 200, total: 1, pages: 1,
|
||||
})
|
||||
vi.mocked(listPlatformWarehouses).mockReset().mockResolvedValue({
|
||||
records: [warehouse], page: 1, size: 200, total: 1, pages: 1,
|
||||
})
|
||||
@@ -170,14 +140,13 @@ describe('LocationsView', () => {
|
||||
const toolbar = wrapper.findComponent(QueryToolbar)
|
||||
const fields = toolbar.props('fields') as QueryField[]
|
||||
|
||||
expect(listPlatformTenants).toHaveBeenCalledWith({ page: 1, size: 200 })
|
||||
expect(wrapper.find('[aria-label="所属企业"]').exists()).toBe(false)
|
||||
expect(fields[0]?.placeholder).toBe('全部仓库')
|
||||
expect(fields.map(({ key, width }) => [key, width])).toEqual([
|
||||
['warehouseId', 300], ['zoneCode', 210],
|
||||
['locationCode', 220], ['locationType', 170], ['status', 160],
|
||||
])
|
||||
expect(listPlatformWarehouses).toHaveBeenCalledWith(undefined, 1, 200)
|
||||
expect(listPlatformWarehouses).toHaveBeenCalledWith(1, 200)
|
||||
expect(listPlatformLocations).toHaveBeenCalledWith({
|
||||
page: 1,
|
||||
size: 20,
|
||||
@@ -206,7 +175,7 @@ describe('LocationsView', () => {
|
||||
expect(panelHeading.text()).toContain('共 2 个库位')
|
||||
expect(panelHeading.text()).toContain('新增库位')
|
||||
expect(wrapper.findComponent(QueryToolbar).text()).not.toContain('新增库位')
|
||||
expect(listPlatformWarehouses).toHaveBeenCalledWith(undefined, 1, 200)
|
||||
expect(listPlatformWarehouses).toHaveBeenCalledWith(1, 200)
|
||||
expect(listPlatformLocations).toHaveBeenLastCalledWith({
|
||||
page: 1,
|
||||
size: 20,
|
||||
@@ -259,7 +228,8 @@ describe('LocationsView', () => {
|
||||
expect(getPlatformLocation).toHaveBeenCalledWith(tenantId, warehouseId, availableLocation.id)
|
||||
expect(document.body.textContent).toContain('库位详情')
|
||||
expect(document.body.textContent).toContain('义乌收货仓(CN-YW-01)')
|
||||
expect(document.body.textContent).toContain(`所属企业雪花企业(${tenantId})`)
|
||||
expect(document.body.textContent).toContain('资源作用域平台公共资源')
|
||||
expect(document.body.textContent).not.toContain('所属企业')
|
||||
expect(document.body.textContent).not.toContain('企业 ID')
|
||||
expect(document.body.textContent).toContain('库位名称A1 区 01 位')
|
||||
expect(document.body.textContent).toContain('v3')
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
|
||||
import { Eye, MapPin, MoreHorizontal, Pencil, Plus, Power, Trash2 } from '@lucide/vue'
|
||||
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
@@ -43,9 +42,6 @@ type AppliedLocationFilters = {
|
||||
}
|
||||
|
||||
const access = useAccessPreviewStore()
|
||||
const customers = ref<PlatformTenantResource[]>([])
|
||||
const customerLoading = ref(false)
|
||||
const customerError = ref<ApiErrorDetails | null>(null)
|
||||
const warehouses = ref<PlatformWarehouseResource[]>([])
|
||||
const warehouseLoading = ref(false)
|
||||
const warehouseError = ref<ApiErrorDetails | null>(null)
|
||||
@@ -82,7 +78,7 @@ const queryFields = computed<QueryField[]>(() => [
|
||||
},
|
||||
options: warehouses.value.map((warehouse) => ({
|
||||
value: String(warehouse.id),
|
||||
label: `${warehouse.warehouseName}(${warehouse.warehouseCode}) · ${customerLabel(warehouse.tenantId)}`,
|
||||
label: `${warehouse.warehouseName}(${warehouse.warehouseCode})`,
|
||||
})),
|
||||
},
|
||||
{ key: 'zoneCode', type: 'input', width: 210, placeholder: '库区编码', props: { maxlength: 50, 'aria-label': '库区编码' } },
|
||||
@@ -232,32 +228,13 @@ function recordScope(row: PlatformLocationResource) {
|
||||
return { customerId: apiId(String(row.tenantId)), warehouseId: apiId(String(row.warehouseId)) }
|
||||
}
|
||||
|
||||
function customerLabel(customerId: number | string) {
|
||||
const customer = customers.value.find((item) => String(item.id) === String(customerId))
|
||||
return customer ? `${customer.tenantName}(${customer.tenantCode || customer.id})` : String(customerId)
|
||||
}
|
||||
|
||||
async function loadCustomers() {
|
||||
customerLoading.value = true
|
||||
customerError.value = null
|
||||
try {
|
||||
const result = await listPlatformTenants({ page: 1, size: 200 })
|
||||
customers.value = result.records
|
||||
} catch (error) {
|
||||
customers.value = []
|
||||
customerError.value = getApiErrorDetails(error, '所属企业列表加载失败,请稍后重试')
|
||||
} finally {
|
||||
customerLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadWarehouses() {
|
||||
const sequence = ++warehouseLoadSequence
|
||||
warehouseLoading.value = true
|
||||
warehouseError.value = null
|
||||
warehouses.value = []
|
||||
try {
|
||||
const result = await listPlatformWarehouses(undefined, 1, 200)
|
||||
const result = await listPlatformWarehouses(1, 200)
|
||||
if (sequence !== warehouseLoadSequence) return
|
||||
warehouses.value = result.records
|
||||
} catch (error) {
|
||||
@@ -482,7 +459,6 @@ watch(() => form.warehouseId, (warehouseId) => {
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
void loadCustomers()
|
||||
void loadWarehouses()
|
||||
void loadLocations()
|
||||
})
|
||||
@@ -497,12 +473,6 @@ onMounted(() => {
|
||||
@search="applyFilters"
|
||||
/>
|
||||
|
||||
<el-alert v-if="customerError" type="warning" :closable="false" show-icon class="state-alert">
|
||||
<template #title>所属企业列表加载失败</template>
|
||||
<p>{{ customerError.message }}</p>
|
||||
<ApiErrorMeta :details="customerError" />
|
||||
<el-button size="small" @click="loadCustomers">重新加载所属企业</el-button>
|
||||
</el-alert>
|
||||
<el-alert v-if="warehouseError" type="error" :closable="false" show-icon class="state-alert">
|
||||
<template #title>仓库列表加载失败</template>
|
||||
<p>{{ warehouseError.message }}</p>
|
||||
@@ -637,7 +607,7 @@ onMounted(() => {
|
||||
<el-tag :type="statusMeta[detail.location.status].type">{{ statusMeta[detail.location.status].label }}</el-tag>
|
||||
</section>
|
||||
<el-descriptions :column="2" border class="detail-descriptions">
|
||||
<el-descriptions-item label="所属企业">{{ customerLabel(detail.location.tenantId) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="资源作用域">平台公共资源</el-descriptions-item>
|
||||
<el-descriptions-item label="所属仓库">{{ detail.warehouseName }}({{ detail.warehouseCode }})</el-descriptions-item>
|
||||
<el-descriptions-item label="库位编码">{{ detail.location.locationCode }}</el-descriptions-item>
|
||||
<el-descriptions-item label="库位名称">{{ detail.location.locationName || '—' }}</el-descriptions-item>
|
||||
@@ -677,21 +647,21 @@ onMounted(() => {
|
||||
>
|
||||
<div v-if="drawerMode === 'edit'" class="editor-scope">
|
||||
<MapPin :size="18" />
|
||||
<div><strong>{{ detail?.warehouseName || selectedWarehouse?.warehouseName }}({{ detail?.warehouseCode || selectedWarehouse?.warehouseCode }})</strong><span>所属企业:{{ customerLabel(form.customerId) }} · 保存后不可跨仓移动</span></div>
|
||||
<div><strong>{{ detail?.warehouseName || selectedWarehouse?.warehouseName }}({{ detail?.warehouseCode || selectedWarehouse?.warehouseCode }})</strong><span>平台公共资源 · 保存后不可跨仓移动</span></div>
|
||||
</div>
|
||||
<el-form-item v-if="drawerMode === 'create'" label="所属仓库" prop="warehouseId" required>
|
||||
<el-select v-model="form.warehouseId" filterable placeholder="请选择所属仓库" :loading="warehouseLoading">
|
||||
<el-option
|
||||
v-for="warehouse in warehouses"
|
||||
:key="String(warehouse.id)"
|
||||
:label="`${warehouse.warehouseName}(${warehouse.warehouseCode}) · ${customerLabel(warehouse.tenantId)}`"
|
||||
:label="`${warehouse.warehouseName}(${warehouse.warehouseCode})`"
|
||||
:value="String(warehouse.id)"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<section class="form-section">
|
||||
<h3>库位标识</h3>
|
||||
<p class="section-description">库位编码在当前企业和仓库内唯一;编辑后不能移动到其他仓库。</p>
|
||||
<p class="section-description">库位编码在当前仓库内唯一;编辑后不能移动到其他仓库。</p>
|
||||
<div class="form-grid two-cols">
|
||||
<el-form-item label="库位编码" prop="locationCode" required>
|
||||
<el-input v-model.trim="form.locationCode" maxlength="50" placeholder="例如 A1-01" />
|
||||
|
||||
@@ -4,7 +4,6 @@ import { enableAutoUnmount, flushPromises, mount, type VueWrapper } from '@vue/t
|
||||
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { useAuthStore } from '@/core/auth/auth'
|
||||
import { listPlatformTenants as listPlatformCustomers, type PlatformTenantResource as PlatformCustomerResource } from '@/modules/iam'
|
||||
import { listPlatformWarehouses, type PlatformWarehouseResource } from '@/modules/warehouses'
|
||||
import DataTable from '@/shared/components/DataTable.vue'
|
||||
import QueryToolbar from '@/shared/components/QueryToolbar.vue'
|
||||
@@ -16,11 +15,6 @@ import {
|
||||
} from '../api/platformPallets'
|
||||
import PalletsView from './PalletsView.vue'
|
||||
|
||||
vi.mock('@/modules/iam', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/modules/iam')>()
|
||||
const listPlatformTenants = vi.fn<typeof actual.listPlatformTenants>()
|
||||
return { ...actual, listPlatformTenants }
|
||||
})
|
||||
vi.mock('@/modules/warehouses', () => ({ listPlatformWarehouses: vi.fn() }))
|
||||
vi.mock('../api/platformPallets', () => ({
|
||||
listPlatformPallets: vi.fn(),
|
||||
@@ -48,23 +42,14 @@ function createMemoryStorage(): Storage {
|
||||
}
|
||||
}
|
||||
|
||||
const customer = (id: number | string, tenantName: string): PlatformCustomerResource => ({
|
||||
id, tenantCode: String(id), tenantName, status: 'ACTIVE', legalName: tenantName,
|
||||
legalRepresentative: null, unifiedSocialCreditCode: null, contactName: null,
|
||||
contactPhone: null, contactEmail: null, servicePackage: null, contactWechat: null,
|
||||
salesOwnerName: null, paymentTermDays: null, enterpriseType: null, establishedDate: null,
|
||||
registeredCapital: null, registeredCapitalCurrency: null, registeredAddress: null,
|
||||
internalRemark: null, createdAt: null, approvedAt: null,
|
||||
})
|
||||
|
||||
const warehouse: PlatformWarehouseResource = {
|
||||
id: '19001', tenantId: 10001, warehouseCode: 'CN-YW-01', warehouseName: '义乌收货仓',
|
||||
id: '19001', tenantId: 0, warehouseCode: 'CN-YW-01', warehouseName: '义乌收货仓',
|
||||
warehouseType: 'DOMESTIC', countryCode: 'CN', timezone: 'Asia/Shanghai', contactName: null,
|
||||
contactPhone: null, contactEmail: null, status: 'ACTIVE', remark: null,
|
||||
}
|
||||
|
||||
const pallet: PlatformPalletResource = {
|
||||
id: '20001', tenantId: 10001, warehouseId: '19001', palletCode: 'TP-001', palletType: '木托盘',
|
||||
id: '20001', tenantId: 0, warehouseId: '19001', palletCode: 'TP-001', palletType: '木托盘',
|
||||
capacityCbm: '1.200000', remark: '标准川字托盘', createdAt: '2026-08-04T09:00:00', updatedAt: '2026-08-04T10:00:00',
|
||||
}
|
||||
|
||||
@@ -110,9 +95,6 @@ describe('PalletsView', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
|
||||
document.body.innerHTML = ''
|
||||
vi.mocked(listPlatformCustomers).mockReset().mockResolvedValue({
|
||||
records: [customer(10001, '默认企业'), customer('9223372036854775806', '雪花企业')], page: 1, size: 200, total: 2, pages: 1,
|
||||
})
|
||||
vi.mocked(listPlatformWarehouses).mockReset().mockResolvedValue({ records: [warehouse], page: 1, size: 200, total: 1, pages: 1 })
|
||||
vi.mocked(listPlatformPallets).mockReset().mockResolvedValue({ records: [pallet], page: 1, size: 20, total: 1, pages: 1 })
|
||||
vi.mocked(getPlatformPallet).mockReset().mockResolvedValue(pallet)
|
||||
@@ -132,7 +114,7 @@ describe('PalletsView', () => {
|
||||
])
|
||||
expect(wrapper.find('.pallet-filter-toolbar').exists()).toBe(true)
|
||||
expect(fields.find((field) => field.key === 'warehouseId')?.placeholder).toBe('全部仓库')
|
||||
expect(listPlatformWarehouses).toHaveBeenCalledWith(undefined, 1, 200)
|
||||
expect(listPlatformWarehouses).toHaveBeenCalledWith(1, 200)
|
||||
expect(listPlatformPallets).toHaveBeenCalledWith({
|
||||
page: 1, size: 20,
|
||||
})
|
||||
@@ -143,7 +125,7 @@ describe('PalletsView', () => {
|
||||
await loadRows(wrapper)
|
||||
|
||||
expect(listPlatformWarehouses).toHaveBeenCalledTimes(1)
|
||||
expect(listPlatformWarehouses).toHaveBeenCalledWith(undefined, 1, 200)
|
||||
expect(listPlatformWarehouses).toHaveBeenCalledWith(1, 200)
|
||||
expect(listPlatformPallets).toHaveBeenCalledWith({
|
||||
page: 1, size: 20,
|
||||
})
|
||||
@@ -234,7 +216,7 @@ describe('PalletsView', () => {
|
||||
await saveButton?.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(createPlatformPallet).toHaveBeenCalledWith(10001, {
|
||||
expect(createPlatformPallet).toHaveBeenCalledWith(0, {
|
||||
warehouseId: 19001, palletCode: 'TP-002', palletType: '塑料托盘', capacityCbm: 1.5, remark: '蓝色标准托盘',
|
||||
})
|
||||
})
|
||||
@@ -277,33 +259,33 @@ describe('PalletsView', () => {
|
||||
expect(wrapper.text()).not.toContain('TP-001')
|
||||
})
|
||||
|
||||
it('keeps platform warehouse and pallet queries available when customer names cannot load', async () => {
|
||||
vi.mocked(listPlatformCustomers).mockRejectedValueOnce(new Error('Gateway unavailable'))
|
||||
it('keeps platform warehouse and pallet queries independent from customer names', async () => {
|
||||
const wrapper = await mountView()
|
||||
|
||||
expect(wrapper.text()).toContain('客户列表暂时无法加载')
|
||||
expect(wrapper.text()).toContain('重新加载')
|
||||
expect(listPlatformWarehouses).toHaveBeenCalledWith(undefined, 1, 200)
|
||||
expect(wrapper.text()).not.toContain('客户列表暂时无法加载')
|
||||
expect(wrapper.text()).not.toContain('所属企业')
|
||||
expect(listPlatformWarehouses).toHaveBeenCalledWith(1, 200)
|
||||
expect(listPlatformPallets).toHaveBeenCalledWith({
|
||||
page: 1, size: 20,
|
||||
})
|
||||
})
|
||||
|
||||
it('shows only the pallet fields exposed by the backend contract', async () => {
|
||||
const crossCustomerPallet = { ...pallet, tenantId: '9223372036854775806' }
|
||||
vi.mocked(listPlatformPallets).mockResolvedValue({ records: [crossCustomerPallet], page: 1, size: 20, total: 1, pages: 1 })
|
||||
vi.mocked(getPlatformPallet).mockResolvedValue(crossCustomerPallet)
|
||||
const publicPallet = { ...pallet, tenantId: '0' }
|
||||
vi.mocked(listPlatformPallets).mockResolvedValue({ records: [publicPallet], page: 1, size: 20, total: 1, pages: 1 })
|
||||
vi.mocked(getPlatformPallet).mockResolvedValue(publicPallet)
|
||||
const wrapper = await mountView()
|
||||
await loadRows(wrapper)
|
||||
expect(wrapper.findComponent(DataTable).props('data')).toEqual([crossCustomerPallet])
|
||||
expect(wrapper.findComponent(DataTable).props('data')).toEqual([publicPallet])
|
||||
const tableBody = wrapper.find('.el-table__body-wrapper')
|
||||
const viewButton = tableBody.findAll('.pallet-row-actions button').find((button) => button.text().trim() === '查看')
|
||||
await viewButton?.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(getPlatformPallet).toHaveBeenCalledWith('9223372036854775806', 19001, '20001')
|
||||
expect(document.body.textContent).toContain('所属企业')
|
||||
expect(document.body.textContent).toContain('雪花企业(9223372036854775806)')
|
||||
expect(getPlatformPallet).toHaveBeenCalledWith(0, 19001, '20001')
|
||||
expect(document.body.textContent).toContain('资源作用域')
|
||||
expect(document.body.textContent).toContain('平台公共资源')
|
||||
expect(document.body.textContent).not.toContain('所属企业')
|
||||
expect(document.body.textContent).toContain('托盘编码')
|
||||
expect(document.body.textContent).toContain('托盘类型')
|
||||
expect(document.body.textContent).toContain('托盘容积')
|
||||
|
||||
@@ -4,7 +4,6 @@ import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'elem
|
||||
import { Boxes, Eye, Pencil, Plus, Trash2, Warehouse as WarehouseIcon } from '@lucide/vue'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
|
||||
import { listPlatformTenants as listPlatformCustomers, type PlatformTenantResource as PlatformCustomerResource } from '@/modules/iam'
|
||||
import { listPlatformWarehouses, type PlatformWarehouseResource } from '@/modules/warehouses'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
@@ -21,9 +20,6 @@ import {
|
||||
} from '../api/platformPallets'
|
||||
|
||||
const access = useAccessPreviewStore()
|
||||
const customers = ref<PlatformCustomerResource[]>([])
|
||||
const customerLoading = ref(false)
|
||||
const customerError = ref<ApiErrorDetails | null>(null)
|
||||
const warehouses = ref<PlatformWarehouseResource[]>([])
|
||||
const warehouseLoading = ref(false)
|
||||
const warehouseError = ref<ApiErrorDetails | null>(null)
|
||||
@@ -140,44 +136,17 @@ function warehouseFor(warehouseId: number | string) {
|
||||
return warehouses.value.find((item) => String(item.id) === String(warehouseId)) || null
|
||||
}
|
||||
|
||||
function customerLabel(customerId: number | string) {
|
||||
const customer = customers.value.find((item) => String(item.id) === String(customerId))
|
||||
return customer ? `${customer.tenantName}(${customer.tenantCode || customer.id})` : '—'
|
||||
}
|
||||
|
||||
function errorMessage(details: ApiErrorDetails) {
|
||||
return details.requestId ? `${details.message}(requestId: ${details.requestId})` : details.message
|
||||
}
|
||||
|
||||
async function loadCustomers() {
|
||||
customerLoading.value = true
|
||||
customerError.value = null
|
||||
try {
|
||||
const records: PlatformCustomerResource[] = []
|
||||
let currentPage = 1
|
||||
let pages = 1
|
||||
do {
|
||||
const result = await listPlatformCustomers({ page: currentPage, size: 200 })
|
||||
records.push(...result.records)
|
||||
pages = Math.max(1, result.pages)
|
||||
currentPage += 1
|
||||
} while (currentPage <= pages)
|
||||
customers.value = records
|
||||
} catch (error) {
|
||||
customers.value = []
|
||||
customerError.value = getApiErrorDetails(error, '所属企业列表加载失败,请稍后重试')
|
||||
} finally {
|
||||
customerLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadWarehouses() {
|
||||
const sequence = ++warehouseSequence
|
||||
warehouses.value = []
|
||||
warehouseError.value = null
|
||||
warehouseLoading.value = true
|
||||
try {
|
||||
const result = await listPlatformWarehouses(undefined, 1, 200)
|
||||
const result = await listPlatformWarehouses(1, 200)
|
||||
if (sequence !== warehouseSequence) return
|
||||
warehouses.value = result.records
|
||||
} catch (error) {
|
||||
@@ -343,7 +312,6 @@ watch(() => access.can(editorPermission.value), (canWrite) => {
|
||||
if (!canWrite && editorOpen.value) editorOpen.value = false
|
||||
}, { flush: 'sync' })
|
||||
onMounted(() => {
|
||||
void loadCustomers()
|
||||
void loadWarehouses()
|
||||
void loadPallets()
|
||||
})
|
||||
@@ -351,14 +319,6 @@ onMounted(() => {
|
||||
|
||||
<template>
|
||||
<section class="pallet-page">
|
||||
<LoadErrorState
|
||||
v-if="customerError"
|
||||
title="客户列表暂时无法加载"
|
||||
:message="customerError.message"
|
||||
:loading="customerLoading"
|
||||
@retry="loadCustomers"
|
||||
/>
|
||||
|
||||
<QueryToolbar
|
||||
v-model="filters"
|
||||
class="surface-panel pallet-filter-toolbar"
|
||||
@@ -430,7 +390,7 @@ onMounted(() => {
|
||||
<div class="detail-hero"><span><Boxes :size="22" /></span><div><h2>{{ detail.palletCode }}</h2><p>{{ detail.palletType }}</p></div></div>
|
||||
<h3 class="detail-section-title">归属信息</h3>
|
||||
<el-descriptions :column="1" border>
|
||||
<el-descriptions-item label="所属企业">{{ customerLabel(detail.tenantId) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="资源作用域">平台公共资源</el-descriptions-item>
|
||||
<el-descriptions-item label="所属仓库">{{ warehouseFor(detail.warehouseId)?.warehouseName || '—' }}({{ warehouseFor(detail.warehouseId)?.warehouseCode || '—' }})</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<h3 class="detail-section-title">托盘规格</h3>
|
||||
@@ -467,7 +427,7 @@ onMounted(() => {
|
||||
<div><h3 id="pallet-ownership-heading">归属仓库</h3><p>托盘创建后固定归属于所选仓库,不支持跨仓移动。</p></div>
|
||||
</div>
|
||||
<div v-if="form.id" class="editor-context">
|
||||
<div><strong>{{ editorWarehouse?.warehouseName || '当前仓库' }}</strong><span>{{ editorWarehouse?.warehouseCode || form.warehouseId }} · 客户 {{ customerLabel(form.customerId) }}</span></div>
|
||||
<div><strong>{{ editorWarehouse?.warehouseName || '当前仓库' }}</strong><span>{{ editorWarehouse?.warehouseCode || form.warehouseId }} · 平台公共资源</span></div>
|
||||
</div>
|
||||
<el-form-item v-else label="所属仓库" prop="warehouseId" required>
|
||||
<el-select v-model="form.warehouseId" filterable placeholder="请选择所属仓库" :loading="warehouseLoading">
|
||||
|
||||
@@ -2,8 +2,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { http } from '@/shared/api/http'
|
||||
import {
|
||||
changePlatformWarehouseStatus,
|
||||
createFbaWarehouse,
|
||||
createPlatformWarehouse,
|
||||
getPlatformWarehouse,
|
||||
importWarehouses,
|
||||
listPlatformWarehouses,
|
||||
updatePlatformWarehouse,
|
||||
type SavePlatformWarehouseRequest,
|
||||
@@ -15,12 +17,11 @@ vi.mock('@/shared/api/http', () => ({
|
||||
}))
|
||||
|
||||
const payload: SavePlatformWarehouseRequest = {
|
||||
tenantId: 10001,
|
||||
warehouseCode: 'CN-YW-01',
|
||||
warehouseName: '义乌收货仓',
|
||||
warehouseType: 'DOMESTIC',
|
||||
countryCode: 'CN',
|
||||
timezone: 'Asia/Shanghai',
|
||||
timezone: 'UTC+8',
|
||||
contactName: '仓库客服',
|
||||
contactPhone: '0579-12345678',
|
||||
contactEmail: null,
|
||||
@@ -55,20 +56,45 @@ describe('platform warehouse API', () => {
|
||||
vi.mocked(http.patch).mockReset()
|
||||
})
|
||||
|
||||
it('lists and loads warehouse details for the target tenant', async () => {
|
||||
const page = { records: [], page: 1, size: 200, total: 0, pages: 0 }
|
||||
it('lists server-paged platform warehouses and normalizes summary fields', async () => {
|
||||
const page = {
|
||||
records: [{
|
||||
id: '1',
|
||||
tenantId: 0,
|
||||
warehouseCode: 'LAX9',
|
||||
warehouseName: 'LAX9',
|
||||
warehouseType: 'FBA',
|
||||
countryCode: 'US',
|
||||
timezone: 'UTC-8',
|
||||
contactName: null,
|
||||
contactPhone: null,
|
||||
contactEmail: null,
|
||||
status: 'ACTIVE',
|
||||
remark: null,
|
||||
}],
|
||||
page: '1',
|
||||
size: '20',
|
||||
total: '1',
|
||||
pages: '1',
|
||||
}
|
||||
const detail = { warehouse: {}, addresses: [], capabilities: [] }
|
||||
vi.mocked(http.get)
|
||||
.mockResolvedValueOnce({ code: '200', message: 'success', data: page })
|
||||
.mockResolvedValueOnce({ code: '200', message: 'success', data: detail })
|
||||
|
||||
await expect(listPlatformWarehouses(10001)).resolves.toEqual(page)
|
||||
await expect(getPlatformWarehouse(10001, '19001')).resolves.toEqual(detail)
|
||||
await expect(listPlatformWarehouses({ page: 1, size: 20, keyword: 'LAX', warehouseType: 'FBA', status: 'ACTIVE' })).resolves.toEqual({
|
||||
records: [{ ...page.records[0], primaryAddress: null, addressSummary: null }],
|
||||
page: 1,
|
||||
size: 20,
|
||||
total: 1,
|
||||
pages: 1,
|
||||
})
|
||||
await expect(getPlatformWarehouse('19001')).resolves.toEqual(detail)
|
||||
expect(http.get).toHaveBeenNthCalledWith(1, '/platform/v1/warehouse-admin', {
|
||||
params: { tenantId: 10001, page: 1, size: 200 },
|
||||
params: { page: 1, size: 20, keyword: 'LAX', warehouseType: 'FBA', status: 'ACTIVE' },
|
||||
})
|
||||
expect(http.get).toHaveBeenNthCalledWith(2, '/platform/v1/warehouse-admin/19001', {
|
||||
params: { tenantId: 10001 },
|
||||
params: { tenantId: 0 },
|
||||
})
|
||||
})
|
||||
|
||||
@@ -89,15 +115,87 @@ describe('platform warehouse API', () => {
|
||||
vi.mocked(http.patch).mockResolvedValue({ code: '200', message: 'success', data: null })
|
||||
|
||||
await expect(createPlatformWarehouse(payload)).resolves.toBe('19001')
|
||||
await expect(updatePlatformWarehouse(10001, '19001', payload)).resolves.toBeNull()
|
||||
await expect(changePlatformWarehouseStatus(10001, '19001', 'INACTIVE')).resolves.toBeNull()
|
||||
await expect(updatePlatformWarehouse('19001', payload)).resolves.toBeNull()
|
||||
await expect(changePlatformWarehouseStatus('19001', 'INACTIVE')).resolves.toBeNull()
|
||||
|
||||
expect(http.post).toHaveBeenCalledWith('/platform/v1/warehouse-admin', payload)
|
||||
expect(http.put).toHaveBeenCalledWith('/platform/v1/warehouse-admin/19001', payload, {
|
||||
params: { tenantId: 10001 },
|
||||
expect(http.post).toHaveBeenCalledWith('/platform/v1/warehouse-admin', { ...payload, tenantId: 0 })
|
||||
expect(http.put).toHaveBeenCalledWith('/platform/v1/warehouse-admin/19001', { ...payload, tenantId: 0 }, {
|
||||
params: { tenantId: 0 },
|
||||
})
|
||||
expect(http.patch).toHaveBeenCalledWith('/platform/v1/warehouse-admin/19001/status', undefined, {
|
||||
params: { tenantId: 10001, status: 'INACTIVE' },
|
||||
params: { tenantId: 0, status: 'INACTIVE' },
|
||||
})
|
||||
})
|
||||
|
||||
it('uses the FBA fast-create defaults and implemented import routes without client-side success simulation', async () => {
|
||||
vi.mocked(http.post)
|
||||
.mockResolvedValueOnce({ code: '200', message: 'success', data: '19002' })
|
||||
.mockResolvedValueOnce({
|
||||
code: '200',
|
||||
message: 'success',
|
||||
data: {
|
||||
fileName: 'fba.xlsx',
|
||||
totalRows: '3',
|
||||
createdRows: '1',
|
||||
updatedRows: '1',
|
||||
unchangedRows: '0',
|
||||
success: false,
|
||||
committed: false,
|
||||
errors: [{ row: '3', field: '仓库编码', message: '编码重复' }],
|
||||
},
|
||||
})
|
||||
|
||||
await expect(createFbaWarehouse({
|
||||
warehouseCode: 'LAX9',
|
||||
stateProvince: 'CA',
|
||||
city: 'Long Beach',
|
||||
postalCode: '90802',
|
||||
addressLine1: '2400 E Spring St',
|
||||
})).resolves.toBe('19002')
|
||||
|
||||
const file = new File(['content'], 'warehouses.xlsx')
|
||||
await expect(importWarehouses(file, true, 'CREATE_ONLY')).resolves.toMatchObject({
|
||||
totalRows: 3,
|
||||
validRows: 2,
|
||||
createCount: 1,
|
||||
updateCount: 1,
|
||||
unchangedCount: 0,
|
||||
errorCount: 1,
|
||||
committed: false,
|
||||
errors: [{ row: 3, field: '仓库编码', message: '编码重复' }],
|
||||
})
|
||||
|
||||
expect(http.post).toHaveBeenNthCalledWith(1, '/platform/v1/warehouse-admin', {
|
||||
warehouseCode: 'LAX9',
|
||||
warehouseName: 'LAX9',
|
||||
warehouseType: 'FBA',
|
||||
countryCode: 'US',
|
||||
timezone: '',
|
||||
contactName: null,
|
||||
contactPhone: null,
|
||||
contactEmail: null,
|
||||
status: 'ACTIVE',
|
||||
remark: null,
|
||||
address: {
|
||||
addressType: 'OPERATING',
|
||||
countryCode: 'US',
|
||||
stateProvince: 'CA',
|
||||
city: 'Long Beach',
|
||||
district: null,
|
||||
postalCode: '90802',
|
||||
addressLine1: '2400 E Spring St',
|
||||
addressLine2: null,
|
||||
latitude: null,
|
||||
longitude: null,
|
||||
},
|
||||
capabilities: [],
|
||||
tenantId: 0,
|
||||
})
|
||||
expect(http.post).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'/platform/v1/warehouse-admin/import',
|
||||
expect.any(FormData),
|
||||
{ params: { tenantId: 0, dryRun: true, mode: 'CREATE_ONLY' } },
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -54,14 +54,34 @@ export interface PlatformWarehouseDetailResource {
|
||||
capabilities: PlatformWarehouseCapabilityResource[]
|
||||
}
|
||||
|
||||
export interface PlatformWarehouseSummaryResource extends PlatformWarehouseResource {
|
||||
primaryAddress?: PlatformWarehouseAddressResource | null
|
||||
addressSummary?: string | null
|
||||
}
|
||||
|
||||
export interface PlatformWarehousePage {
|
||||
records: PlatformWarehouseResource[]
|
||||
records: PlatformWarehouseSummaryResource[]
|
||||
page: number
|
||||
size: number
|
||||
total: number
|
||||
pages: number
|
||||
}
|
||||
|
||||
type PlatformWarehousePagePayload = Omit<PlatformWarehousePage, 'page' | 'size' | 'total' | 'pages'> & {
|
||||
page: number | string
|
||||
size: number | string
|
||||
total: number | string
|
||||
pages: number | string
|
||||
}
|
||||
|
||||
export interface PlatformWarehouseQuery {
|
||||
page: number
|
||||
size: number
|
||||
keyword?: string
|
||||
warehouseType?: WarehouseType
|
||||
status?: PlatformWarehouseStatus
|
||||
}
|
||||
|
||||
export interface PlatformWarehouseAddressRequest {
|
||||
addressType: string
|
||||
countryCode: string
|
||||
@@ -84,7 +104,6 @@ export interface PlatformWarehouseCapabilityRequest {
|
||||
}
|
||||
|
||||
export interface SavePlatformWarehouseRequest {
|
||||
tenantId: number | string
|
||||
warehouseCode: string
|
||||
warehouseName: string
|
||||
warehouseType: WarehouseType
|
||||
@@ -99,42 +118,178 @@ export interface SavePlatformWarehouseRequest {
|
||||
capabilities: PlatformWarehouseCapabilityRequest[]
|
||||
}
|
||||
|
||||
const basePath = '/platform/v1/warehouse-admin'
|
||||
|
||||
export async function listPlatformWarehouses(tenantId?: number | string, page = 1, size = 200) {
|
||||
const response = await http.get<ApiEnvelope<PlatformWarehousePage>, ApiEnvelope<PlatformWarehousePage>>(basePath, {
|
||||
params: { ...(tenantId === undefined || tenantId === '' ? {} : { tenantId }), page, size },
|
||||
})
|
||||
return unwrapApiResponse(response)
|
||||
export interface CreateFbaWarehouseRequest {
|
||||
warehouseCode: string
|
||||
stateProvince: string
|
||||
city: string
|
||||
postalCode: string
|
||||
addressLine1: string
|
||||
timezone?: string | null
|
||||
}
|
||||
|
||||
export async function getPlatformWarehouse(tenantId: number | string, warehouseId: number | string) {
|
||||
export type WarehouseImportMode = 'CREATE_ONLY' | 'UPSERT'
|
||||
|
||||
export interface WarehouseImportError {
|
||||
row: number
|
||||
field: string
|
||||
code?: string | null
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface WarehouseImportResult {
|
||||
fileName: string
|
||||
totalRows: number
|
||||
validRows: number
|
||||
createCount: number
|
||||
updateCount: number
|
||||
unchangedCount: number
|
||||
errorCount: number
|
||||
committed: boolean
|
||||
errors: WarehouseImportError[]
|
||||
}
|
||||
|
||||
const basePath = '/platform/v1/warehouse-admin'
|
||||
const platformResourceScope = 0
|
||||
|
||||
type PlatformWarehouseApiSaveRequest = SavePlatformWarehouseRequest & { tenantId: number }
|
||||
type WarehouseImportErrorPayload = Omit<WarehouseImportError, 'row'> & { row: number | string }
|
||||
type WarehouseImportResultPayload = Partial<Omit<
|
||||
WarehouseImportResult,
|
||||
'totalRows' | 'validRows' | 'createCount' | 'updateCount' | 'unchangedCount' | 'errorCount' | 'committed' | 'errors'
|
||||
>> & {
|
||||
totalRows: number | string
|
||||
validRows?: number | string
|
||||
createCount?: number | string
|
||||
createdRows?: number | string
|
||||
updateCount?: number | string
|
||||
updatedRows?: number | string
|
||||
unchangedCount?: number | string
|
||||
unchangedRows?: number | string
|
||||
errorCount?: number | string
|
||||
success?: boolean
|
||||
committed?: boolean
|
||||
errors?: WarehouseImportErrorPayload[] | null
|
||||
}
|
||||
|
||||
function withPlatformScope(payload: SavePlatformWarehouseRequest): PlatformWarehouseApiSaveRequest {
|
||||
return { ...payload, tenantId: platformResourceScope }
|
||||
}
|
||||
|
||||
function toNumber(value: number | string | undefined) {
|
||||
return Number(value ?? 0)
|
||||
}
|
||||
|
||||
function normalizeWarehouseImportResult(result: WarehouseImportResultPayload, dryRun: boolean): WarehouseImportResult {
|
||||
const errors = (result.errors || []).map((error) => ({ ...error, row: toNumber(error.row) }))
|
||||
const errorCount = toNumber(result.errorCount) || errors.length
|
||||
const totalRows = toNumber(result.totalRows)
|
||||
return {
|
||||
...result,
|
||||
fileName: result.fileName || '',
|
||||
totalRows,
|
||||
validRows: toNumber(result.validRows) || Math.max(totalRows - errorCount, 0),
|
||||
createCount: toNumber(result.createCount ?? result.createdRows),
|
||||
updateCount: toNumber(result.updateCount ?? result.updatedRows),
|
||||
unchangedCount: toNumber(result.unchangedCount ?? result.unchangedRows),
|
||||
errorCount,
|
||||
committed: result.committed ?? (!dryRun && Boolean(result.success) && errorCount === 0),
|
||||
errors,
|
||||
}
|
||||
}
|
||||
|
||||
export async function listPlatformWarehouses(query?: PlatformWarehouseQuery): Promise<PlatformWarehousePage>
|
||||
export async function listPlatformWarehouses(page?: number, size?: number): Promise<PlatformWarehousePage>
|
||||
export async function listPlatformWarehouses(pageOrQuery: number | PlatformWarehouseQuery = 1, size = 200) {
|
||||
const query = typeof pageOrQuery === 'number'
|
||||
? { page: pageOrQuery, size }
|
||||
: pageOrQuery
|
||||
const response = await http.get<ApiEnvelope<PlatformWarehousePagePayload>, ApiEnvelope<PlatformWarehousePagePayload>>(basePath, {
|
||||
params: query,
|
||||
})
|
||||
const result = unwrapApiResponse(response)
|
||||
return {
|
||||
...result,
|
||||
records: result.records.map((warehouse) => ({
|
||||
...warehouse,
|
||||
primaryAddress: warehouse.primaryAddress ?? null,
|
||||
addressSummary: warehouse.addressSummary ?? null,
|
||||
})),
|
||||
page: Number(result.page),
|
||||
size: Number(result.size),
|
||||
total: Number(result.total),
|
||||
pages: Number(result.pages),
|
||||
}
|
||||
}
|
||||
|
||||
export async function getPlatformWarehouse(warehouseId: number | string) {
|
||||
const response = await http.get<ApiEnvelope<PlatformWarehouseDetailResource>, ApiEnvelope<PlatformWarehouseDetailResource>>(
|
||||
`${basePath}/${warehouseId}`,
|
||||
{ params: { tenantId } },
|
||||
{ params: { tenantId: platformResourceScope } },
|
||||
)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function createPlatformWarehouse(payload: SavePlatformWarehouseRequest) {
|
||||
const response = await http.post<ApiEnvelope<number | string>, ApiEnvelope<number | string>>(basePath, payload)
|
||||
const response = await http.post<ApiEnvelope<number | string>, ApiEnvelope<number | string>>(basePath, withPlatformScope(payload))
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function updatePlatformWarehouse(tenantId: number | string, warehouseId: number | string, payload: SavePlatformWarehouseRequest) {
|
||||
const response = await http.put<ApiEnvelope<null>, ApiEnvelope<null>>(`${basePath}/${warehouseId}`, payload, {
|
||||
params: { tenantId },
|
||||
export async function createFbaWarehouse(payload: CreateFbaWarehouseRequest) {
|
||||
return createPlatformWarehouse({
|
||||
warehouseCode: payload.warehouseCode,
|
||||
warehouseName: payload.warehouseCode,
|
||||
warehouseType: 'FBA',
|
||||
countryCode: 'US',
|
||||
timezone: payload.timezone?.trim() || '',
|
||||
contactName: null,
|
||||
contactPhone: null,
|
||||
contactEmail: null,
|
||||
status: 'ACTIVE',
|
||||
remark: null,
|
||||
address: {
|
||||
addressType: 'OPERATING',
|
||||
countryCode: 'US',
|
||||
stateProvince: payload.stateProvince,
|
||||
city: payload.city,
|
||||
district: null,
|
||||
postalCode: payload.postalCode,
|
||||
addressLine1: payload.addressLine1,
|
||||
addressLine2: null,
|
||||
latitude: null,
|
||||
longitude: null,
|
||||
},
|
||||
capabilities: [],
|
||||
})
|
||||
}
|
||||
|
||||
export async function downloadWarehouseImportTemplate() {
|
||||
return http.get<Blob, Blob>(`${basePath}/import-template`, { responseType: 'blob' })
|
||||
}
|
||||
|
||||
export async function importWarehouses(file: File, dryRun: boolean, mode: WarehouseImportMode) {
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
const response = await http.post<ApiEnvelope<WarehouseImportResultPayload>, ApiEnvelope<WarehouseImportResultPayload>>(
|
||||
`${basePath}/import`,
|
||||
form,
|
||||
{ params: { tenantId: platformResourceScope, dryRun, mode } },
|
||||
)
|
||||
return normalizeWarehouseImportResult(unwrapApiResponse(response), dryRun)
|
||||
}
|
||||
|
||||
export async function updatePlatformWarehouse(warehouseId: number | string, payload: SavePlatformWarehouseRequest) {
|
||||
const response = await http.put<ApiEnvelope<null>, ApiEnvelope<null>>(`${basePath}/${warehouseId}`, withPlatformScope(payload), {
|
||||
params: { tenantId: platformResourceScope },
|
||||
})
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function changePlatformWarehouseStatus(
|
||||
tenantId: number | string,
|
||||
warehouseId: number | string,
|
||||
status: PlatformWarehouseStatus,
|
||||
) {
|
||||
const response = await http.patch<ApiEnvelope<null>, ApiEnvelope<null>>(`${basePath}/${warehouseId}/status`, undefined, {
|
||||
params: { tenantId, status },
|
||||
params: { tenantId: platformResourceScope, status },
|
||||
})
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
@@ -2,25 +2,32 @@ import ElementPlus from 'element-plus'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
|
||||
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import DataTable from '@/shared/components/DataTable.vue'
|
||||
import QueryToolbar from '@/shared/components/QueryToolbar.vue'
|
||||
import {
|
||||
createFbaWarehouse,
|
||||
createPlatformWarehouse,
|
||||
getPlatformWarehouse,
|
||||
importWarehouses,
|
||||
listPlatformWarehouses,
|
||||
updatePlatformWarehouse,
|
||||
type PlatformWarehouseDetailResource,
|
||||
type PlatformWarehouseResource,
|
||||
type PlatformWarehouseSummaryResource,
|
||||
type WarehouseImportResult,
|
||||
} from '@/modules/warehouses/api/platformWarehouses'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { useAuthStore } from '@/core/auth/auth'
|
||||
import QueryToolbar from '@/shared/components/QueryToolbar.vue'
|
||||
import WarehousesView from './WarehousesView.vue'
|
||||
|
||||
vi.mock('@/modules/warehouses/api/platformWarehouses', () => ({
|
||||
listPlatformWarehouses: vi.fn(),
|
||||
getPlatformWarehouse: vi.fn(),
|
||||
createPlatformWarehouse: vi.fn(),
|
||||
createFbaWarehouse: vi.fn(),
|
||||
updatePlatformWarehouse: vi.fn(),
|
||||
changePlatformWarehouseStatus: vi.fn(),
|
||||
downloadWarehouseImportTemplate: vi.fn(),
|
||||
importWarehouses: vi.fn(),
|
||||
}))
|
||||
|
||||
class ResizeObserverStub implements ResizeObserver {
|
||||
@@ -41,43 +48,109 @@ function createMemoryStorage(initial: Record<string, string> = {}): Storage {
|
||||
}
|
||||
}
|
||||
|
||||
const warehouse: PlatformWarehouseResource = {
|
||||
const domesticAddress = {
|
||||
id: '19002',
|
||||
tenantId: 0,
|
||||
warehouseId: '19001',
|
||||
addressType: 'OPERATING',
|
||||
countryCode: 'CN',
|
||||
stateProvince: '浙江省',
|
||||
city: '义乌市',
|
||||
district: '北苑街道',
|
||||
postalCode: '322000',
|
||||
addressLine1: '真实物流园 1 号库',
|
||||
addressLine2: 'A 栋',
|
||||
latitude: '29.3',
|
||||
longitude: '120.0',
|
||||
}
|
||||
|
||||
const domesticWarehouse: PlatformWarehouseSummaryResource = {
|
||||
id: '19001',
|
||||
tenantId: 10001,
|
||||
warehouseCode: 'CN-TEST-01',
|
||||
warehouseName: '测试环境收货仓',
|
||||
tenantId: 0,
|
||||
warehouseCode: 'CN-YW-01',
|
||||
warehouseName: '义乌收货仓',
|
||||
warehouseType: 'DOMESTIC',
|
||||
countryCode: 'CN',
|
||||
timezone: 'Asia/Shanghai',
|
||||
contactName: '真实联系人',
|
||||
timezone: 'UTC+8',
|
||||
contactName: '仓库客服',
|
||||
contactPhone: '0579-12345678',
|
||||
contactEmail: 'warehouse@example.com',
|
||||
status: 'ACTIVE',
|
||||
remark: '测试环境数据',
|
||||
updatedAt: '2026-07-31T10:20:00',
|
||||
addressSummary: '浙江省义乌市北苑街道 1 号库',
|
||||
primaryAddress: domesticAddress,
|
||||
}
|
||||
|
||||
const fbaAddress = {
|
||||
id: '19012',
|
||||
tenantId: 0,
|
||||
warehouseId: '19011',
|
||||
addressType: 'OPERATING',
|
||||
countryCode: 'US',
|
||||
stateProvince: 'OH',
|
||||
city: 'Canton',
|
||||
district: null,
|
||||
postalCode: '44705',
|
||||
addressLine1: '4747 Rebar Ave NE',
|
||||
addressLine2: null,
|
||||
latitude: null,
|
||||
longitude: null,
|
||||
}
|
||||
|
||||
const fbaWarehouse: PlatformWarehouseSummaryResource = {
|
||||
id: '19011',
|
||||
tenantId: 0,
|
||||
warehouseCode: 'AKR1',
|
||||
warehouseName: 'AKR1',
|
||||
warehouseType: 'FBA',
|
||||
countryCode: 'US',
|
||||
timezone: 'UTC-5',
|
||||
contactName: null,
|
||||
contactPhone: null,
|
||||
contactEmail: null,
|
||||
status: 'ACTIVE',
|
||||
remark: null,
|
||||
updatedAt: '2026-07-31T10:20:00',
|
||||
addressSummary: 'OH · Canton',
|
||||
primaryAddress: fbaAddress,
|
||||
}
|
||||
|
||||
const fbaDetail: PlatformWarehouseDetailResource = {
|
||||
warehouse: fbaWarehouse,
|
||||
addresses: [fbaAddress],
|
||||
capabilities: [],
|
||||
}
|
||||
|
||||
const detail: PlatformWarehouseDetailResource = {
|
||||
warehouse,
|
||||
addresses: [{
|
||||
id: '19002', tenantId: 10001, warehouseId: '19001', addressType: 'OPERATING', countryCode: 'CN',
|
||||
stateProvince: '浙江省', city: '义乌市', district: '北苑街道', postalCode: '322000',
|
||||
addressLine1: '真实物流园 1 号库', addressLine2: 'A 栋', latitude: '29.3', longitude: '120.0',
|
||||
}],
|
||||
warehouse: domesticWarehouse,
|
||||
addresses: [domesticAddress],
|
||||
capabilities: [
|
||||
{ id: '19003', tenantId: 10001, warehouseId: '19001', capabilityCode: 'RECEIVING', capabilityName: '收货服务', enabled: true, cutoffTime: '17:00:00', description: '周一至周六 08:30-18:00' },
|
||||
{ id: '19004', tenantId: 10001, warehouseId: '19001', capabilityCode: 'STORAGE', capabilityName: '仓储', enabled: true, cutoffTime: null, description: '托盘仓储' },
|
||||
{ id: '19003', tenantId: 0, warehouseId: '19001', capabilityCode: 'RECEIVING', capabilityName: '收货服务', enabled: true, cutoffTime: '17:00:00', description: '周一至周六 08:30-18:00' },
|
||||
{ id: '19004', tenantId: 0, warehouseId: '19001', capabilityCode: 'STORAGE', capabilityName: '仓储', enabled: true, cutoffTime: null, description: '托盘仓储' },
|
||||
],
|
||||
}
|
||||
|
||||
async function mountView() {
|
||||
const importPreview: WarehouseImportResult = {
|
||||
fileName: 'warehouses.xlsx',
|
||||
totalRows: 4,
|
||||
validRows: 3,
|
||||
createCount: 2,
|
||||
updateCount: 1,
|
||||
unchangedCount: 0,
|
||||
errorCount: 1,
|
||||
committed: false,
|
||||
errors: [{ row: 4, field: '仓库编码', code: 'DUPLICATE_CODE', message: '文件内编码重复' }],
|
||||
}
|
||||
|
||||
async function mountView(permissions = ['platform:warehouse:list', 'platform:warehouse:create', 'platform:warehouse:edit', 'platform:warehouse:status']) {
|
||||
vi.stubGlobal('localStorage', createMemoryStorage())
|
||||
vi.stubGlobal('sessionStorage', createMemoryStorage())
|
||||
const pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
const auth = useAuthStore()
|
||||
auth.token = 'token'
|
||||
auth.permissions = ['platform:warehouse:list', 'platform:warehouse:create', 'platform:warehouse:edit', 'platform:warehouse:status']
|
||||
auth.permissions = permissions
|
||||
useAccessPreviewStore().initializeAfterLogin()
|
||||
const wrapper = mount(WarehousesView, {
|
||||
attachTo: document.body,
|
||||
@@ -87,72 +160,80 @@ async function mountView() {
|
||||
return wrapper
|
||||
}
|
||||
|
||||
function setField(selector: string, value: string) {
|
||||
const field = document.querySelector<HTMLInputElement | HTMLTextAreaElement>(selector)
|
||||
expect(field).not.toBeNull()
|
||||
field!.value = value
|
||||
field!.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
}
|
||||
|
||||
enableAutoUnmount(afterEach)
|
||||
afterAll(() => vi.unstubAllGlobals())
|
||||
|
||||
describe('WarehousesView real API integration', () => {
|
||||
describe('WarehousesView', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
|
||||
document.body.innerHTML = ''
|
||||
vi.mocked(listPlatformWarehouses).mockReset().mockResolvedValue({ records: [warehouse], page: 1, size: 200, total: 1, pages: 1 })
|
||||
vi.mocked(listPlatformWarehouses).mockReset().mockResolvedValue({ records: [domesticWarehouse], page: 1, size: 20, total: 1, pages: 1 })
|
||||
vi.mocked(getPlatformWarehouse).mockReset().mockResolvedValue(detail)
|
||||
vi.mocked(createPlatformWarehouse).mockReset().mockResolvedValue('19005')
|
||||
vi.mocked(createFbaWarehouse).mockReset().mockResolvedValue('19006')
|
||||
vi.mocked(updatePlatformWarehouse).mockReset().mockResolvedValue(null)
|
||||
vi.mocked(importWarehouses).mockReset().mockResolvedValue(importPreview)
|
||||
})
|
||||
|
||||
it('automatically loads the fixed tenant and renders address and receiving capability from details', async () => {
|
||||
it('loads a server-paged warehouse list with backend address summaries and no row detail fan-out', async () => {
|
||||
const wrapper = await mountView()
|
||||
|
||||
expect(listPlatformWarehouses).toHaveBeenCalledWith(10001, 1, 200)
|
||||
expect(getPlatformWarehouse).toHaveBeenCalledWith(10001, '19001')
|
||||
expect(wrapper.text()).toContain('测试环境收货仓')
|
||||
expect(wrapper.text()).toContain('北苑街道 真实物流园 1 号库 A 栋')
|
||||
expect(wrapper.text()).toContain('周一至周六 08:30-18:00')
|
||||
expect(listPlatformWarehouses).toHaveBeenCalledWith({
|
||||
page: 1,
|
||||
size: 20,
|
||||
keyword: undefined,
|
||||
warehouseType: undefined,
|
||||
status: undefined,
|
||||
})
|
||||
expect(getPlatformWarehouse).not.toHaveBeenCalled()
|
||||
expect(wrapper.text()).toContain('义乌收货仓')
|
||||
expect(wrapper.text()).toContain('浙江省义乌市北苑街道 1 号库')
|
||||
expect(wrapper.text()).toContain('国内收货仓')
|
||||
expect(wrapper.text()).toContain('Asia/Shanghai')
|
||||
expect(wrapper.text()).not.toContain('全部收货仓')
|
||||
expect(wrapper.find('.query-toolbar').exists()).toBe(true)
|
||||
expect(wrapper.text()).toContain('中国 CN')
|
||||
expect(wrapper.text()).toContain('UTC+8')
|
||||
expect(wrapper.text()).toContain('共 1 个公共仓库')
|
||||
expect(wrapper.find('.data-table').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('updates filters locally and only reloads after search', async () => {
|
||||
const wrapper = await mountView()
|
||||
const keywordInput = wrapper.find('input[placeholder="仓库名称、编码、城市或地址"]')
|
||||
|
||||
await keywordInput.setValue('义乌')
|
||||
await flushPromises()
|
||||
expect(listPlatformWarehouses).toHaveBeenCalledTimes(1)
|
||||
|
||||
const searchButton = wrapper.findAll('button').find((button) => button.text().trim() === '查询')
|
||||
expect(searchButton).toBeDefined()
|
||||
await searchButton?.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(listPlatformWarehouses).toHaveBeenCalledTimes(2)
|
||||
expect(wrapper.text()).toContain('测试环境收货仓')
|
||||
})
|
||||
|
||||
it('uses the fixed public tenant without rendering a tenant selector', async () => {
|
||||
const wrapper = await mountView()
|
||||
|
||||
expect(wrapper.find('[aria-label="目标企业"]').exists()).toBe(false)
|
||||
expect(listPlatformWarehouses).toHaveBeenCalledWith(10001, 1, 200)
|
||||
expect(wrapper.text()).toContain('公共仓库')
|
||||
})
|
||||
|
||||
it('filters the public warehouse table by warehouse type', async () => {
|
||||
it('submits filters and pagination to the API instead of filtering rows locally', async () => {
|
||||
vi.mocked(listPlatformWarehouses).mockResolvedValue({ records: [domesticWarehouse], page: 1, size: 20, total: 45, pages: 3 })
|
||||
const wrapper = await mountView()
|
||||
|
||||
wrapper.findComponent(QueryToolbar).vm.$emit('search', {
|
||||
keyword: '', warehouseType: 'FBA', status: '',
|
||||
keyword: 'FBA',
|
||||
warehouseType: 'FBA',
|
||||
status: 'ACTIVE',
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).not.toContain('测试环境收货仓')
|
||||
expect(wrapper.text()).toContain('暂无符合筛选条件的仓库')
|
||||
expect(listPlatformWarehouses).toHaveBeenLastCalledWith({
|
||||
page: 1,
|
||||
size: 20,
|
||||
keyword: 'FBA',
|
||||
warehouseType: 'FBA',
|
||||
status: 'ACTIVE',
|
||||
})
|
||||
|
||||
wrapper.findComponent(DataTable).vm.$emit('update:page', 2)
|
||||
await flushPromises()
|
||||
|
||||
expect(listPlatformWarehouses).toHaveBeenLastCalledWith({
|
||||
page: 2,
|
||||
size: 20,
|
||||
keyword: 'FBA',
|
||||
warehouseType: 'FBA',
|
||||
status: 'ACTIVE',
|
||||
})
|
||||
})
|
||||
|
||||
it('shows a recoverable real API error without falling back to demo warehouses', async () => {
|
||||
it('shows a recoverable API error without falling back to demo warehouses', async () => {
|
||||
vi.mocked(listPlatformWarehouses).mockRejectedValueOnce(new Error('Gateway unavailable'))
|
||||
const wrapper = await mountView()
|
||||
|
||||
@@ -162,66 +243,219 @@ describe('WarehousesView real API integration', () => {
|
||||
expect(wrapper.text()).not.toContain('演示物流园')
|
||||
})
|
||||
|
||||
it('preserves backend-only address, email, capability and cutoff fields when updating', async () => {
|
||||
it('keeps create actions behind permissions and preserves a read-only table state', async () => {
|
||||
const wrapper = await mountView(['platform:warehouse:list'])
|
||||
|
||||
expect(wrapper.text()).not.toContain('新增仓库')
|
||||
expect(wrapper.text()).not.toContain('导入仓库')
|
||||
expect(wrapper.text()).toContain('仅查看')
|
||||
})
|
||||
|
||||
it('creates an FBA address from the fast path with selectable state and timezone values', async () => {
|
||||
const wrapper = await mountView()
|
||||
const editButton = wrapper.findAll('button').find((button) => button.text().trim() === '编辑')
|
||||
expect(editButton).toBeDefined()
|
||||
await editButton?.trigger('click')
|
||||
|
||||
wrapper.findComponent({ name: 'ElDropdown' }).vm.$emit('command', 'FBA')
|
||||
await flushPromises()
|
||||
|
||||
const typeFormItem = wrapper.findAll('.el-form-item').find((item) => item.text().includes('仓库类型'))
|
||||
expect(typeFormItem).toBeDefined()
|
||||
typeFormItem?.findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', 'FBA')
|
||||
await wrapper.find('input[placeholder="例如 CN、US"]').setValue('US')
|
||||
await wrapper.find('input[placeholder="例如 Asia/Shanghai"]').setValue('America/Los_Angeles')
|
||||
const drawerText = document.querySelector('.el-drawer')?.textContent || ''
|
||||
expect(drawerText).toContain('新增FBA 地址')
|
||||
expect(drawerText).toContain('国家或地区')
|
||||
expect(drawerText).not.toContain('时区覆盖')
|
||||
expect(drawerText).not.toContain('联系人')
|
||||
expect(drawerText).not.toContain('收货时间')
|
||||
const countrySelect = wrapper.findAllComponents({ name: 'ElSelect' })
|
||||
.find((select) => select.props('placeholder') === '美国 US')
|
||||
expect(countrySelect).toBeDefined()
|
||||
expect(countrySelect!.props('modelValue')).toBe('US')
|
||||
expect(countrySelect!.props('disabled')).toBe(true)
|
||||
|
||||
const saveButton = wrapper.findAll('button').find((button) => button.text().trim() === '保存仓库')
|
||||
const stateSelect = wrapper.findAllComponents({ name: 'ElSelect' })
|
||||
.find((select) => select.props('placeholder') === '请选择州')
|
||||
expect(stateSelect).toBeDefined()
|
||||
|
||||
setField('input[placeholder="例如 LAX9"]', 'lax9')
|
||||
stateSelect!.vm.$emit('update:modelValue', 'CA')
|
||||
stateSelect!.vm.$emit('change', 'CA')
|
||||
await flushPromises()
|
||||
setField('input[placeholder="例如 Long Beach"]', 'Long Beach')
|
||||
setField('input[placeholder="例如 90802"]', '90802')
|
||||
setField('textarea[placeholder="请输入街道和门牌信息"]', '2400 E Spring St')
|
||||
|
||||
const saveButton = [...document.querySelectorAll('button')].find((button) => button.textContent?.trim() === '确认新增')
|
||||
expect(saveButton).toBeDefined()
|
||||
await saveButton?.trigger('click')
|
||||
saveButton!.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
||||
await flushPromises()
|
||||
|
||||
expect(createFbaWarehouse).toHaveBeenCalledWith({
|
||||
warehouseCode: 'LAX9',
|
||||
stateProvince: 'CA',
|
||||
city: 'Long Beach',
|
||||
postalCode: '90802',
|
||||
addressLine1: '2400 E Spring St',
|
||||
timezone: 'UTC-8',
|
||||
})
|
||||
expect(createPlatformWarehouse).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('hides domestic country, timezone and type inputs while submitting fixed CN and UTC+8 defaults', async () => {
|
||||
const wrapper = await mountView()
|
||||
|
||||
wrapper.findComponent({ name: 'ElDropdown' }).vm.$emit('command', 'DOMESTIC')
|
||||
await flushPromises()
|
||||
|
||||
const drawerText = document.querySelector('.el-drawer')?.textContent || ''
|
||||
expect(drawerText).not.toContain('国家或地区')
|
||||
expect(drawerText).not.toContain('时区')
|
||||
expect(drawerText).not.toContain('仓库类型')
|
||||
|
||||
setField('input[placeholder="请输入仓库名称"]', '义乌新仓')
|
||||
setField('input[placeholder="例如 浙江省"]', '浙江省')
|
||||
setField('input[placeholder="例如 义乌市"]', '义乌市')
|
||||
setField('textarea[placeholder="请输入街道、园区、楼栋及门牌信息"]', '稠州北路 100 号')
|
||||
|
||||
const saveButton = [...document.querySelectorAll('button')].find((button) => button.textContent?.trim() === '确认新增')
|
||||
expect(saveButton).toBeDefined()
|
||||
saveButton!.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
||||
await flushPromises()
|
||||
|
||||
expect(createPlatformWarehouse).toHaveBeenCalledOnce()
|
||||
expect(vi.mocked(createPlatformWarehouse).mock.calls[0][0]).toMatchObject({
|
||||
warehouseName: '义乌新仓',
|
||||
warehouseType: 'DOMESTIC',
|
||||
countryCode: 'CN',
|
||||
timezone: 'UTC+8',
|
||||
address: expect.objectContaining({ countryCode: 'CN', city: '义乌市' }),
|
||||
})
|
||||
})
|
||||
|
||||
it('uses country and state selectors for overseas warehouse creation and auto-fills timezone when safe', async () => {
|
||||
const wrapper = await mountView()
|
||||
|
||||
wrapper.findComponent({ name: 'ElDropdown' }).vm.$emit('command', 'OVERSEAS')
|
||||
await flushPromises()
|
||||
|
||||
const countrySelect = wrapper.findAllComponents({ name: 'ElSelect' })
|
||||
.find((select) => select.props('placeholder') === '请选择国家或地区')
|
||||
expect(countrySelect).toBeDefined()
|
||||
expect(countrySelect!.props('modelValue')).toBe('US')
|
||||
|
||||
const stateSelect = wrapper.findAllComponents({ name: 'ElSelect' })
|
||||
.find((select) => select.props('placeholder') === '请选择州')
|
||||
expect(stateSelect).toBeDefined()
|
||||
const timezoneSelect = wrapper.findAllComponents({ name: 'ElSelect' })
|
||||
.find((select) => select.props('placeholder') === '请选择时区')
|
||||
expect(timezoneSelect).toBeDefined()
|
||||
expect(wrapper.findAllComponents({ name: 'ElOption' }).some((option) => option.props('value') === 'UTC-8')).toBe(true)
|
||||
|
||||
setField('input[placeholder="请输入仓库名称"]', '洛杉矶海外仓')
|
||||
stateSelect!.vm.$emit('update:modelValue', 'CA')
|
||||
stateSelect!.vm.$emit('change', 'CA')
|
||||
setField('input[placeholder="例如 Long Beach"]', 'Long Beach')
|
||||
setField('textarea[placeholder="请输入街道、园区、楼栋及门牌信息"]', '2400 E Spring St')
|
||||
|
||||
const saveButton = [...document.querySelectorAll('button')].find((button) => button.textContent?.trim() === '确认新增')
|
||||
expect(saveButton).toBeDefined()
|
||||
saveButton!.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
||||
await flushPromises()
|
||||
|
||||
expect(createPlatformWarehouse).toHaveBeenCalledOnce()
|
||||
expect(vi.mocked(createPlatformWarehouse).mock.calls[0][0]).toMatchObject({
|
||||
warehouseName: '洛杉矶海外仓',
|
||||
warehouseType: 'OVERSEAS',
|
||||
countryCode: 'US',
|
||||
timezone: 'UTC-8',
|
||||
address: expect.objectContaining({ stateProvince: 'CA' }),
|
||||
})
|
||||
})
|
||||
|
||||
it('edits FBA warehouses with selectors for country, timezone and state', async () => {
|
||||
vi.mocked(listPlatformWarehouses).mockResolvedValueOnce({ records: [fbaWarehouse, domesticWarehouse], page: 1, size: 20, total: 2, pages: 1 })
|
||||
vi.mocked(getPlatformWarehouse).mockResolvedValueOnce(fbaDetail)
|
||||
const wrapper = await mountView()
|
||||
|
||||
const editButton = wrapper.find('.el-table__body-wrapper .table-actions button')
|
||||
expect(editButton).toBeDefined()
|
||||
await editButton.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
const drawerText = document.querySelector('.el-drawer')?.textContent || ''
|
||||
expect(drawerText).toContain('编辑FBA 地址')
|
||||
expect(drawerText).not.toContain('例如 US')
|
||||
expect(drawerText).not.toContain('例如 UTC-8')
|
||||
|
||||
const countrySelect = wrapper.findAllComponents({ name: 'ElSelect' })
|
||||
.find((select) => select.props('placeholder') === '请选择国家或地区')
|
||||
const stateSelect = wrapper.findAllComponents({ name: 'ElSelect' })
|
||||
.find((select) => select.props('placeholder') === '请选择州')
|
||||
const timezoneSelect = wrapper.findAllComponents({ name: 'ElSelect' })
|
||||
.find((select) => select.props('placeholder') === '请选择时区')
|
||||
expect(countrySelect?.props('modelValue')).toBe('US')
|
||||
expect(stateSelect?.props('modelValue')).toBe('OH')
|
||||
expect(timezoneSelect?.props('modelValue')).toBe('UTC-5')
|
||||
expect(countrySelect).toBeDefined()
|
||||
|
||||
const saveButton = [...document.querySelectorAll('button')].find((button) => button.textContent?.trim() === '保存修改')
|
||||
expect(saveButton).toBeDefined()
|
||||
saveButton!.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
||||
await flushPromises()
|
||||
|
||||
expect(updatePlatformWarehouse).toHaveBeenCalledOnce()
|
||||
const [, , payload] = vi.mocked(updatePlatformWarehouse).mock.calls[0]
|
||||
expect(payload.contactEmail).toBe('warehouse@example.com')
|
||||
const [, payload] = vi.mocked(updatePlatformWarehouse).mock.calls[0]
|
||||
expect(payload).toMatchObject({
|
||||
tenantId: 10001, warehouseType: 'FBA', countryCode: 'US', timezone: 'America/Los_Angeles',
|
||||
})
|
||||
expect(payload.address).toMatchObject({
|
||||
countryCode: 'US', district: '北苑街道', postalCode: '322000', addressLine1: '真实物流园 1 号库', addressLine2: 'A 栋',
|
||||
latitude: '29.3', longitude: '120.0',
|
||||
warehouseType: 'FBA',
|
||||
countryCode: 'US',
|
||||
timezone: 'UTC-5',
|
||||
address: expect.objectContaining({ stateProvince: 'OH' }),
|
||||
})
|
||||
})
|
||||
|
||||
it('edits existing records with full fields and preserves backend-only capabilities', async () => {
|
||||
const wrapper = await mountView()
|
||||
|
||||
const editButton = wrapper.find('.el-table__body-wrapper .table-actions button')
|
||||
expect(editButton).toBeDefined()
|
||||
await editButton.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
const saveButton = [...document.querySelectorAll('button')].find((button) => button.textContent?.trim() === '保存修改')
|
||||
expect(saveButton).toBeDefined()
|
||||
saveButton!.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
||||
await flushPromises()
|
||||
|
||||
expect(getPlatformWarehouse).toHaveBeenCalledWith('19001')
|
||||
expect(updatePlatformWarehouse).toHaveBeenCalledOnce()
|
||||
const [, payload] = vi.mocked(updatePlatformWarehouse).mock.calls[0]
|
||||
expect(payload.contactEmail).toBe('warehouse@example.com')
|
||||
expect(payload.capabilities).toContainEqual(expect.objectContaining({
|
||||
capabilityCode: 'RECEIVING', cutoffTime: '17:00:00', description: '周一至周六 08:30-18:00',
|
||||
capabilityCode: 'RECEIVING',
|
||||
cutoffTime: '17:00:00',
|
||||
description: '周一至周六 08:30-18:00',
|
||||
}))
|
||||
expect(payload.capabilities).toContainEqual(expect.objectContaining({ capabilityCode: 'STORAGE', description: '托盘仓储' }))
|
||||
})
|
||||
|
||||
it('shows field-level required errors and does not submit an empty warehouse', async () => {
|
||||
it('renders FBA import precheck totals and row-level errors without committing by default', async () => {
|
||||
const wrapper = await mountView()
|
||||
const createButton = wrapper.findAll('button').find((button) => button.text().trim() === '新增仓库')
|
||||
expect(createButton).toBeDefined()
|
||||
await createButton?.trigger('click')
|
||||
|
||||
await wrapper.findAll('button').find((button) => button.text().trim() === '导入仓库')?.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(document.querySelector('input[placeholder="例如 广东省"]')).not.toBeNull()
|
||||
expect(document.querySelector('input[placeholder="例如 CN、US"]')).not.toBeNull()
|
||||
expect(document.querySelector('input[placeholder="例如 Asia/Shanghai"]')).not.toBeNull()
|
||||
expect(document.querySelector('input[placeholder="请输入仓库联系人姓名"]')).not.toBeNull()
|
||||
expect(document.querySelector('textarea[placeholder="请输入街道、园区、楼栋及库位信息"]')).not.toBeNull()
|
||||
|
||||
await wrapper.find('input[placeholder="例如 CN-NB-01"]').trigger('blur')
|
||||
await wrapper.vm.$nextTick()
|
||||
expect(wrapper.find('#warehouse-code-error').text()).toBe('请输入仓库编码')
|
||||
|
||||
const saveButton = wrapper.findAll('button').find((button) => button.text().trim() === '保存仓库')
|
||||
expect(saveButton).toBeDefined()
|
||||
await saveButton?.trigger('click')
|
||||
const upload = wrapper.findComponent({ name: 'ElUpload' })
|
||||
const file = new File(['content'], 'fba.xlsx', { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' })
|
||||
upload.props('onChange')({ name: file.name, raw: file })
|
||||
await flushPromises()
|
||||
|
||||
expect(createPlatformWarehouse).not.toHaveBeenCalled()
|
||||
expect(wrapper.text()).toContain('请输入仓库编码')
|
||||
expect(wrapper.text()).toContain('请输入仓库名称')
|
||||
expect(wrapper.text()).toContain('请输入城市')
|
||||
expect(wrapper.text()).toContain('请输入详细地址')
|
||||
const precheckButton = [...document.querySelectorAll('button')].find((button) => button.textContent?.trim() === '预检文件')
|
||||
expect(precheckButton).toBeDefined()
|
||||
precheckButton!.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
||||
await flushPromises()
|
||||
|
||||
expect(importWarehouses).toHaveBeenCalledWith(file, true, 'CREATE_ONLY')
|
||||
expect(document.body.textContent).toContain('新增')
|
||||
expect(document.body.textContent).toContain('更新')
|
||||
expect(document.body.textContent).toContain('无变化')
|
||||
expect(document.body.textContent).toContain('错误')
|
||||
expect(document.body.textContent).toContain('文件内编码重复')
|
||||
expect(importWarehouses).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,43 +1,135 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { MapPin, Pencil, Plus, Power, Warehouse as WarehouseIcon } from '@lucide/vue'
|
||||
import { ElMessage, ElMessageBox, type UploadFile } from 'element-plus'
|
||||
import { ChevronDown, Download, FileSpreadsheet, MapPin, Pencil, Plus, Power, Upload, Warehouse as WarehouseIcon } from '@lucide/vue'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import {
|
||||
changePlatformWarehouseStatus,
|
||||
createFbaWarehouse,
|
||||
createPlatformWarehouse,
|
||||
downloadWarehouseImportTemplate,
|
||||
getPlatformWarehouse,
|
||||
importWarehouses,
|
||||
listPlatformWarehouses,
|
||||
updatePlatformWarehouse,
|
||||
type WarehouseImportMode,
|
||||
type WarehouseImportResult,
|
||||
type PlatformWarehouseDetailResource,
|
||||
type PlatformWarehouseResource,
|
||||
type PlatformWarehouseSummaryResource,
|
||||
type SavePlatformWarehouseRequest,
|
||||
type WarehouseType,
|
||||
} from '@/modules/warehouses/api/platformWarehouses'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import type { Warehouse } from '@/modules/warehouses/types'
|
||||
|
||||
const DEFAULT_TENANT_ID = 10001
|
||||
const warehouseTypeLabels: Record<WarehouseType, string> = {
|
||||
DOMESTIC: '国内收货仓',
|
||||
FBA: 'FBA 仓',
|
||||
FBA: 'FBA 地址',
|
||||
OVERSEAS: '海外仓',
|
||||
}
|
||||
const timezoneOptions = [
|
||||
'UTC-12', 'UTC-11', 'UTC-10', 'UTC-9', 'UTC-8', 'UTC-7', 'UTC-6', 'UTC-5', 'UTC-4', 'UTC-3',
|
||||
'UTC-2', 'UTC-1', 'UTC+0', 'UTC+1', 'UTC+2', 'UTC+3', 'UTC+4', 'UTC+5', 'UTC+6', 'UTC+7',
|
||||
'UTC+8', 'UTC+9', 'UTC+10', 'UTC+11', 'UTC+12', 'UTC+13', 'UTC+14',
|
||||
]
|
||||
const countryOptions = [
|
||||
{ label: '美国 US', value: 'US' },
|
||||
{ label: '加拿大 CA', value: 'CA' },
|
||||
{ label: '英国 GB', value: 'GB' },
|
||||
{ label: '德国 DE', value: 'DE' },
|
||||
{ label: '法国 FR', value: 'FR' },
|
||||
{ label: '意大利 IT', value: 'IT' },
|
||||
{ label: '西班牙 ES', value: 'ES' },
|
||||
{ label: '日本 JP', value: 'JP' },
|
||||
{ label: '澳大利亚 AU', value: 'AU' },
|
||||
]
|
||||
const countryDisplayLabels: Record<string, string> = {
|
||||
CN: '中国',
|
||||
US: '美国',
|
||||
CA: '加拿大',
|
||||
GB: '英国',
|
||||
DE: '德国',
|
||||
FR: '法国',
|
||||
IT: '意大利',
|
||||
ES: '西班牙',
|
||||
JP: '日本',
|
||||
AU: '澳大利亚',
|
||||
}
|
||||
const usStateOptions = [
|
||||
{ label: 'AL Alabama', value: 'AL', timezone: 'UTC-6' },
|
||||
{ label: 'AK Alaska', value: 'AK', timezone: 'UTC-9' },
|
||||
{ label: 'AZ Arizona', value: 'AZ', timezone: 'UTC-7' },
|
||||
{ label: 'AR Arkansas', value: 'AR', timezone: 'UTC-6' },
|
||||
{ label: 'CA California', value: 'CA', timezone: 'UTC-8' },
|
||||
{ label: 'CO Colorado', value: 'CO', timezone: 'UTC-7' },
|
||||
{ label: 'CT Connecticut', value: 'CT', timezone: 'UTC-5' },
|
||||
{ label: 'DE Delaware', value: 'DE', timezone: 'UTC-5' },
|
||||
{ label: 'FL Florida', value: 'FL', timezone: null },
|
||||
{ label: 'GA Georgia', value: 'GA', timezone: 'UTC-5' },
|
||||
{ label: 'HI Hawaii', value: 'HI', timezone: 'UTC-10' },
|
||||
{ label: 'IA Iowa', value: 'IA', timezone: 'UTC-6' },
|
||||
{ label: 'ID Idaho', value: 'ID', timezone: null },
|
||||
{ label: 'IL Illinois', value: 'IL', timezone: 'UTC-6' },
|
||||
{ label: 'IN Indiana', value: 'IN', timezone: null },
|
||||
{ label: 'KS Kansas', value: 'KS', timezone: null },
|
||||
{ label: 'KY Kentucky', value: 'KY', timezone: null },
|
||||
{ label: 'LA Louisiana', value: 'LA', timezone: 'UTC-6' },
|
||||
{ label: 'MA Massachusetts', value: 'MA', timezone: 'UTC-5' },
|
||||
{ label: 'MD Maryland', value: 'MD', timezone: 'UTC-5' },
|
||||
{ label: 'ME Maine', value: 'ME', timezone: 'UTC-5' },
|
||||
{ label: 'MI Michigan', value: 'MI', timezone: null },
|
||||
{ label: 'MN Minnesota', value: 'MN', timezone: 'UTC-6' },
|
||||
{ label: 'MO Missouri', value: 'MO', timezone: 'UTC-6' },
|
||||
{ label: 'MS Mississippi', value: 'MS', timezone: 'UTC-6' },
|
||||
{ label: 'MT Montana', value: 'MT', timezone: 'UTC-7' },
|
||||
{ label: 'NC North Carolina', value: 'NC', timezone: 'UTC-5' },
|
||||
{ label: 'ND North Dakota', value: 'ND', timezone: null },
|
||||
{ label: 'NE Nebraska', value: 'NE', timezone: null },
|
||||
{ label: 'NH New Hampshire', value: 'NH', timezone: 'UTC-5' },
|
||||
{ label: 'NJ New Jersey', value: 'NJ', timezone: 'UTC-5' },
|
||||
{ label: 'NM New Mexico', value: 'NM', timezone: 'UTC-7' },
|
||||
{ label: 'NV Nevada', value: 'NV', timezone: 'UTC-8' },
|
||||
{ label: 'NY New York', value: 'NY', timezone: 'UTC-5' },
|
||||
{ label: 'OH Ohio', value: 'OH', timezone: 'UTC-5' },
|
||||
{ label: 'OK Oklahoma', value: 'OK', timezone: 'UTC-6' },
|
||||
{ label: 'OR Oregon', value: 'OR', timezone: null },
|
||||
{ label: 'PA Pennsylvania', value: 'PA', timezone: 'UTC-5' },
|
||||
{ label: 'RI Rhode Island', value: 'RI', timezone: 'UTC-5' },
|
||||
{ label: 'SC South Carolina', value: 'SC', timezone: 'UTC-5' },
|
||||
{ label: 'SD South Dakota', value: 'SD', timezone: null },
|
||||
{ label: 'TN Tennessee', value: 'TN', timezone: null },
|
||||
{ label: 'TX Texas', value: 'TX', timezone: null },
|
||||
{ label: 'VA Virginia', value: 'VA', timezone: 'UTC-5' },
|
||||
{ label: 'VT Vermont', value: 'VT', timezone: 'UTC-5' },
|
||||
{ label: 'WA Washington', value: 'WA', timezone: 'UTC-8' },
|
||||
{ label: 'WI Wisconsin', value: 'WI', timezone: 'UTC-6' },
|
||||
{ label: 'WV West Virginia', value: 'WV', timezone: 'UTC-5' },
|
||||
{ label: 'WY Wyoming', value: 'WY', timezone: 'UTC-7' },
|
||||
]
|
||||
|
||||
const access = useAccessPreviewStore()
|
||||
const editorOpen = ref(false)
|
||||
const editorMode = ref<'CREATE' | 'EDIT'>('CREATE')
|
||||
const createType = ref<WarehouseType>('FBA')
|
||||
const saving = ref(false)
|
||||
const loading = ref(false)
|
||||
const editLoading = ref(false)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(10)
|
||||
const pageSize = ref(20)
|
||||
const total = ref(0)
|
||||
const loaded = ref(false)
|
||||
const loadError = ref<ApiErrorDetails | null>(null)
|
||||
const detailWarningCount = ref(0)
|
||||
const statusLoadingIds = ref(new Set<string>())
|
||||
const importOpen = ref(false)
|
||||
const importFile = ref<File | null>(null)
|
||||
const importMode = ref<WarehouseImportMode>('CREATE_ONLY')
|
||||
const importResult = ref<WarehouseImportResult | null>(null)
|
||||
const importing = ref(false)
|
||||
const downloadingTemplate = ref(false)
|
||||
|
||||
type WarehouseFilters = QueryModel & { keyword: string; warehouseType: WarehouseType | ''; status: Warehouse['status'] | '' }
|
||||
const filters = ref<WarehouseFilters>({ keyword: '', warehouseType: '', status: '' })
|
||||
const appliedFilters = ref<WarehouseFilters>({ ...filters.value })
|
||||
@@ -63,20 +155,20 @@ interface WarehouseRow extends Warehouse {
|
||||
warehouseType: WarehouseType
|
||||
countryCode: string
|
||||
timezone: string
|
||||
postalCode: string
|
||||
detail: PlatformWarehouseDetailResource | null
|
||||
}
|
||||
|
||||
const rows = ref<WarehouseRow[]>([])
|
||||
const warehouseColumns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 180, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'warehouse', label: '仓库', minWidth: 200, slotName: 'warehouse' },
|
||||
{ key: 'warehouseType', label: '仓库类型', width: 130, slotName: 'warehouseType' },
|
||||
{ prop: 'countryCode', label: '国家/地区码', width: 120 },
|
||||
{ prop: 'timezone', label: '时区', minWidth: 170 },
|
||||
{ key: 'city', label: '所在城市', minWidth: 130, slotName: 'city' },
|
||||
{ key: 'address', label: '收货地址', minWidth: 260, slotName: 'address' },
|
||||
{ key: 'warehouse', label: '仓库', minWidth: 190, slotName: 'warehouse' },
|
||||
{ key: 'warehouseType', label: '仓库类型', width: 120, slotName: 'warehouseType' },
|
||||
{ key: 'countryCode', label: '国家/地区', width: 140, slotName: 'countryCode' },
|
||||
{ prop: 'timezone', label: '时区', width: 110 },
|
||||
{ key: 'city', label: '所在城市', minWidth: 140, slotName: 'city' },
|
||||
{ key: 'address', label: '地址', minWidth: 280, slotName: 'address' },
|
||||
{ key: 'contact', label: '联系人', minWidth: 150, slotName: 'contact' },
|
||||
{ prop: 'businessHours', label: '收货时间', minWidth: 190, formatter: (row: WarehouseRow) => row.businessHours || '-' },
|
||||
{ key: 'status', label: '状态', width: 100, slotName: 'status' },
|
||||
]
|
||||
|
||||
@@ -84,27 +176,67 @@ type WarehouseForm = Omit<Warehouse, 'updatedAt'> & {
|
||||
warehouseType: WarehouseType
|
||||
countryCode: string
|
||||
timezone: string
|
||||
postalCode: string
|
||||
}
|
||||
const emptyForm = (): WarehouseForm => ({
|
||||
id: '', code: '', name: '', province: '', city: '', address: '', contactName: '', contactPhone: '',
|
||||
warehouseType: 'DOMESTIC', countryCode: 'CN', timezone: 'Asia/Shanghai',
|
||||
businessHours: '周一至周六 08:30-18:00', status: 'ACTIVE', remark: '',
|
||||
|
||||
function generatedCode(type: WarehouseType) {
|
||||
const prefix = type === 'DOMESTIC' ? 'CN' : type === 'FBA' ? 'FBA' : 'US'
|
||||
return `${prefix}-WH-${Date.now().toString(36).slice(-7).toUpperCase()}`
|
||||
}
|
||||
|
||||
const emptyForm = (type: WarehouseType = 'FBA'): WarehouseForm => ({
|
||||
id: '',
|
||||
code: type === 'FBA' ? '' : generatedCode(type),
|
||||
name: '',
|
||||
province: '',
|
||||
city: '',
|
||||
address: '',
|
||||
postalCode: '',
|
||||
contactName: '',
|
||||
contactPhone: '',
|
||||
warehouseType: type,
|
||||
countryCode: type === 'DOMESTIC' ? 'CN' : 'US',
|
||||
timezone: type === 'DOMESTIC' ? 'UTC+8' : '',
|
||||
businessHours: type === 'FBA' ? '' : '周一至周六 08:30-18:00',
|
||||
status: 'ACTIVE',
|
||||
remark: '',
|
||||
})
|
||||
|
||||
const form = reactive(emptyForm())
|
||||
type WarehouseRequiredField = 'code' | 'name' | 'countryCode' | 'timezone' | 'city' | 'address'
|
||||
type WarehouseRequiredField = 'code' | 'name' | 'province' | 'countryCode' | 'timezone' | 'city' | 'postalCode' | 'address'
|
||||
const requiredFieldMessages: Record<WarehouseRequiredField, string> = {
|
||||
code: '请输入仓库编码',
|
||||
name: '请输入仓库名称',
|
||||
countryCode: '请输入国家或地区二字码',
|
||||
timezone: '请输入 IANA 时区',
|
||||
province: '请选择或输入州/省',
|
||||
countryCode: '请选择国家或地区',
|
||||
timezone: '请输入时区',
|
||||
city: '请输入城市',
|
||||
postalCode: '请输入邮编',
|
||||
address: '请输入详细地址',
|
||||
}
|
||||
const formErrors = reactive<Record<WarehouseRequiredField, string>>({
|
||||
code: '', name: '', countryCode: '', timezone: '', city: '', address: '',
|
||||
code: '', name: '', province: '', countryCode: '', timezone: '', city: '', postalCode: '', address: '',
|
||||
})
|
||||
const editorLabel = computed(() => form.id ? '编辑公共仓库' : '新增公共仓库')
|
||||
const editorPermission = computed(() => form.id ? 'platform:warehouse:edit' : 'platform:warehouse:create')
|
||||
const editorLabel = computed(() => editorMode.value === 'EDIT'
|
||||
? `编辑${warehouseTypeLabels[form.warehouseType]}`
|
||||
: `新增${warehouseTypeLabels[createType.value]}`)
|
||||
const editorPermission = computed(() => editorMode.value === 'EDIT' ? 'platform:warehouse:edit' : 'platform:warehouse:create')
|
||||
const isFbaCreate = computed(() => editorMode.value === 'CREATE' && createType.value === 'FBA')
|
||||
const isDomesticCreate = computed(() => editorMode.value === 'CREATE' && createType.value === 'DOMESTIC')
|
||||
const activeWarehouseType = computed(() => editorMode.value === 'EDIT' ? form.warehouseType : createType.value)
|
||||
const isDomesticForm = computed(() => activeWarehouseType.value === 'DOMESTIC')
|
||||
const usesUsStateSelect = computed(() => !isDomesticForm.value && form.countryCode === 'US')
|
||||
const addressRegionLabel = computed(() => usesUsStateSelect.value || isFbaCreate.value ? '州' : '省 / 州')
|
||||
const regionPlaceholder = computed(() => isDomesticForm.value ? '例如 浙江省' : usesUsStateSelect.value ? '请选择州' : '例如 Ontario')
|
||||
const cityPlaceholder = computed(() => isDomesticForm.value ? '例如 义乌市' : '例如 Long Beach')
|
||||
const addressPlaceholder = computed(() => isFbaCreate.value ? '请输入街道和门牌信息' : '请输入街道、园区、楼栋及门牌信息')
|
||||
const canCommitImport = computed(() => Boolean(
|
||||
importFile.value
|
||||
&& importResult.value
|
||||
&& !importResult.value.committed
|
||||
&& importResult.value.errorCount === 0
|
||||
&& (importMode.value === 'CREATE_ONLY' || access.can('platform:warehouse:edit')),
|
||||
))
|
||||
const { captureBaseline } = useUnsavedWorkRegistration({
|
||||
key: 'warehouse-editor',
|
||||
label: editorLabel,
|
||||
@@ -114,15 +246,6 @@ const { captureBaseline } = useUnsavedWorkRegistration({
|
||||
})
|
||||
|
||||
const warehouseReady = computed(() => loaded.value && !loading.value)
|
||||
const filteredRows = computed(() => rows.value.filter((warehouse) => {
|
||||
const keyword = appliedFilters.value.keyword.trim().toLowerCase()
|
||||
const matchesKeyword = !keyword || [warehouse.code, warehouse.name, warehouse.province, warehouse.city, warehouse.address]
|
||||
.some((value) => value.toLowerCase().includes(keyword))
|
||||
return matchesKeyword
|
||||
&& (!appliedFilters.value.warehouseType || warehouse.warehouseType === appliedFilters.value.warehouseType)
|
||||
&& (!appliedFilters.value.status || warehouse.status === appliedFilters.value.status)
|
||||
}))
|
||||
const pagedRows = computed(() => filteredRows.value.slice((page.value - 1) * pageSize.value, page.value * pageSize.value))
|
||||
const loadErrorDescription = computed(() => {
|
||||
if (!loadError.value) return ''
|
||||
const references = [
|
||||
@@ -143,10 +266,28 @@ watch(
|
||||
},
|
||||
{ flush: 'sync' },
|
||||
)
|
||||
watch([page, pageSize], ([nextPage, nextSize], [previousPage, previousSize]) => {
|
||||
if (nextPage === previousPage && nextSize === previousSize) return
|
||||
void loadWarehouses()
|
||||
})
|
||||
watch(importMode, () => { importResult.value = null })
|
||||
watch(activeWarehouseType, (type) => {
|
||||
if (type === 'DOMESTIC') {
|
||||
form.countryCode = 'CN'
|
||||
form.timezone = 'UTC+8'
|
||||
} else if (form.countryCode === 'CN') {
|
||||
form.countryCode = 'US'
|
||||
form.province = ''
|
||||
form.timezone = ''
|
||||
}
|
||||
clearFieldError('countryCode')
|
||||
clearFieldError('timezone')
|
||||
})
|
||||
|
||||
function toRow(warehouse: PlatformWarehouseResource, detail: PlatformWarehouseDetailResource | null): WarehouseRow {
|
||||
const address = detail?.addresses[0]
|
||||
const receiving = detail?.capabilities.find((item) => item.capabilityCode === 'RECEIVING')
|
||||
function toRow(warehouse: PlatformWarehouseSummaryResource): WarehouseRow {
|
||||
const address = warehouse.primaryAddress
|
||||
const addressText = warehouse.addressSummary
|
||||
|| [address?.district, address?.addressLine1, address?.addressLine2].filter(Boolean).join(' ')
|
||||
return {
|
||||
id: String(warehouse.id),
|
||||
code: warehouse.warehouseCode,
|
||||
@@ -156,14 +297,15 @@ function toRow(warehouse: PlatformWarehouseResource, detail: PlatformWarehouseDe
|
||||
timezone: warehouse.timezone,
|
||||
province: address?.stateProvince || '',
|
||||
city: address?.city || '',
|
||||
address: [address?.district, address?.addressLine1, address?.addressLine2].filter(Boolean).join(' '),
|
||||
address: addressText,
|
||||
postalCode: address?.postalCode || '',
|
||||
contactName: warehouse.contactName || '',
|
||||
contactPhone: warehouse.contactPhone || '',
|
||||
businessHours: receiving?.description || (receiving?.cutoffTime ? `每日截单 ${receiving.cutoffTime}` : ''),
|
||||
businessHours: '',
|
||||
status: warehouse.status,
|
||||
remark: warehouse.remark || '',
|
||||
updatedAt: warehouse.updatedAt?.replace('T', ' ').slice(0, 16) || '',
|
||||
detail,
|
||||
detail: null,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -173,8 +315,8 @@ function queryWarehouses(model: QueryModel) {
|
||||
warehouseType: (model.warehouseType || '') as WarehouseType | '',
|
||||
status: (model.status || '') as Warehouse['status'] | '',
|
||||
}
|
||||
page.value = 1
|
||||
void loadWarehouses()
|
||||
if (page.value === 1) void loadWarehouses()
|
||||
else page.value = 1
|
||||
}
|
||||
|
||||
function clearWarehouseFormErrors() {
|
||||
@@ -185,15 +327,56 @@ function clearFieldError(field: WarehouseRequiredField) {
|
||||
if (formErrors[field]) formErrors[field] = ''
|
||||
}
|
||||
|
||||
function validateRequiredField(field: WarehouseRequiredField) {
|
||||
formErrors[field] = form[field].trim() ? '' : requiredFieldMessages[field]
|
||||
return !formErrors[field]
|
||||
function formatCountryDisplay(countryCode: string) {
|
||||
const code = countryCode.trim().toUpperCase()
|
||||
if (!code) return '-'
|
||||
const label = countryDisplayLabels[code]
|
||||
return label ? `${label} ${code}` : code
|
||||
}
|
||||
|
||||
function validateFields(fields: WarehouseRequiredField[]) {
|
||||
return fields.map((field) => {
|
||||
formErrors[field] = form[field].trim() ? '' : requiredFieldMessages[field]
|
||||
return !formErrors[field]
|
||||
}).every(Boolean)
|
||||
}
|
||||
|
||||
function validateWarehouseForm() {
|
||||
return (Object.keys(requiredFieldMessages) as WarehouseRequiredField[])
|
||||
.map(validateRequiredField)
|
||||
.every(Boolean)
|
||||
if (isFbaCreate.value) return validateFields(['code', 'province', 'city', 'postalCode', 'timezone', 'address'])
|
||||
const fields: WarehouseRequiredField[] = ['code', 'name', 'countryCode', 'timezone', 'city', 'address']
|
||||
if (usesUsStateSelect.value) fields.splice(4, 0, 'province')
|
||||
return validateFields(fields)
|
||||
}
|
||||
|
||||
function onCountryChange(value: string) {
|
||||
form.countryCode = value.toUpperCase()
|
||||
clearFieldError('countryCode')
|
||||
if (!usesUsStateSelect.value) {
|
||||
form.province = ''
|
||||
form.timezone = ''
|
||||
clearFieldError('province')
|
||||
return
|
||||
}
|
||||
const matchedState = usStateOptions.find((state) => state.value === form.province.toUpperCase())
|
||||
if (!matchedState) {
|
||||
form.province = ''
|
||||
form.timezone = ''
|
||||
return
|
||||
}
|
||||
onRegionChange(matchedState.value)
|
||||
}
|
||||
|
||||
function onRegionChange(value: string) {
|
||||
form.province = value.toUpperCase()
|
||||
clearFieldError('province')
|
||||
if (!usesUsStateSelect.value) return
|
||||
const matchedState = usStateOptions.find((state) => state.value === form.province)
|
||||
if (matchedState?.timezone) {
|
||||
form.timezone = matchedState.timezone
|
||||
clearFieldError('timezone')
|
||||
} else {
|
||||
form.timezone = ''
|
||||
}
|
||||
}
|
||||
|
||||
async function loadWarehouses() {
|
||||
@@ -201,18 +384,17 @@ async function loadWarehouses() {
|
||||
loading.value = true
|
||||
loadError.value = null
|
||||
try {
|
||||
const result = await listPlatformWarehouses(DEFAULT_TENANT_ID, 1, 200)
|
||||
const details = await Promise.allSettled(
|
||||
result.records.map((warehouse) => getPlatformWarehouse(DEFAULT_TENANT_ID, warehouse.id)),
|
||||
)
|
||||
if (sequence !== loadSequence) return
|
||||
rows.value = result.records.map((warehouse, index) => {
|
||||
const detailResult = details[index]
|
||||
return toRow(warehouse, detailResult?.status === 'fulfilled' ? detailResult.value : null)
|
||||
const result = await listPlatformWarehouses({
|
||||
page: page.value,
|
||||
size: pageSize.value,
|
||||
keyword: appliedFilters.value.keyword.trim() || undefined,
|
||||
warehouseType: appliedFilters.value.warehouseType || undefined,
|
||||
status: appliedFilters.value.status || undefined,
|
||||
})
|
||||
detailWarningCount.value = details.filter((item) => item.status === 'rejected').length
|
||||
if (sequence !== loadSequence) return
|
||||
rows.value = result.records.map(toRow)
|
||||
total.value = result.total
|
||||
loaded.value = true
|
||||
page.value = 1
|
||||
} catch (error) {
|
||||
if (sequence !== loadSequence) return
|
||||
loadError.value = getApiErrorDetails(error, '仓库数据加载失败,请稍后重试')
|
||||
@@ -229,9 +411,11 @@ function requireWarehouseReady() {
|
||||
return true
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
function openCreate(type: WarehouseType) {
|
||||
if (!requireWarehouseReady()) return
|
||||
Object.assign(form, emptyForm())
|
||||
editorMode.value = 'CREATE'
|
||||
createType.value = type
|
||||
Object.assign(form, emptyForm(type))
|
||||
clearWarehouseFormErrors()
|
||||
captureBaseline()
|
||||
editorOpen.value = true
|
||||
@@ -241,9 +425,8 @@ async function ensureDetail(warehouse: WarehouseRow) {
|
||||
if (warehouse.detail) return warehouse.detail
|
||||
if (!requireWarehouseReady()) return null
|
||||
try {
|
||||
const detail = await getPlatformWarehouse(DEFAULT_TENANT_ID, warehouse.id)
|
||||
Object.assign(warehouse, toRow(detail.warehouse, detail))
|
||||
detailWarningCount.value = rows.value.filter((item) => !item.detail).length
|
||||
const detail = await getPlatformWarehouse(warehouse.id)
|
||||
warehouse.detail = detail
|
||||
return detail
|
||||
} catch (error) {
|
||||
const details = getApiErrorDetails(error, '仓库详情加载失败,请重试')
|
||||
@@ -258,13 +441,24 @@ async function openEdit(warehouse: WarehouseRow) {
|
||||
const detail = await ensureDetail(warehouse)
|
||||
if (!detail || !loaded.value) return
|
||||
const address = detail.addresses[0]
|
||||
Object.assign(form, warehouse, {
|
||||
province: address?.stateProvince || '',
|
||||
city: address?.city || '',
|
||||
address: address?.addressLine1 || '',
|
||||
const receiving = detail.capabilities.find((item) => item.capabilityCode === 'RECEIVING')
|
||||
editorMode.value = 'EDIT'
|
||||
Object.assign(form, {
|
||||
id: String(detail.warehouse.id),
|
||||
code: detail.warehouse.warehouseCode,
|
||||
name: detail.warehouse.warehouseName,
|
||||
warehouseType: detail.warehouse.warehouseType,
|
||||
countryCode: detail.warehouse.countryCode,
|
||||
timezone: detail.warehouse.timezone,
|
||||
province: address?.stateProvince || '',
|
||||
city: address?.city || '',
|
||||
address: address?.addressLine1 || '',
|
||||
postalCode: address?.postalCode || '',
|
||||
contactName: detail.warehouse.contactName || '',
|
||||
contactPhone: detail.warehouse.contactPhone || '',
|
||||
businessHours: receiving?.description || '',
|
||||
status: detail.warehouse.status,
|
||||
remark: detail.warehouse.remark || '',
|
||||
})
|
||||
clearWarehouseFormErrors()
|
||||
captureBaseline()
|
||||
@@ -278,10 +472,12 @@ function nullable(value: string) {
|
||||
return value.trim() || null
|
||||
}
|
||||
|
||||
function buildSaveRequest(tenantId: number | string): SavePlatformWarehouseRequest {
|
||||
function buildSaveRequest(): SavePlatformWarehouseRequest {
|
||||
const existing = form.id ? rows.value.find((item) => item.id === form.id)?.detail : null
|
||||
const warehouse = existing?.warehouse
|
||||
const currentAddress = existing?.addresses[0]
|
||||
const countryCode = isDomesticForm.value ? 'CN' : form.countryCode.trim().toUpperCase()
|
||||
const timezone = isDomesticForm.value ? 'UTC+8' : form.timezone.trim()
|
||||
const capabilities = (existing?.capabilities || []).map((capability) => ({
|
||||
capabilityCode: capability.capabilityCode,
|
||||
capabilityName: capability.capabilityName,
|
||||
@@ -289,22 +485,18 @@ function buildSaveRequest(tenantId: number | string): SavePlatformWarehouseReque
|
||||
cutoffTime: capability.cutoffTime,
|
||||
description: capability.capabilityCode === 'RECEIVING' ? nullable(form.businessHours) : capability.description,
|
||||
}))
|
||||
if (!capabilities.some((item) => item.capabilityCode === 'RECEIVING')) {
|
||||
if (form.warehouseType !== 'FBA' && !capabilities.some((item) => item.capabilityCode === 'RECEIVING')) {
|
||||
capabilities.push({
|
||||
capabilityCode: 'RECEIVING',
|
||||
capabilityName: '收货服务',
|
||||
enabled: true,
|
||||
cutoffTime: null,
|
||||
capabilityCode: 'RECEIVING', capabilityName: '收货服务', enabled: true, cutoffTime: null,
|
||||
description: nullable(form.businessHours),
|
||||
})
|
||||
}
|
||||
return {
|
||||
tenantId,
|
||||
warehouseCode: form.code.trim().toUpperCase(),
|
||||
warehouseName: form.name.trim(),
|
||||
warehouseType: form.warehouseType,
|
||||
countryCode: form.countryCode.trim().toUpperCase(),
|
||||
timezone: form.timezone.trim(),
|
||||
countryCode,
|
||||
timezone,
|
||||
contactName: nullable(form.contactName),
|
||||
contactPhone: nullable(form.contactPhone),
|
||||
contactEmail: warehouse?.contactEmail || null,
|
||||
@@ -312,11 +504,11 @@ function buildSaveRequest(tenantId: number | string): SavePlatformWarehouseReque
|
||||
remark: nullable(form.remark),
|
||||
address: {
|
||||
addressType: currentAddress?.addressType || 'OPERATING',
|
||||
countryCode: form.countryCode.trim().toUpperCase(),
|
||||
countryCode,
|
||||
stateProvince: nullable(form.province),
|
||||
city: form.city.trim(),
|
||||
district: currentAddress?.district || null,
|
||||
postalCode: currentAddress?.postalCode || null,
|
||||
postalCode: nullable(form.postalCode),
|
||||
addressLine1: form.address.trim(),
|
||||
addressLine2: currentAddress?.addressLine2 || null,
|
||||
latitude: currentAddress?.latitude || null,
|
||||
@@ -327,17 +519,27 @@ function buildSaveRequest(tenantId: number | string): SavePlatformWarehouseReque
|
||||
}
|
||||
|
||||
async function saveWarehouse() {
|
||||
if (!requireWarehouseReady()) return
|
||||
if (!validateWarehouseForm()) return
|
||||
if (!requireWarehouseReady() || !validateWarehouseForm()) return
|
||||
saving.value = true
|
||||
try {
|
||||
const payload = buildSaveRequest(DEFAULT_TENANT_ID)
|
||||
if (form.id) await updatePlatformWarehouse(DEFAULT_TENANT_ID, form.id, payload)
|
||||
else await createPlatformWarehouse(payload)
|
||||
const successMessage = form.id ? '仓库资料已更新' : '公共仓库已创建'
|
||||
if (isFbaCreate.value) {
|
||||
await createFbaWarehouse({
|
||||
warehouseCode: form.code.trim().toUpperCase(),
|
||||
stateProvince: form.province.trim().toUpperCase(),
|
||||
city: form.city.trim(),
|
||||
postalCode: form.postalCode.trim(),
|
||||
addressLine1: form.address.trim(),
|
||||
timezone: form.timezone.trim() || null,
|
||||
})
|
||||
} else {
|
||||
const payload = buildSaveRequest()
|
||||
if (editorMode.value === 'EDIT') await updatePlatformWarehouse(form.id, payload)
|
||||
else await createPlatformWarehouse(payload)
|
||||
}
|
||||
editorOpen.value = false
|
||||
ElMessage.success(successMessage)
|
||||
await loadWarehouses()
|
||||
ElMessage.success(editorMode.value === 'EDIT' ? '仓库资料已更新' : `${warehouseTypeLabels[createType.value]}已创建`)
|
||||
if (page.value === 1) await loadWarehouses()
|
||||
else page.value = 1
|
||||
} catch (error) {
|
||||
const details = getApiErrorDetails(error, '仓库保存失败,请检查后重试')
|
||||
ElMessage.error(details.requestId ? `${details.message}(requestId: ${details.requestId})` : details.message)
|
||||
@@ -359,7 +561,7 @@ async function toggleStatus(warehouse: WarehouseRow) {
|
||||
{ confirmButtonText: `确认${targetText}`, cancelButtonText: '取消', type: target === 'INACTIVE' ? 'warning' : 'info' },
|
||||
)
|
||||
statusLoadingIds.value = new Set(statusLoadingIds.value).add(warehouse.id)
|
||||
await changePlatformWarehouseStatus(DEFAULT_TENANT_ID, warehouse.id, target)
|
||||
await changePlatformWarehouseStatus(warehouse.id, target)
|
||||
warehouse.status = target
|
||||
if (warehouse.detail) warehouse.detail.warehouse.status = target
|
||||
ElMessage.success(`仓库已${targetText}`)
|
||||
@@ -374,6 +576,91 @@ async function toggleStatus(warehouse: WarehouseRow) {
|
||||
}
|
||||
}
|
||||
|
||||
function openImport() {
|
||||
importFile.value = null
|
||||
importMode.value = 'CREATE_ONLY'
|
||||
importResult.value = null
|
||||
importOpen.value = true
|
||||
}
|
||||
|
||||
function selectImportFile(uploadFile: UploadFile) {
|
||||
const file = uploadFile.raw ?? null
|
||||
if (!file) return
|
||||
if (!file.name.toLowerCase().endsWith('.xlsx')) {
|
||||
importFile.value = null
|
||||
ElMessage.error('请选择标准 XLSX 文件')
|
||||
return
|
||||
}
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
importFile.value = null
|
||||
ElMessage.error('文件不得超过 5MB')
|
||||
return
|
||||
}
|
||||
importFile.value = file
|
||||
importResult.value = null
|
||||
}
|
||||
|
||||
function removeImportFile() {
|
||||
importFile.value = null
|
||||
importResult.value = null
|
||||
}
|
||||
|
||||
function saveBlob(blob: Blob, fileName: string) {
|
||||
const url = URL.createObjectURL(blob)
|
||||
const anchor = document.createElement('a')
|
||||
anchor.href = url
|
||||
anchor.download = fileName
|
||||
anchor.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
async function downloadTemplate() {
|
||||
downloadingTemplate.value = true
|
||||
try {
|
||||
const blob = await downloadWarehouseImportTemplate()
|
||||
saveBlob(blob, '公共仓库导入模板.xlsx')
|
||||
} catch (error) {
|
||||
ElMessage.error(getApiErrorDetails(error, '模板下载失败,请稍后重试').message)
|
||||
} finally {
|
||||
downloadingTemplate.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function runImport(dryRun: boolean) {
|
||||
if (!importFile.value) {
|
||||
ElMessage.warning('请先选择 XLSX 文件')
|
||||
return
|
||||
}
|
||||
importing.value = true
|
||||
try {
|
||||
importResult.value = await importWarehouses(importFile.value, dryRun, importMode.value)
|
||||
if (dryRun) {
|
||||
ElMessage.success(importResult.value.errorCount ? '预检完成,请处理错误后重新上传' : '预检通过,可以提交导入')
|
||||
} else if (importResult.value.committed) {
|
||||
ElMessage.success('公共仓库已导入')
|
||||
if (page.value === 1) await loadWarehouses()
|
||||
else page.value = 1
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error(getApiErrorDetails(error, dryRun ? '文件预检失败,请检查后重试' : '公共仓库导入失败,请重试').message)
|
||||
} finally {
|
||||
importing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function downloadImportErrors() {
|
||||
if (!importResult.value?.errors.length) return
|
||||
const escape = (value: string | number) => `"${String(value).replaceAll('"', '""')}"`
|
||||
const lines = ['行号,字段,错误码,原因', ...importResult.value.errors.map((error) => (
|
||||
[error.row, error.field, error.code || '-', error.message].map(escape).join(',')
|
||||
))]
|
||||
saveBlob(new Blob([`\uFEFF${lines.join('\r\n')}`], { type: 'text/csv;charset=utf-8' }), '公共仓库导入错误清单.csv')
|
||||
}
|
||||
|
||||
function formatImportErrorCode(row: WarehouseImportResult['errors'][number]) {
|
||||
return row.code || '-'
|
||||
}
|
||||
|
||||
onMounted(() => void loadWarehouses())
|
||||
</script>
|
||||
|
||||
@@ -384,89 +671,210 @@ onMounted(() => void loadWarehouses())
|
||||
<p>{{ loadErrorDescription }}</p>
|
||||
<el-button size="small" @click="loadWarehouses()">重新查询</el-button>
|
||||
</el-alert>
|
||||
<el-alert v-else-if="detailWarningCount" type="warning" :closable="false" show-icon class="state-alert">
|
||||
<template #title>部分仓库详情暂未加载</template>
|
||||
<p>{{ detailWarningCount }} 个仓库缺少地址或服务能力详情;编辑时会再次尝试加载,不会用模拟数据补齐。</p>
|
||||
</el-alert>
|
||||
|
||||
<QueryToolbar v-model="filters" class="surface-panel" ariaLabel="仓库筛选" :fields="queryFields" @search="queryWarehouses" />
|
||||
|
||||
<section class="surface-panel table-panel" aria-live="polite">
|
||||
<div class="panel-heading">
|
||||
<div class="panel-heading warehouse-heading">
|
||||
<div>
|
||||
<h2>公共仓库</h2>
|
||||
<p>国内收货仓、FBA 仓和海外仓统一维护 · 共 {{ filteredRows.length }} 个</p>
|
||||
<p>国内收货仓、FBA 地址和海外仓统一维护 · 共 {{ total }} 个</p>
|
||||
</div>
|
||||
<div v-if="access.can('platform:warehouse:create')" class="warehouse-primary-actions">
|
||||
<el-button :disabled="!warehouseReady" @click="openImport"><Upload :size="16" />导入仓库</el-button>
|
||||
<el-dropdown trigger="click" :disabled="!warehouseReady" @command="openCreate($event as WarehouseType)">
|
||||
<el-button type="primary" :disabled="!warehouseReady"><Plus :size="16" />新增仓库<ChevronDown :size="15" /></el-button>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item command="FBA"><FileSpreadsheet :size="15" />新增 FBA 地址</el-dropdown-item>
|
||||
<el-dropdown-item command="DOMESTIC"><WarehouseIcon :size="15" />新增国内仓</el-dropdown-item>
|
||||
<el-dropdown-item command="OVERSEAS"><MapPin :size="15" />新增海外仓</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
<el-button
|
||||
v-if="access.can('platform:warehouse:create')"
|
||||
type="primary"
|
||||
:disabled="!warehouseReady"
|
||||
@click="openCreate"
|
||||
><Plus :size="16" />新增仓库</el-button>
|
||||
</div>
|
||||
<DataTable
|
||||
v-model:page="page"
|
||||
v-model:page-size="pageSize"
|
||||
:data="pagedRows"
|
||||
:data="rows"
|
||||
:columns="warehouseColumns"
|
||||
:loading="loading"
|
||||
:total="filteredRows.length"
|
||||
:page-sizes="[10, 20, 50]"
|
||||
:total="total"
|
||||
:page-sizes="[20, 50, 100]"
|
||||
row-key="id"
|
||||
aria-label="公共仓库列表"
|
||||
:empty-text="loaded ? '暂无符合筛选条件的仓库' : '仓库数据暂不可用,请重新加载'"
|
||||
>
|
||||
<template #actions="{ row }">
|
||||
<div v-if="access.can('platform:warehouse:edit') || access.can('platform:warehouse:status')" class="table-actions">
|
||||
<el-button v-if="access.can('platform:warehouse:edit')" link type="primary" :disabled="!warehouseReady" @click="openEdit(row as WarehouseRow)"><Pencil :size="15" />编辑</el-button>
|
||||
<el-button
|
||||
v-if="access.can('platform:warehouse:status')"
|
||||
link
|
||||
:type="(row as WarehouseRow).status === 'ACTIVE' ? 'danger' : 'success'"
|
||||
:disabled="!warehouseReady"
|
||||
:loading="statusLoadingIds.has((row as WarehouseRow).id)"
|
||||
@click="toggleStatus(row as WarehouseRow)"
|
||||
><Power :size="15" />{{ (row as WarehouseRow).status === 'ACTIVE' ? '停用' : '启用' }}</el-button>
|
||||
</div>
|
||||
<span v-else class="warehouse-subline">仅查看</span>
|
||||
<div v-if="access.can('platform:warehouse:edit') || access.can('platform:warehouse:status')" class="table-actions">
|
||||
<el-button v-if="access.can('platform:warehouse:edit')" link type="primary" :disabled="!warehouseReady || editLoading" @click="openEdit(row as WarehouseRow)"><Pencil :size="15" />编辑</el-button>
|
||||
<el-button
|
||||
v-if="access.can('platform:warehouse:status')"
|
||||
link
|
||||
:type="(row as WarehouseRow).status === 'ACTIVE' ? 'danger' : 'success'"
|
||||
:disabled="!warehouseReady"
|
||||
:loading="statusLoadingIds.has((row as WarehouseRow).id)"
|
||||
@click="toggleStatus(row as WarehouseRow)"
|
||||
><Power :size="15" />{{ (row as WarehouseRow).status === 'ACTIVE' ? '停用' : '启用' }}</el-button>
|
||||
</div>
|
||||
<span v-else class="warehouse-subline">仅查看</span>
|
||||
</template>
|
||||
<template #warehouse="{ row }"><div class="warehouse-cell"><span><WarehouseIcon :size="18" /></span><div><strong>{{ (row as WarehouseRow).name }}</strong><small>{{ (row as WarehouseRow).code }}</small></div></div></template>
|
||||
<template #warehouseType="{ row }"><el-tag effect="plain">{{ warehouseTypeLabels[(row as WarehouseRow).warehouseType] }}</el-tag></template>
|
||||
<template #countryCode="{ row }">{{ formatCountryDisplay((row as WarehouseRow).countryCode) }}</template>
|
||||
<template #city="{ row }">{{ [(row as WarehouseRow).province, (row as WarehouseRow).city].filter(Boolean).join(' · ') || '-' }}</template>
|
||||
<template #address="{ row }"><div class="warehouse-address"><MapPin :size="15" />{{ (row as WarehouseRow).address || '详情暂未加载' }}</div></template>
|
||||
<template #address="{ row }"><div class="warehouse-address"><MapPin :size="15" /><span>{{ (row as WarehouseRow).address || '-' }}<small v-if="(row as WarehouseRow).postalCode">{{ (row as WarehouseRow).postalCode }}</small></span></div></template>
|
||||
<template #contact="{ row }"><strong>{{ (row as WarehouseRow).contactName || '-' }}</strong><span class="warehouse-subline">{{ (row as WarehouseRow).contactPhone || '-' }}</span></template>
|
||||
<template #status="{ row }"><el-tag :type="(row as WarehouseRow).status === 'ACTIVE' ? 'success' : 'info'" effect="light">{{ (row as WarehouseRow).status === 'ACTIVE' ? '启用' : '停用' }}</el-tag></template>
|
||||
<template #pagination-left>共 {{ filteredRows.length }} 个公共仓库</template>
|
||||
<template #pagination-left="{ total: tableTotal }">共 {{ tableTotal }} 个公共仓库</template>
|
||||
</DataTable>
|
||||
</section>
|
||||
|
||||
<el-drawer v-model="editorOpen" :title="editorLabel" size="620px" class="business-drawer editor-drawer" :close-on-click-modal="false">
|
||||
<el-form :model="form" label-position="top" class="drawer-form" :disabled="saving">
|
||||
<div class="form-section"><h3>仓库标识</h3><div class="form-grid two-cols"><el-form-item label="仓库编码" required :class="{ 'has-field-error': formErrors.code }"><el-input v-model="form.code" maxlength="50" placeholder="例如 CN-NB-01" :aria-invalid="Boolean(formErrors.code)" :aria-describedby="formErrors.code ? 'warehouse-code-error' : undefined" @input="clearFieldError('code')" @blur="validateRequiredField('code')" /><p v-if="formErrors.code" id="warehouse-code-error" class="field-error" role="alert">{{ formErrors.code }}</p></el-form-item><el-form-item label="仓库名称" required :class="{ 'has-field-error': formErrors.name }"><el-input v-model="form.name" maxlength="120" placeholder="请输入客户下单时显示的名称" :aria-invalid="Boolean(formErrors.name)" :aria-describedby="formErrors.name ? 'warehouse-name-error' : undefined" @input="clearFieldError('name')" @blur="validateRequiredField('name')" /><p v-if="formErrors.name" id="warehouse-name-error" class="field-error" role="alert">{{ formErrors.name }}</p></el-form-item><el-form-item label="仓库类型" required><el-select v-model="form.warehouseType" placeholder="请选择仓库类型"><el-option v-for="(label, value) in warehouseTypeLabels" :key="value" :label="label" :value="value" /></el-select></el-form-item><el-form-item label="状态"><el-radio-group v-model="form.status"><el-radio-button value="ACTIVE">启用</el-radio-button><el-radio-button value="INACTIVE">停用</el-radio-button></el-radio-group></el-form-item><el-form-item label="国家或地区二字码" required :class="{ 'has-field-error': formErrors.countryCode }"><el-input v-model="form.countryCode" maxlength="2" placeholder="例如 CN、US" :aria-invalid="Boolean(formErrors.countryCode)" :aria-describedby="formErrors.countryCode ? 'warehouse-country-error' : undefined" @input="form.countryCode = String($event).toUpperCase(); clearFieldError('countryCode')" @blur="validateRequiredField('countryCode')" /><p v-if="formErrors.countryCode" id="warehouse-country-error" class="field-error" role="alert">{{ formErrors.countryCode }}</p></el-form-item><el-form-item label="IANA 时区" required :class="{ 'has-field-error': formErrors.timezone }"><el-input v-model="form.timezone" maxlength="64" placeholder="例如 Asia/Shanghai" :aria-invalid="Boolean(formErrors.timezone)" :aria-describedby="formErrors.timezone ? 'warehouse-timezone-error' : undefined" @input="clearFieldError('timezone')" @blur="validateRequiredField('timezone')" /><p v-if="formErrors.timezone" id="warehouse-timezone-error" class="field-error" role="alert">{{ formErrors.timezone }}</p></el-form-item></div></div>
|
||||
<div class="form-section"><h3>地址与收货</h3><div class="form-grid two-cols"><el-form-item label="省份"><el-input v-model="form.province" maxlength="100" placeholder="例如 广东省" /></el-form-item><el-form-item label="城市" required :class="{ 'has-field-error': formErrors.city }"><el-input v-model="form.city" maxlength="100" placeholder="例如 深圳市" :aria-invalid="Boolean(formErrors.city)" :aria-describedby="formErrors.city ? 'warehouse-city-error' : undefined" @input="clearFieldError('city')" @blur="validateRequiredField('city')" /><p v-if="formErrors.city" id="warehouse-city-error" class="field-error" role="alert">{{ formErrors.city }}</p></el-form-item><el-form-item label="联系人"><el-input v-model="form.contactName" maxlength="64" placeholder="请输入仓库联系人姓名" /></el-form-item><el-form-item label="联系电话"><el-input v-model="form.contactPhone" maxlength="32" placeholder="请输入手机号或固定电话" /></el-form-item><el-form-item label="收货时间"><el-input v-model="form.businessHours" maxlength="500" placeholder="例如 周一至周六 08:30-18:00" /></el-form-item></div><el-form-item label="详细地址" required :class="{ 'has-field-error': formErrors.address }"><el-input v-model="form.address" maxlength="255" type="textarea" :rows="3" placeholder="请输入街道、园区、楼栋及库位信息" :aria-invalid="Boolean(formErrors.address)" :aria-describedby="formErrors.address ? 'warehouse-address-error' : undefined" @input="clearFieldError('address')" @blur="validateRequiredField('address')" /><p v-if="formErrors.address" id="warehouse-address-error" class="field-error" role="alert">{{ formErrors.address }}</p></el-form-item></div>
|
||||
<div class="form-section"><h3>备注</h3><el-form-item label="客户可理解的收货说明"><el-input v-model="form.remark" maxlength="500" type="textarea" :rows="3" show-word-limit placeholder="例如 预约要求、进仓指引或其他注意事项" /></el-form-item></div>
|
||||
<div class="form-section">
|
||||
<h3>基础信息</h3>
|
||||
<div class="form-grid two-cols">
|
||||
<el-form-item v-if="isFbaCreate" label="FBA 编码" required :class="{ 'has-field-error': formErrors.code }"><el-input v-model="form.code" maxlength="50" placeholder="例如 LAX9" @input="form.code = String($event).toUpperCase(); clearFieldError('code')" /><p v-if="formErrors.code" class="field-error" role="alert">{{ formErrors.code }}</p></el-form-item>
|
||||
<el-form-item v-if="isFbaCreate" label="国家或地区" required>
|
||||
<el-select v-model="form.countryCode" disabled placeholder="美国 US">
|
||||
<el-option label="美国 US" value="US" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<template v-else>
|
||||
<el-form-item label="仓库名称" required :class="{ 'has-field-error': formErrors.name }"><el-input v-model="form.name" maxlength="120" placeholder="请输入仓库名称" @input="clearFieldError('name')" /><p v-if="formErrors.name" class="field-error" role="alert">{{ formErrors.name }}</p></el-form-item>
|
||||
<el-form-item v-if="editorMode === 'EDIT'" label="仓库类型" required><el-select v-model="form.warehouseType"><el-option v-for="(label, value) in warehouseTypeLabels" :key="value" :label="label" :value="value" /></el-select></el-form-item>
|
||||
<el-form-item v-if="!isDomesticCreate" label="国家或地区" required :class="{ 'has-field-error': formErrors.countryCode }">
|
||||
<el-select v-if="isDomesticForm" v-model="form.countryCode" disabled placeholder="中国 CN"><el-option label="中国 CN" value="CN" /></el-select>
|
||||
<el-select v-else v-model="form.countryCode" filterable placeholder="请选择国家或地区" @change="onCountryChange"><el-option v-for="country in countryOptions" :key="country.value" :label="country.label" :value="country.value" /></el-select>
|
||||
<p v-if="formErrors.countryCode" class="field-error" role="alert">{{ formErrors.countryCode }}</p>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="!isDomesticCreate" label="时区" required :class="{ 'has-field-error': formErrors.timezone }">
|
||||
<el-select v-if="isDomesticForm" v-model="form.timezone" disabled placeholder="UTC+8"><el-option label="UTC+8" value="UTC+8" /></el-select>
|
||||
<el-select v-else v-model="form.timezone" filterable placeholder="请选择时区" @change="clearFieldError('timezone')"><el-option v-for="timezone in timezoneOptions" :key="timezone" :label="timezone" :value="timezone" /></el-select>
|
||||
<p v-if="formErrors.timezone" class="field-error" role="alert">{{ formErrors.timezone }}</p>
|
||||
</el-form-item>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-section">
|
||||
<h3>地址信息</h3>
|
||||
<div class="form-grid two-cols">
|
||||
<el-form-item :label="addressRegionLabel" :required="isFbaCreate || usesUsStateSelect" :class="{ 'has-field-error': formErrors.province }">
|
||||
<el-select v-if="usesUsStateSelect" v-model="form.province" filterable :placeholder="regionPlaceholder" @change="onRegionChange"><el-option v-for="state in usStateOptions" :key="state.value" :label="state.label" :value="state.value" /></el-select>
|
||||
<el-input v-else v-model="form.province" maxlength="100" :placeholder="regionPlaceholder" @input="isFbaCreate && (form.province = String($event).toUpperCase()); clearFieldError('province')" />
|
||||
<p v-if="formErrors.province" class="field-error" role="alert">{{ formErrors.province }}</p>
|
||||
</el-form-item>
|
||||
<el-form-item label="城市" required :class="{ 'has-field-error': formErrors.city }"><el-input v-model="form.city" maxlength="100" :placeholder="cityPlaceholder" @input="clearFieldError('city')" /><p v-if="formErrors.city" class="field-error" role="alert">{{ formErrors.city }}</p></el-form-item>
|
||||
<el-form-item label="邮编" :required="isFbaCreate" :class="{ 'has-field-error': formErrors.postalCode }"><el-input v-model="form.postalCode" maxlength="20" :placeholder="isDomesticForm ? '例如 322000' : '例如 90802'" @input="clearFieldError('postalCode')" /><p v-if="formErrors.postalCode" class="field-error" role="alert">{{ formErrors.postalCode }}</p></el-form-item>
|
||||
</div>
|
||||
<el-form-item label="详细地址" required :class="{ 'has-field-error': formErrors.address }"><el-input v-model="form.address" maxlength="255" type="textarea" :rows="3" :placeholder="addressPlaceholder" @input="clearFieldError('address')" /><p v-if="formErrors.address" class="field-error" role="alert">{{ formErrors.address }}</p></el-form-item>
|
||||
</div>
|
||||
|
||||
<section v-if="!isFbaCreate" class="form-section">
|
||||
<h3>编码与状态</h3>
|
||||
<div class="form-grid two-cols compact-grid">
|
||||
<el-form-item label="仓库编码" :class="{ 'has-field-error': formErrors.code }"><el-input v-model="form.code" maxlength="50" placeholder="系统自动生成" @input="form.code = String($event).toUpperCase(); clearFieldError('code')" /><p v-if="formErrors.code" class="field-error" role="alert">{{ formErrors.code }}</p></el-form-item>
|
||||
<el-form-item label="状态"><el-radio-group v-model="form.status" class="warehouse-status-toggle"><el-radio-button value="ACTIVE">启用</el-radio-button><el-radio-button value="INACTIVE">停用</el-radio-button></el-radio-group></el-form-item>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="!isFbaCreate" class="form-section">
|
||||
<h3>联系信息</h3>
|
||||
<div class="form-grid two-cols compact-grid">
|
||||
<el-form-item label="联系人"><el-input v-model="form.contactName" maxlength="64" placeholder="选填" /></el-form-item>
|
||||
<el-form-item label="联系电话"><el-input v-model="form.contactPhone" maxlength="32" placeholder="选填" /></el-form-item>
|
||||
<el-form-item label="收货时间"><el-input v-model="form.businessHours" maxlength="500" placeholder="例如 周一至周六 08:30-18:00" /></el-form-item>
|
||||
</div>
|
||||
<el-form-item label="备注"><el-input v-model="form.remark" maxlength="500" type="textarea" :rows="3" show-word-limit placeholder="选填" /></el-form-item>
|
||||
</section>
|
||||
</el-form>
|
||||
<template #footer><div class="drawer-footer"><el-button :disabled="saving" @click="editorOpen = false">取消</el-button><el-button type="primary" :loading="saving" @click="saveWarehouse">保存仓库</el-button></div></template>
|
||||
<template #footer><div class="drawer-footer"><el-button :disabled="saving" @click="editorOpen = false">取消</el-button><el-button type="primary" :loading="saving" @click="saveWarehouse">{{ editorMode === 'EDIT' ? '保存修改' : '确认新增' }}</el-button></div></template>
|
||||
</el-drawer>
|
||||
|
||||
<el-dialog v-model="importOpen" title="导入公共仓库" width="min(760px, calc(100vw - 32px))" :close-on-click-modal="!importing" :close-on-press-escape="!importing">
|
||||
<div class="import-content">
|
||||
<div class="import-toolbar">
|
||||
<el-button :loading="downloadingTemplate" @click="downloadTemplate"><Download :size="16" />下载 XLSX 模板</el-button>
|
||||
<el-radio-group v-model="importMode" :disabled="importing">
|
||||
<el-radio-button value="CREATE_ONLY">仅新增</el-radio-button>
|
||||
<el-radio-button value="UPSERT" :disabled="!access.can('platform:warehouse:edit')">新增并更新</el-radio-button>
|
||||
</el-radio-group>
|
||||
</div>
|
||||
<el-upload accept=".xlsx" :auto-upload="false" :limit="1" :on-change="selectImportFile" :on-remove="removeImportFile">
|
||||
<el-button><Upload :size="16" />选择 XLSX 文件</el-button>
|
||||
<template #tip><div class="el-upload__tip">最多 5MB、1000 行;请使用标准模板,FBA 名称可不填并默认使用编码。</div></template>
|
||||
</el-upload>
|
||||
<div v-if="importResult" class="import-result" role="status">
|
||||
<dl class="import-summary">
|
||||
<div><dt>总行数</dt><dd>{{ importResult.totalRows }}</dd></div>
|
||||
<div><dt>有效</dt><dd>{{ importResult.validRows }}</dd></div>
|
||||
<div><dt>新增</dt><dd>{{ importResult.createCount }}</dd></div>
|
||||
<div><dt>更新</dt><dd>{{ importResult.updateCount }}</dd></div>
|
||||
<div><dt>无变化</dt><dd>{{ importResult.unchangedCount }}</dd></div>
|
||||
<div><dt>错误</dt><dd :class="{ 'is-error': importResult.errorCount }">{{ importResult.errorCount }}</dd></div>
|
||||
</dl>
|
||||
<div class="import-result-heading"><strong>{{ importResult.committed ? '导入已提交' : (importResult.errorCount ? '预检未通过,未写入数据' : '预检通过,尚未写入数据') }}</strong><el-button v-if="importResult.errors.length" size="small" @click="downloadImportErrors"><Download :size="15" />下载错误清单</el-button></div>
|
||||
<el-table v-if="importResult.errors.length" :data="importResult.errors" max-height="260" size="small">
|
||||
<el-table-column prop="row" label="行号" width="70" /><el-table-column prop="field" label="字段" width="110" /><el-table-column prop="code" label="错误码" width="130" :formatter="formatImportErrorCode" /><el-table-column prop="message" label="原因" min-width="220" />
|
||||
</el-table>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer><el-button :disabled="importing" @click="importOpen = false">关闭</el-button><el-button :loading="importing" :disabled="!importFile" @click="runImport(true)">预检文件</el-button><el-button type="primary" :loading="importing" :disabled="!canCommitImport" @click="runImport(false)">确认导入</el-button></template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.state-alert p { margin: 6px 0 10px; line-height: 1.55; }
|
||||
.warehouse-table-state { min-height: 180px; }
|
||||
.warehouse-heading { gap: 16px; }
|
||||
.warehouse-primary-actions, .import-toolbar, .import-result-heading { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; }
|
||||
.warehouse-cell { display: flex; align-items: center; gap: 10px; }
|
||||
.warehouse-cell > span { display: grid; width: 34px; height: 34px; flex: 0 0 34px; place-items: center; border-radius: 7px; background: #eef3fa; color: var(--cc-primary); }
|
||||
.warehouse-cell > span { display: grid; width: 34px; height: 34px; flex: 0 0 34px; place-items: center; border-radius: 8px; background: #eef3fa; color: var(--cc-primary); }
|
||||
.warehouse-cell > div { display: flex; min-width: 0; flex-direction: column; gap: 3px; }
|
||||
.warehouse-cell strong { overflow-wrap: anywhere; }
|
||||
.warehouse-cell small, .warehouse-subline { display: block; color: var(--cc-muted); font-size: 11px; }
|
||||
.warehouse-cell small, .warehouse-subline, .warehouse-address small { display: block; color: var(--cc-muted); font-size: 11px; }
|
||||
.warehouse-address { display: flex; align-items: flex-start; gap: 7px; line-height: 1.55; overflow-wrap: anywhere; }
|
||||
.warehouse-address svg { flex: 0 0 15px; margin-top: 2px; color: var(--cc-muted); }
|
||||
.warehouse-address span { min-width: 0; }
|
||||
.field-error { width: 100%; margin: 6px 0 0; color: var(--el-color-danger); font-size: 12px; line-height: 1.35; overflow-wrap: anywhere; }
|
||||
.compact-grid { row-gap: 10px; }
|
||||
.warehouse-status-toggle :deep(.el-radio-button__inner) { min-width: 74px; }
|
||||
.import-content { display: grid; gap: 20px; }
|
||||
.import-toolbar { justify-content: space-between; }
|
||||
.import-result { display: grid; gap: 14px; padding-top: 16px; border-top: 1px solid var(--cc-border); }
|
||||
.import-summary { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); margin: 0; border: 1px solid var(--cc-border); border-radius: 8px; overflow: hidden; }
|
||||
.import-summary div { min-width: 0; padding: 10px 12px; border-right: 1px solid var(--cc-border); background: var(--cc-surface-soft, #f8fafc); }
|
||||
.import-summary div:last-child { border-right: 0; }
|
||||
.import-summary dt { color: var(--cc-muted); font-size: 11px; }
|
||||
.import-summary dd { margin: 3px 0 0; color: var(--cc-text); font-size: 16px; font-weight: 700; }
|
||||
.import-summary dd.is-error { color: var(--el-color-danger); }
|
||||
.import-result-heading { justify-content: space-between; }
|
||||
|
||||
:deep(.has-field-error .el-input__wrapper) { box-shadow: 0 0 0 1px var(--el-color-danger) inset; }
|
||||
:deep(.has-field-error .el-textarea__inner) { box-shadow: 0 0 0 1px var(--el-color-danger) inset; }
|
||||
:global(.el-dropdown-menu__item) { gap: 8px; }
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.panel-heading .el-button { width: 100%; }
|
||||
.warehouse-heading { align-items: stretch; }
|
||||
.warehouse-primary-actions { width: 100%; }
|
||||
.warehouse-primary-actions > .el-button, .warehouse-primary-actions > .el-dropdown { flex: 1 1 150px; }
|
||||
.warehouse-primary-actions :deep(.el-dropdown .el-button) { width: 100%; min-height: 44px; }
|
||||
.warehouse-primary-actions > .el-button { min-height: 44px; }
|
||||
.import-toolbar { align-items: stretch; flex-direction: column; }
|
||||
.import-summary { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
.import-summary div:nth-child(3) { border-right: 0; }
|
||||
.import-summary div:nth-child(-n+3) { border-bottom: 1px solid var(--cc-border); }
|
||||
}
|
||||
|
||||
@media (max-width: 420px) {
|
||||
.warehouse-primary-actions { flex-direction: column; }
|
||||
.warehouse-primary-actions > .el-button, .warehouse-primary-actions > .el-dropdown { width: 100%; flex-basis: auto; }
|
||||
.import-summary { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.import-summary div:nth-child(3) { border-right: 1px solid var(--cc-border); }
|
||||
.import-summary div:nth-child(2n) { border-right: 0; }
|
||||
.import-summary div:nth-child(-n+4) { border-bottom: 1px solid var(--cc-border); }
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user