Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ba6599646f | ||
|
|
513bbe2bb0 | ||
|
|
d910b24755 | ||
|
|
666be8a80d | ||
|
|
33db45f1c6 | ||
|
|
dcdaf1438c | ||
|
|
17580fb0a2 | ||
|
|
34650b06df | ||
|
|
04a06668a1 | ||
|
|
f1436f2889 | ||
|
|
82dcc5a14d | ||
|
|
9a24e0c83d |
@@ -8,4 +8,27 @@
|
||||
| 美国本土卡 | 列表 | 现有 `BusinessOrdersView.vue` 的独立列表与左侧操作列 | 对应列展示车型和取送时间 | 待人工截图确认 |
|
||||
| 美国本土卡 | 详情 | 现有独立详情路由及键值网格 | 展示保存的两端联系信息、车型和取送时间;加宽长字段标签 | 待人工截图确认 |
|
||||
|
||||
不改动散板字段和承载方式。无新增审核、删除或附件操作。
|
||||
本节记录本土卡车调整;散板最新范围见下节。无新增审核、删除或附件操作。
|
||||
|
||||
|
||||
## 本土散板简化需求合同
|
||||
|
||||
当前字段规则见 `docs/海外仓业务/README.md`“本土散板简化下单”;依据用户提供的 Admin 简化原型及本轮同步消费者确认,不改变客户认证范围。
|
||||
|
||||
| 页面 | 操作 | 承载与范围 | 验收 |
|
||||
|---|---|---|---|
|
||||
| 美本土散板 | 新增 | 保持现有独立路由和表单容器;同步商品、预计托数、四项联系信息及必填的当地取送时间;删除运输方式、柜型和下单安排项 | 自动测试与人工视觉分开;待人工截图确认 |
|
||||
| 美本土散板 | 列表、详情 | 保持现有独立路由;展示同一保存值和真实承运安排,不访问运力池 | 待人工截图确认 |
|
||||
|
||||
|
||||
## Admin / Client 统一运输下单表单(2026-10-19)
|
||||
|
||||
依据:当前对话明确要求“最多 Admin 添加客户选项,Client 从登录客户下单”,覆盖此前两端独立排版结论。保留应用壳层、正式路由及权限;业务表单统一为同一字段清单、排列、控件、默认值、校验及提交转换。
|
||||
|
||||
| 页面 | 操作 | 承载与现有依据 | 本轮统一结论 | 验收 |
|
||||
|---|---|---|---|---|
|
||||
| 本土卡车 | Client 新增、Admin 新增/编辑 | 既有独立页面;原客户端截图及当前统一确认 | 运输信息双列,车辆必选;两端联系人/电话及详细地址左右对应;货物件重体;费用币种与两个分钟时间;备注;底部取消/提交。Admin 新增前置客户选择 | 自动合同及行为测试;待人工 1920×1080、100% 截图 |
|
||||
| 本土散板 | Client 新增、Admin 新增/编辑 | 既有独立页面;当前统一确认 | 与卡车共用表单;保留商品名、预计托数和件重体,移除车型;其余相同。Admin 新增前置客户选择 | 同上 |
|
||||
| 两类业务 | 列表、详情、承运安排 | 既有独立路由及只读承运摘要 | 本轮不改变承载;Client 不新增编辑路由 | 既有行为回归 |
|
||||
|
||||
统一表单组件和转换逻辑各自位于应用内部,运行时不跨应用引用。两端业务组件源码一致性另作定向检查;下单数据不允许混入客户标识,Admin 在 API 参数显式选择客户,Client 由认证会话确定客户。
|
||||
|
||||
@@ -17,7 +17,6 @@ export interface BusinessOrderQuery {
|
||||
status?: string
|
||||
routeKeyword?: string
|
||||
transportType?: string
|
||||
transportMode?: string
|
||||
vehicleType?: 'PALLET_15' | 'PALLET_30'
|
||||
pickupFrom?: string
|
||||
pickupTo?: string
|
||||
@@ -31,20 +30,17 @@ export interface TransportSummary {
|
||||
fleetArrangement: import('../fleetArrangement').FleetArrangement | null
|
||||
orderNo: string
|
||||
transportType?: string
|
||||
transportMode?: string
|
||||
palletRequired?: string | null
|
||||
origin: string
|
||||
destination: string
|
||||
containerType?: string | null
|
||||
palletCount?: number
|
||||
packageCount: number
|
||||
totalWeightKg: number
|
||||
totalVolumeCbm: number
|
||||
budgetAmount: number | null
|
||||
currency: string | null
|
||||
contactName?: string | null
|
||||
contactInfo?: string | null
|
||||
requestedDate?: string | null
|
||||
productName?: string | null
|
||||
estimatedPalletCount?: number | null
|
||||
vehicleType?: 'PALLET_15' | 'PALLET_30' | null
|
||||
originContactName?: string | null
|
||||
originContactPhone?: string | null
|
||||
@@ -66,22 +62,23 @@ export interface TransportDetail {
|
||||
}
|
||||
|
||||
export interface TransportCreate {
|
||||
transportType: string
|
||||
transportMode?: string
|
||||
palletRequired?: string
|
||||
productName?: string
|
||||
estimatedPalletCount: number
|
||||
origin: string
|
||||
originAddress?: string
|
||||
destination: string
|
||||
destinationAddress?: string
|
||||
containerType?: string
|
||||
packageCount: number
|
||||
totalWeightKg: number
|
||||
totalVolumeCbm: number
|
||||
budgetAmount?: number
|
||||
currency?: string
|
||||
contactName?: string
|
||||
contactInfo?: string
|
||||
requestedDate?: string
|
||||
originContactName: string
|
||||
originContactPhone: string
|
||||
destinationContactName: string
|
||||
destinationContactPhone: string
|
||||
pickupAt: string
|
||||
deliveryAt: string
|
||||
remark?: string
|
||||
idempotencyKey: string
|
||||
}
|
||||
|
||||
@@ -7,6 +7,9 @@ import ts from 'typescript'
|
||||
const api = readFileSync(new URL('./api/businessOrders.ts', import.meta.url), 'utf8')
|
||||
const routes = readFileSync(new URL('./routes.ts', import.meta.url), 'utf8')
|
||||
const view = readFileSync(new URL('./components/BusinessOrdersView.vue', import.meta.url), 'utf8')
|
||||
const demandComponent = readFileSync(new URL('./components/TransportDemandForm.vue', import.meta.url), 'utf8')
|
||||
const demandLogic = ts.transpileModule(readFileSync(new URL('./transportDemandForm.ts', import.meta.url), 'utf8'), { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ESNext } }).outputText.replace(/^export /gm, '')
|
||||
const demandHelpers = runInNewContext(`${demandLogic}\n({ createTransportDemandForm, transportDemandRules, transportDemandError, buildTransportDemand })`)
|
||||
const procurementApi = readFileSync(new URL('./api/procurement.ts', import.meta.url), 'utf8')
|
||||
const procurementView = readFileSync(new URL('./views/ProcurementView.vue', import.meta.url), 'utf8')
|
||||
|
||||
@@ -106,7 +109,7 @@ test('preserves the existing transport responsive rules without adding a narrow-
|
||||
assert.match(view, /queryFields/)
|
||||
assert.match(view, /\.detail-grid \{ grid-template-columns: 1fr; \}/)
|
||||
assert.match(view, /\.form-card :deep\(\.el-form-item\) \{ display: block; \}/)
|
||||
assert.match(view, /\.budget-form-item :deep\(\.el-form-item__content\) \{ display: flex; flex-wrap: nowrap; \}/)
|
||||
assert.match(demandComponent, /\.transport-demand-budget \{ display: grid; width: 100%/)
|
||||
})
|
||||
|
||||
test('uses the shared dense order-list and detail visual language', () => {
|
||||
@@ -121,18 +124,18 @@ function transportHarness(kind = 'trucking', failFirst = false) {
|
||||
const script = view.match(/<script setup lang="ts">([\s\S]*?)<\/script>/)[1]
|
||||
const compiled = ts.transpileModule(script, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ESNext } }).outputText
|
||||
.replace(/^import .*?;$/gm, '').replace(/^export \{\};$/gm, '')
|
||||
const harness = runInNewContext(`${compiled}\n({ form, rules, formRef, submit, detail, detailFields })`, {
|
||||
const harness = runInNewContext(`${compiled}\n({ form, formRef, submit, detail, detailFields })`, {
|
||||
defineProps: () => ({ kind, createPage: true, detailPage: false }),
|
||||
withDefaults: props => props,
|
||||
computed: getter => ({ get value() { return typeof getter === 'function' ? getter() : getter.get() } }),
|
||||
reactive: value => value, ref: value => ({ value }), onMounted: () => {}, watch: () => {},
|
||||
useAuthStore: () => ({ hasPermission: () => true }), useRouter: () => ({ push: async () => {} }), useRoute: () => ({ params: {} }),
|
||||
DEFAULT_CONTAINER_TYPE: '40HQ', poolOrderStatusLabels: {},
|
||||
...demandHelpers, poolOrderStatusLabels: {},
|
||||
ElMessage: { success: () => {}, error: () => {} },
|
||||
createTransport: async (business, payload) => { calls.push({ business, payload: { ...payload } }); if (failFirst && calls.length === 1) throw new Error('retry'); },
|
||||
})
|
||||
harness.formRef.value = { validate: async () => true, clearValidate: () => {} }
|
||||
return { ...harness, calls }
|
||||
return { ...harness, calls, rules: { get value() { return demandHelpers.transportDemandRules(kind === 'trucking' ? 'truck' : 'looseboard', harness.form) } } }
|
||||
}
|
||||
|
||||
test('truck requires both contacts vehicle and minute times without inheriting looseboard fields', () => {
|
||||
@@ -142,7 +145,8 @@ test('truck requires both contacts vehicle and minute times without inheriting l
|
||||
const loose = transportHarness('looseboard')
|
||||
assert.equal(loose.rules.value.contactName, undefined)
|
||||
assert.equal(loose.rules.value.contactInfo, undefined)
|
||||
assert.equal(loose.rules.value.transportMode[0].required, true)
|
||||
assert.equal(loose.rules.value.transportMode, undefined)
|
||||
for (const key of ['originContactName', 'originContactPhone', 'destinationContactName', 'destinationContactPhone', 'pickupAt', 'deliveryAt']) assert.equal(loose.rules.value[key][0].required, true)
|
||||
truck.form.pickupAt = '2026-10-20T09:30:00'
|
||||
let failure
|
||||
truck.rules.value.deliveryAt[1].validator({}, '2026-10-20T09:29:00', error => { failure = error })
|
||||
@@ -177,3 +181,30 @@ test('truck details render saved minute times and unknown pool demand without ti
|
||||
assert.equal(fields.has('柜型'), false)
|
||||
assert.equal(fields.has('托盘数'), false)
|
||||
})
|
||||
|
||||
|
||||
test('looseboard saves split contacts and both local minute times without obsolete fields', async () => {
|
||||
const current = transportHarness('looseboard', true)
|
||||
Object.assign(current.form, { originContactName: 'A', originContactPhone: '1', destinationContactName: 'B', destinationContactPhone: '2', productName: '家居用品', estimatedPalletCount: 3, packageCount: 8, origin: 'LA', destination: 'ONT', pickupAt: '2026-10-20T09:00:00', deliveryAt: '2026-10-20T15:30:00' })
|
||||
await current.submit(); await current.submit()
|
||||
const payload = current.calls[1].payload
|
||||
assert.equal(payload.estimatedPalletCount, 3)
|
||||
assert.equal(payload.packageCount, 8)
|
||||
assert.equal(payload.productName, '家居用品')
|
||||
assert.equal(payload.pickupAt, '2026-10-20T09:00:00')
|
||||
assert.equal(payload.originContactName, 'A')
|
||||
assert.equal(payload.destinationContactPhone, '2')
|
||||
assert.equal(payload.deliveryAt, '2026-10-20T15:30:00')
|
||||
assert.equal(payload.idempotencyKey, current.calls[0].payload.idempotencyKey)
|
||||
for (const field of ['palletRequired', 'transportType', 'transportMode', 'containerType', 'contactName', 'contactInfo', 'requestedDate']) assert.equal(Object.hasOwn(payload, field), false)
|
||||
})
|
||||
|
||||
test('looseboard requires both times and rejects backwards schedule', () => {
|
||||
const current = transportHarness('looseboard')
|
||||
assert.equal(current.rules.value.pickupAt[0].required, true)
|
||||
assert.equal(current.rules.value.deliveryAt[0].required, true)
|
||||
let failure
|
||||
current.form.pickupAt = '2026-10-20T10:30:00'
|
||||
current.rules.value.deliveryAt[1].validator({}, '2026-10-20T10:29:00', error => { failure = error })
|
||||
assert.ok(failure)
|
||||
})
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { Package, Truck } from '@lucide/vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
||||
import { ElMessage, type FormInstance } from 'element-plus'
|
||||
import TransportDemandForm from './TransportDemandForm.vue'
|
||||
import { createTransportDemandForm, transportDemandError, buildTransportDemand, type TransportDemandForm as DemandFormModel } from '../transportDemandForm'
|
||||
import DataTable, { type DataTableColumn } from '@/components/common/DataTable.vue'
|
||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/components/common/QueryToolbar.vue'
|
||||
import type { TableAction } from '@/components/common/tableActions'
|
||||
import { useAuthStore } from '@/modules/auth/public'
|
||||
import { getMerchantWarehouses, type MerchantWarehouse } from '@/modules/first-leg/public'
|
||||
import {
|
||||
createTransport,
|
||||
getBusinessOrder,
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
type TransportDetail,
|
||||
type TransportSummary,
|
||||
} from '../api/businessOrders'
|
||||
import { CONTAINER_SPECIFICATIONS, DEFAULT_CONTAINER_TYPE } from '../containerSpecifications'
|
||||
import { poolOrderStatusLabels } from '../fleetArrangement'
|
||||
|
||||
const props = withDefaults(defineProps<{ kind: CrossBorderBusinessKind; createPage?: boolean; detailPage?: boolean }>(), { createPage: false, detailPage: false })
|
||||
@@ -35,35 +34,7 @@ type PageMode = 'list' | 'create' | 'detail'
|
||||
|
||||
interface ColumnDefinition { key: string; label: string; width?: number }
|
||||
|
||||
interface BusinessForm {
|
||||
transportMode: string
|
||||
palletRequired: string
|
||||
transportType: string
|
||||
origin: string
|
||||
originAddress: string
|
||||
destination: string
|
||||
destinationAddress: string
|
||||
containerType: string
|
||||
packageCount: number
|
||||
totalWeightKg: number
|
||||
totalVolumeCbm: number
|
||||
budgetAmount?: number
|
||||
currency: string
|
||||
vehicleType: '' | 'PALLET_15' | 'PALLET_30'
|
||||
originContactName: string
|
||||
originContactPhone: string
|
||||
destinationContactName: string
|
||||
destinationContactPhone: string
|
||||
pickupAt: string
|
||||
deliveryAt: string
|
||||
contactName: string
|
||||
contactInfo: string
|
||||
requestedDate: string
|
||||
remark: string
|
||||
cargoName: string
|
||||
deliveryDate: string
|
||||
palletCount: number
|
||||
}
|
||||
type BusinessForm = DemandFormModel
|
||||
|
||||
const configuration = computed(() => ({
|
||||
trucking: {
|
||||
@@ -81,14 +52,13 @@ const configuration = computed(() => ({
|
||||
function transportColumns(looseboard: boolean): ColumnDefinition[] {
|
||||
return [
|
||||
{ key: 'orderNo', label: '订单编号', width: 180 },
|
||||
...(looseboard ? [{ key: 'transportMode', label: '运输方式', width: 120 }, { key: 'palletRequired', label: '是否打盘', width: 100 }] : []),
|
||||
...(looseboard ? [{ key: 'transportType', label: '运输类型', width: 130 }] : [{ key: 'vehicleType', label: '车辆类型', width: 110 }]),
|
||||
...(!looseboard ? [{ key: 'vehicleType', label: '车辆类型', width: 110 }] : []),
|
||||
{ key: 'origin', label: '起运地', width: 150 },
|
||||
{ key: 'destination', label: '目的地', width: 150 },
|
||||
...(looseboard ? [{ key: 'containerType', label: '柜型', width: 100 }] : []),
|
||||
...(looseboard ? [{ key: 'productName', label: '商品名称', width: 150 }, { key: 'estimatedPalletCount', label: '预计托数', width: 100 }] : []),
|
||||
{ key: 'totalWeightKg', label: '总重量(kg)', width: 120 },
|
||||
{ key: 'budgetAmount', label: '预算总价', width: 110 },
|
||||
...(looseboard ? [{ key: 'requestedDate', label: '要求时间', width: 120 }] : [
|
||||
...([
|
||||
{ key: 'pickupAt', label: '取货时间', width: 165 }, { key: 'deliveryAt', label: '送货时间', width: 165 },
|
||||
]),
|
||||
{ key: 'status', label: '状态', width: 130 },
|
||||
@@ -107,10 +77,6 @@ const fleetArrangement = computed(() => detail.value ? (detail.value as Transpor
|
||||
const formRef = ref<FormInstance>()
|
||||
const query = reactive({ page: 1, size: 20, orderNo: '', routeKeyword: '', status: '' })
|
||||
const total = ref(0)
|
||||
const overseasWarehouses = ref<MerchantWarehouse[]>([])
|
||||
const overseasWarehousesLoading = ref(false)
|
||||
const overseasWarehousesError = ref('')
|
||||
const isSelfDelivery = computed(() => props.kind === 'looseboard' && form.transportMode === 'SHARED_TRUCK')
|
||||
|
||||
const queryModel = computed<QueryModel>({
|
||||
get: () => ({
|
||||
@@ -148,26 +114,6 @@ const queryFields = computed<QueryField[]>(() => [
|
||||
])
|
||||
|
||||
const form = reactive<BusinessForm>(emptyForm())
|
||||
const rules = computed<FormRules<BusinessForm>>(() => ({
|
||||
origin: [{ required: true, message: '请输入起运地', trigger: 'blur' }],
|
||||
destination: [{ required: true, message: '请输入目的地', trigger: 'blur' }],
|
||||
...(props.kind === 'trucking' ? {
|
||||
vehicleType: [{ required: true, message: '请选择车辆类型', trigger: 'change' }],
|
||||
originContactName: [{ required: true, message: '请输入起运地联系人', trigger: 'blur' }],
|
||||
originContactPhone: [{ required: true, message: '请输入起运地联系电话', trigger: 'blur' }],
|
||||
destinationContactName: [{ required: true, message: '请输入目的地联系人', trigger: 'blur' }],
|
||||
destinationContactPhone: [{ required: true, message: '请输入目的地联系电话', trigger: 'blur' }],
|
||||
pickupAt: [{ required: true, message: '请选择取货时间', trigger: 'change' }],
|
||||
deliveryAt: [{ required: true, message: '请选择送货时间', trigger: 'change' }, {
|
||||
validator: (_rule: unknown, value: string, callback: (error?: Error) => void) => {
|
||||
callback(value && form.pickupAt && value < form.pickupAt ? new Error('送货时间不能早于取货时间') : undefined)
|
||||
}, trigger: 'change',
|
||||
}],
|
||||
} : {
|
||||
contactName: [{ required: true, message: '请输入联系人', trigger: 'blur' }],
|
||||
contactInfo: [{ required: true, message: '请输入联系电话或联系方式', trigger: 'blur' }],
|
||||
}),
|
||||
}))
|
||||
|
||||
const detailFields = computed(() => {
|
||||
if (!detail.value) return []
|
||||
@@ -175,21 +121,15 @@ const detailFields = computed(() => {
|
||||
const current = detail.value as TransportDetail
|
||||
return [
|
||||
...(props.kind === 'looseboard' ? [
|
||||
['运输方式', transportModeLabel(read(summary, 'transportMode'))], ['是否打盘', yesNo(read(summary, 'palletRequired'))],
|
||||
['运输类型', transportTypeLabel(read(summary, 'transportType'))],
|
||||
['商品名称', summary.productName], ['预计托数', summary.estimatedPalletCount],
|
||||
] : [['车辆类型', vehicleTypeLabel(summary.vehicleType)]]),
|
||||
['起运地', read(summary, 'origin')], ['起运地址', current.originAddress],
|
||||
['目的地', read(summary, 'destination')], ['目的地址', current.destinationAddress],
|
||||
...(props.kind === 'looseboard' ? [['柜型', read(summary, 'containerType')]] : []),
|
||||
['件数', read(summary, 'packageCount')], ['总重量(kg)', read(summary, 'totalWeightKg')],
|
||||
['总体积(m³)', read(summary, 'totalVolumeCbm')], ['预算总价', money(read(summary, 'budgetAmount'), read(summary, 'currency'))],
|
||||
...(props.kind === 'looseboard' ? [
|
||||
['联系人', summary.contactName], ['联系方式', summary.contactInfo], ['要求时间', summary.requestedDate],
|
||||
] : [
|
||||
['起运地联系人', summary.originContactName], ['起运地联系电话', summary.originContactPhone],
|
||||
['目的地联系人', summary.destinationContactName], ['目的地联系电话', summary.destinationContactPhone],
|
||||
['取货时间', localMinute(summary.pickupAt)], ['送货时间', localMinute(summary.deliveryAt)],
|
||||
]), ['备注', current.remark],
|
||||
['起运地联系人', summary.originContactName], ['起运地联系电话', summary.originContactPhone],
|
||||
['目的地联系人', summary.destinationContactName], ['目的地联系电话', summary.destinationContactPhone],
|
||||
['取货时间', localMinute(summary.pickupAt)], ['送货时间', localMinute(summary.deliveryAt)], ['备注', current.remark],
|
||||
]
|
||||
})
|
||||
|
||||
@@ -205,35 +145,9 @@ const tableColumns = computed<DataTableColumn[]>(() => [
|
||||
|
||||
const detailStatus = computed(() => detail.value ? statusLabel(detail.value.summary.status) : '—')
|
||||
|
||||
function emptyForm(): BusinessForm {
|
||||
return {
|
||||
transportMode: 'SHARED_TRUCK', palletRequired: 'NO', transportType: 'PORT_PICKUP', origin: '', originAddress: '',
|
||||
destination: '', destinationAddress: '', containerType: DEFAULT_CONTAINER_TYPE, packageCount: 0,
|
||||
totalWeightKg: 0, totalVolumeCbm: 0, currency: 'USD', contactName: '', contactInfo: '', requestedDate: '',
|
||||
remark: '', cargoName: '', deliveryDate: '', palletCount: 0, vehicleType: '', originContactName: '', originContactPhone: '', destinationContactName: '', destinationContactPhone: '', pickupAt: '', deliveryAt: '',
|
||||
}
|
||||
}
|
||||
function emptyForm(): BusinessForm { return createTransportDemandForm() }
|
||||
|
||||
function resetForm() { Object.assign(form, emptyForm()); submitKey.value = ''; formRef.value?.clearValidate() }
|
||||
function warehouseLabel(warehouse: MerchantWarehouse) { return `${warehouse.warehouseName} · ${warehouse.warehouseCode}` }
|
||||
function selectOriginMode() {
|
||||
form.origin = ''
|
||||
form.originAddress = ''
|
||||
formRef.value?.clearValidate('origin')
|
||||
}
|
||||
async function loadOverseasWarehouses() {
|
||||
if (props.kind !== 'looseboard') return
|
||||
overseasWarehousesLoading.value = true
|
||||
overseasWarehousesError.value = ''
|
||||
try {
|
||||
const result = await getMerchantWarehouses({ page: 1, size: 200, warehouseType: 'OVERSEAS' })
|
||||
overseasWarehouses.value = result.records.filter(item => item.status === 'ACTIVE' && ['OVERSEAS', 'OVERSEAS_PARTNER'].includes(item.warehouseType) && item.countryCode === 'US')
|
||||
if (!overseasWarehouses.value.length) overseasWarehousesError.value = '暂无可用的海外仓'
|
||||
} catch (cause) {
|
||||
overseasWarehouses.value = []
|
||||
overseasWarehousesError.value = errorMessage(cause)
|
||||
} finally { overseasWarehousesLoading.value = false }
|
||||
}
|
||||
function read(source: object, key: string) { return Reflect.get(source, key) as unknown }
|
||||
function text(value: unknown) { return value === null || value === undefined || value === '' ? '—' : String(value) }
|
||||
function money(amount: unknown, currency: unknown) { return amount === null || amount === undefined ? '—' : `${text(currency)} ${Number(amount).toFixed(2)}` }
|
||||
@@ -242,7 +156,6 @@ function yesNo(value: unknown) { return value === 'YES' ? '是' : value === 'NO'
|
||||
function vehicleTypeLabel(value: unknown) { return ({ PALLET_15: '15拖', PALLET_30: '30拖' } as Record<string, string>)[String(value)] || text(value) }
|
||||
function localMinute(value: unknown) { return value ? String(value).replace('T', ' ').slice(0, 16) : '—' }
|
||||
function transportTypeLabel(value: unknown) { return ({ PORT_PICKUP: '港口提柜', WAREHOUSE_TO_WAREHOUSE: '仓到仓', WAREHOUSE_TO_DOOR: '仓到门' } as Record<string, string>)[String(value)] || text(value) }
|
||||
function transportModeLabel(value: unknown) { return ({ SHARED_TRUCK: '拼车', PRIORITY_CONSOLIDATION: '优先拼载' } as Record<string, string>)[String(value)] || text(value) }
|
||||
function statusLabel(value: unknown) {
|
||||
if (poolOrderStatusLabels[String(value)]) return poolOrderStatusLabels[String(value)]
|
||||
return ({ PENDING_REVIEW: '待审核', APPROVED: '已通过', REJECTED: '已拒绝', PENDING_ACCEPTANCE: '待接单', PROCESSING: '处理中', IN_TRANSIT: '运输中', COMPLETED: '已完成', CANCELLED: '已取消' } as Record<string, string>)[String(value)] || text(value)
|
||||
@@ -257,7 +170,6 @@ function displayCell(candidate: unknown, key: string) {
|
||||
if (key === 'vehicleType') return vehicleTypeLabel(value)
|
||||
if (key === 'pickupAt' || key === 'deliveryAt') return localMinute(value)
|
||||
if (key === 'transportType') return transportTypeLabel(value)
|
||||
if (key === 'transportMode') return transportModeLabel(value)
|
||||
if (key === 'palletRequired') return yesNo(value)
|
||||
if (key === 'createdAt') return dateTime(value)
|
||||
if (key === 'budgetAmount') return money(value, read(row, 'currency'))
|
||||
@@ -336,33 +248,15 @@ async function loadDetail() {
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
const failure = transportDemandError(props.kind === 'trucking' ? 'truck' : 'looseboard', form)
|
||||
if (failure) { ElMessage.error(failure); return }
|
||||
if (!await formRef.value?.validate().catch(() => false)) return
|
||||
submitting.value = true
|
||||
const stableSubmitKey = submitKey.value || idempotencyKey()
|
||||
submitKey.value = stableSubmitKey
|
||||
try {
|
||||
const base = {
|
||||
origin: form.origin, originAddress: form.originAddress || undefined,
|
||||
destination: form.destination, destinationAddress: form.destinationAddress || undefined,
|
||||
packageCount: form.packageCount,
|
||||
totalWeightKg: form.totalWeightKg, totalVolumeCbm: form.totalVolumeCbm,
|
||||
budgetAmount: form.budgetAmount, currency: form.currency || undefined,
|
||||
remark: props.kind === 'looseboard' ? [form.cargoName, form.remark].filter(Boolean).join(' · ') || undefined : form.remark || undefined,
|
||||
idempotencyKey: stableSubmitKey,
|
||||
}
|
||||
if (props.kind === 'trucking') {
|
||||
if (!form.vehicleType) return
|
||||
await createTransport('trucking', { ...base, vehicleType: form.vehicleType,
|
||||
originContactName: form.originContactName, originContactPhone: form.originContactPhone,
|
||||
destinationContactName: form.destinationContactName, destinationContactPhone: form.destinationContactPhone,
|
||||
pickupAt: form.pickupAt, deliveryAt: form.deliveryAt,
|
||||
})
|
||||
} else {
|
||||
await createTransport('looseboard', { ...base, transportMode: form.transportMode, palletRequired: form.palletRequired,
|
||||
transportType: form.transportType, containerType: form.containerType || undefined,
|
||||
contactName: form.contactName || undefined, contactInfo: form.contactInfo || undefined, requestedDate: form.requestedDate || undefined,
|
||||
})
|
||||
}
|
||||
if (props.kind === 'trucking') await createTransport('trucking', { ...buildTransportDemand('truck', form), idempotencyKey: stableSubmitKey })
|
||||
else await createTransport('looseboard', { ...buildTransportDemand('looseboard', form), idempotencyKey: stableSubmitKey })
|
||||
ElMessage.success('订单提交成功')
|
||||
resetForm()
|
||||
if (props.createPage) await router.push({ name: listRouteName.value })
|
||||
@@ -372,12 +266,12 @@ async function submit() {
|
||||
}
|
||||
|
||||
watch(() => [query.page, query.size], () => { if (!props.createPage && mode.value === 'list') void load() })
|
||||
onMounted(() => { if (props.detailPage) void loadDetail(); else if (!props.createPage) void load(); void loadOverseasWarehouses() })
|
||||
onMounted(() => { if (props.detailPage) void loadDetail(); else if (!props.createPage) void load() })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section v-loading="loading" class="business-orders client-ui" :aria-busy="loading">
|
||||
<header v-if="mode === 'create'" class="page-head">
|
||||
<header v-if="mode === 'create'" class="page-head" :class="{ 'looseboard-page-head': props.kind === 'looseboard' }">
|
||||
<div>
|
||||
<h2>{{ configuration.title }}下单</h2>
|
||||
<p>请填写订单信息,带 * 项为必填项</p>
|
||||
@@ -430,75 +324,8 @@ onMounted(() => { if (props.detailPage) void loadDetail(); else if (!props.creat
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<el-card v-else-if="kind === 'looseboard'" shadow="never" class="form-card looseboard-form-card">
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-position="top">
|
||||
<div class="form-section transport-section">
|
||||
<h3><span class="section-icon"><Truck :size="14" /></span>运输方式 <em>*</em></h3>
|
||||
<div class="mode-buttons"><el-radio-group v-model="form.transportMode" @change="selectOriginMode"><el-radio-button value="SHARED_TRUCK"><Truck :size="14" /> 自送</el-radio-button><el-radio-button value="PRIORITY_CONSOLIDATION"><Package :size="14" /> 我司提货</el-radio-button></el-radio-group></div>
|
||||
</div>
|
||||
<div class="form-grid two-col">
|
||||
<el-form-item :label="isSelfDelivery ? '入仓库' : '取货地'" prop="origin" :rules="[{ required: true, message: isSelfDelivery ? '请选择美国仓库' : '请输入取货地', trigger: isSelfDelivery ? 'change' : 'blur' }]">
|
||||
<template v-if="isSelfDelivery">
|
||||
<el-select v-model="form.origin" filterable clearable :loading="overseasWarehousesLoading" placeholder="请选择美国仓库" no-data-text="暂无可用的美国海外仓">
|
||||
<el-option v-for="warehouse in overseasWarehouses" :key="warehouse.id" :label="warehouseLabel(warehouse)" :value="warehouseLabel(warehouse)" />
|
||||
</el-select>
|
||||
<div v-if="overseasWarehousesError" role="alert" class="warehouse-load-error">{{ overseasWarehousesError }} <el-button link type="primary" @click="loadOverseasWarehouses">重新加载</el-button></div>
|
||||
</template>
|
||||
<el-input v-else v-model.trim="form.origin" maxlength="255" placeholder="城市 / 港口 / 州" />
|
||||
</el-form-item>
|
||||
<el-form-item label="目的地" prop="destination"><el-input v-model.trim="form.destination" maxlength="255" placeholder="城市 / 州" /></el-form-item>
|
||||
<el-form-item label="联系人" prop="contactName"><el-input v-model.trim="form.contactName" maxlength="128" placeholder="联系人姓名" /></el-form-item>
|
||||
<el-form-item label="联系方式" prop="contactInfo"><el-input v-model.trim="form.contactInfo" maxlength="128" placeholder="联系电话" /></el-form-item>
|
||||
</div>
|
||||
<div class="form-section cargo-section"><h3><span class="section-icon"><Package :size="14" /></span>托载</h3><div class="form-grid cargo-grid">
|
||||
<el-form-item label="商品名称"><el-input v-model.trim="form.cargoName" placeholder="请输入商品名称" /></el-form-item>
|
||||
<el-form-item label="托数"><el-input-number v-model="form.palletCount" :min="0" controls-position="right" /></el-form-item>
|
||||
<el-form-item label="总重量 (kg)"><el-input-number v-model="form.totalWeightKg" :min="0" :precision="2" controls-position="right" /></el-form-item>
|
||||
<el-form-item label="总体积 (m³)"><el-input-number v-model="form.totalVolumeCbm" :min="0" :precision="3" controls-position="right" /></el-form-item>
|
||||
</div></div>
|
||||
<div class="form-grid two-col lower-grid">
|
||||
<el-form-item label="预算总价($)"><el-input-number v-model="form.budgetAmount" :min="0" :precision="2" controls-position="right" placeholder="0.00" /></el-form-item>
|
||||
<div></div>
|
||||
<el-form-item label="要求取货时间" prop="requestedDate"><el-date-picker v-model="form.requestedDate" type="date" value-format="YYYY-MM-DD" placeholder="年 / 月 / 日" /></el-form-item>
|
||||
<el-form-item label="送货到达时间"><el-date-picker v-model="form.deliveryDate" type="date" value-format="YYYY-MM-DD" placeholder="年 / 月 / 日" /></el-form-item>
|
||||
</div>
|
||||
<div class="form-actions"><el-button @click="returnToList">取消</el-button><el-button v-if="canCreate" type="primary" :loading="submitting" @click="submit">提交订单</el-button></div>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<el-card v-else shadow="never" class="form-card">
|
||||
<el-form ref="formRef" :model="form" :rules="rules" :label-width="kind === 'trucking' ? '140px' : '120px'">
|
||||
<el-form-item v-if="kind === 'looseboard'" label="运输方式"><el-radio-group v-model="form.transportMode"><el-radio-button value="SHARED_TRUCK">拼车</el-radio-button><el-radio-button value="PRIORITY_CONSOLIDATION">优先拼载</el-radio-button></el-radio-group></el-form-item>
|
||||
<el-form-item v-if="kind === 'looseboard'" label="是否打盘"><el-radio-group v-model="form.palletRequired"><el-radio-button value="NO">否</el-radio-button><el-radio-button value="YES">是</el-radio-button></el-radio-group></el-form-item>
|
||||
<el-form-item v-if="kind === 'looseboard'" label="运输类型"><el-select v-model="form.transportType"><el-option label="港口提柜" value="PORT_PICKUP" /><el-option label="仓到仓" value="WAREHOUSE_TO_WAREHOUSE" /><el-option label="仓到门" value="WAREHOUSE_TO_DOOR" /></el-select></el-form-item>
|
||||
<el-form-item v-if="kind === 'trucking'" label="车辆类型" prop="vehicleType"><el-select v-model="form.vehicleType" placeholder="请选择车辆类型"><el-option label="15拖" value="PALLET_15" /><el-option label="30拖" value="PALLET_30" /></el-select></el-form-item>
|
||||
<el-form-item label="起运地" prop="origin"><el-input v-model.trim="form.origin" maxlength="255" placeholder="请输入起运地" /></el-form-item>
|
||||
<el-form-item label="起运地址"><el-input v-model.trim="form.originAddress" maxlength="500" placeholder="请输入详细起运地址" /></el-form-item>
|
||||
<template v-if="kind === 'trucking'">
|
||||
<el-form-item label="起运地联系人" prop="originContactName"><el-input v-model.trim="form.originContactName" maxlength="128" placeholder="请输入起运地联系人" /></el-form-item>
|
||||
<el-form-item label="起运地联系电话" prop="originContactPhone"><el-input v-model.trim="form.originContactPhone" type="tel" maxlength="128" placeholder="请输入起运地联系电话" /></el-form-item>
|
||||
</template>
|
||||
<el-form-item label="目的地" prop="destination"><el-input v-model.trim="form.destination" maxlength="255" placeholder="请输入目的地" /></el-form-item>
|
||||
<el-form-item label="目的地址"><el-input v-model.trim="form.destinationAddress" maxlength="500" placeholder="请输入详细目的地址" /></el-form-item>
|
||||
<template v-if="kind === 'trucking'">
|
||||
<el-form-item label="目的地联系人" prop="destinationContactName"><el-input v-model.trim="form.destinationContactName" maxlength="128" placeholder="请输入目的地联系人" /></el-form-item>
|
||||
<el-form-item label="目的地联系电话" prop="destinationContactPhone"><el-input v-model.trim="form.destinationContactPhone" type="tel" maxlength="128" placeholder="请输入目的地联系电话" /></el-form-item>
|
||||
</template>
|
||||
<el-form-item v-if="kind === 'looseboard'" label="柜型"><el-select v-model="form.containerType"><el-option v-for="item in CONTAINER_SPECIFICATIONS" :key="item.type" :label="item.type" :value="item.type" /></el-select></el-form-item>
|
||||
<el-form-item label="件数"><el-input-number v-model="form.packageCount" :min="0" placeholder="请输入件数" /></el-form-item>
|
||||
<el-form-item label="总重量(kg)"><el-input-number v-model="form.totalWeightKg" :min="0" :precision="2" placeholder="请输入总重量" /></el-form-item>
|
||||
<el-form-item label="总体积(m³)"><el-input-number v-model="form.totalVolumeCbm" :min="0" :precision="3" placeholder="请输入总体积" /></el-form-item>
|
||||
<el-form-item v-if="kind === 'looseboard'" label="要求时间"><el-date-picker v-model="form.requestedDate" type="date" value-format="YYYY-MM-DD" /></el-form-item>
|
||||
<template v-if="kind === 'trucking'">
|
||||
<el-form-item label="取货时间" prop="pickupAt"><el-date-picker v-model="form.pickupAt" type="datetime" format="YYYY-MM-DD HH:mm" time-format="HH:mm" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择取货日期和时间" @change="formRef?.validateField('deliveryAt').catch(() => undefined)" /></el-form-item>
|
||||
<el-form-item label="送货时间" prop="deliveryAt"><el-date-picker v-model="form.deliveryAt" type="datetime" format="YYYY-MM-DD HH:mm" time-format="HH:mm" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择送货日期和时间" /></el-form-item>
|
||||
</template>
|
||||
<el-form-item class="budget-form-item" label="预算金额"><el-input-number v-model="form.budgetAmount" :min="0" :precision="2" placeholder="请输入预算金额" /><el-select v-model="form.currency" class="currency"><el-option label="USD" value="USD" /><el-option label="CNY" value="CNY" /></el-select></el-form-item>
|
||||
<el-form-item v-if="kind === 'looseboard'" label="联系人" prop="contactName"><el-input v-model.trim="form.contactName" maxlength="128" placeholder="请输入联系人" /></el-form-item>
|
||||
<el-form-item v-if="kind === 'looseboard'" label="联系方式" prop="contactInfo"><el-input v-model.trim="form.contactInfo" maxlength="128" placeholder="请输入电话、微信或邮箱" /></el-form-item>
|
||||
<el-form-item label="备注"><el-input v-model="form.remark" type="textarea" :rows="4" maxlength="1000" show-word-limit placeholder="请输入其他要求" /></el-form-item>
|
||||
<el-form-item><el-button @click="returnToList">取消</el-button><el-button v-if="canCreate" type="primary" :loading="submitting" @click="submit">提交订单</el-button></el-form-item>
|
||||
</el-form>
|
||||
<TransportDemandForm ref="formRef" :model="form" :kind="kind === 'trucking' ? 'truck' : 'looseboard'" :submitting="submitting" :can-submit="canCreate" @submit="submit" @cancel="returnToList" />
|
||||
</el-card>
|
||||
</section>
|
||||
</template>
|
||||
@@ -506,6 +333,7 @@ onMounted(() => { if (props.detailPage) void loadDetail(); else if (!props.creat
|
||||
<style scoped>
|
||||
.business-orders { display: grid; gap: var(--client-section-gap); }
|
||||
.page-head { display: flex; align-items: center; justify-content: space-between; gap: 24px; }
|
||||
.looseboard-page-head { max-width: 1180px; padding-right: 30px; box-sizing: border-box; }
|
||||
.page-head h2 { margin: 0; color: #111827; font-size: 26px; }
|
||||
.page-head p { margin: 7px 0 0; color: #64748b; font-size: 14px; }
|
||||
.business-table-card { overflow: hidden; border: 1px solid var(--client-border); border-radius: var(--client-radius); background: #fff; }
|
||||
@@ -529,35 +357,7 @@ onMounted(() => { if (props.detailPage) void loadDetail(); else if (!props.creat
|
||||
.arrangement-grid div { grid-template-columns: 124px minmax(0, 1fr); }
|
||||
.arrangement-grid dt { white-space: normal; overflow-wrap: anywhere; }
|
||||
.detail-grid dd { min-width: 0; margin: 0; overflow-wrap: anywhere; color: var(--navy); font-size: 12px; font-weight: 600; line-height: 1.4; }
|
||||
.form-card :deep(.el-form) { max-width: 860px; }
|
||||
.form-card :deep(.el-input), .form-card :deep(.el-textarea), .form-card :deep(.el-select), .form-card :deep(.el-date-editor) { width: 100%; }
|
||||
.form-card .currency { width: 110px; margin-left: 12px; }
|
||||
|
||||
.warehouse-load-error { color: #b42318; font-size: 12px; }
|
||||
.looseboard-form-card { padding: 0; border: 1px solid #e7ebf1; border-radius: 10px; background: #fff; box-shadow: 0 8px 22px rgb(31 45 61 / 4%); }
|
||||
.looseboard-form-card :deep(.el-card__body) { padding: 0; }
|
||||
.looseboard-form-card :deep(.el-form) { max-width: none; }
|
||||
.looseboard-form-card :deep(.el-form-item) { margin-bottom: 16px; }
|
||||
.looseboard-form-card :deep(.el-form-item__label) { height: auto; padding-bottom: 7px; color: #27364d; font-size: 13px; font-weight: 600; line-height: 1.2; }
|
||||
.looseboard-form-card :deep(.el-input__wrapper), .looseboard-form-card :deep(.el-textarea__inner), .looseboard-form-card :deep(.el-select__wrapper), .looseboard-form-card :deep(.el-input-number) { min-height: 36px; border-radius: 8px; box-shadow: 0 0 0 1px #e1e6ed inset; }
|
||||
.looseboard-form-card :deep(.el-input-number) { width: 100%; }
|
||||
.form-section { padding: 25px 30px 5px; }
|
||||
.form-section h3 { display: flex; align-items: center; gap: 7px; margin: 0 0 15px; color: #27364d; font-size: 14px; }
|
||||
.form-section h3 em { color: #e05252; font-style: normal; }
|
||||
.section-icon { display: inline-flex; width: 20px; height: 20px; align-items: center; justify-content: center; border-radius: 5px; background: #fff3e0; color: #dd8d2c; }
|
||||
.mode-buttons :deep(.el-radio-group) { display: flex; gap: 10px; }
|
||||
.mode-buttons :deep(.el-radio-button__inner) { display: inline-flex; align-items: center; gap: 6px; height: 36px; padding: 0 18px; border: 1px solid #dce3eb; border-radius: 8px !important; color: #4b5563; box-shadow: none; }
|
||||
.mode-buttons :deep(.el-radio-button:first-child .el-radio-button__inner) { border-left: 1px solid #dce3eb; }
|
||||
.mode-buttons :deep(.el-radio-button__original-radio:checked + .el-radio-button__inner) { border-color: #5965d8; background: #f0f2ff; color: #4f5bd5; box-shadow: none; }
|
||||
.form-grid { display: grid; gap: 0 22px; padding: 0 30px; }
|
||||
.two-col { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.cargo-section { margin-top: 4px; padding-top: 7px; background: #fafafa; border-top: 1px solid #f0f2f5; border-bottom: 1px solid #f0f2f5; }
|
||||
.cargo-grid { grid-template-columns: 1.25fr 1.25fr 1.25fr 1.25fr; }
|
||||
.lower-grid { padding-top: 20px; }
|
||||
.lower-grid :deep(.el-date-editor) { width: 100%; }
|
||||
.form-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 5px; padding: 20px 30px 25px; border-top: 1px solid #f0f2f5; }
|
||||
.form-actions .el-button { min-width: 80px; height: 36px; border-radius: 7px; }
|
||||
.form-actions .el-button--primary { background: linear-gradient(135deg, #6774df, #7553aa); border: 0; box-shadow: 0 5px 12px rgb(103 116 223 / 24%); }
|
||||
.form-card :deep(.el-card__body) { padding: 0; }
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.business-orders { gap: 14px; }
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import type { FormInstance } from 'element-plus'
|
||||
import { transportDemandRules, type TransportDemandForm, type TransportDemandKind } from '../transportDemandForm'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
model: TransportDemandForm
|
||||
kind: TransportDemandKind
|
||||
disabled?: boolean
|
||||
submitting?: boolean
|
||||
canSubmit?: boolean
|
||||
editing?: boolean
|
||||
}>(), { disabled: false, submitting: false, canSubmit: true, editing: false })
|
||||
const emit = defineEmits<{ submit: []; cancel: [] }>()
|
||||
const formRef = ref<FormInstance>()
|
||||
const rules = computed(() => transportDemandRules(props.kind, props.model))
|
||||
defineExpose({
|
||||
validate: () => formRef.value?.validate() ?? Promise.resolve(false),
|
||||
clearValidate: (fields?: string | string[]) => formRef.value?.clearValidate(fields),
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-form ref="formRef" class="transport-demand-form" :model="model" :rules="rules" label-position="top" :disabled="disabled || submitting">
|
||||
<slot name="customer" />
|
||||
<section class="transport-demand-section">
|
||||
<h3>运输信息</h3>
|
||||
<div class="transport-demand-grid">
|
||||
<el-form-item v-if="kind === 'truck'" class="transport-demand-wide" label="车辆类型" prop="vehicleType">
|
||||
<el-select v-model="model.vehicleType" placeholder="请选择车辆类型"><el-option label="15拖" value="PALLET_15" /><el-option label="30拖" value="PALLET_30" /></el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="起运地" prop="origin"><el-input v-model.trim="model.origin" maxlength="255" placeholder="城市 / 港口 / 州" /></el-form-item>
|
||||
<el-form-item label="目的地" prop="destination"><el-input v-model.trim="model.destination" maxlength="255" placeholder="城市 / 州" /></el-form-item>
|
||||
<el-form-item label="起运地联系人" prop="originContactName"><el-input v-model.trim="model.originContactName" maxlength="128" placeholder="请输入起运地联系人" /></el-form-item>
|
||||
<el-form-item label="目的地联系人" prop="destinationContactName"><el-input v-model.trim="model.destinationContactName" maxlength="128" placeholder="请输入目的地联系人" /></el-form-item>
|
||||
<el-form-item label="起运地联系电话" prop="originContactPhone"><el-input v-model.trim="model.originContactPhone" type="tel" maxlength="128" placeholder="请输入起运地联系电话" /></el-form-item>
|
||||
<el-form-item label="目的地联系电话" prop="destinationContactPhone"><el-input v-model.trim="model.destinationContactPhone" type="tel" maxlength="128" placeholder="请输入目的地联系电话" /></el-form-item>
|
||||
<el-form-item label="起运详细地址"><el-input v-model.trim="model.originAddress" maxlength="500" placeholder="街道 / 门牌等补充信息(选填)" /></el-form-item>
|
||||
<el-form-item label="目的详细地址"><el-input v-model.trim="model.destinationAddress" maxlength="500" placeholder="街道 / 门牌等补充信息(选填)" /></el-form-item>
|
||||
</div>
|
||||
</section>
|
||||
<section class="transport-demand-section">
|
||||
<h3>货物信息</h3>
|
||||
<div class="transport-demand-grid" :class="kind === 'looseboard' ? 'transport-demand-cargo-four' : 'transport-demand-cargo-three'">
|
||||
<el-form-item v-if="kind === 'looseboard'" label="商品名称"><el-input v-model.trim="model.productName" maxlength="255" placeholder="请输入商品名称(选填)" /></el-form-item>
|
||||
<el-form-item v-if="kind === 'looseboard'" label="预计托数" prop="estimatedPalletCount"><el-input-number v-model="model.estimatedPalletCount" :min="0" :precision="0" controls-position="right" placeholder="请输入预计托数" /></el-form-item>
|
||||
<el-form-item v-if="kind === 'truck'" label="件数" prop="packageCount"><el-input-number v-model="model.packageCount" :min="0" :precision="0" controls-position="right" placeholder="请输入件数" /></el-form-item>
|
||||
<el-form-item label="总重量 (kg)" prop="totalWeightKg"><el-input-number v-model="model.totalWeightKg" :min="0" :precision="3" controls-position="right" placeholder="请输入总重量" /></el-form-item>
|
||||
<el-form-item label="总体积 (m³)" prop="totalVolumeCbm"><el-input-number v-model="model.totalVolumeCbm" :min="0" :precision="6" controls-position="right" placeholder="请输入总体积" /></el-form-item>
|
||||
<el-form-item v-if="kind === 'looseboard'" label="件数" prop="packageCount"><el-input-number v-model="model.packageCount" :min="0" :precision="0" controls-position="right" placeholder="请输入件数" /></el-form-item>
|
||||
</div>
|
||||
</section>
|
||||
<section class="transport-demand-section">
|
||||
<h3>费用与时效</h3>
|
||||
<div class="transport-demand-grid">
|
||||
<el-form-item label="预算总价" prop="budgetAmount"><div class="transport-demand-budget">
|
||||
<el-input-number v-model="model.budgetAmount" :min="0" :precision="2" controls-position="right" placeholder="请输入预算总价(选填)" />
|
||||
<el-select v-model="model.currency" aria-label="预算币种" placeholder="请选择预算币种"><el-option label="USD" value="USD" /><el-option label="CNY" value="CNY" /></el-select>
|
||||
</div></el-form-item>
|
||||
<div />
|
||||
<el-form-item label="取货时间" prop="pickupAt"><el-date-picker v-model="model.pickupAt" type="datetime" format="YYYY-MM-DD HH:mm" time-format="HH:mm" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择取货日期和时间" @change="formRef?.validateField('deliveryAt').catch(() => undefined)" /></el-form-item>
|
||||
<el-form-item label="送货时间" prop="deliveryAt"><el-date-picker v-model="model.deliveryAt" type="datetime" format="YYYY-MM-DD HH:mm" time-format="HH:mm" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择送货日期和时间" /></el-form-item>
|
||||
<el-form-item class="transport-demand-wide" label="备注"><el-input v-model="model.remark" type="textarea" :rows="3" maxlength="1000" show-word-limit placeholder="请输入其他要求(选填)" /></el-form-item>
|
||||
</div>
|
||||
</section>
|
||||
<footer class="transport-demand-actions">
|
||||
<el-button :disabled="submitting" @click="emit('cancel')">取消</el-button>
|
||||
<el-button v-if="canSubmit" type="primary" :loading="submitting" :disabled="disabled" @click="emit('submit')">{{ editing ? '保存修改' : '提交订单' }}</el-button>
|
||||
</footer>
|
||||
</el-form>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.transport-demand-form { --el-component-size: 36px; width: 100%; max-width: 1180px; margin: 0; background: #fff; }
|
||||
.transport-demand-section { padding: 20px 24px 0; border-bottom: 1px solid #e7ebf1; }
|
||||
.transport-demand-section h3 { margin: 0 0 16px; color: #27364d; font-size: 14px; font-weight: 600; }
|
||||
.transport-demand-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 24px; }
|
||||
.transport-demand-cargo-three { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
.transport-demand-cargo-four { grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
||||
.transport-demand-wide { grid-column: 1 / -1; }
|
||||
.transport-demand-form :deep(.el-form-item) { min-width: 0; margin-bottom: 18px; }
|
||||
.transport-demand-form :deep(.el-form-item__label) { height: auto; padding-bottom: 6px; font-size: 13px; line-height: 20px; }
|
||||
.transport-demand-form :deep(.el-input), .transport-demand-form :deep(.el-input-number), .transport-demand-form :deep(.el-select), .transport-demand-form :deep(.el-date-editor) { width: 100%; }
|
||||
.transport-demand-budget { display: grid; width: 100%; grid-template-columns: minmax(0, 1fr) 100px; gap: 10px; }
|
||||
.transport-demand-actions { display: flex; justify-content: flex-end; gap: 10px; padding: 18px 24px; }
|
||||
.transport-demand-actions :deep(.el-button + .el-button) { margin-left: 0; }
|
||||
</style>
|
||||
@@ -0,0 +1,61 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { runInNewContext } from 'node:vm'
|
||||
import test from 'node:test'
|
||||
import ts from 'typescript'
|
||||
|
||||
const source = readFileSync(new URL('./transportDemandForm.ts', import.meta.url), 'utf8')
|
||||
const code = ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ESNext } }).outputText.replace(/^export /gm, '')
|
||||
const { createTransportDemandForm, transportDemandError, buildTransportDemand } = runInNewContext(`${code}\n({ createTransportDemandForm, transportDemandError, buildTransportDemand })`)
|
||||
const valid = () => ({ ...createTransportDemandForm(), vehicleType: 'PALLET_15', origin: ' LA ', destination: ' ONT ', originContactName: ' 发货人 ', originContactPhone: ' +1-555-0100 ', destinationContactName: ' 收货人 ', destinationContactPhone: ' +1-555-0101 ', pickupAt: '2026-10-20T09:30:45', deliveryAt: '2026-10-20T15:45:12' })
|
||||
|
||||
test('initial form leaves contacts and times blank, zeroes cargo and distinguishes blank budget from zero', () => {
|
||||
const form = createTransportDemandForm()
|
||||
for (const key of ['originContactName', 'originContactPhone', 'destinationContactName', 'destinationContactPhone', 'pickupAt', 'deliveryAt']) assert.equal(form[key], '')
|
||||
for (const key of ['estimatedPalletCount', 'packageCount', 'totalWeightKg', 'totalVolumeCbm']) assert.equal(form[key], 0)
|
||||
assert.equal(form.budgetAmount, undefined)
|
||||
assert.equal(form.currency, 'USD')
|
||||
assert.equal(buildTransportDemand('truck', valid()).budgetAmount, undefined)
|
||||
assert.equal(buildTransportDemand('truck', { ...valid(), budgetAmount: 0 }).budgetAmount, 0)
|
||||
})
|
||||
|
||||
for (const kind of ['truck', 'looseboard']) {
|
||||
test(`${kind} requires both route contacts and both service times before building a payload`, () => {
|
||||
for (const field of ['origin', 'destination', 'originContactName', 'originContactPhone', 'destinationContactName', 'destinationContactPhone', 'pickupAt', 'deliveryAt']) {
|
||||
const form = { ...valid(), [field]: '' }
|
||||
assert.ok(transportDemandError(kind, form), field)
|
||||
assert.throws(() => buildTransportDemand(kind, form), undefined, field)
|
||||
}
|
||||
})
|
||||
test(`${kind} rejects negative cargo, fractional counts, invalid budgets and reversed times`, () => {
|
||||
for (const field of ['packageCount', 'totalWeightKg', 'totalVolumeCbm', 'budgetAmount']) {
|
||||
assert.ok(transportDemandError(kind, { ...valid(), [field]: -1 }), field)
|
||||
assert.ok(transportDemandError(kind, { ...valid(), [field]: Number.NaN }), field)
|
||||
}
|
||||
assert.ok(transportDemandError(kind, { ...valid(), packageCount: 1.5 }))
|
||||
assert.ok(transportDemandError(kind, { ...valid(), deliveryAt: '2026-10-20T09:29:00' }))
|
||||
assert.ok(transportDemandError(kind, { ...valid(), currency: 'EUR' }))
|
||||
})
|
||||
test(`${kind} sends minute wall-clock values, trims contacts, and excludes customer and old fields`, () => {
|
||||
const payload = buildTransportDemand(kind, { ...valid(), customerId: 'forged', tenantId: 'forged', transportMode: 'OLD', containerType: '40HQ' })
|
||||
assert.equal(payload.pickupAt, '2026-10-20T09:30:00')
|
||||
assert.equal(payload.deliveryAt, '2026-10-20T15:45:00')
|
||||
assert.equal(payload.origin, 'LA')
|
||||
assert.equal(payload.originContactName, '发货人')
|
||||
assert.equal(payload.destinationContactPhone, '+1-555-0101')
|
||||
for (const key of ['customerId', 'tenantId', 'transportMode', 'transportType', 'containerType', 'contactName', 'contactInfo', 'requestedDate']) assert.equal(Object.hasOwn(payload, key), false, key)
|
||||
assert.equal(transportDemandError(kind, { ...valid(), deliveryAt: '2026-10-20T09:30:01' }), undefined)
|
||||
})
|
||||
}
|
||||
|
||||
test('truck accepts only the two vehicle types and does not submit looseboard cargo forecasts', () => {
|
||||
assert.ok(transportDemandError('truck', { ...valid(), vehicleType: 'PALLET_99' }))
|
||||
const truck = buildTransportDemand('truck', { ...valid(), productName: 'cargo', estimatedPalletCount: 3 })
|
||||
assert.equal(Object.hasOwn(truck, 'productName'), false)
|
||||
assert.equal(Object.hasOwn(truck, 'estimatedPalletCount'), false)
|
||||
const loose = buildTransportDemand('looseboard', { ...valid(), vehicleType: '', productName: ' cargo ', estimatedPalletCount: 3, packageCount: 8 })
|
||||
assert.equal(Object.hasOwn(loose, 'vehicleType'), false)
|
||||
assert.equal(loose.productName, 'cargo')
|
||||
assert.equal(loose.estimatedPalletCount, 3)
|
||||
assert.equal(loose.packageCount, 8)
|
||||
})
|
||||
@@ -0,0 +1,106 @@
|
||||
import type { FormRules } from 'element-plus'
|
||||
|
||||
export type TransportDemandKind = 'truck' | 'looseboard'
|
||||
export interface TransportDemandForm {
|
||||
vehicleType: '' | 'PALLET_15' | 'PALLET_30'
|
||||
origin: string
|
||||
destination: string
|
||||
originAddress: string
|
||||
destinationAddress: string
|
||||
originContactName: string
|
||||
originContactPhone: string
|
||||
destinationContactName: string
|
||||
destinationContactPhone: string
|
||||
productName: string
|
||||
estimatedPalletCount: number
|
||||
packageCount: number
|
||||
totalWeightKg: number
|
||||
totalVolumeCbm: number
|
||||
budgetAmount: number | undefined
|
||||
currency: 'USD' | 'CNY'
|
||||
pickupAt: string
|
||||
deliveryAt: string
|
||||
remark: string
|
||||
}
|
||||
|
||||
export function createTransportDemandForm(): TransportDemandForm {
|
||||
return {
|
||||
vehicleType: '', origin: '', destination: '', originAddress: '', destinationAddress: '',
|
||||
originContactName: '', originContactPhone: '', destinationContactName: '', destinationContactPhone: '',
|
||||
productName: '', estimatedPalletCount: 0, packageCount: 0, totalWeightKg: 0, totalVolumeCbm: 0,
|
||||
budgetAmount: undefined, currency: 'USD', pickupAt: '', deliveryAt: '', remark: '',
|
||||
}
|
||||
}
|
||||
|
||||
const requiredText = {
|
||||
origin: '起运地', destination: '目的地', originContactName: '起运地联系人',
|
||||
originContactPhone: '起运地联系电话', destinationContactName: '目的地联系人', destinationContactPhone: '目的地联系电话',
|
||||
} as const
|
||||
|
||||
export function localMinuteInput(value: unknown) {
|
||||
return typeof value === 'string' && value ? `${value.slice(0, 16)}:00` : ''
|
||||
}
|
||||
|
||||
export function transportDemandRules(kind: TransportDemandKind, form: TransportDemandForm): FormRules<TransportDemandForm> {
|
||||
const rules: FormRules<TransportDemandForm> = {}
|
||||
for (const [field, label] of Object.entries(requiredText)) {
|
||||
rules[field as keyof typeof requiredText] = [{ required: true, whitespace: true, message: `请输入${label}`, trigger: 'blur' }]
|
||||
}
|
||||
if (kind === 'truck') rules.vehicleType = [{ required: true, message: '请选择车辆类型', trigger: 'change' }]
|
||||
for (const [field, label] of [['packageCount', '件数'], ['estimatedPalletCount', '预计托数'], ['totalWeightKg', '总重量'], ['totalVolumeCbm', '总体积']] as const) {
|
||||
if (field === 'estimatedPalletCount' && kind !== 'looseboard') continue
|
||||
rules[field] = [{ required: true, type: 'number', min: 0, message: `${label}须为不小于 0 的数字`, trigger: 'change' }]
|
||||
}
|
||||
rules.budgetAmount = [{ type: 'number', min: 0, message: '预算总价不能小于 0', trigger: 'change' }]
|
||||
rules.pickupAt = [{ required: true, message: '请选择取货时间', trigger: 'change' }]
|
||||
rules.deliveryAt = [{ required: true, message: '请选择送货时间', trigger: 'change' }, {
|
||||
validator: (_rule, value, callback) => callback(value && form.pickupAt && localMinuteInput(value) < localMinuteInput(form.pickupAt) ? new Error('送货时间不能早于取货时间') : undefined), trigger: 'change',
|
||||
}]
|
||||
return rules
|
||||
}
|
||||
|
||||
export function transportDemandError(kind: TransportDemandKind, form: TransportDemandForm): string | undefined {
|
||||
if (kind === 'truck' && !['PALLET_15', 'PALLET_30'].includes(form.vehicleType)) return '请选择车辆类型'
|
||||
for (const [field, label] of Object.entries(requiredText)) {
|
||||
if (!String(form[field as keyof typeof requiredText] || '').trim()) return `请输入${label}`
|
||||
}
|
||||
for (const [field, label] of [['pickupAt', '取货时间'], ['deliveryAt', '送货时间']] as const) {
|
||||
if (!form[field]) return `请选择${label}`
|
||||
if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?$/.test(form[field])) return `请选择有效的${label}`
|
||||
}
|
||||
if (localMinuteInput(form.deliveryAt) < localMinuteInput(form.pickupAt)) return '送货时间不能早于取货时间'
|
||||
for (const [field, label] of [['packageCount', '件数'], ['totalWeightKg', '总重量'], ['totalVolumeCbm', '总体积'], ...(kind === 'looseboard' ? [['estimatedPalletCount', '预计托数'] as const] : [])] as const) {
|
||||
if (!Number.isFinite(form[field]) || form[field] < 0) return `${label}须为不小于 0 的数字`
|
||||
}
|
||||
if (!Number.isInteger(form.packageCount) || kind === 'looseboard' && !Number.isInteger(form.estimatedPalletCount)) return '件数和预计托数须为整数'
|
||||
if (form.budgetAmount != null && (!Number.isFinite(form.budgetAmount) || form.budgetAmount < 0)) return '预算总价不能小于 0'
|
||||
if (!['USD', 'CNY'].includes(form.currency)) return '请选择预算币种'
|
||||
return undefined
|
||||
}
|
||||
|
||||
function commonPayload(form: TransportDemandForm) {
|
||||
return {
|
||||
origin: form.origin.trim(), destination: form.destination.trim(),
|
||||
originAddress: form.originAddress.trim() || undefined, destinationAddress: form.destinationAddress.trim() || undefined,
|
||||
originContactName: form.originContactName.trim(), originContactPhone: form.originContactPhone.trim(),
|
||||
destinationContactName: form.destinationContactName.trim(), destinationContactPhone: form.destinationContactPhone.trim(),
|
||||
packageCount: form.packageCount, totalWeightKg: form.totalWeightKg, totalVolumeCbm: form.totalVolumeCbm,
|
||||
budgetAmount: form.budgetAmount ?? undefined, currency: form.currency,
|
||||
pickupAt: localMinuteInput(form.pickupAt), deliveryAt: localMinuteInput(form.deliveryAt), remark: form.remark.trim() || undefined,
|
||||
}
|
||||
}
|
||||
|
||||
type CommonPayload = ReturnType<typeof commonPayload>
|
||||
type TruckPayload = CommonPayload & { vehicleType: 'PALLET_15' | 'PALLET_30' }
|
||||
type LooseboardPayload = CommonPayload & { productName: string | undefined; estimatedPalletCount: number }
|
||||
|
||||
export function buildTransportDemand(kind: 'truck', form: TransportDemandForm): TruckPayload
|
||||
export function buildTransportDemand(kind: 'looseboard', form: TransportDemandForm): LooseboardPayload
|
||||
export function buildTransportDemand(kind: TransportDemandKind, form: TransportDemandForm) {
|
||||
const failure = transportDemandError(kind, form)
|
||||
if (failure) throw new Error(failure)
|
||||
const common = commonPayload(form)
|
||||
return kind === 'truck'
|
||||
? { ...common, vehicleType: form.vehicleType as 'PALLET_15' | 'PALLET_30' }
|
||||
: { ...common, productName: form.productName.trim() || undefined, estimatedPalletCount: form.estimatedPalletCount }
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
# 头程下单交货方式调整
|
||||
|
||||
依据:当前对话 2026-10-19 用户确认“卡车自送排第一,我司提货排第二,快递自提排第三;快递自提增加非必填快递单号”。该确认优先于历史原型对应字段。
|
||||
|
||||
| 页面 | 操作 | 承载方式 | 布局 | 当前实现 | 结论 |
|
||||
|---|---|---|---|---|---|
|
||||
| 头程下单 | 新增 | 现有独立页面 | 保持现有国内交货区布局 | 快递自送、我司提货、卡车自送;无快递单号输入 | 按确认顺序排列;第三项改为快递自提,下方条件显示选填快递单号 |
|
||||
| 头程订单 | 编辑草稿 | 现有居中弹框 | 复用新增表单 | 无快递单号输入 | 同步新增规则,回显已保存单号 |
|
||||
| 头程订单 | 详情 | 现有独立页面 | 保持现有信息区 | 已有快递单号只读展示 | 复用当前展示;说明客户可在快递自提草稿填写单号 |
|
||||
|
||||
自动检查与人工视觉验收分别记录;1920×1080、100% 缩放的页面效果待人工截图确认。
|
||||
@@ -139,6 +139,7 @@ export interface FirstLegOrderSaveRequest {
|
||||
privateDestinationAddressId?: number | string
|
||||
cargoes: FirstLegOrderCargoPayload[]
|
||||
pickupMethod: ApiPickupMethod
|
||||
expressTrackingNo?: string
|
||||
receivingWarehouseId: number | string
|
||||
receivingWarehouseName: string
|
||||
receivingWarehouseAddress?: string | null
|
||||
|
||||
@@ -8,6 +8,21 @@ const storeSource = readFileSync(new URL('./firstLegOrders.ts', import.meta.url)
|
||||
const apiSource = readFileSync(new URL('../api/firstLeg.ts', import.meta.url), 'utf8')
|
||||
const unifiedApiSource = readFileSync(new URL('../api/unifiedOrders.ts', import.meta.url), 'utf8')
|
||||
|
||||
test('saves optional express tracking, trims it, and omits it for other pickup methods', () => {
|
||||
const functions = storeSource.slice(storeSource.indexOf('function apiTransport('), storeSource.indexOf('function prepareDraftForSave('))
|
||||
const javascript = ts.transpileModule(functions, { compilerOptions: { target: ts.ScriptTarget.ES2022 } }).outputText
|
||||
const save = runInNewContext(`${javascript}; savePayload`)
|
||||
const draft = { pickupMethod: 'SELF_EXPRESS', trackingNumber: ' SF123456789 ', destinationCountryCode: 'US', destinationType: 'OVERSEAS', items: [] }
|
||||
const warehouse = { id: '1', name: '收货仓' }
|
||||
assert.equal(save(draft, warehouse, '客户').expressTrackingNo, 'SF123456789')
|
||||
for (const trackingNumber of ['', ' ']) {
|
||||
assert.equal(save({ ...draft, trackingNumber }, warehouse, '客户').expressTrackingNo, undefined)
|
||||
}
|
||||
for (const pickupMethod of ['SELF_TRUCK', 'PICKUP']) {
|
||||
assert.equal(save({ ...draft, pickupMethod }, warehouse, '客户').expressTrackingNo, undefined)
|
||||
}
|
||||
})
|
||||
|
||||
test('detail fetches authoritative progress on direct entry and refresh, and clears stale progress on failure', async () => {
|
||||
const functionSource = storeSource.slice(storeSource.indexOf(' async function loadDetail('), storeSource.indexOf(' async function loadWarehouseDetail('))
|
||||
const progressSource = readFileSync(new URL('../utils/orderProgress.ts', import.meta.url), 'utf8')
|
||||
|
||||
@@ -120,6 +120,7 @@ function savePayload(draft: FirstLegOrderDraft, warehouse: ClientWarehouse, cust
|
||||
productImageFileId: item.productImageFileId || undefined,
|
||||
})),
|
||||
pickupMethod: draft.pickupMethod,
|
||||
expressTrackingNo: draft.pickupMethod === 'SELF_EXPRESS' ? draft.trackingNumber.trim() || undefined : undefined,
|
||||
receivingWarehouseId: warehouse.id,
|
||||
receivingWarehouseName: warehouse.name,
|
||||
channelCode: draft.channelCode,
|
||||
|
||||
@@ -183,6 +183,7 @@ export type FirstLegOrderDraft = Omit<Pick<
|
||||
| 'destinationWarehouseId'
|
||||
| 'destinationWarehouseName'
|
||||
| 'pickupMethod'
|
||||
| 'trackingNumber'
|
||||
| 'channelCode'
|
||||
| 'channelName'
|
||||
| 'pricingTaxMode'
|
||||
|
||||
@@ -549,9 +549,19 @@ test('shows fulfillment courier information as read-only client detail', () => {
|
||||
assert.match(source, /履约快递信息/)
|
||||
assert.match(source, /detailOrder\.expressCompanyName/)
|
||||
assert.match(source, /detailOrder\.trackingNumber/)
|
||||
assert.match(source, /客户端仅只读展示/)
|
||||
assert.match(source, /快递公司由平台维护/)
|
||||
assert.doesNotMatch(source, /v-model="form\.expressCompanyName"/)
|
||||
assert.doesNotMatch(source, /v-model="form\.trackingNumber"/)
|
||||
})
|
||||
|
||||
test('orders pickup choices and offers an optional tracking number only for express pickup', () => {
|
||||
const group = source.match(/<el-radio-group v-model="form\.pickupMethod"[\s\S]*?<\/el-radio-group>/)[0]
|
||||
assert.deepEqual([...group.matchAll(/value="([A-Z_]+)"/g)].map(match => match[1]), ['SELF_TRUCK', 'PICKUP', 'SELF_EXPRESS'])
|
||||
assert.match(group, /value="SELF_EXPRESS">快递自提/)
|
||||
const field = source.match(/<el-form-item v-if="form\.pickupMethod === 'SELF_EXPRESS'"[^>]*label="快递单号"[\s\S]*?<\/el-form-item>/)[0]
|
||||
assert.match(field, /v-model="form\.trackingNumber" maxlength="128"/)
|
||||
assert.doesNotMatch(field, /\brequired\b|:rules=/)
|
||||
assert.match(source, /trackingNumber: order\.pickupMethod === 'SELF_EXPRESS' \? order\.trackingNumber : ''/)
|
||||
assert.match(source, /if \(form\.pickupMethod !== 'SELF_EXPRESS'\) form\.trackingNumber = ''/)
|
||||
})
|
||||
|
||||
test('shows the full customer-safe order detail without platform-only controls', () => {
|
||||
|
||||
@@ -372,9 +372,9 @@ function formatExportDateTime(value: string | null | undefined) {
|
||||
}
|
||||
|
||||
const pickupMethodLabels: Record<FirstLegOrder['pickupMethod'], string> = {
|
||||
SELF_EXPRESS: '自行快递到仓',
|
||||
PICKUP: '平台上门提货',
|
||||
SELF_TRUCK: '自行送货到仓',
|
||||
SELF_TRUCK: '卡车自送',
|
||||
PICKUP: '我司提货',
|
||||
SELF_EXPRESS: '快递自提',
|
||||
}
|
||||
const destinationTypeLabels: Record<FirstLegOrder['destinationType'], string> = {
|
||||
FBA: 'FBA 仓',
|
||||
@@ -424,6 +424,7 @@ function emptyDraft(): FirstLegOrderDraft {
|
||||
destinationWarehouseId: '',
|
||||
destinationWarehouseName: '',
|
||||
pickupMethod: 'SELF_EXPRESS',
|
||||
trackingNumber: '',
|
||||
channelCode: '',
|
||||
channelName: '',
|
||||
pricingTaxMode: 'TAX_INCLUDED',
|
||||
@@ -920,6 +921,7 @@ function draftFromOrder(order: FirstLegOrder): FirstLegOrderDraft {
|
||||
destinationWarehouseId: order.destinationWarehouseId,
|
||||
destinationWarehouseName: order.destinationWarehouseName,
|
||||
pickupMethod: order.pickupMethod,
|
||||
trackingNumber: order.pickupMethod === 'SELF_EXPRESS' ? order.trackingNumber : '',
|
||||
channelCode: order.channelCode,
|
||||
channelName: order.channelName,
|
||||
pricingTaxMode: order.pricingTaxMode,
|
||||
@@ -942,6 +944,7 @@ function copyDraftFromOrder(order: FirstLegOrder): FirstLegOrderDraft {
|
||||
return {
|
||||
...draft,
|
||||
inboundCode: '',
|
||||
trackingNumber: '',
|
||||
items: draft.items.map((item) => ({
|
||||
...item,
|
||||
id: `copied-cargo-${++cargoSequence}`,
|
||||
@@ -1134,6 +1137,7 @@ async function changeDestinationType() {
|
||||
}
|
||||
|
||||
function changePickupMethod() {
|
||||
if (form.pickupMethod !== 'SELF_EXPRESS') form.trackingNumber = ''
|
||||
if (form.pickupMethod === 'PICKUP') {
|
||||
form.shipperPartySnapshot ??= createPickupPartySnapshot()
|
||||
} else {
|
||||
@@ -2241,7 +2245,8 @@ async function searchOrders(model: QueryModel) {
|
||||
<div class="prototype-form-flow shipping-form-flow">
|
||||
<el-form-item label="履约类型" prop="fulfillmentType" required class="prototype-form-row prototype-form-row--tabs"><el-radio-group v-model="form.fulfillmentType" class="segmented-options" @change="changeFulfillmentType"><el-radio-button value="STANDARD">普通头程</el-radio-button><el-radio-button value="ONE_CLICK_DROPSHIP">一件代发</el-radio-button></el-radio-group><small v-if="form.fulfillmentType === 'ONE_CLICK_DROPSHIP'" class="field-guidance">测量完成后不办理国内库存入库,将直接推送 PDA 合托。</small></el-form-item>
|
||||
<el-form-item v-if="form.fulfillmentType === 'ONE_CLICK_DROPSHIP'" label="LX入库单号" prop="lxCode" class="prototype-form-row prototype-form-row--wide"><el-input v-model="form.lxCode" maxlength="128" placeholder="请输入LX入库单号" /><small class="field-guidance">用于绑定领星入库单,提交后可在尾程订单、入库单和出库单中查询。</small></el-form-item>
|
||||
<el-form-item label="取货方式" prop="pickupMethod" required class="prototype-form-row prototype-form-row--tabs"><el-radio-group v-model="form.pickupMethod" class="segmented-options" @change="changePickupMethod"><el-radio-button value="SELF_EXPRESS">快递自送</el-radio-button><el-radio-button value="PICKUP">我司提货</el-radio-button><el-radio-button value="SELF_TRUCK">卡车自送</el-radio-button></el-radio-group></el-form-item>
|
||||
<el-form-item label="取货方式" prop="pickupMethod" required class="prototype-form-row prototype-form-row--tabs"><el-radio-group v-model="form.pickupMethod" class="segmented-options" @change="changePickupMethod"><el-radio-button value="SELF_TRUCK">卡车自送</el-radio-button><el-radio-button value="PICKUP">我司提货</el-radio-button><el-radio-button value="SELF_EXPRESS">快递自提</el-radio-button></el-radio-group></el-form-item>
|
||||
<el-form-item v-if="form.pickupMethod === 'SELF_EXPRESS'" label="快递单号" prop="trackingNumber" class="prototype-form-row prototype-form-row--wide"><el-input v-model="form.trackingNumber" maxlength="128" placeholder="请输入快递单号(选填)" /></el-form-item>
|
||||
<template v-if="form.pickupMethod === 'PICKUP' && form.shipperPartySnapshot">
|
||||
<el-form-item label="企业提货地址簿" prop="senderAddressId" required :rules="requiredSelectRules('请选择企业提货地址')" class="prototype-form-row prototype-form-row--wide address-book-row">
|
||||
<div class="address-book-control"><el-select v-model="form.senderAddressId" clearable filterable :loading="orderAddressesLoading" placeholder="选择企业保存的提货地址" @change="selectPickupAddress"><el-option v-for="address in pickupAddresses" :key="String(address.id)" :label="addressLabel(address)" :value="String(address.id)" /></el-select><el-button @click="savePickupAddress">保存当前地址</el-button><el-button v-if="form.senderAddressId" link type="danger" @click="removeOrderAddress('PICKUP')">停用</el-button></div>
|
||||
@@ -2474,7 +2479,7 @@ async function searchOrders(model: QueryModel) {
|
||||
<section v-if="detailOrder.status !== 'CANCELLED' || detailOrder.expressCompanyName || detailOrder.trackingNumber" class="detail-section">
|
||||
<h3>履约快递信息</h3>
|
||||
<dl v-if="detailOrder.expressCompanyName || detailOrder.trackingNumber" class="express-information"><div><dt>快递公司</dt><dd>{{ detailOrder.expressCompanyName || (detailOrder.status === 'CANCELLED' ? '—' : '待平台补充') }}</dd></div><div><dt>快递单号</dt><dd class="table-code">{{ detailOrder.trackingNumber || (detailOrder.status === 'CANCELLED' ? '—' : '待平台补充') }}</dd></div></dl>
|
||||
<div v-else class="express-empty"><PackageCheck :size="20" /><div><strong>平台尚未维护快递信息</strong><span>快递公司和快递单号由平台后台维护,客户端仅只读展示。</span></div></div>
|
||||
<div v-else class="express-empty"><PackageCheck :size="20" /><div><strong>暂无快递信息</strong><span>快递自提可在下单或编辑草稿时选填快递单号,快递公司由平台维护。</span></div></div>
|
||||
</section>
|
||||
<section class="detail-section">
|
||||
<h3>货物明细</h3>
|
||||
|
||||
@@ -202,6 +202,7 @@ function buildOrderDraft(row: QuickOrderRow, channel: QuickChannelOption): First
|
||||
destinationWarehouseId: row.destinationWarehouseId,
|
||||
destinationWarehouseName: row.destinationWarehouseName,
|
||||
pickupMethod: 'SELF_EXPRESS',
|
||||
trackingNumber: '',
|
||||
channelCode: channel.channelCode,
|
||||
channelName: channel.channelName,
|
||||
pricingTaxMode,
|
||||
|
||||
@@ -104,17 +104,6 @@ interface DeliveryOrderDetailResponse {
|
||||
}>
|
||||
}
|
||||
|
||||
export interface DeliveryTransferResult {
|
||||
dispatchNo: string
|
||||
outboundNo: string
|
||||
outboundStatus: string
|
||||
deliveryNo: string
|
||||
deliveryStatus: string
|
||||
palletCount: number
|
||||
packageCount: number
|
||||
replayed: boolean
|
||||
}
|
||||
|
||||
export interface DeliveryQuery {
|
||||
page: number
|
||||
size: number
|
||||
@@ -140,26 +129,6 @@ export function normalizeDeliveryPage(data: PageResult<DeliveryRow>): PageResult
|
||||
}
|
||||
}
|
||||
|
||||
export interface DeliveryTransferPayload {
|
||||
dispatchNo: string
|
||||
idempotencyKey: string
|
||||
plannedDeliveryAt: string
|
||||
deliveryMethod: string
|
||||
pickupMethod: string
|
||||
loadingType: string
|
||||
waybillNo?: string
|
||||
carrierName?: string
|
||||
vehicleType?: string
|
||||
vehicleSize?: string
|
||||
driverName?: string
|
||||
driverPhone?: string
|
||||
vehiclePlate?: string
|
||||
appointmentAccount?: string
|
||||
sellerName?: string
|
||||
remark?: string
|
||||
fees: DeliveryOrderFee[]
|
||||
}
|
||||
|
||||
export function listDeliveryOrders(params: DeliveryQuery) {
|
||||
return apiRequest<PageResult<DeliveryRow>>({
|
||||
method: 'GET',
|
||||
@@ -179,13 +148,3 @@ export function getDeliveryOrder(deliveryNo: string) {
|
||||
items: skus,
|
||||
}))
|
||||
}
|
||||
|
||||
export function createDeliveryOrder(payload: DeliveryTransferPayload) {
|
||||
const { dispatchNo, ...request } = payload
|
||||
return apiRequest<DeliveryTransferResult>({
|
||||
method: 'POST',
|
||||
url: '/merchant/v1/last-leg/delivery-orders',
|
||||
params: { dispatchNo },
|
||||
data: request,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
import ts from 'typescript'
|
||||
|
||||
@@ -8,7 +8,6 @@ const routesSource = readFileSync(new URL('./routes.ts', import.meta.url), 'utf8
|
||||
const listSource = readFileSync(new URL('./views/DeliveryOrdersView.vue', import.meta.url), 'utf8')
|
||||
const detailSource = readFileSync(new URL('./views/DeliveryOrderDetailView.vue', import.meta.url), 'utf8')
|
||||
const deliveryStyles = readFileSync(new URL('./views/deliveryOrders.css', import.meta.url), 'utf8')
|
||||
const createSource = readFileSync(new URL('./views/DeliveryOrderCreateView.vue', import.meta.url), 'utf8')
|
||||
const permissionSource = readFileSync(new URL('../tenant-management/contracts.ts', import.meta.url), 'utf8')
|
||||
|
||||
async function importTypeScriptModule(source) {
|
||||
@@ -33,22 +32,18 @@ for (const signedPackages of [null, 0, 3]) test(`preserves actual signed quantit
|
||||
test('uses only authenticated merchant delivery endpoints without customer scope or mock fallback', () => {
|
||||
assert.match(apiSource, /method: 'GET'[\s\S]*url: '\/merchant\/v1\/last-leg\/delivery-orders'/)
|
||||
assert.match(apiSource, /delivery-orders\/\$\{encodeURIComponent\(deliveryNo\)\}/)
|
||||
assert.match(apiSource, /method: 'POST'[\s\S]*url: '\/merchant\/v1\/last-leg\/delivery-orders'/)
|
||||
assert.match(apiSource, /const \{ dispatchNo, \.\.\.request \} = payload/)
|
||||
assert.match(apiSource, /params: \{ dispatchNo \}/)
|
||||
assert.doesNotMatch(apiSource, /method: '(?:POST|PUT|PATCH|DELETE)'|createDeliveryOrder|DeliveryTransferPayload|DeliveryTransferResult/)
|
||||
assert.match(apiSource, /apiRequest<DeliveryOrderDetailResponse>/)
|
||||
assert.match(apiSource, /\.then\(\(\{ order, skus, fees \}\) => \(\{[\s\S]*\.\.\.order,[\s\S]*fees,[\s\S]*items: skus/)
|
||||
assert.match(apiSource, /apiRequest<DeliveryTransferResult>/)
|
||||
assert.doesNotMatch(apiSource, /customerId|tenantId|Record<string, unknown>|mock|fallback/i)
|
||||
})
|
||||
|
||||
test('keeps list, read-only detail and create permissions independent in active routes', () => {
|
||||
test('keeps customer delivery list and read-only detail active without a creation route', () => {
|
||||
const listRoute = routesSource.match(/path: 'orders\/delivery',[\s\S]*?\n\s*},/)?.[0] ?? ''
|
||||
assert.match(listRoute, /permission: 'merchant:warehouse:delivery:list'/)
|
||||
assert.match(listRoute, /navigation: \{ key: 'orders-delivery', icon: Truck \}/)
|
||||
assert.match(routesSource, /path: 'orders\/delivery\/create'[\s\S]*DeliveryOrderCreateView\.vue[\s\S]*permission: 'merchant:warehouse:delivery:create'/)
|
||||
assert.doesNotMatch(routesSource, /orders\/delivery\/create|orders-delivery-create|DeliveryOrderCreateView/)
|
||||
assert.match(routesSource, /path: 'orders\/delivery\/:deliveryNo'[\s\S]*DeliveryOrderDetailView\.vue[\s\S]*props: true[\s\S]*permission: 'merchant:warehouse:delivery:list'/)
|
||||
assert.match(routesSource, /orders-delivery-create[\s\S]*keepAlive: false/)
|
||||
assert.match(routesSource, /orders-delivery-detail[\s\S]*keepAlive: false/)
|
||||
})
|
||||
|
||||
@@ -70,10 +65,9 @@ test('renders the operation column first and covers loading, empty, error and pa
|
||||
assert.doesNotMatch(listSource, /row\.id[^a-zA-Z]|deliveryNo \|\| row\.id|customerId|tenantId/)
|
||||
})
|
||||
|
||||
test('shows the create button only with the independent create permission', () => {
|
||||
assert.match(listSource, /auth\.hasPermission\('merchant:warehouse:delivery:create'\)/)
|
||||
assert.match(listSource, /<el-button v-if="canCreate" type="primary" @click="openCreate"/)
|
||||
assert.doesNotMatch(listSource, /hasPermission\('merchant:warehouse:delivery:list'\)[\s\S]{0,120}创建派送单/)
|
||||
test('does not expose customer delivery creation even to an account retaining an old permission snapshot', () => {
|
||||
assert.doesNotMatch(listSource, /delivery:create|canCreate|openCreate|orders-delivery-create|创建派送单/)
|
||||
assert.equal(existsSync(new URL('./views/DeliveryOrderCreateView.vue', import.meta.url)), false)
|
||||
})
|
||||
|
||||
test('loads a typed detail route by delivery number and never exposes an internal id fallback', () => {
|
||||
@@ -126,33 +120,12 @@ test('renders a compact customer-readable delivery detail surface', () => {
|
||||
assert.match(deliveryStyles, /delivery-detail-surface \.delivery-detail-grid div\{display:grid;min-height:42px;/)
|
||||
})
|
||||
|
||||
test('submits the complete create contract with stable retry idempotency and recoverable failures', () => {
|
||||
for (const model of ['dispatchNo', 'plannedDeliveryAt', 'deliveryMethod', 'pickupMethod', 'loadingType', 'waybillNo', 'carrierName', 'vehicleType', 'vehicleSize', 'driverName', 'driverPhone', 'vehiclePlate', 'appointmentAccount', 'sellerName', 'remark']) {
|
||||
assert.match(createSource, new RegExp(`v-model="form\\.${model}"`))
|
||||
}
|
||||
assert.match(createSource, /if \(!pendingIdempotencyKey\) pendingIdempotencyKey = createIdempotencyKey\(\)/)
|
||||
assert.match(createSource, /createDeliveryOrder\(requestPayload\(\)\)/)
|
||||
assert.match(createSource, /fees: form\.fees\.map/)
|
||||
assert.match(createSource, /if \(form\.fees\.length === 0\)/)
|
||||
assert.match(createSource, /请至少添加一条费用明细/)
|
||||
for (const value of ['FBA_PICKUP', 'CUSTOMER_PICKUP', 'TRUCK_PALLET', 'TRUCK_FLOOR', 'EXPRESS', 'OUTSOURCED', 'DEDICATED_TRUCK', 'LTL']) {
|
||||
assert.match(createSource, new RegExp(`value="${value}"`))
|
||||
}
|
||||
for (const value of ['ONE_PICKUP_ONE_DROP', 'ONE_PICKUP_MULTI_DROP', 'MULTI_PICKUP_ONE_DROP', 'MULTI_PICKUP_MULTI_DROP', 'WAREHOUSE_PICKUP', 'PALLET', 'FLOOR', 'MIXED']) {
|
||||
assert.match(createSource, new RegExp(`value="${value}"`))
|
||||
}
|
||||
assert.doesNotMatch(createSource, /value="(?:TRUCK|COURIER|SELF_PICKUP|DOOR_PICKUP|LOOSE_CARTON)"/)
|
||||
assert.match(createSource, /submitError\.value = error instanceof Error/)
|
||||
assert.match(createSource, /useUnsavedWorkRegistration/)
|
||||
assert.doesNotMatch(createSource, /customerId|tenantId|mock|fallback/i)
|
||||
})
|
||||
|
||||
test('maps both merchant delivery permissions independently', async () => {
|
||||
test('keeps merchant delivery reads and removes creation from the advertised permission map', async () => {
|
||||
assert.match(permissionSource, /'merchant:warehouse:delivery:list': \['merchant:warehouse:delivery:list'\]/)
|
||||
assert.match(permissionSource, /'merchant:warehouse:delivery:create': \['merchant:warehouse:delivery:create'\]/)
|
||||
assert.doesNotMatch(permissionSource, /merchant:warehouse:delivery:create/)
|
||||
const contracts = await importTypeScriptModule(permissionSource)
|
||||
assert.equal(contracts.hasMappedMerchantPermission('merchant:warehouse:delivery:list', new Set(['merchant:warehouse:delivery:list'])), true)
|
||||
assert.equal(contracts.hasMappedMerchantPermission('merchant:warehouse:delivery:list', new Set(['merchant:warehouse:delivery:create'])), false)
|
||||
assert.equal(contracts.hasMappedMerchantPermission('merchant:warehouse:delivery:create', new Set(['merchant:warehouse:delivery:create'])), true)
|
||||
assert.equal(Object.hasOwn(contracts.FRONTEND_TO_MERCHANT_PERMISSION_CODES, 'merchant:warehouse:delivery:create'), false)
|
||||
assert.equal(contracts.hasMappedMerchantPermission('merchant:warehouse:delivery:create', new Set(['merchant:warehouse:delivery:list'])), false)
|
||||
})
|
||||
|
||||
@@ -37,13 +37,6 @@ export const orderManagementModule = {
|
||||
meta: { title: '派送订单列表', description: '查询海外仓派送订单', permission: 'merchant:warehouse:delivery:list' },
|
||||
navigation: { key: 'orders-delivery', icon: Truck },
|
||||
},
|
||||
{
|
||||
path: 'orders/delivery/create',
|
||||
name: 'orders-delivery-create',
|
||||
component: () => import('./views/DeliveryOrderCreateView.vue'),
|
||||
release: 'active',
|
||||
meta: { title: '创建派送单', workspaceTitle: '派送订单 · 新建', description: '从排车单创建海外仓派送单', permission: 'merchant:warehouse:delivery:create', keepAlive: false },
|
||||
},
|
||||
{
|
||||
path: 'orders/delivery/:deliveryNo',
|
||||
name: 'orders-delivery-detail',
|
||||
|
||||
@@ -1,201 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ArrowLeft, Plus, Trash2 } from '@lucide/vue'
|
||||
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
|
||||
import { useAuthStore } from '@/modules/auth/public'
|
||||
import { useUnsavedWorkRegistration } from '@/navigation/workspace-tabs/unsavedWork'
|
||||
import { createIdempotencyKey } from '@/utils/idempotencyKey'
|
||||
import { createDeliveryOrder, type DeliveryOrderFee, type DeliveryTransferPayload } from '../api/deliveryOrders'
|
||||
|
||||
type DeliveryCreateForm = Omit<DeliveryTransferPayload, 'idempotencyKey'>
|
||||
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
const formRef = ref<FormInstance>()
|
||||
const submitting = ref(false)
|
||||
const submitError = ref('')
|
||||
const submitted = ref(false)
|
||||
let pendingIdempotencyKey = ''
|
||||
|
||||
const form = reactive<DeliveryCreateForm>({
|
||||
dispatchNo: '',
|
||||
plannedDeliveryAt: '',
|
||||
deliveryMethod: '',
|
||||
pickupMethod: '',
|
||||
loadingType: '',
|
||||
waybillNo: '',
|
||||
carrierName: '',
|
||||
vehicleType: '',
|
||||
vehicleSize: '',
|
||||
driverName: '',
|
||||
driverPhone: '',
|
||||
vehiclePlate: '',
|
||||
appointmentAccount: '',
|
||||
sellerName: '',
|
||||
remark: '',
|
||||
fees: [],
|
||||
})
|
||||
|
||||
const rules: FormRules<DeliveryCreateForm> = {
|
||||
dispatchNo: [{ required: true, message: '请输入排车单号', trigger: 'blur' }],
|
||||
plannedDeliveryAt: [{ required: true, message: '请选择计划派送时间', trigger: 'change' }],
|
||||
deliveryMethod: [{ required: true, message: '请选择派送方式', trigger: 'change' }],
|
||||
pickupMethod: [{ required: true, message: '请选择提货方式', trigger: 'change' }],
|
||||
loadingType: [{ required: true, message: '请选择装载类型', trigger: 'change' }],
|
||||
}
|
||||
|
||||
const dirty = computed(() => !submitted.value && Boolean(
|
||||
form.dispatchNo || form.plannedDeliveryAt || form.deliveryMethod || form.pickupMethod || form.loadingType
|
||||
|| form.waybillNo || form.carrierName || form.vehicleType || form.vehicleSize || form.driverName
|
||||
|| form.driverPhone || form.vehiclePlate || form.appointmentAccount || form.sellerName || form.remark || form.fees.length,
|
||||
))
|
||||
|
||||
useUnsavedWorkRegistration({
|
||||
key: 'delivery-order-create-form',
|
||||
routeKey: 'orders-delivery-create',
|
||||
label: '创建派送单',
|
||||
dirty,
|
||||
})
|
||||
|
||||
function optional(value: string | undefined) {
|
||||
const normalized = value?.trim()
|
||||
return normalized || undefined
|
||||
}
|
||||
|
||||
function addFee() {
|
||||
form.fees.push({ feeType: '', currency: 'USD', amount: 0, remark: '' })
|
||||
}
|
||||
|
||||
function removeFee(index: number) {
|
||||
form.fees.splice(index, 1)
|
||||
}
|
||||
|
||||
function validateFees() {
|
||||
if (form.fees.length === 0) {
|
||||
ElMessage.warning('请至少添加一条费用明细')
|
||||
return false
|
||||
}
|
||||
const invalidIndex = form.fees.findIndex((fee) => !fee.feeType.trim() || !fee.currency.trim() || fee.amount <= 0)
|
||||
if (invalidIndex < 0) return true
|
||||
ElMessage.warning(`请完整填写第 ${invalidIndex + 1} 条费用的类型、币种和大于 0 的金额`)
|
||||
return false
|
||||
}
|
||||
|
||||
function requestPayload(): DeliveryTransferPayload {
|
||||
return {
|
||||
dispatchNo: form.dispatchNo.trim(),
|
||||
idempotencyKey: pendingIdempotencyKey,
|
||||
plannedDeliveryAt: form.plannedDeliveryAt,
|
||||
deliveryMethod: form.deliveryMethod,
|
||||
pickupMethod: form.pickupMethod,
|
||||
loadingType: form.loadingType,
|
||||
waybillNo: optional(form.waybillNo),
|
||||
carrierName: optional(form.carrierName),
|
||||
vehicleType: optional(form.vehicleType),
|
||||
vehicleSize: optional(form.vehicleSize),
|
||||
driverName: optional(form.driverName),
|
||||
driverPhone: optional(form.driverPhone),
|
||||
vehiclePlate: optional(form.vehiclePlate),
|
||||
appointmentAccount: optional(form.appointmentAccount),
|
||||
sellerName: optional(form.sellerName),
|
||||
remark: optional(form.remark),
|
||||
fees: form.fees.map((fee): DeliveryOrderFee => ({
|
||||
feeType: fee.feeType.trim(),
|
||||
currency: fee.currency.trim().toUpperCase(),
|
||||
amount: fee.amount,
|
||||
remark: optional(fee.remark ?? ''),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
submitError.value = ''
|
||||
const valid = await formRef.value?.validate().catch(() => false)
|
||||
if (!valid || !validateFees()) return
|
||||
if (!pendingIdempotencyKey) pendingIdempotencyKey = createIdempotencyKey()
|
||||
submitting.value = true
|
||||
try {
|
||||
const created = await createDeliveryOrder(requestPayload())
|
||||
submitted.value = true
|
||||
pendingIdempotencyKey = ''
|
||||
ElMessage.success('派送单创建成功')
|
||||
if (auth.hasPermission('merchant:warehouse:delivery:list')) {
|
||||
await router.replace({ name: 'orders-delivery-detail', params: { deliveryNo: created.deliveryNo } })
|
||||
} else {
|
||||
await router.replace('/')
|
||||
}
|
||||
} catch (error) {
|
||||
submitError.value = error instanceof Error ? error.message : '派送单创建失败,请检查后重试'
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function cancel() {
|
||||
if (dirty.value) {
|
||||
try {
|
||||
await ElMessageBox.confirm('当前派送单尚未提交,离开后填写内容将丢失。', '放弃创建?', { type: 'warning', confirmButtonText: '放弃', cancelButtonText: '继续填写' })
|
||||
} catch (error) {
|
||||
if (error === 'cancel' || error === 'close') return
|
||||
throw error
|
||||
}
|
||||
}
|
||||
submitted.value = true
|
||||
await router.push({ name: 'orders-delivery' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="delivery-page" aria-labelledby="delivery-create-title">
|
||||
<button type="button" class="delivery-back" @click="cancel"><ArrowLeft :size="16" />返回派送单列表</button>
|
||||
<div class="delivery-heading"><div><h2 id="delivery-create-title">创建派送单</h2><p>从已确认的排车单创建本企业海外仓派送任务。</p></div></div>
|
||||
|
||||
<div v-if="submitError" class="delivery-card delivery-state delivery-state--error" role="alert"><p>{{ submitError }}</p></div>
|
||||
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-position="top" class="delivery-card delivery-form" @submit.prevent="submit">
|
||||
<section>
|
||||
<h3>基础信息</h3>
|
||||
<div class="delivery-form-grid">
|
||||
<el-form-item label="排车单号" prop="dispatchNo" required><el-input v-model="form.dispatchNo" maxlength="64" placeholder="请输入排车单号" /></el-form-item>
|
||||
<el-form-item label="计划派送时间" prop="plannedDeliveryAt" required><el-date-picker v-model="form.plannedDeliveryAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择计划派送时间" /></el-form-item>
|
||||
<el-form-item label="派送方式" prop="deliveryMethod" required><el-select v-model="form.deliveryMethod" placeholder="请选择派送方式"><el-option label="FBA自提" value="FBA_PICKUP" /><el-option label="客户自提" value="CUSTOMER_PICKUP" /><el-option label="卡派打托" value="TRUCK_PALLET" /><el-option label="卡派地板" value="TRUCK_FLOOR" /><el-option label="快递" value="EXPRESS" /><el-option label="外配" value="OUTSOURCED" /><el-option label="专车直送" value="DEDICATED_TRUCK" /><el-option label="零担派送" value="LTL" /></el-select></el-form-item>
|
||||
<el-form-item label="提货方式" prop="pickupMethod" required><el-select v-model="form.pickupMethod" placeholder="请选择提货方式"><el-option label="一提一卸" value="ONE_PICKUP_ONE_DROP" /><el-option label="一提多卸" value="ONE_PICKUP_MULTI_DROP" /><el-option label="多提一卸" value="MULTI_PICKUP_ONE_DROP" /><el-option label="多提多卸" value="MULTI_PICKUP_MULTI_DROP" /><el-option label="仓库自提" value="WAREHOUSE_PICKUP" /></el-select></el-form-item>
|
||||
<el-form-item label="装载类型" prop="loadingType" required><el-select v-model="form.loadingType" placeholder="请选择装载类型"><el-option label="托盘" value="PALLET" /><el-option label="地板" value="FLOOR" /><el-option label="混装" value="MIXED" /></el-select></el-form-item>
|
||||
<el-form-item label="运单号"><el-input v-model="form.waybillNo" maxlength="64" placeholder="选填" /></el-form-item>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3>承运与车辆</h3>
|
||||
<div class="delivery-form-grid">
|
||||
<el-form-item label="承运商"><el-input v-model="form.carrierName" maxlength="128" placeholder="选填" /></el-form-item>
|
||||
<el-form-item label="车型"><el-input v-model="form.vehicleType" maxlength="64" placeholder="选填" /></el-form-item>
|
||||
<el-form-item label="车辆尺寸"><el-input v-model="form.vehicleSize" maxlength="64" placeholder="选填" /></el-form-item>
|
||||
<el-form-item label="车牌号"><el-input v-model="form.vehiclePlate" maxlength="32" placeholder="选填" /></el-form-item>
|
||||
<el-form-item label="司机姓名"><el-input v-model="form.driverName" maxlength="64" placeholder="选填" /></el-form-item>
|
||||
<el-form-item label="司机电话"><el-input v-model="form.driverPhone" maxlength="32" placeholder="选填" /></el-form-item>
|
||||
<el-form-item label="预约账号"><el-input v-model="form.appointmentAccount" maxlength="128" placeholder="选填" /></el-form-item>
|
||||
<el-form-item label="卖家名称"><el-input v-model="form.sellerName" maxlength="128" placeholder="选填" /></el-form-item>
|
||||
<el-form-item label="备注" class="delivery-span-3"><el-input v-model="form.remark" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="选填" /></el-form-item>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div class="delivery-fee-heading"><h3>费用明细</h3><el-button @click="addFee"><Plus :size="16" />添加费用</el-button></div>
|
||||
<el-table v-if="form.fees.length" :data="form.fees" table-layout="fixed">
|
||||
<el-table-column label="操作" width="88"><template #default="{ $index }"><el-button link type="danger" aria-label="删除费用" @click="removeFee($index)"><Trash2 :size="16" />删除</el-button></template></el-table-column>
|
||||
<el-table-column label="费用类型" min-width="180"><template #default="{ row }"><el-input v-model="row.feeType" maxlength="64" placeholder="请输入费用类型" /></template></el-table-column>
|
||||
<el-table-column label="币种" width="140"><template #default="{ row }"><el-input v-model="row.currency" maxlength="3" placeholder="USD" /></template></el-table-column>
|
||||
<el-table-column label="金额" width="180"><template #default="{ row }"><el-input-number v-model="row.amount" :min="0" :precision="2" :step="1" controls-position="right" placeholder="请输入金额" /></template></el-table-column>
|
||||
<el-table-column label="备注" min-width="220"><template #default="{ row }"><el-input v-model="row.remark" maxlength="200" placeholder="选填" /></template></el-table-column>
|
||||
</el-table>
|
||||
<div v-else class="delivery-fee-empty">请至少添加一条费用明细后再提交。</div>
|
||||
</section>
|
||||
|
||||
<div class="delivery-form-actions"><el-button :disabled="submitting" @click="cancel">取消</el-button><el-button type="primary" native-type="submit" :loading="submitting">提交创建</el-button></div>
|
||||
</el-form>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped src="./deliveryOrders.css"></style>
|
||||
@@ -1,16 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Plus } from '@lucide/vue'
|
||||
import dayjs from 'dayjs'
|
||||
import { useAuthStore } from '@/modules/auth/public'
|
||||
import { listDeliveryOrders, type DeliveryQuery, type DeliveryRow } from '../api/deliveryOrders'
|
||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/components/common/QueryToolbar.vue'
|
||||
import DataTable, { type DataTableColumn } from '@/components/common/DataTable.vue'
|
||||
import type { TableAction } from '@/components/common/tableActions'
|
||||
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
const loading = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const records = ref<DeliveryRow[]>([])
|
||||
@@ -18,7 +15,6 @@ const total = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const filters = reactive({ documentNo: '', dispatchNo: '', waybillNo: '', status: '', deliveryMethod: '', plannedRange: [] as string[] })
|
||||
const canCreate = computed(() => auth.hasPermission('merchant:warehouse:delivery:create'))
|
||||
let requestSequence = 0
|
||||
|
||||
const deliveryMethodOptions = [
|
||||
@@ -142,9 +138,6 @@ function openDetail(row: DeliveryRow) {
|
||||
void router.push({ name: 'orders-delivery-detail', params: { deliveryNo: row.deliveryNo } })
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
void router.push({ name: 'orders-delivery-create' })
|
||||
}
|
||||
function rowActions(candidate: object): TableAction[] {
|
||||
const row = candidate as DeliveryRow
|
||||
return [{ key: 'detail', label: '详情', disabled: !row.deliveryNo, run: () => openDetail(row) }]
|
||||
@@ -163,7 +156,6 @@ watch([page, pageSize], ([nextPage, nextSize], [previousPage, previousSize]) =>
|
||||
|
||||
<div class="delivery-card delivery-table-card">
|
||||
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="records" :columns="tableColumns" :loading="loading" :total="total" :page-sizes="[10, 20, 50, 100]" :row-actions="rowActions" row-key="deliveryNo" empty-text="暂无派送单">
|
||||
<template #toolbar-actions><el-button v-if="canCreate" type="primary" @click="openCreate"><Plus :size="16" />创建派送单</el-button></template>
|
||||
<template v-if="errorMessage" #error><div class="delivery-state delivery-state--error" role="alert"><p>{{ errorMessage }}</p><el-button @click="loadOrders">重试</el-button></div></template>
|
||||
<template #status="{ row }"><el-tag :type="statusType(row.status)" effect="light">{{ statusLabel(row.status) }}</el-tag></template>
|
||||
<template #planned="{ row }">{{ formatDateTime(row.plannedDeliveryAt) }}</template>
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
import ts from 'typescript'
|
||||
|
||||
const compiled = ts.transpileModule(readFileSync(new URL('./discardDocuments.ts', import.meta.url), 'utf8'), { compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 } }).outputText
|
||||
const { groupDiscardDocuments } = await import(`data:text/javascript;base64,${Buffer.from(compiled).toString('base64')}`)
|
||||
|
||||
const returned = (id, warehouse = 'WH', document = 'TT-BYWL-261019152551') => ({
|
||||
orderId: `return:${id}`, returnCartonId: id, sourceType: 'RETURN_CARTON', overseasWarehouseId: warehouse,
|
||||
inboundCode: document, inboundOrderNo: document, availablePackages: 5, cartonSelectionSupported: true,
|
||||
cartons: [{ cartonId: id, cartonMark: `BOX-${id}`, skus: [{ skuCode: 'A', availablePackages: 5 }] }], skus: [{ skuCode: 'A', availablePackages: 5 }],
|
||||
})
|
||||
|
||||
test('同单两箱合并成一个选项,可用量为两箱而非十件,保留原始提交身份', () => {
|
||||
const sources = [returned('1'), returned('2')]
|
||||
const result = groupDiscardDocuments(sources)
|
||||
assert.equal(result.length, 1)
|
||||
assert.equal(result[0].availablePackages, 2)
|
||||
assert.deepEqual(result[0].sources, sources)
|
||||
assert.equal(result[0].cartonSelectionSupported, true)
|
||||
})
|
||||
|
||||
test('不同单据或操作仓独立展示,混装SKU不重复计算物理箱', () => {
|
||||
assert.equal(groupDiscardDocuments([returned('1'), returned('2', 'OTHER'), returned('3', 'WH', 'TT-OTHER')]).length, 3)
|
||||
const source = { ...returned('1'), sourceType: 'INVENTORY', orderId: 'inventory:1', inventoryPositionId: '1', availablePackages: 10 }
|
||||
const second = { ...source, orderId: 'inventory:2', inventoryPositionId: '2', cartons: source.cartons }
|
||||
assert.equal(groupDiscardDocuments([source, second])[0].availablePackages, 1)
|
||||
})
|
||||
|
||||
test('缺少箱级记录时合并仓位可用箱数并保持全部废弃明细', () => {
|
||||
const first = { ...returned('1'), sourceType: 'INVENTORY', orderId: 'inventory:1', cartonSelectionSupported: false, availablePackages: 3 }
|
||||
const second = { ...first, orderId: 'inventory:2', availablePackages: 4 }
|
||||
const result = groupDiscardDocuments([first, second])[0]
|
||||
assert.equal(result.availablePackages, 7)
|
||||
assert.equal(result.cartonSelectionSupported, false)
|
||||
assert.equal(result.sources.length, 2)
|
||||
})
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { DiscardCandidate } from '../api/warehouseServices'
|
||||
|
||||
export interface DiscardDocument extends DiscardCandidate {
|
||||
sources: DiscardCandidate[]
|
||||
}
|
||||
|
||||
/** 下拉按来源单据和操作仓汇总;原始库存身份保留给选箱与提交。 */
|
||||
export function groupDiscardDocuments(candidates: DiscardCandidate[]): DiscardDocument[] {
|
||||
const groups = new Map<string, DiscardDocument>()
|
||||
for (const candidate of candidates) {
|
||||
const documentNo = candidate.inboundOrderNo || candidate.inboundCode
|
||||
const key = JSON.stringify([candidate.overseasWarehouseId, candidate.sourceType, documentNo || candidate.orderId])
|
||||
let document = groups.get(key)
|
||||
if (!document) {
|
||||
document = { ...candidate, orderId: key, sources: [], availablePackages: 0 }
|
||||
groups.set(key, document)
|
||||
}
|
||||
document.sources.push(candidate)
|
||||
}
|
||||
for (const document of groups.values()) {
|
||||
const boxes = new Set<string>()
|
||||
let untrackedPackages = 0
|
||||
for (const source of document.sources) {
|
||||
if (source.sourceType === 'RETURN_CARTON' && source.returnCartonId) {
|
||||
// 退货候选的 availablePackages 是箱内 SKU 件数,一条退货箱只计一箱。
|
||||
boxes.add(`RETURN_CARTON:${source.returnCartonId}`)
|
||||
} else if (source.cartonSelectionSupported && source.cartons?.length) {
|
||||
for (const box of source.cartons) boxes.add(`${source.sourceType}:${box.cartonId}`)
|
||||
} else {
|
||||
untrackedPackages += source.availablePackages
|
||||
}
|
||||
}
|
||||
document.availablePackages = boxes.size + untrackedPackages
|
||||
document.cartonSelectionSupported = document.sources.every(source => source.cartonSelectionSupported)
|
||||
document.cartonSelectionBlockReason = document.sources.find(source => !source.cartonSelectionSupported)?.cartonSelectionBlockReason
|
||||
}
|
||||
return [...groups.values()]
|
||||
}
|
||||
@@ -62,7 +62,6 @@ test('新建退货预报选择海外仓后带出联系人和电话', () => {
|
||||
assert.match(view, /label="海外仓联系电话"/)
|
||||
assert.match(view, /:readonly="Boolean\(selectedOperationWarehouse\?\.contactName\)"/)
|
||||
assert.match(view, /:readonly="Boolean\(selectedOperationWarehouse\?\.contactPhone\)"/)
|
||||
assert.match(view, /该海外仓未维护,请手动填写/)
|
||||
assert.match(view, /warehouseContactName: form\.warehouseContactName\.trim\(\)/)
|
||||
assert.match(view, /warehouseContactPhone: form\.warehouseContactPhone\.trim\(\)/)
|
||||
})
|
||||
@@ -78,8 +77,8 @@ test('新建退货预报使用独立路由页面,编辑仍保留加宽弹框',
|
||||
assert.match(view, /class="return-create-actions"[\s\S]*?<el-button type="primary" :loading="creating" @click="submit">提交预报<\/el-button>/)
|
||||
})
|
||||
|
||||
test('新建预报居左限宽,联系字段与数量输入保持紧凑', () => {
|
||||
assert.match(view, /return-create-surface \{ width:calc\(100% - 24px\);[^}]*max-width:1120px;[^}]*margin:0 0 0 24px; justify-self:start;/)
|
||||
test('新建预报铺满工作区,联系字段与数量输入保持紧凑', () => {
|
||||
assert.match(view, /return-create-surface \{ width:100%; min-width:0;[^}]*margin:0;/)
|
||||
assert.match(view, /<el-form label-width="130px" class="return-create-form">/)
|
||||
assert.match(view, /return-create-form :deep\(\.el-input\) \{ max-width:320px;/)
|
||||
assert.match(view, /return-create-form :deep\(\.el-select\) \{ max-width:520px;/)
|
||||
|
||||
@@ -298,25 +298,23 @@ onBeforeUnmount(clearPhotoUrls)
|
||||
<template v-if="props.createPage">
|
||||
<article class="return-create-surface">
|
||||
<header class="return-create-header">
|
||||
<div><h2>新建退货预报</h2><p>填写预计退回的 SKU 和数量,仓库签收后将自动更新实收结果。</p></div>
|
||||
<div><h2>新建退货预报</h2></div>
|
||||
<el-button @click="returnToList"><ArrowLeft :size="16" />返回列表</el-button>
|
||||
</header>
|
||||
<div class="return-create-body">
|
||||
<el-form label-width="130px" class="return-create-form">
|
||||
<el-form-item label="海外仓" required><el-select v-model="form.overseasWarehouseId" filterable clearable :loading="operationWarehousesLoading" :disabled="Boolean(operationWarehousesError)" placeholder="请选择海外仓" @change="selectOperationWarehouse"><el-option v-for="warehouse in operationWarehouses" :key="warehouse.id" :label="warehouseLabel(warehouse)" :value="String(warehouse.id)" /></el-select><div v-if="operationWarehousesError" class="warehouse-error">{{ operationWarehousesError }}</div></el-form-item>
|
||||
<el-form-item label="客户参考号"><el-input v-model="form.customerReferenceNo" placeholder="可填写客户侧参考号" /></el-form-item>
|
||||
<el-form-item label="海外仓联系人" required><el-input v-model="form.warehouseContactName" :readonly="Boolean(selectedOperationWarehouse?.contactName)" :placeholder="selectedOperationWarehouse?.contactName ? '已从海外仓资料带出' : '该海外仓未维护,请手动填写'" /></el-form-item>
|
||||
<el-form-item label="海外仓联系电话" required><el-input v-model="form.warehouseContactPhone" :readonly="Boolean(selectedOperationWarehouse?.contactPhone)" :placeholder="selectedOperationWarehouse?.contactPhone ? '已从海外仓资料带出' : '该海外仓未维护,请手动填写'" /></el-form-item>
|
||||
<el-form-item label="备注" class="return-form-wide"><el-input v-model="form.remark" type="textarea" :rows="2" placeholder="填写退货说明(选填)" /></el-form-item>
|
||||
<el-form-item label="海外仓" required><el-select v-model="form.overseasWarehouseId" filterable clearable :loading="operationWarehousesLoading" :disabled="Boolean(operationWarehousesError)" placeholder="" @change="selectOperationWarehouse"><el-option v-for="warehouse in operationWarehouses" :key="warehouse.id" :label="warehouseLabel(warehouse)" :value="String(warehouse.id)" /></el-select><div v-if="operationWarehousesError" class="warehouse-error">{{ operationWarehousesError }}</div></el-form-item>
|
||||
<el-form-item label="客户参考号"><el-input v-model="form.customerReferenceNo" placeholder="" /></el-form-item>
|
||||
<el-form-item label="海外仓联系人" required><el-input v-model="form.warehouseContactName" :readonly="Boolean(selectedOperationWarehouse?.contactName)" placeholder="" /></el-form-item>
|
||||
<el-form-item label="海外仓联系电话" required><el-input v-model="form.warehouseContactPhone" :readonly="Boolean(selectedOperationWarehouse?.contactPhone)" placeholder="" /></el-form-item>
|
||||
<el-form-item label="备注" class="return-form-wide"><el-input v-model="form.remark" type="textarea" :rows="2" placeholder="" /></el-form-item>
|
||||
</el-form>
|
||||
<div class="items-title"><span>SKU 预报明细</span><el-button text type="primary" @click="addItem"><Plus :size="15" />添加 SKU</el-button></div>
|
||||
<div class="return-relabel-option"><span>是否换标</span><el-radio-group v-model="form.needRelabel" aria-label="是否换标"><el-radio-button :value="false">不换标</el-radio-button><el-radio-button :value="true">需要换标</el-radio-button></el-radio-group></div>
|
||||
<div class="items-hint">客户只需填写预计退回的 SKU 和数量;快递单号由仓库签收时按实际包裹扫描绑定。</div>
|
||||
<el-table class="return-forecast-table" :data="form.items" row-key="id" size="small">
|
||||
<div class="return-relabel-option"><span>是否换标</span><el-radio-group v-model="form.needRelabel" aria-label="是否换标"><el-radio-button :value="false">不换标</el-radio-button><el-radio-button :value="true">需要换标</el-radio-button></el-radio-group></div>
<el-table class="return-forecast-table" :data="form.items" row-key="id" size="small">
|
||||
<el-table-column label="操作" width="80" fixed="left"><template #default="{ $index }"><el-button link type="danger" :disabled="form.items.length === 1" @click="removeItem($index)">删除</el-button></template></el-table-column>
|
||||
<el-table-column label="SKU" width="300"><template #default="{ row }"><div><el-input v-model="row.skuCode" :class="{ 'duplicate-sku-input': duplicateSkuIds.has(row.id) }" placeholder="填写 SKU" @blur="row.skuCode = row.skuCode.trim()" /><div v-if="duplicateSkuIds.has(row.id)" class="duplicate-sku-error">该 SKU 已在明细中填写</div></div></template></el-table-column>
|
||||
<el-table-column label="中文品名" min-width="320"><template #default="{ row }"><el-input v-model="row.productNameZh" placeholder="填写中文品名(选填)" /></template></el-table-column>
|
||||
<el-table-column label="预报数量" width="150"><template #default="{ row }"><el-input-number v-model="row.expectedQuantity" :min="1" controls-position="right" placeholder="数量" /></template></el-table-column>
|
||||
<el-table-column label="SKU" width="300"><template #default="{ row }"><div><el-input v-model="row.skuCode" :class="{ 'duplicate-sku-input': duplicateSkuIds.has(row.id) }" placeholder="" @blur="row.skuCode = row.skuCode.trim()" /><div v-if="duplicateSkuIds.has(row.id)" class="duplicate-sku-error">该 SKU 已在明细中填写</div></div></template></el-table-column>
|
||||
<el-table-column label="中文品名" min-width="320"><template #default="{ row }"><el-input v-model="row.productNameZh" placeholder="" /></template></el-table-column>
|
||||
<el-table-column label="预报数量" width="150"><template #default="{ row }"><el-input-number v-model="row.expectedQuantity" :min="1" controls-position="right" placeholder="" /></template></el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
<footer class="return-create-actions"><el-button @click="cancelCreate">取消</el-button><el-button type="primary" :loading="creating" @click="submit">提交预报</el-button></footer>
|
||||
@@ -338,20 +336,18 @@ onBeforeUnmount(clearPhotoUrls)
|
||||
|
||||
<el-dialog v-model="showCreate" :title="editingReturnNo ? '编辑退货预报' : '新建退货预报'" width="1120px" destroy-on-close>
|
||||
<el-form label-width="190px" class="return-create-form">
|
||||
<el-form-item label="海外仓" required class="return-form-wide"><el-select v-model="form.overseasWarehouseId" filterable clearable :loading="operationWarehousesLoading" :disabled="Boolean(operationWarehousesError)" placeholder="请选择海外仓" @change="selectOperationWarehouse"><el-option v-for="warehouse in operationWarehouses" :key="warehouse.id" :label="warehouseLabel(warehouse)" :value="String(warehouse.id)" /></el-select><div v-if="operationWarehousesError" class="warehouse-error">{{ operationWarehousesError }}</div></el-form-item>
|
||||
<el-form-item label="海外仓联系人" required><el-input v-model="form.warehouseContactName" :readonly="Boolean(selectedOperationWarehouse?.contactName)" :placeholder="selectedOperationWarehouse?.contactName ? '已从海外仓资料带出' : '该海外仓未维护,请手动填写'" /></el-form-item>
|
||||
<el-form-item label="海外仓联系电话" required><el-input v-model="form.warehouseContactPhone" :readonly="Boolean(selectedOperationWarehouse?.contactPhone)" :placeholder="selectedOperationWarehouse?.contactPhone ? '已从海外仓资料带出' : '该海外仓未维护,请手动填写'" /></el-form-item>
|
||||
<el-form-item label="客户参考号" class="return-form-wide"><el-input v-model="form.customerReferenceNo" placeholder="可填写客户侧参考号" /></el-form-item>
|
||||
<el-form-item label="备注" class="return-form-wide"><el-input v-model="form.remark" type="textarea" placeholder="填写退货说明(选填)" /></el-form-item>
|
||||
<el-form-item label="海外仓" required class="return-form-wide"><el-select v-model="form.overseasWarehouseId" filterable clearable :loading="operationWarehousesLoading" :disabled="Boolean(operationWarehousesError)" placeholder="" @change="selectOperationWarehouse"><el-option v-for="warehouse in operationWarehouses" :key="warehouse.id" :label="warehouseLabel(warehouse)" :value="String(warehouse.id)" /></el-select><div v-if="operationWarehousesError" class="warehouse-error">{{ operationWarehousesError }}</div></el-form-item>
|
||||
<el-form-item label="海外仓联系人" required><el-input v-model="form.warehouseContactName" :readonly="Boolean(selectedOperationWarehouse?.contactName)" placeholder="" /></el-form-item>
|
||||
<el-form-item label="海外仓联系电话" required><el-input v-model="form.warehouseContactPhone" :readonly="Boolean(selectedOperationWarehouse?.contactPhone)" placeholder="" /></el-form-item>
|
||||
<el-form-item label="客户参考号" class="return-form-wide"><el-input v-model="form.customerReferenceNo" placeholder="" /></el-form-item>
|
||||
<el-form-item label="备注" class="return-form-wide"><el-input v-model="form.remark" type="textarea" placeholder="" /></el-form-item>
|
||||
</el-form>
|
||||
<div class="items-title"><span>SKU 预报明细</span><el-button text type="primary" @click="addItem"><Plus :size="15" />添加 SKU</el-button></div>
|
||||
<div class="return-relabel-option"><span>是否换标</span><el-radio-group v-model="form.needRelabel" aria-label="是否换标"><el-radio-button :value="false">不换标</el-radio-button><el-radio-button :value="true">需要换标</el-radio-button></el-radio-group></div>
|
||||
<div class="items-hint">客户只需填写预计退回的 SKU 和数量;快递单号由仓库签收时按实际包裹扫描绑定。</div>
|
||||
<el-table :data="form.items" row-key="id" size="small">
|
||||
<div class="return-relabel-option"><span>是否换标</span><el-radio-group v-model="form.needRelabel" aria-label="是否换标"><el-radio-button :value="false">不换标</el-radio-button><el-radio-button :value="true">需要换标</el-radio-button></el-radio-group></div>
<el-table :data="form.items" row-key="id" size="small">
|
||||
<el-table-column label="操作" width="80" fixed="left"><template #default="{ $index }"><el-button link type="danger" :disabled="form.items.length === 1" @click="removeItem($index)">删除</el-button></template></el-table-column>
|
||||
<el-table-column label="SKU" min-width="200"><template #default="{ row }"><div><el-input v-model="row.skuCode" :class="{ 'duplicate-sku-input': duplicateSkuIds.has(row.id) }" placeholder="填写 SKU" @blur="row.skuCode = row.skuCode.trim()" /><div v-if="duplicateSkuIds.has(row.id)" class="duplicate-sku-error">该 SKU 已在明细中填写</div></div></template></el-table-column>
|
||||
<el-table-column label="中文品名" min-width="260"><template #default="{ row }"><el-input v-model="row.productNameZh" placeholder="填写中文品名(选填)" /></template></el-table-column>
|
||||
<el-table-column label="预报数量" width="160"><template #default="{ row }"><el-input-number v-model="row.expectedQuantity" :min="1" controls-position="right" placeholder="数量" /></template></el-table-column>
|
||||
<el-table-column label="SKU" min-width="200"><template #default="{ row }"><div><el-input v-model="row.skuCode" :class="{ 'duplicate-sku-input': duplicateSkuIds.has(row.id) }" placeholder="" @blur="row.skuCode = row.skuCode.trim()" /><div v-if="duplicateSkuIds.has(row.id)" class="duplicate-sku-error">该 SKU 已在明细中填写</div></div></template></el-table-column>
|
||||
<el-table-column label="中文品名" min-width="260"><template #default="{ row }"><el-input v-model="row.productNameZh" placeholder="" /></template></el-table-column>
|
||||
<el-table-column label="预报数量" width="160"><template #default="{ row }"><el-input-number v-model="row.expectedQuantity" :min="1" controls-position="right" placeholder="" /></template></el-table-column>
|
||||
</el-table>
|
||||
<template #footer><el-button @click="showCreate=false; editingReturnNo=''">取消</el-button><el-button v-if="canCreate" type="primary" :loading="creating" @click="submit">{{ editingReturnNo ? '保存修改' : '提交预报' }}</el-button></template>
|
||||
</el-dialog>
|
||||
@@ -486,11 +482,11 @@ onBeforeUnmount(clearPhotoUrls)
|
||||
.return-query :deep(.query-toolbar__actions) { margin:0; padding:0; border:0; align-self:end; }
|
||||
@media(max-width:1200px) { .return-query.query-toolbar--grid { grid-template-columns:repeat(2,minmax(0,320px)); } }
|
||||
.relabel-suborder { display: flex; align-items: center; gap: 8px; min-height: 30px; white-space: nowrap; }
|
||||
.return-orders-page { display:grid; gap:12px; } .return-create-form { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); column-gap:20px; } .return-form-wide { grid-column:1 / -1; } .items-title { display:flex; justify-content:space-between; align-items:center; margin:16px 0 4px; font-weight:600; } .items-hint { margin-bottom:10px; color:var(--el-text-color-secondary); font-size:12px; } h3 { margin:24px 0 10px; font-size:16px; } h4 { margin:16px 0 8px; font-size:14px; } .remark { white-space:pre-wrap; } .warehouse-error, .duplicate-sku-error { margin-top:6px; color:var(--el-color-danger); font-size:12px; } :deep(.duplicate-sku-input .el-input__wrapper) { box-shadow:0 0 0 1px var(--el-color-danger) inset; } :deep(.el-dialog .el-select) { width:100%; }
|
||||
.return-create-surface { width:calc(100% - 24px); max-width:1120px; min-width:0; margin:0 0 0 24px; justify-self:start; box-sizing:border-box; overflow:hidden; border:1px solid var(--client-border); border-radius:var(--client-radius); background:#fff; }
|
||||
.return-orders-page { display:grid; gap:12px; } .return-create-form { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); column-gap:20px; } .return-form-wide { grid-column:1 / -1; } .items-title { display:flex; justify-content:space-between; align-items:center; margin:16px 0 4px; font-weight:600; } h3 { margin:24px 0 10px; font-size:16px; } h4 { margin:16px 0 8px; font-size:14px; } .remark { white-space:pre-wrap; } .warehouse-error, .duplicate-sku-error { margin-top:6px; color:var(--el-color-danger); font-size:12px; } :deep(.duplicate-sku-input .el-input__wrapper) { box-shadow:0 0 0 1px var(--el-color-danger) inset; } :deep(.el-dialog .el-select) { width:100%; }
|
||||
.return-create-surface { width:100%; min-width:0; min-height:calc(100dvh - 198px); margin:0; box-sizing:border-box; overflow:hidden; border:1px solid var(--client-border); border-radius:var(--client-radius); background:#fff; }
|
||||
.return-create-header, .return-create-body, .return-create-actions { width:calc(100% - 24px); max-width:1118px; margin-left:24px; box-sizing:border-box; }
|
||||
.return-create-header { display:flex; min-height:68px; align-items:center; justify-content:space-between; gap:20px; padding:12px 20px; border-bottom:1px solid var(--client-border); }
|
||||
.return-create-header h2 { margin:0; color:#1f2d45; font-size:20px; }
|
||||
.return-create-header p { margin:4px 0 0; color:#7c899c; font-size:12px; }
|
||||
.return-create-body { padding:18px 20px 14px; }
|
||||
.return-create-body :deep(.el-select), .return-create-body :deep(.el-input-number) { width:100%; }
|
||||
.return-create-body .return-create-form { max-width:900px; column-gap:24px; }
|
||||
|
||||
@@ -12,6 +12,7 @@ import { prepareTransferCartonLabels } from '../utils/transferCartonPrinting'
|
||||
import CompletionConfirmationDialog from '../components/CompletionConfirmationDialog.vue'
|
||||
import ReviewRejectionNotice from '../components/ReviewRejectionNotice.vue'
|
||||
import { returnRelabelCandidate } from '../utils/returnRelabelCandidates'
|
||||
import { groupDiscardDocuments } from '../utils/discardDocuments'
|
||||
import {
|
||||
createDiscard, createRelabel, createTransfer, getDiscard, getRelabel, getTransfer, resubmitRelabel,
|
||||
getTransferCartonLabel, submitTransfer, updateTransfer, increaseTransferForecastCartons, getTransferSourceCartons, type TransferSourceCarton,
|
||||
@@ -73,8 +74,8 @@ async function refreshAfterCompletion(no: string) {
|
||||
}
|
||||
const canCancelDiscard = computed(() => auth.hasPermission('merchant:overseas-warehouse:discard:cancel'))
|
||||
const configs: Record<ServiceType, ServiceConfig> = {
|
||||
labeling: { title: '贴标申请', createTitle: '新增贴标申请', numberLabel: '贴标申请单号', emptyText: '暂无贴标申请', columns: ['操作', '贴标申请单号', '关联业务单号', '海外操作仓', 'SKU 数(种)', '总箱数/包裹', '换标类型', '状态', '审核时间', '贴标完成时间', '贴标出库时间', '创建时间'] },
|
||||
transfer: { title: '仓储转运单', createTitle: '新增仓储转运单', numberLabel: '转运单号', emptyText: '暂无仓储转运订单', columns: ['操作', '转运单号', '预计箱数', '海外仓', 'FBA 目的仓', '入仓方式', '是否换标', '换标单号', '换标状态', '状态', '审核时间', '测量时间', '出库时间', '创建时间'] },
|
||||
labeling: { title: '贴标申请', createTitle: '新增贴标申请', numberLabel: '贴标申请单号', emptyText: '暂无贴标申请', columns: ['操作', '贴标申请单号', '关联业务单号', '海外操作仓', 'SKU 数(种)', '总箱数/包裹', '换标类型', '状态', '创建时间'] },
|
||||
transfer: { title: '仓储转运单', createTitle: '新增仓储转运单', numberLabel: '转运单号', emptyText: '暂无仓储转运订单', columns: ['操作', '转运单号', '预计箱数', '海外仓', 'FBA 目的仓', '入仓方式', '是否换标', '换标单号', '换标状态', '状态', '创建时间'] },
|
||||
disposal: { title: '货物废弃', createTitle: '新增货物废弃申请', numberLabel: '废弃申请单号', emptyText: '暂无货物废弃申请', columns: ['操作', '废弃申请单号', '废弃出库单号', '进仓编号', '操作仓', 'SKU 汇总', '废弃数量', '预估费用', '状态', '废弃完成时间', '创建时间'] },
|
||||
relabel: { title: '退货换标', createTitle: '新建退货换标单', numberLabel: '退货换标单号', emptyText: '暂无退货换标订单', columns: ['操作', '退货换标单号', '来源 FBA 仓', '目的 FBA 仓', 'SKU 汇总', '退件总数', '是否换标', '换标状态', '尾程状态', '审核时间', '测量时间', '出库时间', '创建时间'] },
|
||||
}
|
||||
@@ -259,21 +260,11 @@ const listQueryFields = computed<QueryField[]>(() => {
|
||||
if (props.service === 'transfer' || props.service === 'relabel') fields.push({ key: 'destination', label: '目的 FBA 仓', type: 'input', placeholder: '请输入仓代码' })
|
||||
fields.push({ key: 'status', label: '订单状态', type: 'select', options: statusOptions.value })
|
||||
fields.push({ key: 'createdDates', label: '创建时间', type: 'daterange' })
|
||||
if (props.service === 'labeling') fields.push(
|
||||
{ key: 'reviewDates', label: '审核时间', type: 'daterange' },
|
||||
{ key: 'completedDates', label: '贴标完成时间', type: 'daterange' },
|
||||
{ key: 'outboundDates', label: '贴标出库时间', type: 'daterange' },
|
||||
)
|
||||
if (props.service === 'relabel') fields.push(
|
||||
{ key: 'reviewDates', label: '审核时间', type: 'daterange' },
|
||||
{ key: 'measuredDates', label: '测量时间', type: 'daterange' },
|
||||
{ key: 'outboundDates', label: '出库时间', type: 'daterange' },
|
||||
)
|
||||
if (props.service === 'transfer') fields.push(
|
||||
{ key: 'reviewDates', label: '审核时间', type: 'daterange' },
|
||||
{ key: 'completedDates', label: '测量时间', type: 'daterange' },
|
||||
{ key: 'outboundDates', label: '出库时间', type: 'daterange' },
|
||||
)
|
||||
return fields
|
||||
})
|
||||
const listColumns = computed<DataTableColumn[]>(() => config.value.columns.map((label, index) => index === 0
|
||||
@@ -284,13 +275,9 @@ const listColumns = computed<DataTableColumn[]>(() => config.value.columns.map((
|
||||
const discardSelectedBoxKeys = ref<string[]>([])
|
||||
const discardBoxSearch = ref('')
|
||||
const discardCandidates = ref<import('../api/warehouseServices').DiscardCandidate[]>([])
|
||||
const discardSelectedCandidate = computed(() => discardCandidates.value.find(item => item.orderId === selectedDiscardOrderId.value))
|
||||
const discardSelectionSources = computed(() => {
|
||||
const current = discardSelectedCandidate.value
|
||||
if (!current) return []
|
||||
if (current.sourceType !== 'RETURN_CARTON') return [current]
|
||||
return discardCandidates.value.filter(item => item.sourceType === current.sourceType && item.inboundCode === current.inboundCode && item.overseasWarehouseId === current.overseasWarehouseId)
|
||||
})
|
||||
const discardDocuments = computed(() => groupDiscardDocuments(discardCandidates.value))
|
||||
const discardSelectedCandidate = computed(() => discardDocuments.value.find(item => item.orderId === selectedDiscardOrderId.value))
|
||||
const discardSelectionSources = computed(() => discardSelectedCandidate.value?.sources || [])
|
||||
const discardSelectableBoxes = computed(() => discardSelectionSources.value.flatMap(source => (source.cartons || []).map(box => ({ ...box, key: `${source.sourceType}:${box.cartonId}`, source }))))
|
||||
const discardVisibleBoxes = computed(() => discardSelectableBoxes.value.filter(box => !discardBoxSearch.value.trim() || box.cartonMark.includes(discardBoxSearch.value.trim()) || box.skus.some(sku => sku.skuCode.includes(discardBoxSearch.value.trim()))))
|
||||
function toggleDiscardBox(key: string, selected: boolean) {
|
||||
@@ -332,6 +319,7 @@ const availableBoxesCollapsed = ref(false)
|
||||
const availableSkusCollapsed = ref(false)
|
||||
const relabelCandidatesLoading = ref(false)
|
||||
const transferInventoryCandidates = ref<RelabelInventoryCandidate[]>([])
|
||||
const transferAvailableSourceCounts = ref<Record<string, number>>({})
|
||||
const selectedTransferInventory = ref('')
|
||||
const inventoryTransfer = computed(() => props.service === 'transfer' && Boolean(selectedTransferInventory.value))
|
||||
// 来源库存发往FBA时默认贴标,私人仓和外来货保留原有选择。
|
||||
@@ -991,17 +979,17 @@ function importSelectedBindingsToDetails() {
|
||||
function selectDiscardInbound(orderId: string) {
|
||||
discardSelectedBoxKeys.value = []
|
||||
discardBoxSearch.value = ''
|
||||
const candidate = discardCandidates.value.find((item) => item.orderId === orderId)
|
||||
const candidate = discardDocuments.value.find((item) => item.orderId === orderId)
|
||||
if (!candidate) { cargoRows.value = []; return }
|
||||
form.inboundNo = candidate.inboundCode || candidate.inboundOrderNo
|
||||
form.warehouse = candidate.overseasWarehouseId
|
||||
if (candidate.cartonSelectionSupported) { selectAllDiscardBoxes(true); return }
|
||||
cargoRows.value = candidate.skus.map((sku) => ({
|
||||
cargoRows.value = candidate.sources.flatMap(source => source.skus.map((sku) => ({
|
||||
id: ++cargoSequence, sku: sku.skuCode, name: sku.productName, quantity: sku.availablePackages,
|
||||
availableQuantity: sku.availablePackages, sourceOrderId: candidate.orderId, sourceType: candidate.sourceType,
|
||||
returnCartonId: candidate.returnCartonId, sourceInventoryPositionId: candidate.inventoryPositionId, palletNo: candidate.palletNo, cartonMark: candidate.cartonMark || '',
|
||||
availableQuantity: sku.availablePackages, sourceOrderId: source.orderId, sourceType: source.sourceType,
|
||||
returnCartonId: source.returnCartonId, sourceInventoryPositionId: source.inventoryPositionId, palletNo: source.palletNo, cartonMark: source.cartonMark || '',
|
||||
fnsku: '', shipmentNo: '', newSkuCode: '', shippingMark: '', customerCartonMarks: '', fbaCartonMark: '', destinationWarehouseId: '', remark: '',
|
||||
}))
|
||||
})))
|
||||
}
|
||||
function dateTime(value: string, end = false) { return value ? `${value}T${end ? '23:59:59' : '00:00:00'}` : undefined }
|
||||
function row(values: Array<string | number | undefined | null>) { return Object.fromEntries(values.map((value, index) => [String(index), value == null || value === '' ? '—' : String(value)])) }
|
||||
@@ -1131,7 +1119,7 @@ async function load() {
|
||||
total.value = Number.isFinite(Number(result.total)) ? Number(result.total) : result.records.length
|
||||
submittedRows.value = result.records.map((item) => {
|
||||
const destinationWarehouses = warehouseDisplay(item.overseasWarehouseId)
|
||||
return { ...row([item.relabelNo, (item.relatedBusinessOrderNos || []).join('、') || '—', destinationWarehouses, new Set(item.skuCodes.split(',').map(code => code.trim()).filter(Boolean)).size, item.totalPackageCount, (item.operationTypes || []).map(type => ({ CARTON_LABEL: '只换箱唛', SKU_LABEL: '只换 SKU', RETURN_BULK_SKU_LABEL: '只换 SKU', CARTON_AND_SKU: 'SKU 和箱唛都换' }[type] || type)).join('、') || '—', statusLabel(item.status), formatDateTime(item.reviewedAt), formatDateTime(item.relabelCompletedAt), formatDateTime(item.outboundAt), formatDateTime(item.createdAt)]), __status: item.status, __confirmedAt: item.customerConfirmedAt || '' }
|
||||
return { ...row([item.relabelNo, (item.relatedBusinessOrderNos || []).join('、') || '—', destinationWarehouses, new Set(item.skuCodes.split(',').map(code => code.trim()).filter(Boolean)).size, item.totalPackageCount, (item.operationTypes || []).map(type => ({ CARTON_LABEL: '只换箱唛', SKU_LABEL: '只换 SKU', RETURN_BULK_SKU_LABEL: '只换 SKU', CARTON_AND_SKU: 'SKU 和箱唛都换' }[type] || type)).join('、') || '—', statusLabel(item.status), formatDateTime(item.createdAt)]), __status: item.status, __confirmedAt: item.customerConfirmedAt || '' }
|
||||
})
|
||||
} else if (props.service === 'disposal') {
|
||||
const result = await listDiscards({ page: page.value, size: pageSize.value, discardNo: filters.number || undefined, inboundCode: filters.inboundNo || undefined, overseasWarehouseId: filters.warehouse || undefined, status: filters.status || undefined, createdFrom: dateTime(filters.start), createdTo: dateTime(filters.end, true), completedFrom: dateTime(filters.completedStart), completedTo: dateTime(filters.completedEnd, true) })
|
||||
@@ -1144,7 +1132,7 @@ async function load() {
|
||||
const operationWarehouse = warehouseDisplay(item.operatingWarehouseId)
|
||||
const relabelStatus = transferRelabelStatus(item)
|
||||
return props.service === 'transfer'
|
||||
? { ...row([item.transferNo, item.packageCount, operationWarehouse, item.fbaWarehouseCode, deliveryMethodLabel(item.deliveryMethod), item.needRelabel ? '是' : '否', item.relabelNo, relabelStatus.label, statusLabel(item.status), formatDateTime(item.reviewedAt), formatDateTime(item.measuredAt), formatDateTime(item.outboundAt), formatDateTime(item.createdAt)]), __status: item.status, __statusLabel: statusLabel(item.status), __relabelStatus: relabelStatus.code, __relabelStatusLabel: relabelStatus.label }
|
||||
? { ...row([item.transferNo, item.packageCount, operationWarehouse, item.fbaWarehouseCode, deliveryMethodLabel(item.deliveryMethod), item.needRelabel ? '是' : '否', item.relabelNo, relabelStatus.label, statusLabel(item.status), formatDateTime(item.createdAt)]), __status: item.status, __statusLabel: statusLabel(item.status), __relabelStatus: relabelStatus.code, __relabelStatusLabel: relabelStatus.label }
|
||||
: row([item.transferNo, '—', item.fbaWarehouseCode, '—', item.packageCount, '是', item.relabelNo ? '已生成' : '待生成', statusLabel(item.status), formatDateTime(item.reviewedAt), formatDateTime(item.measuredAt), formatDateTime(item.outboundAt), formatDateTime(item.createdAt)])
|
||||
})
|
||||
}
|
||||
@@ -1179,6 +1167,10 @@ function printForecastCartons() {
|
||||
}
|
||||
async function submit(action: 'draft' | 'submit' = 'submit') {
|
||||
if (saving.value) return
|
||||
if (props.service === 'transfer' && inventoryTransfer.value && action === 'draft') {
|
||||
ElMessage.warning('选择库存来源后不能保存草稿,请直接提交订单')
|
||||
return
|
||||
}
|
||||
if (transferRelabelRequired.value) form.relabel = true
|
||||
const primaryWarehouse = props.service === 'relabel' ? form.sourceWarehouse : form.warehouse
|
||||
const relabelRows = labelingMode.value === 'carton' ? cartonDetailRows.value : skuRelabelRows.value
|
||||
@@ -1475,6 +1467,7 @@ async function loadWarehouses() {
|
||||
function warehouseLabel(value: MerchantWarehouse) { return `${value.warehouseName} · ${value.warehouseCode}` }
|
||||
async function loadTransferInventoryCandidates(warehouseId: string) {
|
||||
transferInventoryCandidates.value = []
|
||||
transferAvailableSourceCounts.value = {}
|
||||
if (!warehouseId) return
|
||||
relabelCandidatesLoading.value = true
|
||||
try {
|
||||
@@ -1483,6 +1476,18 @@ async function loadTransferInventoryCandidates(warehouseId: string) {
|
||||
item.sourceType === 'RETURN_CARTON' || item.sourceType === 'RETURN_PARCEL'
|
||||
? Number(item.cartonAvailablePackages || 0) > 0
|
||||
: Number(item.availablePackages || 0) > 0)
|
||||
const sourceValues = [...new Set(transferInventoryCandidates.value.map(item => {
|
||||
const returnNo = String(item.inboundOrderNo || '').trim()
|
||||
return item.sourceType === 'RETURN_CARTON' || item.sourceType === 'RETURN_PARCEL'
|
||||
? (returnNo ? `RETURN_ORDER:${returnNo}` : '') : String(item.inboundCode || '')
|
||||
}).filter(Boolean))]
|
||||
const results = await Promise.all(sourceValues.map(async (value) => {
|
||||
try {
|
||||
const cartons = await getTransferSourceCartons(warehouseId, value)
|
||||
return [value, cartons.length] as const
|
||||
} catch { return [value, 0] as const }
|
||||
}))
|
||||
transferAvailableSourceCounts.value = Object.fromEntries(results)
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '当前海外仓库存来源加载失败')
|
||||
} finally { relabelCandidatesLoading.value = false }
|
||||
@@ -1544,7 +1549,7 @@ const transferInventoryOptions = computed(() => {
|
||||
: inventoryQuantity(item.availablePackages)
|
||||
const current = grouped.get(value)
|
||||
if (current) current.packages += packages
|
||||
else grouped.set(value, { value, label: value.startsWith('RETURN_ORDER:') ? `退货单|${returnNo}` : `${item.inboundOrderNo || value}|${value}`, packages })
|
||||
else if ((transferAvailableSourceCounts.value[value] ?? 1) > 0) grouped.set(value, { value, label: value.startsWith('RETURN_ORDER:') ? `退货单|${returnNo}` : `${item.inboundOrderNo || value}|${value}`, packages })
|
||||
}
|
||||
return [...grouped.values()]
|
||||
})
|
||||
@@ -1676,7 +1681,6 @@ function addReturnBoxBinding(source: RelabelInventoryCandidate) {
|
||||
<div class="warehouse-card warehouse-table-card">
|
||||
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="submittedRows" :columns="listColumns" :loading="loading" :total="total" :page-sizes="[15, 30, 50, 100]" :row-actions="listRowActions" :empty-text="config.emptyText">
|
||||
<template #toolbar-actions><el-button v-if="canCreate" type="primary" @click="openCreate"><Plus :size="16" />{{ props.service === 'relabel' ? '新建退货换标单' : props.service === 'transfer' ? '新增订单' : `新增${config.title}` }}</el-button></template>
|
||||
<template v-if="props.service === 'transfer' && submittedRows.some((item) => item.__status === 'WAIT_CUSTOMER_CONFIRMATION')" #before-table><div class="warehouse-list-notice">部分入库完成订单已有 PDA 实测数据,请在对应行确认测量数据。</div></template>
|
||||
<template #transferRelabelStatus="{ row }"><el-tag :type="transferRelabelStatusTagType(row.__relabelStatus)" effect="light">{{ row.__relabelStatusLabel }}</el-tag></template>
|
||||
<template #labelingType="{ row }"><span v-if="row['5'] === '—'">—</span><span v-else class="labeling-type-tags"><el-tag v-for="label in labelingTypeLabels(row['5'])" :key="label" :type="labelingTypeTagType(label)" size="small" effect="light">{{ label }}</el-tag></span></template>
|
||||
<template #labelingStatus="{ row }"><el-tag :type="labelingStatusTagType(row.__status)" size="small" effect="light">{{ statusLabel(row.__status) }}</el-tag></template>
|
||||
@@ -1699,50 +1703,50 @@ function addReturnBoxBinding(source: RelabelInventoryCandidate) {
|
||||
<span>{{ relabelInboundOptions.find(item => item.value === selectedRelabelInboundOrderId)?.inboundOrderNo || '未选择来源单据' }}</span>
|
||||
</div>
|
||||
<div v-show="props.service !== 'labeling' || !orderInfoCollapsed" class="warehouse-form-grid">
|
||||
<label v-if="props.service === 'disposal'" class="warehouse-disposal-field"><span><i>*</i>操作仓</span><el-select v-model="form.warehouse" filterable clearable :loading="fbaWarehousesLoading" :disabled="Boolean(operationWarehousesError)" placeholder="请选择海外仓" @change="selectDiscardWarehouse"><el-option v-for="warehouse in operationWarehouses" :key="warehouse.id" :label="warehouseLabel(warehouse)" :value="String(warehouse.id)" /></el-select><small v-if="operationWarehousesError" class="warehouse-hint">{{ operationWarehousesError }}</small><small v-else class="warehouse-hint">请先选择本次废弃货物所在的海外仓。</small></label>
|
||||
<label v-if="props.service === 'disposal'" class="warehouse-disposal-field"><span><i>*</i>选择入库单</span><el-select v-model="selectedDiscardOrderId" filterable clearable :loading="discardCandidatesLoading" :disabled="!form.warehouse" no-data-text="当前海外仓暂无可废弃库存" placeholder="请先选择操作仓,再选择有可用库存的入库单" @change="selectDiscardInbound"><el-option v-for="candidate in discardCandidates" :key="candidate.orderId" :label="candidate.sourceType === 'RETURN_CARTON' ? `退货|${candidate.inboundOrderNo || '—'}|${candidate.cartonMark || '—'}|可用 ${candidate.availablePackages}` : `${candidate.inboundOrderNo || candidate.inboundCode || '—'}|${candidate.inboundCode || '—'}|${candidate.palletNo || '散箱'}|可用 ${candidate.availablePackages} 箱`" :value="candidate.orderId" /></el-select><small class="warehouse-hint">显示当前操作仓内有可用库存的单据;带出箱唛后可勾选部分箱子,按整箱废弃。</small></label>
|
||||
<label v-if="props.service === 'disposal'" class="warehouse-disposal-field"><span><i>*</i>操作仓</span><el-select v-model="form.warehouse" filterable clearable :loading="fbaWarehousesLoading" :disabled="Boolean(operationWarehousesError)" placeholder="" @change="selectDiscardWarehouse"><el-option v-for="warehouse in operationWarehouses" :key="warehouse.id" :label="warehouseLabel(warehouse)" :value="String(warehouse.id)" /></el-select><small v-if="operationWarehousesError" class="warehouse-hint">{{ operationWarehousesError }}</small></label>
|
||||
<label v-if="props.service === 'disposal'" class="warehouse-disposal-field"><span><i>*</i>选择入库单</span><el-select v-model="selectedDiscardOrderId" filterable clearable :loading="discardCandidatesLoading" :disabled="!form.warehouse" no-data-text="当前海外仓暂无可废弃库存" placeholder="" @change="selectDiscardInbound"><el-option v-for="candidate in discardDocuments" :key="candidate.orderId" :label="`${candidate.sourceType === 'RETURN_CARTON' ? '退货|' : ''}${candidate.inboundOrderNo || candidate.inboundCode || '—'}|可用 ${candidate.availablePackages} 箱`" :value="candidate.orderId" /></el-select></label>
|
||||
<label v-if="props.service === 'relabel'"><span><i>*</i>进仓编号</span><el-input v-model="form.inboundNo" placeholder="请输入已有入仓编号" /></label>
|
||||
<label v-if="props.service === 'relabel'"><span><i>*</i>来源 FBA 仓库</span><el-select v-model="form.sourceWarehouse" :loading="fbaWarehousesLoading" :disabled="Boolean(fbaWarehousesError)" placeholder="请选择平台维护的 FBA 仓"><el-option v-for="warehouse in fbaWarehouses" :key="warehouse.id" :label="warehouseLabel(warehouse)" :value="warehouse.warehouseCode" /></el-select></label>
|
||||
<label v-else-if="props.service === 'disposal'" class="warehouse-disposal-field"><span><i>*</i>废弃原因</span><el-select v-model="form.reason" placeholder="请选择"><el-option label="客户退货" value="客户退货" /><el-option label="亚马逊移除" value="亚马逊移除" /><el-option label="残损不可售" value="残损不可售" /></el-select></label>
|
||||
<label v-else><span><i>*</i>{{ props.service === 'labeling' ? '海外操作仓(中转仓)' : props.service === 'transfer' ? '入库海外仓' : '操作仓' }}</span><el-select v-model="form.warehouse" :loading="fbaWarehousesLoading || operationWarehouseDetailLoading" :disabled="Boolean(operationWarehousesError)" clearable :placeholder="props.service === 'transfer' ? '请选择海外仓' : '请选择国外中转仓'" @change="props.service === 'labeling' ? selectOperationWarehouse() : props.service === 'transfer' && selectTransferWarehouse()"><el-option v-for="warehouse in operationWarehouses" :key="warehouse.id" :label="warehouseLabel(warehouse)" :value="String(warehouse.id)" /></el-select><small v-if="operationWarehousesError" class="warehouse-hint">{{ operationWarehousesError }}</small></label>
|
||||
<label v-if="props.service === 'labeling'"><span><i>*</i>选择入仓单或退货单</span><el-select v-model="selectedRelabelInboundOrderId" filterable clearable :filter-method="(value: string) => relabelInventorySearch = value" :loading="relabelCandidatesLoading" :disabled="!form.warehouse" no-data-text="暂无匹配的可选库存" placeholder="搜索单号或选择可用库存" @change="selectRelabelInventory"><el-option v-for="option in visibleRelabelInboundOptions.filter(item => !relabelInventorySearch.trim() || item.inboundOrderNo.toUpperCase().includes(relabelInventorySearch.trim().toUpperCase()) || item.inboundCode.toUpperCase().includes(relabelInventorySearch.trim().toUpperCase()))" :key="option.value" :label="relabelOptionLabel(option)" :value="option.value" :disabled="option.availablePackages <= 0" /></el-select></label>
|
||||
<label v-if="props.service === 'transfer'"><span>库存转运来源(可选)</span><el-select v-model="selectedTransferInventory" filterable clearable :loading="relabelCandidatesLoading" :disabled="!form.warehouse" no-data-text="当前海外仓暂无可用库存" placeholder="不选则按新货转运;选择后免 PDA 测量" @change="selectTransferInventory"><el-option v-for="option in transferInventoryOptions" :key="option.value" :label="`${option.label}|可用 ${option.packages} 箱`" :value="option.value" /></el-select><small class="warehouse-hint">库存转运可直接进入目的仓、换标和排车流程,PDA 可拆托、重打或同目的仓合托。</small></label>
|
||||
<label v-if="props.service === 'labeling' && labelingMode !== 'sku'"><span><i>*</i>FBA 目的仓</span><el-select v-model="form.destination" :loading="fbaWarehousesLoading" :disabled="Boolean(fbaWarehousesError)" filterable placeholder="请选择本张申请统一的 FBA 目的仓"><el-option v-for="warehouse in fbaWarehouses" :key="warehouse.id" :label="warehouseLabel(warehouse)" :value="warehouse.warehouseCode" /></el-select></label>
|
||||
<label v-else-if="props.service === 'disposal'" class="warehouse-disposal-field"><span><i>*</i>废弃原因</span><el-select v-model="form.reason" placeholder=""><el-option label="客户退货" value="客户退货" /><el-option label="亚马逊移除" value="亚马逊移除" /><el-option label="残损不可售" value="残损不可售" /></el-select></label>
|
||||
<label v-else><span><i>*</i>{{ props.service === 'labeling' ? '海外操作仓(中转仓)' : props.service === 'transfer' ? '入库海外仓' : '操作仓' }}</span><el-select v-model="form.warehouse" :loading="fbaWarehousesLoading || operationWarehouseDetailLoading" :disabled="Boolean(operationWarehousesError)" clearable placeholder="" @change="props.service === 'labeling' ? selectOperationWarehouse() : props.service === 'transfer' && selectTransferWarehouse()"><el-option v-for="warehouse in operationWarehouses" :key="warehouse.id" :label="warehouseLabel(warehouse)" :value="String(warehouse.id)" /></el-select><small v-if="operationWarehousesError" class="warehouse-hint">{{ operationWarehousesError }}</small></label>
|
||||
<label v-if="props.service === 'labeling'"><span><i>*</i>选择入仓单或退货单</span><el-select v-model="selectedRelabelInboundOrderId" filterable clearable :filter-method="(value: string) => relabelInventorySearch = value" :loading="relabelCandidatesLoading" :disabled="!form.warehouse" no-data-text="暂无匹配的可选库存" placeholder="" @change="selectRelabelInventory"><el-option v-for="option in visibleRelabelInboundOptions.filter(item => !relabelInventorySearch.trim() || item.inboundOrderNo.toUpperCase().includes(relabelInventorySearch.trim().toUpperCase()) || item.inboundCode.toUpperCase().includes(relabelInventorySearch.trim().toUpperCase()))" :key="option.value" :label="relabelOptionLabel(option)" :value="option.value" :disabled="option.availablePackages <= 0" /></el-select></label>
|
||||
<label v-if="props.service === 'transfer'"><span>库存转运来源(可选)</span><el-select v-model="selectedTransferInventory" filterable clearable :loading="relabelCandidatesLoading" :disabled="!form.warehouse" no-data-text="当前海外仓暂无可用库存" placeholder="" @change="selectTransferInventory"><el-option v-for="option in transferInventoryOptions" :key="option.value" :label="`${option.label}|可用 ${option.packages} 箱`" :value="option.value" /></el-select></label>
|
||||
<label v-if="props.service === 'labeling' && labelingMode !== 'sku'"><span><i>*</i>FBA 目的仓</span><el-select v-model="form.destination" :loading="fbaWarehousesLoading" :disabled="Boolean(fbaWarehousesError)" filterable placeholder=""><el-option v-for="warehouse in fbaWarehouses" :key="warehouse.id" :label="warehouseLabel(warehouse)" :value="warehouse.warehouseCode" /></el-select></label>
|
||||
<div v-if="props.service === 'transfer' && !inventoryTransfer" class="warehouse-choice-field warehouse-span-2"><span><i>*</i>入仓方式</span><el-radio-group v-model="form.deliveryMethod" @change="selectTransferInboundMethod"><el-radio-button value="WAREHOUSE_PICKUP">仓库上门提货</el-radio-button><el-radio-button value="CUSTOMER_DELIVERY">客户送货到仓</el-radio-button></el-radio-group></div>
|
||||
<label v-if="props.service === 'transfer' && !inventoryTransfer && form.deliveryMethod === 'WAREHOUSE_PICKUP'" class="pickup-address-field"><span><i>*</i>提货地址</span><el-input v-model="form.address" placeholder="请输入仓库上门提货的详细地址" /></label>
|
||||
<label v-if="props.service === 'transfer' && !inventoryTransfer && form.deliveryMethod === 'WAREHOUSE_PICKUP'"><span><i>*</i>提货联系人</span><el-input v-model="form.pickupContact" placeholder="请输入提货联系人" maxlength="128" /></label>
|
||||
<label v-if="props.service === 'transfer' && !inventoryTransfer && form.deliveryMethod === 'WAREHOUSE_PICKUP'"><span><i>*</i>提货联系人电话</span><el-input v-model="form.pickupPhone" placeholder="请输入提货联系人电话" maxlength="64" /></label>
|
||||
<label v-if="props.service === 'transfer' && !inventoryTransfer && form.deliveryMethod === 'WAREHOUSE_PICKUP'" class="pickup-address-field"><span><i>*</i>提货地址</span><el-input v-model="form.address" placeholder="" /></label>
|
||||
<label v-if="props.service === 'transfer' && !inventoryTransfer && form.deliveryMethod === 'WAREHOUSE_PICKUP'"><span><i>*</i>提货联系人</span><el-input v-model="form.pickupContact" placeholder="" maxlength="128" /></label>
|
||||
<label v-if="props.service === 'transfer' && !inventoryTransfer && form.deliveryMethod === 'WAREHOUSE_PICKUP'"><span><i>*</i>提货联系人电话</span><el-input v-model="form.pickupPhone" placeholder="" maxlength="64" /></label>
|
||||
<div v-if="props.service === 'transfer' && !inventoryTransfer && form.deliveryMethod === 'CUSTOMER_DELIVERY'" v-loading="operationWarehouseDetailLoading" class="warehouse-receiving-card warehouse-span-2">
|
||||
<div><span>海外仓收货信息</span><strong>{{ operationWarehouseAddress || '该海外仓暂未维护收货地址' }}</strong></div>
|
||||
<label class="warehouse-span-2"><span><i>*</i>送货地址</span><el-input v-model="form.address" placeholder="请输入实际送货地址" /></label>
|
||||
<label class="warehouse-span-2"><span><i>*</i>送货地址</span><el-input v-model="form.address" placeholder="" /></label>
|
||||
<div><span>收货联系人</span><strong>{{ selectedOperationWarehouse?.warehouse.contactName || '未维护' }}</strong></div>
|
||||
<div><span>联系电话</span><strong>{{ selectedOperationWarehouse?.warehouse.contactPhone || '未维护' }}</strong></div>
|
||||
</div>
|
||||
<label v-if="props.service === 'relabel'"><span><i>*</i>目的 FBA 仓</span><el-select v-model="form.destination" :loading="fbaWarehousesLoading" :disabled="Boolean(fbaWarehousesError)" placeholder="请选择平台维护的 FBA 仓"><el-option v-for="warehouse in fbaWarehouses" :key="warehouse.id" :label="warehouseLabel(warehouse)" :value="warehouse.warehouseCode" /></el-select></label>
|
||||
<label v-if="props.service === 'relabel'"><span><i>*</i>派送方式</span><el-select v-model="form.deliveryMethod" placeholder="请选择"><el-option label="卡车派送" value="卡车派送" /><el-option label="快递派送" value="快递派送" /></el-select></label>
|
||||
<label v-if="props.service === 'relabel'"><span><i>*</i>退货原因</span><el-select v-model="form.reason" placeholder="请选择"><el-option label="客户退货" value="客户退货" /><el-option label="亚马逊移除" value="亚马逊移除" /><el-option label="残损不可售" value="残损不可售" /></el-select></label>
|
||||
<label v-if="props.service === 'labeling'"><span><i>*</i>海外仓签收联系人<em v-if="form.warehouse && !form.contact" class="warehouse-unmaintained">未维护</em></span><el-input v-model="form.contact" placeholder="选择海外操作仓后自动带出" /></label>
|
||||
<label v-if="props.service === 'labeling'"><span><i>*</i>海外仓联系电话<em v-if="form.warehouse && !form.phone" class="warehouse-unmaintained">未维护</em></span><el-input v-model="form.phone" placeholder="选择海外操作仓后自动带出" /></label>
|
||||
<label v-if="props.service === 'labeling'"><span><i>*</i>贴标截止时间</span><el-date-picker v-model="form.labelDeadline" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择截止时间" /></label>
|
||||
<label v-if="props.service === 'labeling' && labelingMode !== 'sku'"><span><i>*</i>换标后派送方式</span><el-select v-model="form.deliveryMethod" placeholder="请选择派送方式"><el-option label="卡车派送" value="TRUCK" /><el-option label="快递派送" value="EXPRESS" /><el-option label="亚马逊自提" value="AMAZON_PICKUP" /><el-option label="客户自提" value="CUSTOMER_PICKUP" /></el-select></label>
|
||||
<label v-if="props.service === 'labeling'"><span><i>*</i>海外仓签收联系人<em v-if="form.warehouse && !form.contact" class="warehouse-unmaintained">未维护</em></span><el-input v-model="form.contact" placeholder="" /></label>
|
||||
<label v-if="props.service === 'labeling'"><span><i>*</i>海外仓联系电话<em v-if="form.warehouse && !form.phone" class="warehouse-unmaintained">未维护</em></span><el-input v-model="form.phone" placeholder="" /></label>
|
||||
<label v-if="props.service === 'labeling'"><span><i>*</i>贴标截止时间</span><el-date-picker v-model="form.labelDeadline" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="" /></label>
|
||||
<label v-if="props.service === 'labeling' && labelingMode !== 'sku'"><span><i>*</i>换标后派送方式</span><el-select v-model="form.deliveryMethod" placeholder=""><el-option label="卡车派送" value="TRUCK" /><el-option label="快递派送" value="EXPRESS" /><el-option label="亚马逊自提" value="AMAZON_PICKUP" /><el-option label="客户自提" value="CUSTOMER_PICKUP" /></el-select></label>
|
||||
<div v-if="props.service === 'labeling' && labelingMode !== 'sku' && form.deliveryMethod === 'EXPRESS'" class="warehouse-checkbox-field"><span>是否打印面单</span><el-checkbox v-model="form.relabel">需要仓库打印面单</el-checkbox></div>
|
||||
<label v-if="canManageRelabelFiles && props.service === 'labeling' && labelingMode !== 'sku' && form.deliveryMethod === 'EXPRESS' && form.relabel"><span><i>*</i>上传面单文件</span><input class="warehouse-native-file" type="file" accept="application/pdf,image/*,.pdf" @change="selectShippingLabel"></label>
|
||||
<label v-if="props.service === 'relabel'" class="warehouse-span-2"><span><i>*</i>详细派送地址</span><el-input v-model="form.address" placeholder="请输入派送地址" /></label>
|
||||
<label v-if="props.service === 'relabel'"><span><i>*</i>预估总体积(CBM)</span><el-input-number v-model="form.volumeCbm" :min="0" :precision="4" :step="0.0001" controls-position="right" placeholder="请输入预估总体积" /></label>
|
||||
<label v-if="(props.service === 'transfer' && !inventoryTransfer) || props.service === 'relabel'"><span><i>*</i>{{ props.service === 'transfer' ? '预计入仓时间' : '预计到达时间' }}</span><el-date-picker v-model="form.estimatedArrivalAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" :placeholder="props.service === 'transfer' ? '请选择预计入仓时间' : '请选择预计到达时间'" /></label>
|
||||
<label v-if="props.service === 'transfer'"><span>期望发货时间</span><el-date-picker v-model="form.estimatedShipAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="选填" /></label>
|
||||
<label v-if="(props.service === 'transfer' && !inventoryTransfer) || props.service === 'relabel'"><span><i>*</i>{{ props.service === 'transfer' ? '预计入仓时间' : '预计到达时间' }}</span><el-date-picker v-model="form.estimatedArrivalAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" :placeholder="props.service === 'transfer' ? '' : '请选择预计到达时间'" /></label>
|
||||
<label v-if="props.service === 'transfer'"><span>期望发货时间</span><el-date-picker v-model="form.estimatedShipAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="" /></label>
|
||||
<div v-if="props.service === 'transfer'" class="warehouse-choice-field warehouse-span-2"><span>目的去向</span><el-radio-group v-model="transferDestinationType" @change="selectTransferDestinationType"><el-radio-button v-if="!inventoryTransfer" value="NONE">暂未确定</el-radio-button><el-radio-button value="FBA">FBA 仓</el-radio-button><el-radio-button value="PRIVATE">私人仓</el-radio-button></el-radio-group></div>
|
||||
<label v-if="props.service === 'transfer' && transferDestinationType === 'FBA'"><span><i>*</i>FBA 目的仓</span><el-select v-model="form.destination" :loading="fbaWarehousesLoading" :disabled="Boolean(fbaWarehousesError)" filterable placeholder="请选择 FBA 仓"><el-option v-for="warehouse in fbaWarehouses" :key="warehouse.id" :label="warehouseLabel(warehouse)" :value="warehouse.warehouseCode" /></el-select></label>
|
||||
<label v-if="props.service === 'transfer' && transferDestinationType === 'FBA'"><span>FBA 货件编号</span><el-input v-model="form.fbaShipmentNo" placeholder="选填,可后续补充" /></label>
|
||||
<label v-if="props.service === 'transfer' && transferDestinationType === 'PRIVATE'"><span><i>*</i>私人仓地址</span><el-input v-model="form.privateDestinationAddress" maxlength="1000" placeholder="请输入私人仓完整地址" /></label>
|
||||
<label v-if="props.service === 'transfer' && transferDestinationType === 'PRIVATE'"><span><i>*</i>邮编</span><el-input v-model="form.privateDestinationPostalCode" maxlength="32" placeholder="请输入邮编" /></label>
|
||||
<label v-if="props.service === 'transfer' && transferDestinationType === 'PRIVATE'"><span><i>*</i>私人仓联系人</span><el-input v-model="form.privateDestinationContact" maxlength="128" placeholder="请输入联系人" /></label>
|
||||
<label v-if="props.service === 'transfer' && transferDestinationType === 'PRIVATE'"><span><i>*</i>联系电话</span><el-input v-model="form.privateDestinationPhone" maxlength="64" placeholder="请输入联系电话" /></label>
|
||||
<label v-if="props.service === 'transfer' && transferDestinationType === 'FBA'"><span><i>*</i>FBA 目的仓</span><el-select v-model="form.destination" :loading="fbaWarehousesLoading" :disabled="Boolean(fbaWarehousesError)" filterable placeholder=""><el-option v-for="warehouse in fbaWarehouses" :key="warehouse.id" :label="warehouseLabel(warehouse)" :value="warehouse.warehouseCode" /></el-select></label>
|
||||
<label v-if="props.service === 'transfer' && transferDestinationType === 'FBA'"><span>FBA 货件编号</span><el-input v-model="form.fbaShipmentNo" placeholder="" /></label>
|
||||
<label v-if="props.service === 'transfer' && transferDestinationType === 'PRIVATE'"><span><i>*</i>私人仓地址</span><el-input v-model="form.privateDestinationAddress" maxlength="1000" placeholder="" /></label>
|
||||
<label v-if="props.service === 'transfer' && transferDestinationType === 'PRIVATE'"><span><i>*</i>邮编</span><el-input v-model="form.privateDestinationPostalCode" maxlength="32" placeholder="" /></label>
|
||||
<label v-if="props.service === 'transfer' && transferDestinationType === 'PRIVATE'"><span><i>*</i>私人仓联系人</span><el-input v-model="form.privateDestinationContact" maxlength="128" placeholder="" /></label>
|
||||
<label v-if="props.service === 'transfer' && transferDestinationType === 'PRIVATE'"><span><i>*</i>联系电话</span><el-input v-model="form.privateDestinationPhone" maxlength="64" placeholder="" /></label>
|
||||
<label v-if="props.service === 'relabel'"><span><i>*</i>FBA 货件编号</span><el-input v-model="form.fbaShipmentNo" placeholder="请输入 FBA 货件编号" /></label>
|
||||
<label v-if="props.service === 'labeling'"><span>贴标要求</span><el-input v-model="form.requirement" placeholder="请输入贴标要求" /></label>
|
||||
<label v-else-if="props.service === 'relabel' || (props.service === 'transfer' && form.deliveryMethod === 'CUSTOMER_DELIVERY')"><span>物流跟踪号</span><el-input v-model="form.trackingNo" placeholder="请输入物流跟踪号(选填)" /></label>
|
||||
<label class="warehouse-span-2"><span>备注</span><el-input v-model="form.remark" type="textarea" :rows="2" placeholder="选填" /></label>
|
||||
<div v-if="props.service === 'transfer'" class="warehouse-choice-field warehouse-choice-field--relabel warehouse-span-2"><span><i>*</i>是否贴标</span><el-radio-group v-model="form.relabel"><el-radio-button v-if="!transferRelabelRequired" :value="false">不需要贴标</el-radio-button><el-radio-button :value="true">需要贴标</el-radio-button></el-radio-group><small>{{ form.relabel ? '已开启贴标,请在右侧填写贴标资料' : 'FBA 仓和私人仓均可选择是否贴标' }}</small></div>
|
||||
<label v-if="props.service === 'labeling'"><span>贴标要求</span><el-input v-model="form.requirement" placeholder="" /></label>
|
||||
<label v-else-if="props.service === 'relabel' || (props.service === 'transfer' && form.deliveryMethod === 'CUSTOMER_DELIVERY')"><span>物流跟踪号</span><el-input v-model="form.trackingNo" :placeholder="props.service === 'transfer' ? '' : '请输入物流跟踪号(选填)'" /></label>
|
||||
<label class="warehouse-span-2"><span>备注</span><el-input v-model="form.remark" type="textarea" :rows="2" :placeholder="props.service === 'relabel' ? '选填' : ''" /></label>
|
||||
<div v-if="props.service === 'transfer'" class="warehouse-choice-field warehouse-choice-field--relabel warehouse-span-2"><span><i>*</i>是否贴标</span><el-radio-group v-model="form.relabel"><el-radio-button v-if="!transferRelabelRequired" :value="false">不需要贴标</el-radio-button><el-radio-button :value="true">需要贴标</el-radio-button></el-radio-group></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="warehouse-card warehouse-form-section warehouse-transfer-cargo-card">
|
||||
@@ -1768,12 +1772,8 @@ function addReturnBoxBinding(source: RelabelInventoryCandidate) {
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<input ref="skuTemplateInput" class="warehouse-hidden-input" type="file" accept=".csv,text/csv" @change="importRelabelTemplate($event, 'sku')"><input ref="cartonTemplateInput" class="warehouse-hidden-input" type="file" accept=".csv,text/csv" @change="importRelabelTemplate($event, 'carton')"><input ref="mixedTemplateInput" class="warehouse-hidden-input" type="file" accept=".csv,text/csv" @change="importRelabelTemplate($event, 'mixed')"><input ref="cartonTotalCreateInput" class="warehouse-hidden-input" type="file" accept="application/pdf,.pdf" @change="selectCartonTotalFile"><input ref="skuTotalCreateInput" class="warehouse-hidden-input" type="file" accept="application/pdf,.pdf" @change="selectSkuTotalFile">
|
||||
<p v-if="isReturnRelabelSource" class="warehouse-list-notice">仓库识别码用于定位实物,不代表商品已换标。SKU 换标完成后扣减旧 SKU、增加新 SKU,包裹/箱总件数不变;已换标件数不可重复申请,剩余未换标件数可继续选择。客户确认前新 SKU 保持占用。</p>
|
||||
<div v-if="isReturnRelabelSource" class="warehouse-table-scroll">
|
||||
<el-input v-model="returnCartonQuery" clearable placeholder="按当前箱唛筛选贴标箱子" />
|
||||
<p class="warehouse-hint">来源退货单用于查找;贴标选择当前实物箱。跨来源单箱内数量为整箱内容,同 SKU 部分贴标请先现场分拣确认。</p>
|
||||
<template v-if="returnLooseCandidates.length">
|
||||
<input ref="skuTemplateInput" class="warehouse-hidden-input" type="file" accept=".csv,text/csv" @change="importRelabelTemplate($event, 'sku')"><input ref="cartonTemplateInput" class="warehouse-hidden-input" type="file" accept=".csv,text/csv" @change="importRelabelTemplate($event, 'carton')"><input ref="mixedTemplateInput" class="warehouse-hidden-input" type="file" accept=".csv,text/csv" @change="importRelabelTemplate($event, 'mixed')"><input ref="cartonTotalCreateInput" class="warehouse-hidden-input" type="file" accept="application/pdf,.pdf" @change="selectCartonTotalFile"><input ref="skuTotalCreateInput" class="warehouse-hidden-input" type="file" accept="application/pdf,.pdf" @change="selectSkuTotalFile">
<div v-if="isReturnRelabelSource" class="warehouse-table-scroll">
|
||||
<el-input v-model="returnCartonQuery" clearable placeholder="" />
<template v-if="returnLooseCandidates.length">
|
||||
<div class="warehouse-section-heading"><h3>可用包裹</h3><el-button text type="primary" native-type="button" :aria-expanded="!availableSkusCollapsed" aria-controls="return-available-skus" @click="availableSkusCollapsed = !availableSkusCollapsed">{{ availableSkusCollapsed ? '展开明细' : '收起明细' }}</el-button></div>
|
||||
<table v-show="!availableSkusCollapsed" id="return-available-skus" class="warehouse-edit-table"><thead><tr><th>包裹识别码</th><th>来源退货单</th><th>SKU</th><th>SKU 换标结果</th><th>剩余可换(件)</th><th>操作</th></tr></thead><tbody><template v-for="source in returnLooseCandidates" :key="source.returnCartonId"><tr v-for="sku in source.skuItems" :key="sku.skuCode"><td>{{ source.cartonMark }}</td><td>{{ source.inboundOrderNo }}</td><td>{{ sku.skuCode }}</td><td><div v-for="conversion in (source.skuConversions || []).filter(item => item.status !== 'REVERSED')" :key="`${conversion.relabelNo}-${conversion.oldSkuCode}-${conversion.newSkuCode}`">{{ conversion.oldSkuCode }} → {{ conversion.newSkuCode }}:已换 {{ conversion.quantity }} 件{{ conversion.status === 'APPLIED' ? '(待确认)' : '' }}</div><span v-if="!source.skuConversions?.some(item => item.status !== 'REVERSED')">尚未换标</span></td><td>{{ sku.quantity }}</td><td><el-button size="small" :disabled="inventoryQuantity(source.availablePackages) <= 0" @click="labelingMode === 'sku' ? addReturnSkuBinding(source, sku.skuCode) : addReturnBoxBinding(source)">添加贴标</el-button></td></tr></template><tr v-if="!returnLooseCandidates.length"><td colspan="6">暂无可用包裹库存</td></tr></tbody></table>
|
||||
</template>
|
||||
@@ -1791,7 +1791,7 @@ function addReturnBoxBinding(source: RelabelInventoryCandidate) {
|
||||
</el-radio-group>
|
||||
</div>
|
||||
<div v-if="importedRelabelBindings.length" class="warehouse-import-preview">
|
||||
<div class="warehouse-section-heading"><div><strong>待导入绑定关系</strong><p>可只勾选本次需要换标的部分,也可以全选后一次导入。</p></div><div><el-checkbox :model-value="importedRelabelBindings.every((item) => item.selected)" @change="selectAllImportedBindings(Boolean($event))">全选</el-checkbox><el-button type="primary" size="small" @click="importSelectedBindingsToDetails">导入到明细</el-button></div></div>
|
||||
<div class="warehouse-section-heading"><div><strong>待导入绑定关系</strong></div><div><el-checkbox :model-value="importedRelabelBindings.every((item) => item.selected)" @change="selectAllImportedBindings(Boolean($event))">全选</el-checkbox><el-button type="primary" size="small" @click="importSelectedBindingsToDetails">导入到明细</el-button></div></div>
|
||||
<div class="warehouse-table-scroll"><table class="warehouse-binding-preview-table"><thead><tr><th>选择</th><th>关系类型</th><th>仓库唛头</th><th>旧 SKU</th><th>新 SKU</th><th>数量</th><th>新 FBA 箱唛</th></tr></thead><tbody><tr v-for="item in importedRelabelBindings" :key="item.id"><td><el-checkbox v-model="item.selected" /></td><td>{{ item.templateType === 'mixed' ? 'SKU 混箱换标' : item.templateType === 'carton' ? '箱号关联' : 'SKU 关联' }}</td><td>{{ item.cartonMark }}</td><td>{{ item.originalSku || '—' }}</td><td>{{ item.newSku || '—' }}</td><td>{{ item.quantity }}</td><td>{{ item.fbaCartonMark || '—' }}</td></tr></tbody></table></div>
|
||||
</div>
|
||||
<div class="warehouse-section-heading warehouse-tab-actions">
|
||||
@@ -1799,14 +1799,14 @@ function addReturnBoxBinding(source: RelabelInventoryCandidate) {
|
||||
<div v-if="labelingMode === 'carton'"><el-button type="primary" size="small" @click="addRelabelCargoRow"><Plus :size="15" />手动添加行</el-button><el-button size="small" @click="clearRelabelRows">清空</el-button></div>
|
||||
<div v-else><el-button type="primary" size="small" @click="addSkuRelabelRow"><Plus :size="15" />添加 SKU 明细</el-button><el-button size="small" @click="skuRelabelRows = []">清空</el-button></div>
|
||||
</div>
|
||||
<div v-if="labelingMode === 'carton'" class="warehouse-table-scroll"><table class="warehouse-edit-table warehouse-label-table"><thead><tr><th>商品 SKU</th><th>中文品名 *</th><th>FBA 货件编号 *</th><th>箱数</th><th>仓库唛头 *</th><th>新 FBA 箱唛 *</th><th>备注</th><th>操作</th></tr></thead><tbody><tr v-for="row in cartonDetailRows" :key="row.id"><td><el-input v-model="row.sku" placeholder="选填" /></td><td><el-input v-model="row.name" placeholder="请输入中文品名" /></td><td><el-input v-model="row.shipmentNo" placeholder="请输入FBA货件编号" /></td><td><strong>1 箱</strong></td><td><el-input v-model="row.cartonMark" :readonly="isReturnRelabelSource" placeholder="请输入仓库唛头" /></td><td><el-input v-model="row.fbaCartonMark" placeholder="请输入新的 FBA 箱唛" /></td><td><el-input v-model="row.remark" type="textarea" :rows="2" maxlength="1000" show-word-limit placeholder="请输入明细备注" /></td><td><button type="button" class="warehouse-delete" title="移出本次明细" @click="removeCargoRow(row.id)"><Trash2 :size="16" />移出</button></td></tr><tr v-if="cartonDetailRows.length === 0"><td colspan="8"><div class="warehouse-empty">暂无本次换标明细,请上传关系后导入或手动添加</div></td></tr></tbody></table></div>
|
||||
<div v-else class="warehouse-table-scroll"><table class="warehouse-edit-table warehouse-sku-label-table"><thead><tr><th>仓库唛头 *</th><th>旧 SKU *</th><th>新 SKU *</th><th>换标数量(件)*</th><th v-if="labelingMode === 'carton_and_sku'">新 FBA 箱唛 *</th><th v-if="labelingMode === 'carton_and_sku'">FBA 货件编号 *</th><th>备注</th><th>操作</th></tr></thead><tbody><tr v-for="row in skuRelabelRows" :key="row.id"><td><el-input v-model="row.cartonMark" :readonly="isReturnRelabelSource" placeholder="请输入仓库唛头" @change="applyExistingCartonBinding(row)" /></td><td><el-input v-model="row.originalSku" :readonly="isReturnRelabelSource" placeholder="请输入旧 SKU" @change="applyExistingSkuBinding(row)" /></td><td><el-input v-model="row.newSku" placeholder="请输入新 SKU" /></td><td><el-input-number v-model="row.quantity" :min="1" :precision="0" controls-position="right" placeholder="请输入件数" /></td><td v-if="labelingMode === 'carton_and_sku'"><el-input v-model="row.fbaCartonMark" placeholder="请输入新的 FBA 箱唛" /></td><td v-if="labelingMode === 'carton_and_sku'"><el-input v-model="row.shipmentNo" placeholder="请输入 FBA 货件编号" /></td><td><el-input v-model="row.remark" placeholder="请输入备注" /></td><td><button type="button" class="warehouse-delete" title="删除" @click="removeSkuRelabelRow(row.id)"><Trash2 :size="16" />删除</button></td></tr><tr v-if="skuRelabelRows.length === 0"><td :colspan="labelingMode === 'carton_and_sku' ? 8 : 6"><div class="warehouse-empty">暂无绑定明细,请点击“添加 SKU 明细”</div></td></tr></tbody></table></div>
|
||||
<div v-if="labelingMode === 'carton'" class="warehouse-table-scroll"><table class="warehouse-edit-table warehouse-label-table"><thead><tr><th>商品 SKU</th><th>中文品名 *</th><th>FBA 货件编号 *</th><th>箱数</th><th>仓库唛头 *</th><th>新 FBA 箱唛 *</th><th>备注</th><th>操作</th></tr></thead><tbody><tr v-for="row in cartonDetailRows" :key="row.id"><td><el-input v-model="row.sku" placeholder="" /></td><td><el-input v-model="row.name" placeholder="" /></td><td><el-input v-model="row.shipmentNo" placeholder="" /></td><td><strong>1 箱</strong></td><td><el-input v-model="row.cartonMark" :readonly="isReturnRelabelSource" placeholder="" /></td><td><el-input v-model="row.fbaCartonMark" placeholder="" /></td><td><el-input v-model="row.remark" type="textarea" :rows="2" maxlength="1000" show-word-limit placeholder="" /></td><td><button type="button" class="warehouse-delete" title="移出本次明细" @click="removeCargoRow(row.id)"><Trash2 :size="16" />移出</button></td></tr><tr v-if="cartonDetailRows.length === 0"><td colspan="8"><div class="warehouse-empty">暂无绑定明细</div></td></tr></tbody></table></div>
|
||||
<div v-else class="warehouse-table-scroll"><table class="warehouse-edit-table warehouse-sku-label-table"><thead><tr><th>仓库唛头 *</th><th>旧 SKU *</th><th>新 SKU *</th><th>换标数量(件)*</th><th v-if="labelingMode === 'carton_and_sku'">新 FBA 箱唛 *</th><th v-if="labelingMode === 'carton_and_sku'">FBA 货件编号 *</th><th>备注</th><th>操作</th></tr></thead><tbody><tr v-for="row in skuRelabelRows" :key="row.id"><td><el-input v-model="row.cartonMark" :readonly="isReturnRelabelSource" placeholder="" @change="applyExistingCartonBinding(row)" /></td><td><el-input v-model="row.originalSku" :readonly="isReturnRelabelSource" placeholder="" @change="applyExistingSkuBinding(row)" /></td><td><el-input v-model="row.newSku" placeholder="" /></td><td><el-input-number v-model="row.quantity" :min="1" :precision="0" controls-position="right" placeholder="" /></td><td v-if="labelingMode === 'carton_and_sku'"><el-input v-model="row.fbaCartonMark" placeholder="" /></td><td v-if="labelingMode === 'carton_and_sku'"><el-input v-model="row.shipmentNo" placeholder="" /></td><td><el-input v-model="row.remark" placeholder="" /></td><td><button type="button" class="warehouse-delete" title="删除" @click="removeSkuRelabelRow(row.id)"><Trash2 :size="16" />删除</button></td></tr><tr v-if="skuRelabelRows.length === 0"><td :colspan="labelingMode === 'carton_and_sku' ? 8 : 6"><div class="warehouse-empty">暂无绑定明细</div></td></tr></tbody></table></div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<section v-if="props.service === 'disposal' && discardSelectedCandidate" class="warehouse-discard-boxes">
|
||||
<div class="warehouse-section-heading"><div><h3>选择废弃箱子</h3><small class="warehouse-hint">选中箱子整体废弃,箱内所有 SKU 一起出库;未选箱子保留库存。</small></div><div v-if="discardSelectedCandidate.cartonSelectionSupported" class="warehouse-heading-actions"><el-button size="small" @click="selectAllDiscardBoxes(true)">全选</el-button><el-button size="small" @click="selectAllDiscardBoxes(false)">清空选择</el-button></div></div>
|
||||
<div class="warehouse-section-heading"><div><h3>选择废弃箱子</h3></div><div v-if="discardSelectedCandidate.cartonSelectionSupported" class="warehouse-heading-actions"><el-button size="small" @click="selectAllDiscardBoxes(true)">全选</el-button><el-button size="small" @click="selectAllDiscardBoxes(false)">清空选择</el-button></div></div>
|
||||
<template v-if="discardSelectedCandidate.cartonSelectionSupported">
|
||||
<div class="warehouse-discard-box-toolbar"><el-input v-model="discardBoxSearch" clearable placeholder="搜索箱唛或 SKU" /><strong>已选 {{ discardSelectedBoxKeys.length }} / {{ discardSelectableBoxes.length }} 箱</strong></div>
|
||||
<div class="warehouse-discard-box-toolbar"><el-input v-model="discardBoxSearch" clearable placeholder="" /><strong>已选 {{ discardSelectedBoxKeys.length }} / {{ discardSelectableBoxes.length }} 箱</strong></div>
|
||||
<div class="warehouse-table-scroll"><table class="warehouse-edit-table"><thead><tr><th>选择</th><th>箱唛</th><th>所属托盘</th><th>箱内 SKU</th><th>中文品名</th></tr></thead><tbody><tr v-for="box in discardVisibleBoxes" :key="box.key"><td><el-checkbox :model-value="discardSelectedBoxKeys.includes(box.key)" :aria-label="`选择废弃箱子 ${box.cartonMark}`" @change="toggleDiscardBox(box.key, Boolean($event))" /></td><td><strong>{{ box.cartonMark }}</strong></td><td>{{ box.palletNo || '散箱' }}</td><td>{{ box.skus.map(sku => sku.skuCode).join('、') }}</td><td>{{ box.skus.map(sku => sku.productName || '—').join('、') }}</td></tr><tr v-if="!discardVisibleBoxes.length"><td colspan="5"><div class="warehouse-empty">暂无匹配箱唛</div></td></tr></tbody></table></div>
|
||||
<small v-if="!discardSelectedBoxKeys.length" class="warehouse-hint">请至少选择一个箱子。</small>
|
||||
</template>
|
||||
@@ -1822,12 +1822,12 @@ function addReturnBoxBinding(source: RelabelInventoryCandidate) {
|
||||
<div v-if="!transferSourceLoading && !transferSourceError && !transferSourceCartons.length" class="warehouse-table-scroll"><table class="warehouse-edit-table"><thead><tr><th>商品 SKU</th><th>中文品名</th><th>库存箱数</th><th>原目的仓</th></tr></thead><tbody><tr v-for="row in cargoRows" :key="row.id"><td>{{ row.sku || '—' }}</td><td>{{ row.name || '—' }}</td><td>{{ row.expectedPackageCount }}</td><td>{{ transferInventoryCandidates.filter(item => item.inboundCode === selectedTransferInventory && item.skuCode === row.sku).map(item => item.destinationFbaWarehouseCode || item.destinationFbaWarehouseName).filter(Boolean).join(' / ') || '未指定' }}</td></tr></tbody></table></div>
|
||||
</section>
|
||||
<div v-if="!inventoryTransfer && (props.service !== 'disposal' || !discardSelectedCandidate?.cartonSelectionSupported)" class="warehouse-section-heading" :class="{ 'warehouse-step-heading': props.service === 'transfer' }"><div><h3><span v-if="props.service === 'transfer'" class="warehouse-step-number">1</span>货物明细</h3></div><div v-if="props.service !== 'disposal'" class="warehouse-heading-actions"><el-button v-if="props.service === 'transfer'" size="small" :loading="saving" @click="printForecastCartons"><Printer :size="15" />打印唛头</el-button><el-button type="primary" size="small" @click="addCargoRow"><Plus :size="15" />添加行</el-button><el-button size="small" @click="cargoRows = []">清空</el-button></div></div>
|
||||
<div v-if="!inventoryTransfer && (props.service !== 'disposal' || !discardSelectedCandidate?.cartonSelectionSupported)" class="warehouse-table-scroll"><table class="warehouse-edit-table"><thead><tr><th>商品 SKU{{ props.service === 'transfer' ? '' : ' *' }}</th><th>中文品名{{ props.service === 'transfer' ? '' : ' *' }}</th><th v-if="props.service === 'disposal'">可用箱数</th><th v-if="props.service === 'relabel'">FNSKU / 箱唛</th><th v-if="props.service === 'transfer'">SKU 数量(件)*</th><th>{{ props.service === 'disposal' ? '本次废弃箱数' : '预计箱数' }} *</th><th v-if="props.service !== 'disposal'">操作</th></tr></thead><tbody><tr v-for="row in cargoRows" :key="row.id"><td><el-input v-model="row.sku" :readonly="props.service === 'disposal'" :placeholder="props.service === 'transfer' ? '选填' : '请输入商品 SKU'" /></td><td><el-input v-model="row.name" :readonly="props.service === 'disposal'" :placeholder="props.service === 'transfer' ? '选填' : '请输入中文品名'" /></td><td v-if="props.service === 'disposal'"><strong>{{ row.availableQuantity }} 箱</strong></td><td v-if="props.service === 'relabel'"><el-input v-model="row.shippingMark" placeholder="请输入FNSKU或箱唛" /></td><td v-if="props.service === 'transfer'"><el-input-number v-model="row.quantity" :min="1" :precision="0" controls-position="right" placeholder="请输入件数" /></td><td v-if="props.service === 'transfer'"><el-input-number v-model="row.expectedPackageCount" :min="1" :precision="0" controls-position="right" placeholder="请输入预计箱数" /></td><td v-else><el-input-number v-model="row.quantity" :disabled="props.service === 'disposal'" :min="props.service === 'disposal' ? 0 : 1" :max="row.availableQuantity" controls-position="right" placeholder="请输入箱数" /></td><td v-if="props.service !== 'disposal'"><button type="button" class="warehouse-delete" title="删除" @click="removeCargoRow(row.id)"><Trash2 :size="16" />删除</button></td></tr><tr v-if="cargoRows.length === 0"><td :colspan="props.service === 'disposal' ? 4 : props.service === 'relabel' || props.service === 'transfer' ? 5 : 4"><div class="warehouse-empty">{{ props.service === 'disposal' ? '请先选择入库单,系统将自动带出 SKU 和可用箱数' : '暂无货物明细,请点击“添加行”' }}</div></td></tr></tbody></table></div>
|
||||
<div v-if="!inventoryTransfer && (props.service !== 'disposal' || !discardSelectedCandidate?.cartonSelectionSupported)" class="warehouse-table-scroll"><table class="warehouse-edit-table"><thead><tr><th>商品 SKU{{ props.service === 'transfer' ? '' : ' *' }}</th><th>中文品名{{ props.service === 'transfer' ? '' : ' *' }}</th><th v-if="props.service === 'disposal'">可用箱数</th><th v-if="props.service === 'relabel'">FNSKU / 箱唛</th><th v-if="props.service === 'transfer'">SKU 数量(件)*</th><th>{{ props.service === 'disposal' ? '本次废弃箱数' : '预计箱数' }} *</th><th v-if="props.service !== 'disposal'">操作</th></tr></thead><tbody><tr v-for="row in cargoRows" :key="row.id"><td><el-input v-model="row.sku" :readonly="props.service === 'disposal'" :placeholder="props.service === 'relabel' ? '请输入商品 SKU' : ''" /></td><td><el-input v-model="row.name" :readonly="props.service === 'disposal'" :placeholder="props.service === 'relabel' ? '请输入中文品名' : ''" /></td><td v-if="props.service === 'disposal'"><strong>{{ row.availableQuantity }} 箱</strong></td><td v-if="props.service === 'relabel'"><el-input v-model="row.shippingMark" placeholder="请输入FNSKU或箱唛" /></td><td v-if="props.service === 'transfer'"><el-input-number v-model="row.quantity" :min="1" :precision="0" controls-position="right" placeholder="" /></td><td v-if="props.service === 'transfer'"><el-input-number v-model="row.expectedPackageCount" :min="1" :precision="0" controls-position="right" placeholder="" /></td><td v-else><el-input-number v-model="row.quantity" :disabled="props.service === 'disposal'" :min="props.service === 'disposal' ? 0 : 1" :max="row.availableQuantity" controls-position="right" :placeholder="props.service === 'relabel' ? '请输入箱数' : ''" /></td><td v-if="props.service !== 'disposal'"><button type="button" class="warehouse-delete" title="删除" @click="removeCargoRow(row.id)"><Trash2 :size="16" />删除</button></td></tr><tr v-if="cargoRows.length === 0"><td :colspan="props.service === 'disposal' ? 4 : props.service === 'relabel' || props.service === 'transfer' ? 5 : 4"><div class="warehouse-empty">暂无货物明细</div></td></tr></tbody></table></div>
|
||||
</template>
|
||||
</div>
|
||||
<div v-if="props.service === 'transfer' && form.relabel" class="warehouse-card warehouse-form-section warehouse-transfer-relabel">
|
||||
<div class="warehouse-section-heading warehouse-labeling-heading warehouse-step-heading"><div><h3><span class="warehouse-step-number">2</span>换标申请</h3></div></div>
|
||||
<div class="warehouse-form-grid warehouse-transfer-relabel-fields"><label><span><i>*</i>换标截止时间</span><el-date-picker v-model="form.labelDeadline" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择截止时间" /></label><label><span><i>*</i>换标联系人</span><el-input v-model="form.relabelContact" placeholder="选择海外仓后自动带出,也可手动填写" maxlength="128" /></label><label><span><i>*</i>换标联系人电话</span><el-input v-model="form.relabelPhone" placeholder="选择海外仓后自动带出,也可手动填写" maxlength="64" /></label><label v-if="labelingMode !== 'sku'"><span><i>*</i>换标后派送方式</span><el-select v-model="form.relabelDeliveryMethod" placeholder="请选择派送方式"><el-option label="卡车派送" value="TRUCK" /><el-option label="快递派送" value="EXPRESS" /><el-option label="亚马逊自提" value="AMAZON_PICKUP" /><el-option label="客户自提" value="CUSTOMER_PICKUP" /></el-select></label><label class="warehouse-span-2"><span><i>*</i>整票换标货物目的 FBA 仓</span><el-select v-model="transferRelabelDefaultDestination" :loading="fbaWarehousesLoading" :disabled="Boolean(fbaWarehousesError)" filterable placeholder="请选择整票货物的目的仓"><el-option v-for="warehouse in fbaWarehouses" :key="warehouse.id" :label="warehouseLabel(warehouse)" :value="warehouse.warehouseCode" /></el-select><small class="warehouse-hint">本票所有换标货物统一前往该目的仓,暂不支持一票多个目的仓。</small></label><label><span>FBA 货件编号</span><el-input v-model="form.fbaShipmentNo" placeholder="请输入 FBA 货件编号(选填)" /></label><label class="warehouse-span-2"><span>贴标要求</span><el-input v-model="form.requirement" type="textarea" :rows="2" maxlength="1000" show-word-limit placeholder="请输入贴标要求(选填)" /></label></div>
|
||||
<div class="warehouse-form-grid warehouse-transfer-relabel-fields"><label><span><i>*</i>换标截止时间</span><el-date-picker v-model="form.labelDeadline" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="" /></label><label><span><i>*</i>换标联系人</span><el-input v-model="form.relabelContact" placeholder="" maxlength="128" /></label><label><span><i>*</i>换标联系人电话</span><el-input v-model="form.relabelPhone" placeholder="" maxlength="64" /></label><label v-if="labelingMode !== 'sku'"><span><i>*</i>换标后派送方式</span><el-select v-model="form.relabelDeliveryMethod" placeholder=""><el-option label="卡车派送" value="TRUCK" /><el-option label="快递派送" value="EXPRESS" /><el-option label="亚马逊自提" value="AMAZON_PICKUP" /><el-option label="客户自提" value="CUSTOMER_PICKUP" /></el-select></label><label class="warehouse-span-2"><span><i>*</i>整票换标货物目的 FBA 仓</span><el-select v-model="transferRelabelDefaultDestination" :loading="fbaWarehousesLoading" :disabled="Boolean(fbaWarehousesError)" filterable placeholder=""><el-option v-for="warehouse in fbaWarehouses" :key="warehouse.id" :label="warehouseLabel(warehouse)" :value="warehouse.warehouseCode" /></el-select></label><label><span>FBA 货件编号</span><el-input v-model="form.fbaShipmentNo" placeholder="" /></label><label class="warehouse-span-2"><span>贴标要求</span><el-input v-model="form.requirement" type="textarea" :rows="2" maxlength="1000" show-word-limit placeholder="" /></label></div>
|
||||
<div class="warehouse-labeling-mode"><span>换标类型</span><el-radio-group v-model="labelingMode" aria-label="换标类型"><el-radio-button value="carton">只换箱唛</el-radio-button><el-radio-button value="sku">只换 SKU 标签</el-radio-button><el-radio-button value="carton_and_sku">SKU 和箱唛都换</el-radio-button></el-radio-group></div>
|
||||
<div class="warehouse-transfer-file-groups warehouse-file-groups--compact">
|
||||
<section v-if="canManageRelabelFiles" class="warehouse-file-group warehouse-transfer-total-files">
|
||||
@@ -1844,13 +1844,13 @@ function addReturnBoxBinding(source: RelabelInventoryCandidate) {
|
||||
<input ref="skuTemplateInput" class="warehouse-hidden-input" type="file" accept=".csv,text/csv" @change="importRelabelTemplate($event, 'sku')"><input ref="cartonTemplateInput" class="warehouse-hidden-input" type="file" accept=".csv,text/csv" @change="importRelabelTemplate($event, 'carton')"><input ref="mixedTemplateInput" class="warehouse-hidden-input" type="file" accept=".csv,text/csv" @change="importRelabelTemplate($event, 'mixed')">
|
||||
</section>
|
||||
</div>
|
||||
<div v-if="importedRelabelBindings.length" class="warehouse-import-preview"><div class="warehouse-section-heading"><div><strong>待导入绑定关系</strong><p>可只勾选本次需要换标的部分,也可以全选后一次导入。</p></div><div><el-checkbox :model-value="importedRelabelBindings.every((item) => item.selected)" @change="selectAllImportedBindings(Boolean($event))">全选</el-checkbox><el-button type="primary" size="small" @click="importSelectedBindingsToDetails">导入到明细</el-button></div></div><div class="warehouse-table-scroll"><table class="warehouse-binding-preview-table"><thead><tr><th>选择</th><th>关系类型</th><th>仓库唛头</th><th>旧 SKU</th><th>新 SKU</th><th>数量</th><th>新 FBA 箱唛</th></tr></thead><tbody><tr v-for="item in importedRelabelBindings" :key="item.id"><td><el-checkbox v-model="item.selected" /></td><td>{{ item.templateType === 'mixed' ? 'SKU 混箱换标' : item.templateType === 'carton' ? '箱号关联' : 'SKU 关联' }}</td><td>{{ item.cartonMark }}</td><td>{{ item.originalSku || '—' }}</td><td>{{ item.newSku || '—' }}</td><td>{{ item.quantity }}</td><td>{{ item.fbaCartonMark || '—' }}</td></tr></tbody></table></div></div>
|
||||
<div v-if="importedRelabelBindings.length" class="warehouse-import-preview"><div class="warehouse-section-heading"><div><strong>待导入绑定关系</strong></div><div><el-checkbox :model-value="importedRelabelBindings.every((item) => item.selected)" @change="selectAllImportedBindings(Boolean($event))">全选</el-checkbox><el-button type="primary" size="small" @click="importSelectedBindingsToDetails">导入到明细</el-button></div></div><div class="warehouse-table-scroll"><table class="warehouse-binding-preview-table"><thead><tr><th>选择</th><th>关系类型</th><th>仓库唛头</th><th>旧 SKU</th><th>新 SKU</th><th>数量</th><th>新 FBA 箱唛</th></tr></thead><tbody><tr v-for="item in importedRelabelBindings" :key="item.id"><td><el-checkbox v-model="item.selected" /></td><td>{{ item.templateType === 'mixed' ? 'SKU 混箱换标' : item.templateType === 'carton' ? '箱号关联' : 'SKU 关联' }}</td><td>{{ item.cartonMark }}</td><td>{{ item.originalSku || '—' }}</td><td>{{ item.newSku || '—' }}</td><td>{{ item.quantity }}</td><td>{{ item.fbaCartonMark || '—' }}</td></tr></tbody></table></div></div>
|
||||
<div class="warehouse-section-heading warehouse-tab-actions warehouse-binding-heading"><div><strong><span class="warehouse-step-number">4</span>绑定明细</strong></div><div><el-button type="primary" size="small" @click="addTransferRelabelRow"><Plus :size="15" />添加 SKU 明细</el-button><el-button size="small" @click="clearTransferRelabelRows">清空</el-button></div></div>
|
||||
<div v-if="labelingMode === 'carton'" class="warehouse-table-scroll"><table class="warehouse-edit-table warehouse-transfer-mark-table"><thead><tr><th>仓库唛头 *</th><th>旧 SKU</th><th>新 FBA 箱唛 *</th><th>备注</th><th>操作</th></tr></thead><tbody><tr v-for="row in transferRelabelRows" :key="row.id"><td><el-input v-model="row.customerCartonMarks" placeholder="请输入仓库唛头" /></td><td><el-input :model-value="row.sku" readonly /></td><td><el-input v-model="row.fbaCartonMark" placeholder="请输入新的 FBA 箱唛" /></td><td><el-input v-model="row.remark" type="textarea" :rows="2" placeholder="请输入备注" /></td><td><button type="button" class="warehouse-delete" title="删除该明细" @click="transferRelabelRows = transferRelabelRows.filter((item) => item.id !== row.id)"><Trash2 :size="16" />删除</button></td></tr><tr v-if="transferRelabelRows.length === 0"><td colspan="5"><div class="warehouse-empty">暂无绑定明细,请点击“添加换标明细”</div></td></tr></tbody></table></div>
|
||||
<div v-else-if="labelingMode === 'sku'" class="warehouse-table-scroll"><table class="warehouse-edit-table warehouse-transfer-mark-table"><thead><tr><th>仓库唛头 *</th><th>旧 SKU *</th><th>新 SKU *</th><th>换标数量(件)*</th><th>备注</th><th>操作</th></tr></thead><tbody><tr v-for="row in transferRelabelRows" :key="row.id"><td><el-input v-model="row.customerCartonMarks" placeholder="请输入仓库唛头" /></td><td><el-input :model-value="row.sku" readonly /></td><td><el-input v-model="row.newSkuCode" placeholder="请输入新 SKU" /></td><td><el-input-number v-model="row.quantity" :min="1" :precision="0" controls-position="right" placeholder="请输入箱数" /></td><td><el-input v-model="row.remark" type="textarea" :rows="2" placeholder="请输入备注" /></td><td><button type="button" class="warehouse-delete" title="删除该明细" @click="transferRelabelRows = transferRelabelRows.filter((item) => item.id !== row.id)"><Trash2 :size="16" />删除</button></td></tr><tr v-if="transferRelabelRows.length === 0"><td colspan="6"><div class="warehouse-empty">暂无绑定明细,请点击“添加 SKU 明细”</div></td></tr></tbody></table></div>
|
||||
<div v-else class="warehouse-table-scroll"><table class="warehouse-edit-table warehouse-transfer-mark-table"><thead><tr><th>仓库唛头 *</th><th>旧 SKU *</th><th>新 SKU *</th><th>换标数量(件)*</th><th>新 FBA 箱唛 *</th><th>备注</th><th>操作</th></tr></thead><tbody><tr v-for="row in transferRelabelRows" :key="row.id"><td><el-input v-model="row.customerCartonMarks" placeholder="请输入仓库唛头" /></td><td><el-input :model-value="row.sku" readonly /></td><td><el-input v-model="row.newSkuCode" placeholder="请输入新 SKU" /></td><td><el-input-number v-model="row.quantity" :min="1" :precision="0" controls-position="right" placeholder="请输入数量" /></td><td><el-input v-model="row.fbaCartonMark" placeholder="请输入新的 FBA 箱唛" /></td><td><el-input v-model="row.remark" type="textarea" :rows="2" placeholder="请输入备注" /></td><td><button type="button" class="warehouse-delete" title="删除该明细" @click="transferRelabelRows = transferRelabelRows.filter((item) => item.id !== row.id)"><Trash2 :size="16" />删除</button></td></tr><tr v-if="transferRelabelRows.length === 0"><td colspan="7"><div class="warehouse-empty">暂无绑定明细,请点击“添加 SKU 明细”</div></td></tr></tbody></table></div>
|
||||
<div v-if="labelingMode === 'carton'" class="warehouse-table-scroll"><table class="warehouse-edit-table warehouse-transfer-mark-table"><thead><tr><th>仓库唛头 *</th><th>旧 SKU</th><th>新 FBA 箱唛 *</th><th>备注</th><th>操作</th></tr></thead><tbody><tr v-for="row in transferRelabelRows" :key="row.id"><td><el-input v-model="row.customerCartonMarks" placeholder="" /></td><td><el-input :model-value="row.sku" readonly /></td><td><el-input v-model="row.fbaCartonMark" placeholder="" /></td><td><el-input v-model="row.remark" type="textarea" :rows="2" placeholder="" /></td><td><button type="button" class="warehouse-delete" title="删除该明细" @click="transferRelabelRows = transferRelabelRows.filter((item) => item.id !== row.id)"><Trash2 :size="16" />删除</button></td></tr><tr v-if="transferRelabelRows.length === 0"><td colspan="5"><div class="warehouse-empty">暂无绑定明细</div></td></tr></tbody></table></div>
|
||||
<div v-else-if="labelingMode === 'sku'" class="warehouse-table-scroll"><table class="warehouse-edit-table warehouse-transfer-mark-table"><thead><tr><th>仓库唛头 *</th><th>旧 SKU *</th><th>新 SKU *</th><th>换标数量(件)*</th><th>备注</th><th>操作</th></tr></thead><tbody><tr v-for="row in transferRelabelRows" :key="row.id"><td><el-input v-model="row.customerCartonMarks" placeholder="" /></td><td><el-input :model-value="row.sku" readonly /></td><td><el-input v-model="row.newSkuCode" placeholder="" /></td><td><el-input-number v-model="row.quantity" :min="1" :precision="0" controls-position="right" placeholder="" /></td><td><el-input v-model="row.remark" type="textarea" :rows="2" placeholder="" /></td><td><button type="button" class="warehouse-delete" title="删除该明细" @click="transferRelabelRows = transferRelabelRows.filter((item) => item.id !== row.id)"><Trash2 :size="16" />删除</button></td></tr><tr v-if="transferRelabelRows.length === 0"><td colspan="6"><div class="warehouse-empty">暂无绑定明细</div></td></tr></tbody></table></div>
|
||||
<div v-else class="warehouse-table-scroll"><table class="warehouse-edit-table warehouse-transfer-mark-table"><thead><tr><th>仓库唛头 *</th><th>旧 SKU *</th><th>新 SKU *</th><th>换标数量(件)*</th><th>新 FBA 箱唛 *</th><th>备注</th><th>操作</th></tr></thead><tbody><tr v-for="row in transferRelabelRows" :key="row.id"><td><el-input v-model="row.customerCartonMarks" placeholder="" /></td><td><el-input :model-value="row.sku" readonly /></td><td><el-input v-model="row.newSkuCode" placeholder="" /></td><td><el-input-number v-model="row.quantity" :min="1" :precision="0" controls-position="right" placeholder="" /></td><td><el-input v-model="row.fbaCartonMark" placeholder="" /></td><td><el-input v-model="row.remark" type="textarea" :rows="2" placeholder="" /></td><td><button type="button" class="warehouse-delete" title="删除该明细" @click="transferRelabelRows = transferRelabelRows.filter((item) => item.id !== row.id)"><Trash2 :size="16" />删除</button></td></tr><tr v-if="transferRelabelRows.length === 0"><td colspan="7"><div class="warehouse-empty">暂无绑定明细</div></td></tr></tbody></table></div>
|
||||
</div>
|
||||
<div class="warehouse-submit-bar warehouse-transfer-submit-bar"><el-button v-if="mode === 'create'" native-type="button" @click="resetForm">重置</el-button><el-button v-if="props.service === 'transfer'" native-type="button" :loading="saving" @click="submit('draft')">保存草稿</el-button><el-button v-if="props.service === 'transfer'" type="primary" native-type="submit" :loading="saving">提交订单</el-button><el-button v-if="props.service !== 'transfer'" type="primary" native-type="submit" :loading="saving">提交{{ config.title }}</el-button></div>
|
||||
<div class="warehouse-submit-bar warehouse-transfer-submit-bar"><el-button v-if="mode === 'create'" native-type="button" @click="resetForm">重置</el-button><el-button v-if="props.service === 'transfer' && !inventoryTransfer" native-type="button" :loading="saving" @click="submit('draft')">保存草稿</el-button><el-button v-if="props.service === 'transfer'" type="primary" native-type="submit" :loading="saving">提交订单</el-button><el-button v-if="props.service !== 'transfer'" type="primary" native-type="submit" :loading="saving">提交{{ config.title }}</el-button></div>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -31,6 +31,13 @@ test('退货箱明细与来源下拉使用同一份逐箱库存,不请求普
|
||||
assert.equal(ctx.transferSourceLoading.value, false)
|
||||
})
|
||||
|
||||
test('选择库存来源时隐藏保存草稿并阻止草稿动作', () => {
|
||||
assert.match(view, /props\.service === 'transfer' && !inventoryTransfer/)
|
||||
assert.match(view, /保存草稿/)
|
||||
assert.match(view, /props\.service === 'transfer' && inventoryTransfer\.value && action === 'draft'/)
|
||||
assert.match(view, /选择库存来源后不能保存草稿,请直接提交订单/)
|
||||
})
|
||||
|
||||
test('来源库存发往FBA默认开启贴标,清除来源或切换私人仓后恢复选项', () => {
|
||||
const source = ref(''), destination = ref('NONE'), form = reactive({ relabel: false })
|
||||
const inventoryTransfer = computed(() => Boolean(source.value))
|
||||
|
||||
@@ -174,7 +174,7 @@ test('海外操作仓和FBA目的仓使用独立字段', () => {
|
||||
assert.match(api, /destinationFbaWarehouseCode\?: string/)
|
||||
assert.match(view, /operationWarehouseId: form\.warehouse/)
|
||||
assert.match(view, /destinationFbaWarehouseCode: form\.destination/)
|
||||
assert.match(view, /<el-select v-model="form\.destination"[^\n]+FBA 目的仓/)
|
||||
assert.match(view, /FBA 目的仓<\/span><el-select v-model="form\.destination"/)
|
||||
assert.doesNotMatch(view, /v-model="row\.destinationWarehouseId"[^\n]+请选择 FBA 目的仓/)
|
||||
})
|
||||
|
||||
|
||||
@@ -42,7 +42,6 @@ test('退货 SKU 申请按实物箱选择,来源标识只读,部分数量转
|
||||
assert.match(view, /item.id === rows\[0\].sourceRowId/)
|
||||
assert.match(view, /row.sourceRowId !== source\?\.id/)
|
||||
assert.match(view, /:readonly="isReturnRelabelSource"/)
|
||||
assert.match(view, /SKU 换标完成后扣减旧 SKU、增加新 SKU/)
|
||||
assert.match(view, /labelingMode.value === 'sku' \? !skuTotalFile.value : !cartonTotalFile.value/)
|
||||
})
|
||||
|
||||
|
||||
@@ -5,8 +5,8 @@ import test from 'node:test'
|
||||
const view = readFileSync(new URL('./WarehouseServiceView.vue', import.meta.url), 'utf8')
|
||||
|
||||
test('货物废弃先选择海外操作仓,再加载并选择该仓入库单', () => {
|
||||
assert.match(view, /props\.service === 'disposal'.*操作仓.*v-model="form\.warehouse".*请选择海外仓.*@change="selectDiscardWarehouse"/s)
|
||||
assert.match(view, /props\.service === 'disposal'.*选择入库单.*:disabled="!form\.warehouse".*请先选择操作仓/s)
|
||||
assert.match(view, /props\.service === 'disposal'.*操作仓.*v-model="form\.warehouse".*@change="selectDiscardWarehouse"/s)
|
||||
assert.match(view, /props\.service === 'disposal'.*选择入库单.*:disabled="!form\.warehouse".*@change="selectDiscardInbound"/s)
|
||||
assert.match(view, /async function selectDiscardWarehouse\(\).*selectedDiscardOrderId\.value = ''.*cargoRows\.value = \[\].*listDiscardCandidates\(\)/s)
|
||||
assert.match(view, /\|\| !primaryWarehouse \|\| invalidCargo/)
|
||||
})
|
||||
@@ -14,9 +14,10 @@ test('货物废弃先选择海外操作仓,再加载并选择该仓入库单',
|
||||
|
||||
test('废弃来源包含通用仓位,选择后带出全部库存且不把选择键提交为订单ID', () => {
|
||||
const body = view.match(/function selectDiscardInbound\(orderId: string\) \{([\s\S]*?)\n\}/)[1]
|
||||
const select = new Function('discardSelectedBoxKeys', 'discardBoxSearch', 'selectAllDiscardBoxes', 'discardCandidates', 'cargoRows', 'form', 'cargoSequence', 'orderId', body)
|
||||
const select = new Function('discardSelectedBoxKeys', 'discardBoxSearch', 'selectAllDiscardBoxes', 'discardDocuments', 'cargoRows', 'form', 'cargoSequence', 'orderId', body)
|
||||
const cargoRows = { value: [] }; const form = {}
|
||||
select({ value: [] }, { value: '' }, () => {}, { value: [{ orderId: 'inventory:42', sourceType: 'INVENTORY', inventoryPositionId: '42', overseasWarehouseId: '99', inboundCode: 'WT-1', skus: [{ skuCode: 'A', productName: '商品', availablePackages: 100 }] }] }, cargoRows, form, 0, 'inventory:42')
|
||||
const source = { orderId: 'inventory:42', sourceType: 'INVENTORY', inventoryPositionId: '42', overseasWarehouseId: '99', inboundCode: 'WT-1', skus: [{ skuCode: 'A', productName: '商品', availablePackages: 100 }] }
|
||||
select({ value: [] }, { value: '' }, () => {}, { value: [{ ...source, orderId: 'document:WT-1', sources: [source] }] }, cargoRows, form, 0, 'document:WT-1')
|
||||
assert.equal(cargoRows.value[0].quantity, 100)
|
||||
assert.equal(cargoRows.value[0].sourceInventoryPositionId, '42')
|
||||
assert.equal(form.inboundNo, 'WT-1')
|
||||
|
||||
@@ -30,7 +30,6 @@ test('仓储转运支持无目的地、FBA 仓和私人仓并显式选择是否
|
||||
assert.match(view, />是否贴标</)
|
||||
assert.match(view, /:value="false">不需要贴标/)
|
||||
assert.match(view, /:value="true">需要贴标/)
|
||||
assert.match(view, /FBA 仓和私人仓均可选择是否贴标/)
|
||||
assert.doesNotMatch(view, /v-model="form\.relabel" disabled/)
|
||||
assert.doesNotMatch(view, /value === 'FBA'\) form\.relabel = true/)
|
||||
assert.doesNotMatch(view, /value === 'PRIVATE'.*form\.relabel = false/)
|
||||
@@ -56,7 +55,7 @@ test('仓储转运换标绑定明细自动同步只读的旧 SKU', () => {
|
||||
|
||||
test('仓储转运换标整票只允许一个目的仓', () => {
|
||||
assert.match(view, /props\.service === 'transfer' && transferDestinationType === 'FBA'[^\n]+FBA 目的仓/)
|
||||
assert.match(view, /FBA 货件编号<\/span><el-input v-model="form\.fbaShipmentNo" placeholder="选填,可后续补充"/)
|
||||
assert.match(view, /FBA 货件编号<\/span><el-input v-model="form\.fbaShipmentNo" placeholder=""/)
|
||||
assert.match(view, /transferDestinationType\.value === 'FBA' && !form\.destination/)
|
||||
assert.match(view, /transferDestinationType\.value === 'PRIVATE'.*!form\.privateDestinationPostalCode/)
|
||||
assert.match(view, /整票换标货物目的 FBA 仓/)
|
||||
@@ -153,7 +152,6 @@ test('上门提货不提交物流号,需要换标时随转运单登记申请',
|
||||
assert.match(view, /form\.pickupPhone/)
|
||||
assert.match(view, /form\.relabelContact/)
|
||||
assert.match(view, /form\.relabelPhone/)
|
||||
assert.match(view, /选择海外仓后自动带出,也可手动填写/)
|
||||
assert.match(view, /pickupContactName: !inventoryTransfer\.value && form\.deliveryMethod === 'WAREHOUSE_PICKUP' \? form\.pickupContact : undefined/)
|
||||
assert.match(view, /pickupContactPhone: !inventoryTransfer\.value && form\.deliveryMethod === 'WAREHOUSE_PICKUP' \? form\.pickupPhone : undefined/)
|
||||
assert.match(view, /relabelContactName: form\.relabel \? form\.relabelContact/)
|
||||
@@ -172,7 +170,7 @@ test('仓储转运期望发货时间可选并在订单概览回填发货时间',
|
||||
assert.doesNotMatch(view, /预计发货时间/)
|
||||
assert.match(view, /期望发货时间/)
|
||||
assert.match(view, /v-model="form\.estimatedShipAt"/)
|
||||
assert.match(view, /v-model="form\.estimatedShipAt"[^>]+placeholder="选填"/)
|
||||
assert.match(view, /v-model="form\.estimatedShipAt"[^>]+placeholder=""/)
|
||||
assert.doesNotMatch(view, /!form\.estimatedShipAt/)
|
||||
assert.match(view, /estimatedShipAt: form\.estimatedShipAt/)
|
||||
assert.match(view, /<span>期望发货时间<\/span><strong>\{\{ formatDateTime\(selectedDetail\?\.estimatedShipAt\) \}\}<\/strong>/)
|
||||
@@ -360,7 +358,7 @@ test('转运贴标单在PDA确认库存前不误报附件上传失败', () => {
|
||||
test('仓储转运基础货物不强制 SKU 和中文品名,只换箱唛不强制旧 SKU', () => {
|
||||
assert.match(view, /商品 SKU\{\{ props\.service === 'transfer' \? '' : ' \*' \}\}/)
|
||||
assert.match(view, /中文品名\{\{ props\.service === 'transfer' \? '' : ' \*' \}\}/)
|
||||
assert.match(view, /props\.service === 'transfer' \? '选填' : '请输入商品 SKU'/)
|
||||
assert.match(view, /props\.service === 'relabel' \? '请输入商品 SKU' : ''/)
|
||||
assert.match(view, /v-if="labelingMode === 'carton'"[^\n]+<th>旧 SKU<\/th>/)
|
||||
assert.match(view, /labelingMode\.value !== 'carton' && \(!row\.sku\.trim\(\) \|\| !row\.newSkuCode\.trim\(\)\)/)
|
||||
assert.match(api, /skuCode\?: string; productNameZh\?: string/)
|
||||
|
||||
@@ -57,7 +57,6 @@ export const TENANT_PERMISSION_GROUPS: readonly TenantPermissionGroup[] = [
|
||||
{ code: 'order:first-leg:submit', label: '提交订单', description: '提交草稿进入平台审核' },
|
||||
{ code: 'order:first-leg:cancel', label: '取消订单', description: '在允许的状态下取消订单' },
|
||||
{ code: 'merchant:warehouse:delivery:list', label: '查看派送单', description: '分页查询本企业海外仓派送单' },
|
||||
{ code: 'merchant:warehouse:delivery:create', label: '创建派送单', description: '从排车单创建海外仓派送单' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -145,7 +144,6 @@ export const FRONTEND_TO_MERCHANT_PERMISSION_CODES: Readonly<Record<string, read
|
||||
'order:first-leg:submit': ['merchant:order:submit'],
|
||||
'order:first-leg:cancel': ['merchant:order:cancel'],
|
||||
'merchant:warehouse:delivery:list': ['merchant:warehouse:delivery:list'],
|
||||
'merchant:warehouse:delivery:create': ['merchant:warehouse:delivery:create'],
|
||||
'merchant:warehouse:inbound:list': ['merchant:warehouse:inbound:list'],
|
||||
'merchant:overseas-warehouse:return-inventory:list': ['merchant:overseas-warehouse:return-inventory:list'],
|
||||
'merchant:overseas-warehouse:last-mile-order:list': ['merchant:overseas-warehouse:last-mile-order:list'],
|
||||
|
||||
@@ -52,13 +52,13 @@ test('keeps standalone business pages at the client sidebar root', () => {
|
||||
{ title: '首页', children: undefined },
|
||||
{
|
||||
title: '订单中心',
|
||||
children: ['头程订单', '入库订单', '派送订单'],
|
||||
children: ['头程订单', '入库订单', '派送订单', '海外采购', '美国本土卡', '美本土散板'],
|
||||
},
|
||||
{ title: '报关资料', children: undefined },
|
||||
{ title: '船期价格', children: undefined },
|
||||
{
|
||||
title: '海外仓',
|
||||
children: ['贴标申请', '仓储转运', '货物废弃', '退货换标', '海外采购', '美国本土卡', '美本土散板'],
|
||||
children: ['贴标申请', '仓储转运', '货物废弃', '退货换标'],
|
||||
},
|
||||
{
|
||||
title: '费用与账单',
|
||||
@@ -90,15 +90,15 @@ test('flattens visible leaves with unique paths', () => {
|
||||
'/orders/first-leg',
|
||||
'/orders/inbound',
|
||||
'/orders/delivery',
|
||||
'/cross-border/procurement',
|
||||
'/cross-border/domestic-trucking',
|
||||
'/cross-border/pallet-shipping',
|
||||
'/first-leg/customs-documents',
|
||||
'/first-leg/sailing-prices',
|
||||
'/overseas-warehouse/labeling',
|
||||
'/overseas-warehouse/storage-transfer',
|
||||
'/overseas-warehouse/cargo-disposal',
|
||||
'/overseas-warehouse/return-relabel',
|
||||
'/cross-border/procurement',
|
||||
'/cross-border/domestic-trucking',
|
||||
'/cross-border/pallet-shipping',
|
||||
'/finance/account',
|
||||
'/finance/bills',
|
||||
'/company/profile',
|
||||
|
||||
@@ -54,6 +54,9 @@ export const clientNavigation: ClientNavigationNode[] = [
|
||||
requireLeaf('first-leg-orders', '头程订单'),
|
||||
requireLeaf('orders-inbound', '入库订单'),
|
||||
requireLeaf('orders-delivery', '派送订单'),
|
||||
requireLeaf('cross-border-procurement'),
|
||||
requireLeaf('cross-border-domestic-trucking'),
|
||||
requireLeaf('cross-border-pallet-shipping'),
|
||||
],
|
||||
},
|
||||
requireLeaf('first-leg-customs-documents', '报关资料'),
|
||||
@@ -67,9 +70,6 @@ export const clientNavigation: ClientNavigationNode[] = [
|
||||
requireLeaf('overseas-storage-transfer'),
|
||||
requireLeaf('overseas-cargo-disposal'),
|
||||
requireLeaf('overseas-return-relabel', '退货换标'),
|
||||
requireLeaf('cross-border-procurement'),
|
||||
requireLeaf('cross-border-domestic-trucking'),
|
||||
requireLeaf('cross-border-pallet-shipping'),
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user