This commit is contained in:
2026-08-18 11:32:58 +08:00
parent 771ed6f059
commit eedaa2f080
8 changed files with 109 additions and 17 deletions
+10
View File
@@ -120,6 +120,7 @@ export interface ProductImageUploadResult {
}
export interface FirstLegOrderSaveRequest {
inboundCode?: string
customerName: string
transportMode: ApiTransportMode
tradeTerm?: string
@@ -148,6 +149,11 @@ export interface FirstLegOrderSaveRequest {
referenceId?: string
}
export interface InboundCodePolicy {
mode: 'AUTO' | 'LEGACY_MANUAL'
manualEntryRequired: boolean
}
export interface MerchantBatchDraftItem {
clientRowId: string
draft: FirstLegOrderSaveRequest
@@ -442,6 +448,10 @@ export function getMerchantFirstLegOrder(id: number | string) {
return apiRequest<MerchantFirstLegOrderDetail>({ method: 'GET', url: `/merchant/v1/orders/first-leg/${id}` })
}
export function getMerchantInboundCodePolicy() {
return apiRequest<InboundCodePolicy>({ method: 'GET', url: '/merchant/v1/orders/first-leg/inbound-code-policy' })
}
export function createMerchantFirstLegOrder(payload: FirstLegOrderSaveRequest) {
return apiRequest<number | string>({
method: 'POST',
@@ -26,6 +26,7 @@ test('maps the first-leg aggregate list fields without loading detail rows', ()
test('declares the documented merchant warehouse and first-leg endpoints', () => {
assert.match(apiSource, /url: '\/merchant\/v1\/warehouses'/)
assert.match(apiSource, /url: '\/merchant\/v1\/orders\/first-leg'/)
assert.match(apiSource, /url: '\/merchant\/v1\/orders\/first-leg\/inbound-code-policy'/)
assert.match(apiSource, /url: '\/merchant\/v1\/orders\/first-leg\/batch'/)
assert.match(apiSource, /`\/merchant\/v1\/orders\/first-leg\/\$\{id\}`/)
assert.match(apiSource, /`\/merchant\/v1\/orders\/first-leg\/\$\{id\}\/submit`/)
@@ -118,6 +119,10 @@ test('maps C02 authoritative status, transport, cargo and pickup snapshots', ()
assert.match(storeSource, /customerRemark: summary\.customerRemark \?\? ''/)
})
test('sends a manually entered old-system inbound code with the draft payload', () => {
assert.match(storeSource, /inboundCode: draft\.inboundCode\?\.trim\(\) \|\| undefined/)
})
test('sends backend-required channel, pricing, customs, destination and cargo fields', () => {
for (const field of ['pickupMethod', 'channelCode', 'channelName', 'pricingTaxMode', 'taxMode', 'customsInspection', 'destinationType', 'packageType']) {
assert.match(storeSource, new RegExp(field))
@@ -103,6 +103,7 @@ function savePayload(draft: FirstLegOrderDraft, warehouse: ClientWarehouse, cust
})
: undefined
return {
inboundCode: draft.inboundCode?.trim() || undefined,
customerName: customerName.trim(),
transportMode: apiTransport(draft),
tradeTerm: draft.tradeTerm || undefined,
@@ -184,4 +184,5 @@ export type FirstLegOrderDraft = Omit<Pick<
| 'shipmentId'
>, 'transportMode'> & {
transportMode: FirstLegTransportMode
inboundCode?: string
}
@@ -483,6 +483,14 @@ test('copies reactive order drafts into plain request data', () => {
assert.doesNotMatch(source, /structuredClone\((?:form|order\.)/)
})
test('uses the global inbound-code policy to require old-system codes during parallel running', () => {
assert.match(source, /getMerchantInboundCodePolicy\(\)/)
assert.match(source, /const manualInboundCodeRequired = computed/)
assert.match(source, /v-if="manualInboundCodeRequired" label="旧系统入仓码"/)
assert.match(source, /当前全系统处于旧系统并跑模式/)
assert.match(source, /当前为旧系统并跑模式,请填写旧系统入仓码/)
})
test('shows authoritative warehouse, loading and sailing fields on desktop and narrow screens', () => {
for (const label of ['国内收货仓', '期望出运', '柜号 / 柜次', '船名 / 航次', '预计船期', '更新时间']) {
assert.match(source, new RegExp(label.replace('/', '\\/')))
@@ -16,11 +16,13 @@ import {
estimateMerchantFirstLegPrice,
downloadMerchantOrderCartonLabelsBatch,
downloadMerchantProductImage,
getMerchantInboundCodePolicy,
getMerchantPricingChannels,
uploadMerchantProductImage,
type MerchantPriceEstimate,
type MerchantPriceEstimateRequest,
type MerchantPricingChannel,
type InboundCodePolicy,
} from '@/modules/first-leg/api/firstLeg'
import { useFirstLegOrdersStore } from '@/modules/first-leg/store/firstLegOrders'
import { useTenantAdministrationStore } from '@/modules/tenant-management/public'
@@ -68,6 +70,8 @@ const queryFields = computed<QueryField[]>(() => [
{ key: 'trackingNumber', label: '快递单号', type: 'input', placeholder: '请输入快递单号', props: { 'aria-label': '快递单号' } },
])
const channelOptions = ref<MerchantPricingChannel[]>([])
const inboundCodePolicy = ref<InboundCodePolicy | null>(null)
const manualInboundCodeRequired = computed(() => inboundCodePolicy.value?.mode === 'LEGACY_MANUAL')
const channelLoading = ref(false)
const channelError = ref('')
const priceEstimate = ref<MerchantPriceEstimate | null>(null)
@@ -331,6 +335,7 @@ function displayValue(value: string | null | undefined) {
function emptyDraft(): FirstLegOrderDraft {
return {
inboundCode: '',
referenceNo: '',
transportMode: 'FCL',
businessType: 'EXPORT',
@@ -512,11 +517,17 @@ async function loadPageData() {
const orderPageRequest = isCreatePage.value
? store.loadWarehouseType('DOMESTIC', true)
: store.load()
const [ordersResult, companyResult, destinationResult] = await Promise.allSettled([
const [ordersResult, companyResult, destinationResult, policyResult] = await Promise.allSettled([
orderPageRequest,
companyRequest,
destinationRequest,
getMerchantInboundCodePolicy(),
])
if (policyResult.status === 'fulfilled') inboundCodePolicy.value = policyResult.value
else {
inboundCodePolicy.value = null
supportingDataErrors.value.push(`入仓码配置加载失败:${errorMessage(policyResult.reason)}`)
}
if (canViewCompanyProfile.value && companyResult.status === 'fulfilled') {
companyAuditLoaded.value = true
} else if (companyResult.status === 'rejected') {
@@ -675,6 +686,7 @@ function removeCargoProductImage(item: FirstLegCargoItem) {
function draftFromOrder(order: FirstLegOrder): FirstLegOrderDraft {
return {
inboundCode: order.businessSummary.inboundCode,
referenceNo: order.referenceNo,
transportMode: order.transportMode ?? 'FCL',
businessType: order.businessType,
@@ -1388,6 +1400,15 @@ async function validateEditorForm() {
return false
}
if (!inboundCodePolicy.value) {
ElMessage.warning('入仓码配置尚未加载,请稍后重试')
return false
}
if (manualInboundCodeRequired.value && !form.inboundCode?.trim()) {
ElMessage.warning('当前为旧系统并跑模式,请填写旧系统入仓码')
return false
}
const errors = validateFirstLegOrderDraft(form)
if (errors.length > 0) {
ElMessage.warning(errors[0])
@@ -1631,6 +1652,10 @@ async function searchOrders(model: QueryModel) {
<el-form-item label="国内收货仓" prop="receivingWarehouseId" required class="prototype-form-row prototype-form-row--wide"><el-radio-group v-model="form.receivingWarehouseId" class="warehouse-options" @change="loadSelectedWarehouse"><el-radio v-for="warehouse in availableWarehouses" :key="warehouse.id" :value="warehouse.id">{{ warehouse.name }}</el-radio></el-radio-group></el-form-item>
</div>
<div class="order-form-grid compact-transport-grid">
<el-form-item v-if="manualInboundCodeRequired" label="旧系统入仓码" prop="inboundCode" required :rules="requiredTextRules('请输入旧系统生成的入仓码')">
<el-input v-model="form.inboundCode" maxlength="40" placeholder="例如:NYEXX2608140001" @input="form.inboundCode = String($event).trim().toUpperCase()" />
<small class="field-guidance">当前全系统处于旧系统并跑模式,请粘贴旧系统生成的入仓码;订单提交后不可修改。</small>
</el-form-item>
<el-form-item label="渠道" prop="channelCode" required>
<el-select v-model="form.channelCode" :loading="channelLoading" :disabled="!form.receivingWarehouseId || channelLoading" :placeholder="form.receivingWarehouseId ? '请选择运输渠道' : '请先选择国内收货仓'" @change="selectChannel"><el-option v-for="channel in channelOptions" :key="channel.channelCode" :label="channel.channelName" :value="channel.channelCode" /></el-select>
<small v-if="channelError" class="field-guidance field-guidance--error">渠道加载失败:{{ channelError }} <el-button link type="primary" @click="retryPricingChannels">重试</el-button></small>
@@ -16,7 +16,7 @@ test('exposes quick order as an active first-leg route with its dedicated permis
assert.match(orderViewSource, /store\.loadDraftDetail\(id\)/)
})
test('keeps the primary quick-order table limited to channel, destination, SKU and package count', () => {
test('keeps the primary quick-order table focused on inbound code policy, channel, destination and cargo', () => {
for (const label of ['选择渠道', '目的仓', '产品信息:产品 SKU / 箱数', '新增一行', '生成草稿单']) {
assert.match(source, new RegExp(label))
}
@@ -24,6 +24,10 @@ test('keeps the primary quick-order table limited to channel, destination, SKU a
assert.match(source, /destinationType: 'FBA'/)
assert.match(source, /function createProduct\(\): QuickOrderProduct/)
assert.match(source, /products: \[createProduct\(\)\]/)
assert.match(source, /getMerchantInboundCodePolicy/)
assert.match(source, /manualInboundCodeRequired/)
assert.match(source, /v-if="manualInboundCodeRequired"[^>]*>旧系统入仓码/)
assert.match(source, /inboundCode: row\.inboundCode\.trim\(\) \|\| undefined/)
})
test('creates one real backend draft per order row and keeps its products together', () => {
@@ -33,7 +37,7 @@ test('creates one real backend draft per order row and keeps its products togeth
assert.match(source, /clientRowId: row\.id, draft: buildOrderDraft\(row, channel\)/)
assert.match(source, /store\.saveDraftBatch\(drafts, customerName, activeBatchKey\.value\)/)
assert.match(source, /response\.items\.flatMap/)
assert.match(source, /item\.inboundCode\?\.trim\(\)/)
assert.match(source, /inboundCode: item\.inboundCode\?\.trim\(\) \?\? ''/)
assert.match(source, /已生成 \$\{successes\.length\} 张草稿单/)
assert.match(source, /successfulIds\.has\(rows\[index\]\.id\)/)
assert.match(source, /response\.items\.filter/)
@@ -46,7 +50,9 @@ test('creates one real backend draft per order row and keeps its products togeth
assert.doesNotMatch(source, /name: 'first-leg-order-create'/)
})
test('requires only channel, destination, SKU and package count before directly creating drafts', () => {
test('requires the old inbound code globally only while legacy manual mode is active', () => {
assert.match(source, /manualInboundCodeRequired\.value[\s\S]*?rows\.findIndex\(\(row\) => !row\.inboundCode\.trim\(\)\)/)
assert.match(source, /请填写第 \$\{missingInboundCodeIndex \+ 1\} 行的旧系统入仓码/)
assert.match(source, /!row\.channelKey \|\| !row\.destinationWarehouseName/)
assert.match(source, /!product\.sku\.trim\(\) \|\| product\.packageCount < 1/)
assert.match(source, /渠道、目的仓、产品 SKU 和箱数/)
@@ -69,7 +75,7 @@ test('requires only channel, destination, SKU and package count before directly
test('shows every created draft and keeps failed rows available for retry', () => {
assert.match(source, /v-for="result in draftResults"/)
assert.match(source, /入仓码 \{\{ result\.inboundCode \}\}/)
assert.match(source, /入仓码将在提交订单时生成/)
assert.match(source, /@click="editDraft\(result\)"/)
assert.match(source, /if \(rows\.length === 0\) rows\.push\(createRow\(\)\)/)
assert.match(source, /resultDialogOpen\.value = successes\.length > 0/)
+48 -12
View File
@@ -4,7 +4,12 @@ import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { MapPin, Plus, Send, Trash2 } from '@lucide/vue'
import { useAuthStore } from '@/modules/auth/store/auth'
import { getMerchantPricingChannels, type MerchantPricingChannel } from '@/modules/first-leg/api/firstLeg'
import {
getMerchantInboundCodePolicy,
getMerchantPricingChannels,
type InboundCodePolicy,
type MerchantPricingChannel,
} from '@/modules/first-leg/api/firstLeg'
import { useFirstLegOrdersStore } from '@/modules/first-leg/store/firstLegOrders'
import type { FirstLegOrderDraft } from '@/modules/first-leg/types/firstLegOrder'
import { createEmptyCargoItem } from '@/modules/first-leg/utils/firstLegOrder'
@@ -19,6 +24,7 @@ interface QuickOrderProduct {
interface QuickOrderRow {
id: string
inboundCode: string
channelKey: string
destinationType: DestinationType
destinationWarehouseId: string
@@ -52,6 +58,7 @@ const store = useFirstLegOrdersStore()
const loading = ref(false)
const submitting = ref(false)
const loadError = ref('')
const inboundCodePolicy = ref<InboundCodePolicy | null>(null)
const channelOptions = ref<QuickChannelOption[]>([])
const destinationDialogOpen = ref(false)
const destinationLoading = ref(false)
@@ -85,6 +92,7 @@ function createProduct(): QuickOrderProduct {
function createRow(): QuickOrderRow {
return reactive({
id: `quick-order-${++rowSequence}`,
inboundCode: '',
channelKey: '',
destinationType: 'FBA',
destinationWarehouseId: '',
@@ -102,6 +110,7 @@ function createRow(): QuickOrderRow {
const rows = reactive<QuickOrderRow[]>([createRow()])
const hasChannels = computed(() => channelOptions.value.length > 0)
const manualInboundCodeRequired = computed(() => inboundCodePolicy.value?.mode === 'LEGACY_MANUAL')
const availableDestinationWarehouses = computed(() => destinationForm.type === 'PRIVATE'
? []
: store.warehouses.filter((warehouse) => warehouse.warehouseType === destinationForm.type))
@@ -163,6 +172,7 @@ function hasCompletePrivateContact(row: QuickOrderRow) {
function buildOrderDraft(row: QuickOrderRow, channel: QuickChannelOption): FirstLegOrderDraft {
const pricingTaxMode = channel.pricingTaxModes[0] ?? 'TAX_INCLUDED'
return {
inboundCode: row.inboundCode.trim() || undefined,
referenceNo: '',
transportMode: 'FCL',
businessType: 'EXPORT',
@@ -288,7 +298,11 @@ async function loadChannels() {
loading.value = true
loadError.value = ''
try {
await store.loadWarehouseType('DOMESTIC', true)
const [, policy] = await Promise.all([
store.loadWarehouseType('DOMESTIC', true),
getMerchantInboundCodePolicy(),
])
inboundCodePolicy.value = policy
const results = await Promise.allSettled(store.warehouses
.filter((warehouse) => warehouse.warehouseType === 'DOMESTIC')
.map(async (warehouse) => {
@@ -305,6 +319,17 @@ async function loadChannels() {
}
async function submitQuickOrder() {
if (!inboundCodePolicy.value) {
ElMessage.warning('入仓码规则尚未加载,请刷新页面后重试')
return
}
const missingInboundCodeIndex = manualInboundCodeRequired.value
? rows.findIndex((row) => !row.inboundCode.trim())
: -1
if (missingInboundCodeIndex >= 0) {
ElMessage.warning(`请填写第 ${missingInboundCodeIndex + 1} 行的旧系统入仓码`)
return
}
const incompleteIndex = rows.findIndex((row) => !row.channelKey || !row.destinationWarehouseName
|| row.products.some((product) => !product.sku.trim() || product.packageCount < 1))
if (incompleteIndex >= 0) {
@@ -330,7 +355,7 @@ async function submitQuickOrder() {
if (!activeBatchKey.value) activeBatchKey.value = createBatchKey()
const response = await store.saveDraftBatch(drafts, customerName, activeBatchKey.value)
const successes = response.items.flatMap((item): QuickDraftResult[] => {
if (!item.success || item.orderId == null || !item.inboundCode?.trim()) return []
if (!item.success || item.orderId == null) return []
const row = rows.find((candidate) => candidate.id === item.clientRowId)
if (!row) return []
return [{
@@ -338,10 +363,10 @@ async function submitQuickOrder() {
sku: row.products.map((product) => product.sku.trim()).join('、'),
draftId: String(item.orderId),
orderNo: item.orderNo ?? '',
inboundCode: item.inboundCode.trim(),
inboundCode: item.inboundCode?.trim() ?? '',
}]
})
const failures = response.items.filter((item) => !item.success || item.orderId == null || !item.inboundCode?.trim())
const failures = response.items.filter((item) => !item.success || item.orderId == null)
draftResults.splice(0, draftResults.length, ...successes)
const successfulIds = new Set(successes.map((item) => item.rowId))
for (let index = rows.length - 1; index >= 0; index -= 1) {
@@ -374,6 +399,7 @@ onMounted(loadChannels)
<header class="quick-order-intro">
<h2 id="quick-order-heading">客户下单</h2>
<p>只需输入渠道、目的仓、产品 SKU 和箱数,即可快速填写订单。</p>
<small v-if="manualInboundCodeRequired">当前全局使用旧系统入仓码,每张草稿都需要填写旧系统生成的入仓码。</small>
<small>每一行会直接生成一张独立草稿单,其他明细可进入草稿后继续编辑并提交。</small>
</header>
@@ -385,14 +411,23 @@ onMounted(loadChannels)
<table class="quick-order-table">
<thead>
<tr>
<th>选择渠道</th>
<th>目的仓</th>
<th>操作</th>
<th v-if="manualInboundCodeRequired" class="inbound-code-heading">旧系统入仓码</th>
<th class="channel-heading">选择渠道</th>
<th class="destination-heading">目的仓</th>
<th class="operation-heading">操作</th>
<th>产品信息:产品 SKU / 箱数</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, rowIndex) in rows" :key="row.id">
<td v-if="manualInboundCodeRequired" class="inbound-code-cell">
<el-input
v-model="row.inboundCode"
maxlength="40"
placeholder="请输入旧系统入仓码"
@input="row.inboundCode = String($event).trim().toUpperCase()"
/>
</td>
<td class="channel-cell">
<el-select v-model="row.channelKey" :disabled="!hasChannels" placeholder="请选择">
<el-option
@@ -476,7 +511,7 @@ onMounted(loadChannels)
<el-dialog v-model="resultDialogOpen" class="quick-result-dialog" title="草稿单生成结果" width="min(700px, 94vw)">
<div class="quick-draft-results">
<article v-for="result in draftResults" :key="result.rowId">
<div><strong>{{ result.orderNo || `草稿 ${result.draftId}` }}</strong><span>SKU {{ result.sku }}</span><span>入仓码 {{ result.inboundCode }}</span></div>
<div><strong>{{ result.orderNo || `草稿 ${result.draftId}` }}</strong><span>SKU {{ result.sku }}</span><span>{{ result.inboundCode ? `入仓码 ${result.inboundCode}` : '入仓码将在提交订单时生成' }}</span></div>
<el-button type="primary" plain @click="editDraft(result)">编辑明细</el-button>
</article>
</div>
@@ -494,11 +529,12 @@ onMounted(loadChannels)
.quick-order-table-wrap { min-height: 126px; overflow-x: auto; border: 1px solid #e5eaf2; border-radius: 8px; }
.quick-order-table { width: 100%; min-width: 900px; border-collapse: collapse; table-layout: fixed; }
.quick-order-table th { height: 42px; padding: 0 14px; background: #f4f5fb; color: #637fec; font-size: 12px; font-weight: 650; text-align: left; }
.quick-order-table th:nth-child(1) { width: 220px; }
.quick-order-table th:nth-child(2) { width: 145px; }
.quick-order-table th:nth-child(3) { width: 210px; }
.quick-order-table .inbound-code-heading, .quick-order-table .channel-heading { width: 220px; }
.quick-order-table .destination-heading { width: 145px; }
.quick-order-table .operation-heading { width: 210px; }
.quick-order-table td { padding: 12px 14px; border-top: 1px solid #edf0f5; vertical-align: middle; }
.channel-cell :deep(.el-select) { width: 100%; }
.inbound-code-cell :deep(.el-input) { width: 100%; }
.destination-options { display: flex; align-items: flex-start; flex-direction: column; gap: 4px; }
.destination-options :deep(.el-radio) { height: 20px; margin-right: 0; }
.destination-binding { display: flex; width: 100%; min-width: 0; align-items: center; gap: 5px; margin-top: 7px; padding: 0; border: 0; background: transparent; color: #637fec; cursor: pointer; font-size: 11px; text-align: left; }