Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e433f5086b | ||
|
|
95fb17c5aa | ||
|
|
5a1cde6c0f | ||
|
|
63193d04de | ||
|
|
119b4d6d66 |
@@ -1,5 +1,19 @@
|
||||
# 本土卡车字段调整核对
|
||||
|
||||
## 2026-11-03 运输照片回显
|
||||
|
||||
| 页面 | 承载与依据 | 验收 |
|
||||
|---|---|---|
|
||||
| 美本土散板、本土卡车详情 | 按本轮用户截图及文字要求,在订单信息下显示取货照片、派送照片;缩略图横向换行、点击放大,不显示图片名称,不提供客户上传或删除操作 | 定向测试及类型检查通过;待人工截图确认 |
|
||||
|
||||
## 2026-11-02 用户截图问题修复
|
||||
|
||||
| 页面 | 操作及承载 | 截图依据与调整 | 验收 |
|
||||
|---|---|---|---|
|
||||
| 美本土散板 | 既有独立详情页 | 本轮第1张截图:地址跨两列时由网格紧密填补空位,备注占满一行,保留所有字段 | 定向测试与类型检查通过;待人工1920×1080、100%截图确认 |
|
||||
| 美国本土卡车 | 既有独立新增页 | 本轮第6、10张截图与文字确认:双地址必填;标题右侧补返回列表,沿用Client新增页按钮外观 | 定向测试与类型检查通过;待人工截图确认 |
|
||||
| 美国本土卡车 | 列表、详情及状态筛选 | 本轮第7、11张截图:补齐当前正式状态中文文案,DISPATCHED显示待取货 | 当前全部正式状态的定向测试通过 |
|
||||
|
||||
依据:用户提供的下单页截图 `codex-clipboard-c458c2e1-e9a8-4726-a8cd-4581e5e7102f.png` 及本轮字段、必填和时分确认。当前需求规则见 `docs/海外仓业务/README.md`“美国本土卡独立下单”。
|
||||
|
||||
| 页面 | 操作 | 依据及承载 | 当前调整 | 验收 |
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { apiRequest } from '@/api/http'
|
||||
import { apiRequest, apiBlobRequest } from '@/api/http'
|
||||
|
||||
export interface PageResult<T> {
|
||||
records: T[]
|
||||
@@ -27,6 +27,16 @@ export interface BusinessOrderQuery {
|
||||
}
|
||||
|
||||
export interface TransportSummary {
|
||||
deliveryMode?: 'DELIVERY_TO_WAREHOUSE' | 'WAREHOUSE_PICKUP'
|
||||
operatingWarehouseId?: string
|
||||
destinationType?: 'FBA' | 'PRIVATE'
|
||||
destinationPostalCode?: string
|
||||
deliveryContactName?: string
|
||||
deliveryContactPhone?: string
|
||||
estimatedArrivalAt?: string
|
||||
trackingNo?: string
|
||||
transferNo?: string
|
||||
|
||||
fleetArrangement: import('../fleetArrangement').FleetArrangement | null
|
||||
orderNo: string
|
||||
transportType?: string
|
||||
@@ -63,6 +73,16 @@ export interface TransportDetail {
|
||||
}
|
||||
|
||||
export interface TransportCreate {
|
||||
deliveryMode?: 'DELIVERY_TO_WAREHOUSE' | 'WAREHOUSE_PICKUP'
|
||||
operatingWarehouseId?: string
|
||||
destinationType?: 'FBA' | 'PRIVATE'
|
||||
destinationPostalCode?: string
|
||||
deliveryContactName?: string
|
||||
deliveryContactPhone?: string
|
||||
estimatedArrivalAt?: string
|
||||
trackingNo?: string
|
||||
transferNo?: string
|
||||
|
||||
productName?: string
|
||||
estimatedPalletCount: number
|
||||
origin: string
|
||||
@@ -130,6 +150,16 @@ export function getBusinessOrder<T>(kind: CrossBorderBusinessKind, orderNo: stri
|
||||
return apiRequest<T>({ method: 'GET', url: `${endpointByKind[kind]}/${encodeURIComponent(orderNo)}` })
|
||||
}
|
||||
|
||||
export interface TransportPhoto { fileId: string; fileName: string; contentType: string; sizeBytes: number }
|
||||
export interface TransportPhotos { pickupPhotos: TransportPhoto[]; arrivalPhotos: TransportPhoto[] }
|
||||
export type TransportPhotoPhase = 'PICKUP' | 'ARRIVAL'
|
||||
export function getTransportPhotos(kind: CrossBorderBusinessKind, orderNo: string) {
|
||||
return apiRequest<TransportPhotos>({ method: 'GET', url: `${endpointByKind[kind]}/${encodeURIComponent(orderNo)}/transport-photos` })
|
||||
}
|
||||
export function readTransportPhoto(kind: CrossBorderBusinessKind, orderNo: string, phase: TransportPhotoPhase, fileId: string) {
|
||||
return apiBlobRequest({ method: 'GET', url: `${endpointByKind[kind]}/${encodeURIComponent(orderNo)}/transport-photos/${phase}/${encodeURIComponent(fileId)}/content` })
|
||||
}
|
||||
|
||||
export function createTransport(kind: 'trucking', payload: TruckCreate): Promise<TransportDetail>
|
||||
export function createTransport(kind: 'looseboard', payload: TransportCreate): Promise<TransportDetail>
|
||||
export function createTransport(kind: CrossBorderBusinessKind, payload: TransportCreate | TruckCreate) {
|
||||
@@ -145,3 +175,8 @@ export function updateTransport(kind: CrossBorderBusinessKind, orderNo: string,
|
||||
data: payload,
|
||||
})
|
||||
}
|
||||
|
||||
/** 客户按当前版本取消待审核本土卡车订单。 */
|
||||
export function cancelTruckOrder(orderNo: string, payload: { version: number; status: 'CANCELLED'; idempotencyKey: string; remark: string }) {
|
||||
return apiRequest<TransportDetail>({ method: 'PATCH', url: endpointByKind.trucking + '/' + encodeURIComponent(orderNo) + '/status', data: payload })
|
||||
}
|
||||
|
||||
@@ -13,6 +13,19 @@ const demandHelpers = runInNewContext(`${demandLogic}\n({ createTransportDemandF
|
||||
const procurementApi = readFileSync(new URL('./api/procurement.ts', import.meta.url), 'utf8')
|
||||
const procurementView = readFileSync(new URL('./views/ProcurementView.vue', import.meta.url), 'utf8')
|
||||
|
||||
test('all current truck statuses display Chinese labels in lists and details', () => {
|
||||
const labelsCode = ts.transpileModule(readFileSync(new URL('./fleetArrangement.ts', import.meta.url), 'utf8'), { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ESNext } }).outputText.replace(/^export /gm, '')
|
||||
const { poolOrderStatusLabels } = runInNewContext(`${labelsCode}\n({ poolOrderStatusLabels })`)
|
||||
const helpers = view.slice(view.indexOf('function read('), view.indexOf('function idempotencyKey('))
|
||||
const compiled = ts.transpileModule(helpers, { compilerOptions: { target: ts.ScriptTarget.ES2022 } }).outputText
|
||||
const { statusLabel, displayCell } = runInNewContext(`${compiled}\n({ statusLabel, displayCell })`, { poolOrderStatusLabels })
|
||||
for (const status of ['PENDING_REVIEW', 'FUNDING_PENDING', 'FUNDING_FAILED', 'WAITING_INBOUND', 'RECEIVING', 'WAITING_CONSOLIDATION', 'PALLET_OVERAGE_REVIEW', 'PARTIALLY_ALLOCATED', 'ALLOCATED', 'PENDING_DISPATCH', 'DISPATCHED', 'IN_TRANSIT', 'COMPLETED', 'REJECTED', 'CANCELLED']) {
|
||||
assert.match(statusLabel(status), /[\u4e00-\u9fff]/, status)
|
||||
assert.equal(displayCell({ status }, 'status'), statusLabel(status))
|
||||
}
|
||||
assert.equal(statusLabel('DISPATCHED'), '待取货')
|
||||
})
|
||||
|
||||
test('renders unknown pallet service and container type as dashes while preserving explicit values', () => {
|
||||
const helpers = view.slice(view.indexOf('function read('), view.indexOf('function idempotencyKey('))
|
||||
const compiled = ts.transpileModule(helpers, { compilerOptions: { target: ts.ScriptTarget.ES2022 } }).outputText
|
||||
@@ -126,7 +139,7 @@ function transportHarness(kind = 'trucking', failFirst = false, budgetCheck = as
|
||||
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, formRef, submit, detail, detailFields })`, {
|
||||
const harness = runInNewContext(`${compiled}\n({ form, formRef, submit, detail, detailFields, configuration })`, {
|
||||
defineProps: () => ({ kind, createPage: true, detailPage: false }),
|
||||
withDefaults: props => props,
|
||||
computed: getter => ({ get value() { return typeof getter === 'function' ? getter() : getter.get() } }),
|
||||
@@ -136,6 +149,7 @@ function transportHarness(kind = 'trucking', failFirst = false, budgetCheck = as
|
||||
ElMessage: { success: () => {}, error: () => {} },
|
||||
createTransport: async (business, payload) => { calls.push({ business, payload: { ...payload } }); if (failFirst && calls.length === 1) throw new Error('retry'); },
|
||||
})
|
||||
Object.assign(harness.form, { deliveryMode: 'WAREHOUSE_PICKUP', operatingWarehouseId: '9', destinationType: 'FBA', originAddress: '123 Pickup Road', destinationAddress: '456 Delivery Road' })
|
||||
harness.formRef.value = { validate: async () => true, clearValidate: () => {} }
|
||||
return { ...harness, calls, rules: { get value() { return demandHelpers.transportDemandRules(kind === 'trucking' ? 'truck' : 'looseboard', harness.form) } } }
|
||||
}
|
||||
@@ -176,13 +190,18 @@ test('truck retry submits the complete new contract with identical local times a
|
||||
|
||||
test('truck details render saved minute times and unknown pool demand without timezone conversion', () => {
|
||||
const current = transportHarness()
|
||||
current.detail.value = { summary: { vehicleType: null, pickupAt: '2026-10-20T09:30:00', deliveryAt: null }, originAddress: null, destinationAddress: null, remark: null }
|
||||
current.detail.value = { summary: { vehicleType: null, palletCount: 3, packageCount: 0, pickupAt: '2026-10-20T09:30:00', deliveryAt: null }, originAddress: null, destinationAddress: null, remark: null }
|
||||
const fields = new Map(current.detailFields.value)
|
||||
assert.equal(fields.get('车辆类型'), '—')
|
||||
assert.equal(fields.get('取货时间'), '2026-10-20 09:30')
|
||||
assert.equal(fields.get('送达时间'), '—')
|
||||
assert.equal(fields.has('柜型'), false)
|
||||
assert.equal(fields.has('托盘数'), false)
|
||||
assert.equal(fields.get('托数'), 3)
|
||||
assert.equal(fields.has('件数'), false)
|
||||
const columns = current.configuration.value.columns
|
||||
assert.equal(columns.find(column => column.label === '托数').key, 'palletCount')
|
||||
assert.equal(columns.some(column => column.key === 'packageCount'), false)
|
||||
})
|
||||
|
||||
|
||||
@@ -199,6 +218,12 @@ test('looseboard saves split contacts and both local minute times without obsole
|
||||
assert.equal(payload.destinationContactPhone, '2')
|
||||
assert.equal(payload.deliveryAt, '2026-10-20T15:30:00')
|
||||
assert.equal(payload.idempotencyKey, current.calls[0].payload.idempotencyKey)
|
||||
current.detail.value = { summary: { ...payload }, originAddress: null, destinationAddress: null, remark: null }
|
||||
const fields = new Map(current.detailFields.value)
|
||||
assert.equal(fields.get('件数'), 8)
|
||||
assert.equal(fields.get('预计托数'), 3)
|
||||
assert.equal(fields.has('托数'), false)
|
||||
assert.equal(current.configuration.value.columns.some(column => column.key === 'palletCount'), false)
|
||||
for (const field of ['palletRequired', 'transportType', 'transportMode', 'containerType', 'contactName', 'contactInfo', 'requestedDate']) assert.equal(Object.hasOwn(payload, field), false)
|
||||
})
|
||||
|
||||
@@ -219,3 +244,17 @@ test('truck creation does not block on a separate budget reservation', async ()
|
||||
await current.submit()
|
||||
assert.equal(current.calls.length, 1)
|
||||
})
|
||||
|
||||
|
||||
test('customer truck cancellation uses encoded order identity and current version without client scope', async () => {
|
||||
const compiled = ts.transpileModule(api.replace(/^import .*$/gm, ''), { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ESNext } }).outputText.replace(/^export /gm, '')
|
||||
let sent
|
||||
const { cancelTruckOrder } = runInNewContext(compiled + '\n({ cancelTruckOrder })', { apiRequest: input => { sent = input; return Promise.resolve({}) } })
|
||||
const payload = { version: 3, status: 'CANCELLED', idempotencyKey: 'cancel-1', remark: '客户取消' }
|
||||
await cancelTruckOrder('TR/1', payload)
|
||||
assert.equal(sent.method, 'PATCH')
|
||||
assert.equal(sent.url, '/merchant/v1/last-leg/us-truck-orders/TR%2F1/status')
|
||||
assert.equal(sent.data, payload)
|
||||
assert.match(view, /summary.status === 'PENDING_REVIEW'/)
|
||||
assert.match(view, /merchant:cross-border:domestic-trucking:edit/)
|
||||
})
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage, type FormInstance } from 'element-plus'
|
||||
import { ElMessage, ElMessageBox, type FormInstance } from 'element-plus'
|
||||
import TransportDemandForm from './TransportDemandForm.vue'
|
||||
import OrderFundingStatus from './OrderFundingStatus.vue'
|
||||
import TransportPhotoGallery from './TransportPhotoGallery.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 {
|
||||
cancelTruckOrder,
|
||||
createTransport,
|
||||
getBusinessOrder,
|
||||
listBusinessOrders,
|
||||
@@ -61,6 +63,7 @@ function transportColumns(looseboard: boolean): ColumnDefinition[] {
|
||||
{ key: 'origin', label: originLabel, width: 150 },
|
||||
{ key: 'destination', label: destinationLabel, width: 150 },
|
||||
...(looseboard ? [{ key: 'productName', label: '商品名称', width: 150 }, { key: 'estimatedPalletCount', label: '预计托数', width: 100 }] : []),
|
||||
...(!looseboard ? [{ key: 'palletCount', label: '托数', width: 80 }] : []),
|
||||
{ key: 'totalWeightKg', label: '总重量(kg)', width: 120 },
|
||||
{ key: 'budgetAmount', label: '预算总价', width: 110 },
|
||||
...([
|
||||
@@ -68,7 +71,9 @@ function transportColumns(looseboard: boolean): ColumnDefinition[] {
|
||||
]),
|
||||
{ key: 'status', label: '状态', width: 130 },
|
||||
...(!looseboard ? [{ key: 'failureReason', label: '失败 / 拒绝原因', width: 240 }] : []),
|
||||
{ key: 'fleetArrangement', label: '承运安排', width: 190 },
|
||||
...(looseboard
|
||||
? [{ key: 'fleetArrangement', label: '承运安排', width: 190 }]
|
||||
: [{ key: 'createdAt', label: '创建时间', width: 180 }]),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -79,6 +84,7 @@ const submitKey = ref('')
|
||||
const error = ref('')
|
||||
const rows = ref<BusinessRow[]>([])
|
||||
const detail = ref<BusinessDetail | null>(null)
|
||||
const photoRefresh = ref(0)
|
||||
const fleetArrangement = computed(() => detail.value ? (detail.value as TransportDetail).summary.fleetArrangement : null)
|
||||
const formRef = ref<FormInstance>()
|
||||
const query = reactive({ page: 1, size: 20, orderNo: '', routeKeyword: '', status: '' })
|
||||
@@ -99,13 +105,23 @@ const queryModel = computed<QueryModel>({
|
||||
|
||||
const queryFields = computed<QueryField[]>(() => [
|
||||
{ key: 'orderNo', label: '订单号', type: 'input', placeholder: '请输入订单号' },
|
||||
{ key: 'routeKeyword', label: '运输路线', type: 'input', placeholder: props.kind === 'trucking' ? '请输入 B 点或 C 点' : '请输入起运地或目的地' },
|
||||
{ key: 'routeKeyword', label: '运输路线', type: 'input', placeholder: props.kind === 'trucking' ? '请输入取货地或目的地' : '请输入起运地或目的地' },
|
||||
{
|
||||
key: 'status',
|
||||
label: '状态',
|
||||
type: 'select',
|
||||
placeholder: '全部',
|
||||
options: [
|
||||
options: props.kind === 'trucking' ? [
|
||||
{ label: '订单受理中', value: 'FUNDING_PENDING' },
|
||||
{ label: '订单处理失败', value: 'FUNDING_FAILED' },
|
||||
{ label: '待审核', value: 'PENDING_REVIEW' },
|
||||
{ label: '待排车', value: 'PENDING_DISPATCH' },
|
||||
{ label: '待取货', value: 'DISPATCHED' },
|
||||
{ label: '运输中', value: 'IN_TRANSIT' },
|
||||
{ label: '已完成', value: 'COMPLETED' },
|
||||
{ label: '已拒绝', value: 'REJECTED' },
|
||||
{ label: '已取消', value: 'CANCELLED' },
|
||||
] : [
|
||||
{ label: '订单受理中', value: 'FUNDING_PENDING' },
|
||||
{ label: '订单处理失败', value: 'FUNDING_FAILED' },
|
||||
{ label: '待审核', value: 'PENDING_REVIEW' },
|
||||
@@ -134,7 +150,7 @@ const detailFields = computed(() => {
|
||||
] : [['车辆类型', vehicleTypeLabel(summary.vehicleType)]]),
|
||||
[props.kind === 'trucking' ? '取货地' : '起运地', read(summary, 'origin')], [props.kind === 'trucking' ? '取货地址' : '起运地址', current.originAddress],
|
||||
[props.kind === 'trucking' ? '送达地' : '目的地', read(summary, 'destination')], [props.kind === 'trucking' ? '送达地址' : '目的地址', current.destinationAddress],
|
||||
['件数', read(summary, 'packageCount')], ['总重量(kg)', read(summary, 'totalWeightKg')],
|
||||
[props.kind === 'trucking' ? '托数' : '件数', read(summary, props.kind === 'trucking' ? 'palletCount' : 'packageCount')], ['总重量(kg)', read(summary, 'totalWeightKg')],
|
||||
['总体积(m³)', read(summary, 'totalVolumeCbm')], ['预算总价', money(read(summary, 'budgetAmount'), read(summary, 'currency'))],
|
||||
[props.kind === 'trucking' ? '取货联系人' : '起运地联系人', summary.originContactName], [props.kind === 'trucking' ? '取货联系电话' : '起运地联系电话', summary.originContactPhone],
|
||||
[props.kind === 'trucking' ? '送达联系人' : '目的地联系人', summary.destinationContactName], [props.kind === 'trucking' ? '送达联系电话' : '目的地联系电话', summary.destinationContactPhone],
|
||||
@@ -152,6 +168,24 @@ const tableColumns = computed<DataTableColumn[]>(() => [
|
||||
})),
|
||||
])
|
||||
|
||||
const canCancelTruck = computed(() => props.kind === 'trucking' && detail.value?.summary.status === 'PENDING_REVIEW'
|
||||
&& auth.hasPermission('merchant:cross-border:domestic-trucking:edit'))
|
||||
/** 取消前核对客户意愿,失败保留订单并刷新服务端状态。 */
|
||||
async function cancelTruck() {
|
||||
if (!canCancelTruck.value || !detail.value || submitting.value) return
|
||||
let reason: string
|
||||
try {
|
||||
const result = await ElMessageBox.prompt('请填写取消原因', '取消订单', { inputValidator: value => Boolean(value?.trim()) || '请填写取消原因', confirmButtonText: '确认取消', cancelButtonText: '保留订单', type: 'warning' })
|
||||
reason = result.value.trim()
|
||||
} catch { return }
|
||||
submitting.value = true
|
||||
try {
|
||||
detail.value = await cancelTruckOrder(detail.value.summary.orderNo, { version: detail.value.summary.version, status: 'CANCELLED', idempotencyKey: idempotencyKey(), remark: reason })
|
||||
ElMessage.success('订单已取消')
|
||||
} catch (cause) { ElMessage.error(errorMessage(cause)); await loadDetail() }
|
||||
finally { submitting.value = false }
|
||||
}
|
||||
|
||||
const detailStatus = computed(() => detail.value ? statusLabel(detail.value.summary.status) : '—')
|
||||
|
||||
function emptyForm(): BusinessForm { return createTransportDemandForm() }
|
||||
@@ -221,7 +255,8 @@ function getRowActions(candidate: object): TableAction[] {
|
||||
}
|
||||
|
||||
function detailFieldClass(label: unknown) {
|
||||
return ['备注', props.kind === 'trucking' ? '取货地址' : '起运地址', props.kind === 'trucking' ? '送达地址' : '目的地址'].includes(String(label)) ? 'is-wide' : ''
|
||||
if (label === '备注') return 'is-full'
|
||||
return [props.kind === 'trucking' ? '取货地址' : '起运地址', props.kind === 'trucking' ? '送达地址' : '目的地址'].includes(String(label)) ? 'is-wide' : ''
|
||||
}
|
||||
|
||||
const listRouteName = computed(() => ({
|
||||
@@ -251,6 +286,7 @@ async function loadDetail() {
|
||||
error.value = ''
|
||||
try {
|
||||
detail.value = await getBusinessOrder<TransportDetail>(props.kind, detailOrderNo.value)
|
||||
photoRefresh.value++
|
||||
mode.value = 'detail'
|
||||
} catch (cause) { detail.value = null; error.value = errorMessage(cause); ElMessage.error(error.value) }
|
||||
finally { loading.value = false }
|
||||
@@ -312,17 +348,17 @@ onMounted(() => { if (props.detailPage) void loadDetail(); else if (!props.creat
|
||||
<section v-else-if="mode === 'detail'" class="business-detail-page" aria-label="订单详情">
|
||||
<header class="business-detail-heading">
|
||||
<div class="business-detail-heading-main"><el-button link type="primary" @click="returnToList">← 返回列表</el-button><div><h2>{{ detail?.summary.orderNo || '订单详情' }}</h2></div></div>
|
||||
<el-tag :type="statusTone(detail?.summary.status)" effect="light">{{ detailStatus }}</el-tag>
|
||||
<div class="business-detail-heading-actions"><el-button v-if="canCancelTruck" type="danger" :loading="submitting" :disabled="loading" @click="cancelTruck">取消订单</el-button><el-tag :type="statusTone(detail?.summary.status)" effect="light">{{ detailStatus }}</el-tag><el-button :disabled="loading" @click="loadDetail">刷新</el-button></div>
|
||||
</header>
|
||||
<el-button :disabled="loading" @click="loadDetail">刷新</el-button>
|
||||
<OrderFundingStatus v-if="kind === 'trucking' && detail" kind="trucking" :order-no="detail.summary.orderNo" :status="detail.summary.status" :can-retry="canCreate" @changed="loadDetail" />
|
||||
<div v-if="error" class="business-detail-error" role="alert"><strong>{{ error }}</strong><el-button size="small" @click="loadDetail">重新加载</el-button></div>
|
||||
<section v-else class="business-detail-section">
|
||||
<h3>订单信息</h3>
|
||||
<dl class="detail-grid" :class="{ 'trucking-detail-grid': kind === 'trucking' }">
|
||||
<dl class="detail-grid" :class="{ 'trucking-detail-grid': kind === 'trucking', 'looseboard-detail-grid': kind === 'looseboard' }">
|
||||
<div v-for="field in detailFields" :key="String(field[0])" :class="detailFieldClass(field[0])"><dt>{{ field[0] }}</dt><dd>{{ text(field[1]) }}</dd></div>
|
||||
</dl>
|
||||
</section>
|
||||
<TransportPhotoGallery v-if="!error && detail" :kind="kind" :order-no="detail.summary.orderNo" :refresh-key="photoRefresh" />
|
||||
<section v-if="!error && fleetArrangement" class="business-detail-section" aria-label="承运安排">
|
||||
<h3>承运安排</h3>
|
||||
<dl class="detail-grid arrangement-grid">
|
||||
@@ -363,6 +399,7 @@ onMounted(() => { if (props.detailPage) void loadDetail(); else if (!props.creat
|
||||
.business-detail-heading { display: flex; min-height: 50px; align-items: center; justify-content: space-between; gap: 16px; padding: 5px 0; border-bottom: 1px solid var(--border); }
|
||||
.business-detail-heading-main { display: flex; min-width: 0; align-items: center; gap: 12px; }
|
||||
.business-detail-heading-main > div { min-width: 0; }
|
||||
.business-detail-heading-actions { display: flex; align-items: center; gap: 12px; flex: 0 0 auto; }
|
||||
.business-detail-heading h2 { margin: 0; overflow: hidden; color: var(--navy); font-size: 18px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.business-detail-heading p { margin: 3px 0 0; color: var(--muted); font-size: 11px; }
|
||||
.business-detail-section { margin-top: 10px; }
|
||||
@@ -371,6 +408,8 @@ onMounted(() => { if (props.detailPage) void loadDetail(); else if (!props.creat
|
||||
.detail-grid div { display: grid; min-width: 0; min-height: 34px; grid-template-columns: 76px minmax(0, 1fr); align-items: center; gap: 7px; padding: 3px 8px; border-right: 1px solid var(--border); border-bottom: 1px solid var(--border); }
|
||||
.trucking-detail-grid div { grid-template-columns: 112px minmax(0, 1fr); }
|
||||
.detail-grid div.is-wide { grid-column: span 2; }
|
||||
.detail-grid div.is-full { grid-column: 1 / -1; }
|
||||
.looseboard-detail-grid { grid-auto-flow: row dense; }
|
||||
.detail-grid dt { color: var(--muted); font-size: 10px; white-space: nowrap; }
|
||||
.arrangement-grid div { grid-template-columns: 124px minmax(0, 1fr); }
|
||||
.arrangement-grid dt { white-space: normal; overflow-wrap: anywhere; }
|
||||
@@ -382,6 +421,21 @@ onMounted(() => { if (props.detailPage) void loadDetail(); else if (!props.creat
|
||||
.business-orders--create .page-head :deep(.el-button) { height: auto; min-height: 44px; padding: 10px 18px; border-radius: 6px; color: #1d5fe0; font-size: 14px; box-shadow: none; }
|
||||
.business-orders--create .form-card { min-width: 0; overflow: visible; border: 0; border-radius: 0; background: transparent; box-shadow: none; }
|
||||
|
||||
/* The detail page is intentionally calmer than the list: the order identity is
|
||||
separated from the data grid, while values remain easy to scan at desktop size. */
|
||||
.business-detail-page { padding: 18px 20px 24px; border-color: #dbe4ef; border-radius: 10px; box-shadow: 0 2px 8px rgb(15 42 74 / 4%); }
|
||||
.business-detail-heading { min-height: 58px; padding: 0 0 14px; border-bottom-color: #e5ebf3; }
|
||||
.business-detail-heading-main { gap: 18px; }
|
||||
.business-detail-heading-main :deep(.el-button) { padding: 0; font-size: 14px; }
|
||||
.business-detail-heading h2 { font-size: 22px; letter-spacing: .01em; }
|
||||
.business-detail-section { margin-top: 20px; }
|
||||
.business-detail-section h3 { padding: 0 0 10px; border-bottom: 0; font-size: 15px; }
|
||||
.detail-grid { border-color: #e2e8f0; }
|
||||
.detail-grid div { min-height: 46px; gap: 12px; padding: 8px 14px; background: #fff; border-color: #e2e8f0; }
|
||||
.detail-grid dt { color: #718096; font-size: 12px; }
|
||||
.detail-grid dd { color: #1f3552; font-size: 14px; font-weight: 600; }
|
||||
.arrangement-grid { background: #f8fafc; }
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.business-orders { gap: 14px; }
|
||||
.page-head { align-items: flex-start; gap: 12px; }
|
||||
@@ -392,6 +446,7 @@ onMounted(() => { if (props.detailPage) void loadDetail(); else if (!props.creat
|
||||
.detail-grid div, .detail-grid div.is-wide { grid-column: auto; }
|
||||
.detail-grid dd { overflow-wrap: break-word; word-break: normal; }
|
||||
.business-detail-heading { align-items: flex-start; flex-direction: column; }
|
||||
.business-detail-heading-actions { width: 100%; justify-content: space-between; }
|
||||
|
||||
.form-card :deep(.el-form-item) { display: block; }
|
||||
.form-card :deep(.el-form-item__label) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, ref, onMounted, watch } from 'vue'
|
||||
import { getMerchantWarehouses, type MerchantWarehouse } from '@/modules/first-leg/api/firstLeg'
|
||||
import type { FormInstance } from 'element-plus'
|
||||
import { transportDemandRules, type TransportDemandForm, type TransportDemandKind } from '../transportDemandForm'
|
||||
|
||||
@@ -13,6 +14,20 @@ const props = withDefaults(defineProps<{
|
||||
}>(), { disabled: false, submitting: false, canSubmit: true, editing: false })
|
||||
const emit = defineEmits<{ submit: []; cancel: [] }>()
|
||||
const formRef = ref<FormInstance>()
|
||||
const warehouses = ref<MerchantWarehouse[]>([]), warehouseError = ref(''), warehouseLoading = ref(false)
|
||||
async function loadWarehouses() {
|
||||
if (props.kind !== 'looseboard') return
|
||||
warehouseLoading.value = true; warehouseError.value = ''
|
||||
try { warehouses.value = (await getMerchantWarehouses({ warehouseType: 'OVERSEAS', status: 'ACTIVE', size: 200 })).records }
|
||||
catch (cause) { warehouseError.value = cause instanceof Error ? cause.message : '仓库读取失败' }
|
||||
finally { warehouseLoading.value = false }
|
||||
}
|
||||
function chooseWarehouse() {
|
||||
const warehouse = warehouses.value.find(row => String(row.id) === String(props.model.operatingWarehouseId))
|
||||
props.model.deliveryContactName = warehouse?.contactName || ''; props.model.deliveryContactPhone = warehouse?.contactPhone || ''
|
||||
}
|
||||
onMounted(loadWarehouses)
|
||||
watch([() => props.model.operatingWarehouseId, warehouses], chooseWarehouse, { immediate: true })
|
||||
const rules = computed(() => transportDemandRules(props.kind, props.model))
|
||||
const isCreate = computed(() => !props.editing)
|
||||
const createPopperClass = computed(() => isCreate.value ? 'transport-create-popper' : undefined)
|
||||
@@ -25,24 +40,38 @@ defineExpose({
|
||||
<template>
|
||||
<el-form ref="formRef" class="transport-demand-form" :class="{ 'transport-demand-form--create': isCreate }" :model="model" :rules="rules" label-position="top" :disabled="disabled || submitting">
|
||||
<slot name="customer" />
|
||||
<section v-if="kind === 'looseboard'" class="transport-demand-section">
|
||||
<h3>入仓安排</h3>
|
||||
<div class="transport-demand-grid">
|
||||
<el-form-item class="transport-demand-wide" label="送货方式" prop="deliveryMode"><el-radio-group v-model="model.deliveryMode"><el-radio-button value="DELIVERY_TO_WAREHOUSE">送货到仓</el-radio-button><el-radio-button value="WAREHOUSE_PICKUP">仓库上门取货</el-radio-button></el-radio-group></el-form-item>
|
||||
<el-form-item label="收货仓库" prop="operatingWarehouseId"><el-select v-model="model.operatingWarehouseId" :loading="warehouseLoading" filterable placeholder="请选择收货仓库"><el-option v-for="warehouse in warehouses" :key="String(warehouse.id)" :value="String(warehouse.id)" :label="warehouse.warehouseName" /></el-select><span v-if="warehouseError" role="alert">{{ warehouseError }} <el-button link @click="loadWarehouses">重试</el-button></span></el-form-item>
|
||||
<template v-if="model.deliveryMode === 'DELIVERY_TO_WAREHOUSE'">
|
||||
<el-form-item label="到仓联系人" prop="deliveryContactName"><el-input v-model="model.deliveryContactName" readonly placeholder="随收货仓库带出,未维护请联系仓库" /></el-form-item>
|
||||
<el-form-item label="到仓联系电话" prop="deliveryContactPhone"><el-input v-model="model.deliveryContactPhone" readonly placeholder="随收货仓库带出,未维护请联系仓库" /></el-form-item>
|
||||
<el-form-item label="预计到货时间" prop="estimatedArrivalAt"><el-date-picker v-model="model.estimatedArrivalAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" format="YYYY-MM-DD HH:mm" /></el-form-item>
|
||||
<el-form-item label="物流跟踪号" prop="trackingNo"><el-input v-model.trim="model.trackingNo" maxlength="255" /></el-form-item>
|
||||
</template>
|
||||
</div>
|
||||
</section>
|
||||
<section class="transport-demand-section">
|
||||
<h3>运输信息</h3>
|
||||
<div class="transport-demand-grid transport-demand-route-grid">
|
||||
<el-form-item v-if="kind === 'looseboard' && model.destinationType === 'PRIVATE'" label="目的地邮编" prop="destinationPostalCode"><el-input v-model.trim="model.destinationPostalCode" maxlength="32" /></el-form-item>
|
||||
<el-form-item v-if="kind === 'truck'" class="transport-demand-wide transport-demand-vehicle" label="车辆类型" prop="vehicleType">
|
||||
<el-select v-model="model.vehicleType" :popper-class="createPopperClass" :placeholder="isCreate ? '请选择车辆类型' : ''"><el-option label="15拖" value="PALLET_15" /><el-option label="30拖" value="PALLET_30" /></el-select>
|
||||
</el-form-item>
|
||||
<el-form-item :label="kind === 'truck' ? '取货地' : '起运地'" prop="origin"><el-input v-model.trim="model.origin" maxlength="255" placeholder="城市 / 港口 / 州" /></el-form-item>
|
||||
<el-form-item v-if="kind === 'truck' || model.deliveryMode === 'WAREHOUSE_PICKUP'" :label="kind === 'truck' ? '取货地' : '起运地'" prop="origin"><el-input v-model.trim="model.origin" maxlength="255" placeholder="城市 / 港口 / 州" /></el-form-item>
|
||||
<el-form-item v-if="!isCreate" :label="kind === 'truck' ? '送达地' : '目的地'" prop="destination"><el-input v-model.trim="model.destination" maxlength="255" placeholder="城市 / 州" /></el-form-item>
|
||||
<el-form-item :label="kind === 'truck' ? '取货联系人' : '起运地联系人'" prop="originContactName"><el-input v-model.trim="model.originContactName" maxlength="128" :placeholder="isCreate ? (kind === 'truck' ? '请输入取货联系人姓名' : '请输入起运地联系人姓名') : ''" /></el-form-item>
|
||||
<el-form-item v-if="kind === 'truck' || model.deliveryMode === 'WAREHOUSE_PICKUP'" :label="kind === 'truck' ? '取货联系人' : '起运地联系人'" prop="originContactName"><el-input v-model.trim="model.originContactName" maxlength="128" :placeholder="isCreate ? (kind === 'truck' ? '请输入取货联系人姓名' : '请输入起运地联系人姓名') : ''" /></el-form-item>
|
||||
<el-form-item v-if="!isCreate" :label="kind === 'truck' ? '送达联系人' : '目的地联系人'" prop="destinationContactName"><el-input v-model.trim="model.destinationContactName" maxlength="128" placeholder="" /></el-form-item>
|
||||
<el-form-item :label="kind === 'truck' ? '取货联系电话' : '起运地联系电话'" prop="originContactPhone"><el-input v-model.trim="model.originContactPhone" type="tel" maxlength="128" :placeholder="isCreate ? (kind === 'truck' ? '请输入取货联系电话' : '请输入起运地联系电话') : ''" /></el-form-item>
|
||||
<el-form-item v-if="kind === 'truck' || model.deliveryMode === 'WAREHOUSE_PICKUP'" :label="kind === 'truck' ? '取货联系电话' : '起运地联系电话'" prop="originContactPhone"><el-input v-model.trim="model.originContactPhone" type="tel" maxlength="128" :placeholder="isCreate ? (kind === 'truck' ? '请输入取货联系电话' : '请输入起运地联系电话') : ''" /></el-form-item>
|
||||
<template v-if="isCreate">
|
||||
<el-form-item :label="kind === 'truck' ? '送达地' : '目的地'" prop="destination"><el-input v-model.trim="model.destination" maxlength="255" placeholder="城市 / 州" /></el-form-item>
|
||||
<el-form-item :label="kind === 'truck' ? '送达联系人' : '目的地联系人'" prop="destinationContactName"><el-input v-model.trim="model.destinationContactName" maxlength="128" :placeholder="kind === 'truck' ? '请输入送达联系人姓名' : '请输入目的地联系人姓名'" /></el-form-item>
|
||||
</template>
|
||||
<el-form-item :label="kind === 'truck' ? '送达联系电话' : '目的地联系电话'" prop="destinationContactPhone"><el-input v-model.trim="model.destinationContactPhone" type="tel" maxlength="128" :placeholder="isCreate ? (kind === 'truck' ? '请输入送达联系电话' : '请输入目的地联系电话') : ''" /></el-form-item>
|
||||
<el-form-item class="transport-demand-address" :label="kind === 'truck' ? '取货地址' : '起运详细地址'"><el-input v-model.trim="model.originAddress" maxlength="500" placeholder="街道 / 门牌等补充信息(选填)" /></el-form-item>
|
||||
<el-form-item class="transport-demand-address" :label="kind === 'truck' ? '送达地址' : '目的详细地址'"><el-input v-model.trim="model.destinationAddress" maxlength="500" placeholder="街道 / 门牌等补充信息(选填)" /></el-form-item>
|
||||
<el-form-item v-if="kind === 'truck' || model.deliveryMode === 'WAREHOUSE_PICKUP'" class="transport-demand-address" :label="kind === 'truck' ? '取货地址' : '起运详细地址'" prop="originAddress"><el-input v-model.trim="model.originAddress" maxlength="500" placeholder="请输入详细地址" /></el-form-item>
|
||||
<el-form-item class="transport-demand-address" :label="kind === 'truck' ? '送达地址' : '目的详细地址'" prop="destinationAddress"><el-input v-model.trim="model.destinationAddress" maxlength="500" :placeholder="kind === 'truck' ? '请输入详细地址' : model.destinationType === 'PRIVATE' ? '请输入目的详细地址' : '街道 / 门牌等补充信息(选填)'" /></el-form-item>
|
||||
</div>
|
||||
</section>
|
||||
<section class="transport-demand-section">
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { getTransportPhotos, readTransportPhoto, type CrossBorderBusinessKind, type TransportPhotos, type TransportPhotoPhase } from '../api/businessOrders'
|
||||
|
||||
const props = defineProps<{ kind: CrossBorderBusinessKind; orderNo: string; refreshKey: number }>()
|
||||
const photos = ref<TransportPhotos | null>(null), urls = ref<Record<string, string>>({}), loading = ref(false), error = ref('')
|
||||
const groups: { phase: TransportPhotoPhase; key: keyof TransportPhotos; label: string }[] = [
|
||||
{ phase: 'PICKUP', key: 'pickupPhotos', label: '取货照片' }, { phase: 'ARRIVAL', key: 'arrivalPhotos', label: '派送照片' },
|
||||
]
|
||||
const previewUrls = computed(() => groups.flatMap(group => (photos.value?.[group.key] || []).filter(file => file.contentType.startsWith('image/')).map(file => urls.value[`${group.phase}:${file.fileId}`]).filter((url): url is string => Boolean(url))))
|
||||
let sequence = 0
|
||||
function clear() { Object.values(urls.value).forEach(url => URL.revokeObjectURL(url)); urls.value = {} }
|
||||
async function load() {
|
||||
const current = ++sequence, kind = props.kind, number = props.orderNo
|
||||
loading.value = true; error.value = ''; photos.value = null; clear()
|
||||
try {
|
||||
const value = await getTransportPhotos(kind, number)
|
||||
if (current !== sequence) return
|
||||
photos.value = value
|
||||
await Promise.all(groups.flatMap(group => value[group.key].map(async file => {
|
||||
try {
|
||||
const blob = await readTransportPhoto(kind, number, group.phase, file.fileId)
|
||||
if (current === sequence) urls.value[`${group.phase}:${file.fileId}`] = URL.createObjectURL(blob)
|
||||
} catch { if (current === sequence) error.value = '部分照片加载失败,请重试' }
|
||||
})))
|
||||
} catch (cause) { if (current === sequence) error.value = cause instanceof Error ? cause.message : '运输照片加载失败' }
|
||||
finally { if (current === sequence) loading.value = false }
|
||||
}
|
||||
watch(() => [props.kind, props.orderNo, props.refreshKey], () => void load(), { immediate: true })
|
||||
onBeforeUnmount(() => { sequence++; clear() })
|
||||
</script>
|
||||
<template>
|
||||
<section class="transport-photos" aria-label="运输照片" v-loading="loading">
|
||||
<div v-if="error" class="photo-error" role="alert">{{ error }} <el-button link type="primary" :disabled="loading" @click="load">重试</el-button></div>
|
||||
<section v-for="group in groups" :key="group.phase" class="photo-group">
|
||||
<h3>{{ group.label }}</h3>
|
||||
<div class="photo-row">
|
||||
<template v-for="file in photos?.[group.key] || []" :key="file.fileId">
|
||||
<el-image v-if="file.contentType.startsWith('image/')" :src="urls[group.phase + ':' + file.fileId]" :alt="group.label" fit="contain" :preview-src-list="previewUrls" :initial-index="previewUrls.indexOf(urls[group.phase + ':' + file.fileId] || '')" preview-teleported><template #error><span>{{ loading ? '加载中' : '未加载' }}</span></template></el-image>
|
||||
<a v-else :href="urls[group.phase + ':' + file.fileId]" target="_blank" rel="noopener">查看凭证</a>
|
||||
</template>
|
||||
<span v-if="!loading && !photos?.[group.key].length" class="photo-empty">—</span>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
</template>
|
||||
<style scoped>
|
||||
.transport-photos { margin-top: 20px; }
|
||||
.photo-group + .photo-group { margin-top: 16px; }
|
||||
.photo-group h3 { margin: 0 0 10px; color: var(--navy); font-size: 15px; }
|
||||
.photo-row { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; }
|
||||
.photo-row :deep(.el-image) { width: 112px; height: 84px; border: 1px solid var(--border); border-radius: 4px; }
|
||||
.photo-row :deep(.el-image__error) { font-size: 12px; }
|
||||
.photo-empty { color: var(--muted); }
|
||||
.photo-error { margin-bottom: 10px; color: var(--el-color-danger); }
|
||||
</style>
|
||||
@@ -24,5 +24,6 @@ export interface FleetArrangement {
|
||||
}
|
||||
export const poolOrderStatusLabels: Record<string, string> = {
|
||||
WAITING_INBOUND: '待入仓', RECEIVING: '收货中', WAITING_CONSOLIDATION: '待合托',
|
||||
PALLET_OVERAGE_REVIEW: '托数超量待审核', PARTIALLY_ALLOCATED: '部分已排车', ALLOCATED: '已排车', DISPATCHED: '待取货',
|
||||
PENDING_DISPATCH: '待派车', IN_TRANSIT: '运输中', COMPLETED: '已完成', CANCELLED: '已取消',
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { runInNewContext } from 'node:vm'
|
||||
import ts from 'typescript'
|
||||
import * as vue from 'vue'
|
||||
import { parse, compileScript } from 'vue/compiler-sfc'
|
||||
import { renderToString } from 'vue/server-renderer'
|
||||
|
||||
const { default: Schema } = createRequire(import.meta.resolve('element-plus'))('async-validator')
|
||||
|
||||
@@ -63,6 +64,27 @@ test('new requests get unique keys without restoring stored submissions', () =>
|
||||
|
||||
class ApiError extends Error { constructor(message, code, status) { super(message); this.code = code; this.status = status } }
|
||||
const flush = async () => { await vue.nextTick(); await new Promise(resolve => setImmediate(resolve)); await vue.nextTick() }
|
||||
|
||||
test('shows only the active procurement rejection, hiding it after reupload, resubmission and confirmation', async () => {
|
||||
const source = readFileSync(new URL('./views/ProcurementView.vue', import.meta.url), 'utf8')
|
||||
const template = source.match(/<el-alert\b[^>]*title="采购结果驳回原因"[^>]*\/>/)?.[0]
|
||||
assert.ok(template)
|
||||
for (const [status, photosNeedUpdate, rejectionReason, visible] of [
|
||||
['RECEIVING', true, '照片不清晰', true],
|
||||
['RECEIVING', false, '照片不清晰', true],
|
||||
['RECEIVING', false, null, false],
|
||||
['WAIT_CUSTOMER_CONFIRM', true, '照片不清晰', false],
|
||||
['CONFIRMED', true, '照片不清晰', false],
|
||||
['RECEIVING', true, null, false],
|
||||
]) {
|
||||
const app = vue.createSSRApp({ render: vue.compile(template), setup: () => ({ workflow: { status, photosNeedUpdate, rejectionReason } }) })
|
||||
app.component('ElAlert', { props: ['title', 'description'], setup: props => () => vue.h('aside', `${props.title} ${props.description}`) })
|
||||
const html = await renderToString(app)
|
||||
assert.equal(html.includes('采购结果驳回原因'), visible, `${status}, photosNeedUpdate=${photosNeedUpdate}`)
|
||||
assert.equal(html.includes('照片不清晰'), visible)
|
||||
}
|
||||
})
|
||||
|
||||
function viewHarness({ createPage = false, detailPage = false, api = {}, storage = new Map() } = {}) {
|
||||
const props = vue.reactive({ createPage, detailPage })
|
||||
const route = vue.reactive({ params: { orderNo: detailPage ? 'PO-1' : '' } })
|
||||
|
||||
@@ -6,8 +6,26 @@ 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', estimatedPalletCount: 3, budgetAmount: 100, 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' })
|
||||
const { createTransportDemandForm, transportDemandRules, transportDemandError, buildTransportDemand } = runInNewContext(`${code}\n({ createTransportDemandForm, transportDemandRules, transportDemandError, buildTransportDemand })`)
|
||||
const valid = () => ({ ...createTransportDemandForm(), deliveryMode: 'WAREHOUSE_PICKUP', operatingWarehouseId: '9', destinationType: 'FBA', packageCount: 8, vehicleType: 'PALLET_15', estimatedPalletCount: 3, budgetAmount: 100, origin: ' LA ', originAddress: '123 Test Road', destinationAddress: '456 Delivery Road', 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('truck requires both full addresses in form validation and submission', () => {
|
||||
for (const [field, label] of [['originAddress', '取货地址'], ['destinationAddress', '送达地址']]) {
|
||||
for (const value of ['', ' ']) {
|
||||
const form = { ...valid(), [field]: value }
|
||||
assert.equal(transportDemandRules('truck', form)[field][0].required, true)
|
||||
assert.equal(transportDemandError('truck', form), `请输入${label}`)
|
||||
assert.throws(() => buildTransportDemand('truck', form), new RegExp(label))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('delivery to warehouse permits no tracking number while requiring receiving contacts and arrival time', () => {
|
||||
const form = { ...valid(), deliveryMode: 'DELIVERY_TO_WAREHOUSE', deliveryContactName: '仓库收货员', deliveryContactPhone: '10003', estimatedArrivalAt: '2026-10-20T09:00:00', trackingNo: '' }
|
||||
assert.equal(transportDemandError('looseboard', form), undefined)
|
||||
assert.equal(buildTransportDemand('looseboard', form).trackingNo, undefined)
|
||||
for (const field of ['deliveryContactName', 'deliveryContactPhone', 'estimatedArrivalAt']) assert.ok(transportDemandError('looseboard', { ...form, [field]: '' }))
|
||||
})
|
||||
|
||||
test('initial form leaves contacts and times blank, zeroes cargo and distinguishes blank budget from zero', () => {
|
||||
const form = createTransportDemandForm()
|
||||
@@ -61,3 +79,11 @@ test('truck accepts only the two vehicle types and does not submit looseboard ca
|
||||
assert.equal(loose.estimatedPalletCount, 3)
|
||||
assert.equal(loose.packageCount, 8)
|
||||
})
|
||||
|
||||
test('warehouse delivery clears hidden origin data and pickup requires the full origin address', () => {
|
||||
const form={...valid(),deliveryMode:'DELIVERY_TO_WAREHOUSE',deliveryContactName:'warehouse',deliveryContactPhone:'000',estimatedArrivalAt:'2026-10-20T09:00:00'}
|
||||
for(const field of ['origin','originAddress','originContactName','originContactPhone']) assert.equal(transportDemandError('looseboard',{...form,[field]:''}),undefined)
|
||||
const payload=buildTransportDemand('looseboard',form)
|
||||
assert.equal(payload.origin,'');assert.equal(payload.originAddress,undefined);assert.equal(payload.originContactName,'');assert.equal(payload.originContactPhone,'')
|
||||
assert.match(transportDemandError('looseboard',{...valid(),originAddress:''}),/详细地址/)
|
||||
})
|
||||
|
||||
@@ -2,6 +2,14 @@ import type { FormRules } from 'element-plus'
|
||||
|
||||
export type TransportDemandKind = 'truck' | 'looseboard'
|
||||
export interface TransportDemandForm {
|
||||
deliveryMode: 'DELIVERY_TO_WAREHOUSE' | 'WAREHOUSE_PICKUP'
|
||||
operatingWarehouseId: string
|
||||
deliveryContactName: string
|
||||
deliveryContactPhone: string
|
||||
estimatedArrivalAt: string
|
||||
trackingNo: string
|
||||
destinationType: 'FBA' | 'PRIVATE'
|
||||
destinationPostalCode: string
|
||||
vehicleType: '' | 'PALLET_15' | 'PALLET_30'
|
||||
origin: string
|
||||
destination: string
|
||||
@@ -25,6 +33,7 @@ export interface TransportDemandForm {
|
||||
|
||||
export function createTransportDemandForm(): TransportDemandForm {
|
||||
return {
|
||||
deliveryMode: 'DELIVERY_TO_WAREHOUSE', operatingWarehouseId: '', deliveryContactName: '', deliveryContactPhone: '', estimatedArrivalAt: '', trackingNo: '', destinationType: 'PRIVATE', destinationPostalCode: '',
|
||||
vehicleType: '', origin: '', destination: '', originAddress: '', destinationAddress: '',
|
||||
originContactName: '', originContactPhone: '', destinationContactName: '', destinationContactPhone: '',
|
||||
productName: '', estimatedPalletCount: 0, packageCount: 0, totalWeightKg: 0, totalVolumeCbm: 0,
|
||||
@@ -32,11 +41,10 @@ export function createTransportDemandForm(): TransportDemandForm {
|
||||
}
|
||||
}
|
||||
|
||||
const requiredText = (kind: TransportDemandKind) => ({
|
||||
origin: kind === 'truck' ? '取货地' : '起运地',
|
||||
const requiredText = (kind: TransportDemandKind, form: TransportDemandForm) => ({
|
||||
...(kind === 'truck' ? { originAddress: '取货地址', destinationAddress: '送达地址' } : {}),
|
||||
...(kind === 'truck' || form.deliveryMode === 'WAREHOUSE_PICKUP' ? { origin: kind === 'truck' ? '取货地' : '起运地', originContactName: kind === 'truck' ? '取货联系人' : '起运地联系人', originContactPhone: kind === 'truck' ? '取货联系电话' : '起运地联系电话' } : {}),
|
||||
destination: kind === 'truck' ? '送达地' : '目的地',
|
||||
originContactName: kind === 'truck' ? '取货联系人' : '起运地联系人',
|
||||
originContactPhone: kind === 'truck' ? '取货联系电话' : '起运地联系电话',
|
||||
destinationContactName: kind === 'truck' ? '送达联系人' : '目的地联系人',
|
||||
destinationContactPhone: kind === 'truck' ? '送达联系电话' : '目的地联系电话',
|
||||
} as const)
|
||||
@@ -47,10 +55,21 @@ export function localMinuteInput(value: unknown) {
|
||||
|
||||
export function transportDemandRules(kind: TransportDemandKind, form: TransportDemandForm): FormRules<TransportDemandForm> {
|
||||
const rules: FormRules<TransportDemandForm> = {}
|
||||
const labels = requiredText(kind)
|
||||
const labels = requiredText(kind, form)
|
||||
for (const [field, label] of Object.entries(labels)) {
|
||||
rules[field as keyof typeof labels] = [{ required: true, whitespace: true, message: `请输入${label}`, trigger: 'blur' }]
|
||||
}
|
||||
if (kind === 'looseboard') {
|
||||
if (form.deliveryMode === 'WAREHOUSE_PICKUP') rules.originAddress = [{ required: true, whitespace: true, message: '请输入起运详细地址', trigger: 'blur' }]
|
||||
rules.operatingWarehouseId = [{ required: true, message: '请选择收货仓库', trigger: 'change' }]
|
||||
if (form.destinationType === 'PRIVATE') {
|
||||
rules.destinationAddress = [{ required: true, whitespace: true, message: '请输入目的详细地址', trigger: 'blur' }]
|
||||
rules.destinationPostalCode = [{ required: true, whitespace: true, message: '请输入目的地邮编', trigger: 'blur' }]
|
||||
}
|
||||
if (form.deliveryMode === 'DELIVERY_TO_WAREHOUSE') {
|
||||
for (const [field, label] of [['deliveryContactName', '到仓联系人'], ['deliveryContactPhone', '到仓联系电话'], ['estimatedArrivalAt', '预计到货时间']] as const) rules[field] = [{ required: true, message: '请填写' + label, trigger: 'change' }]
|
||||
}
|
||||
}
|
||||
if (kind === 'truck') rules.vehicleType = [{ required: true, message: '请选择车辆类型', trigger: 'change' }]
|
||||
for (const [field, label] of [['packageCount', '件数'], ['estimatedPalletCount', '预计托数'], ['totalWeightKg', '总重量'], ['totalVolumeCbm', '总体积']] as const) {
|
||||
if (field === 'packageCount' && kind === 'truck') continue
|
||||
@@ -66,8 +85,16 @@ export function transportDemandRules(kind: TransportDemandKind, form: TransportD
|
||||
}
|
||||
|
||||
export function transportDemandError(kind: TransportDemandKind, form: TransportDemandForm): string | undefined {
|
||||
if (kind === 'looseboard') {
|
||||
if (!form.operatingWarehouseId) return '请选择收货仓库'
|
||||
if (!['DELIVERY_TO_WAREHOUSE', 'WAREHOUSE_PICKUP'].includes(form.deliveryMode)) return '请选择送货方式'
|
||||
if (form.destinationType === 'PRIVATE' && (!form.destinationAddress.trim() || !form.destinationPostalCode.trim())) return '请填写目的详细地址和邮编'
|
||||
if (form.deliveryMode === 'DELIVERY_TO_WAREHOUSE' && (!form.deliveryContactName.trim() || !form.deliveryContactPhone.trim() || !form.estimatedArrivalAt)) return '送货到仓须填写仓库联系人和预计到货时间'
|
||||
if (form.deliveryMode === 'WAREHOUSE_PICKUP' && !form.originAddress.trim()) return '请输入起运详细地址'
|
||||
if (form.packageCount < 1) return '散板收货件数须大于 0'
|
||||
}
|
||||
if (kind === 'truck' && !['PALLET_15', 'PALLET_30'].includes(form.vehicleType)) return '请选择车辆类型'
|
||||
const labels = requiredText(kind)
|
||||
const labels = requiredText(kind, form)
|
||||
for (const [field, label] of Object.entries(labels)) {
|
||||
if (!String(form[field as keyof typeof labels] || '').trim()) return `请输入${label}`
|
||||
}
|
||||
@@ -102,15 +129,15 @@ function commonPayload(form: TransportDemandForm) {
|
||||
|
||||
type CommonPayload = ReturnType<typeof commonPayload>
|
||||
type TruckPayload = Omit<CommonPayload, 'packageCount'> & { palletCount: number; vehicleType: 'PALLET_15' | 'PALLET_30' }
|
||||
type LooseboardPayload = CommonPayload & { productName: string | undefined; estimatedPalletCount: number }
|
||||
type LooseboardPayload = CommonPayload & { deliveryMode: 'DELIVERY_TO_WAREHOUSE' | 'WAREHOUSE_PICKUP'; operatingWarehouseId: string; destinationType: 'FBA' | 'PRIVATE'; destinationPostalCode?: string; deliveryContactName?: string; deliveryContactPhone?: string; estimatedArrivalAt?: string; trackingNo?: string; 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) {
|
||||
export function buildTransportDemand(kind: TransportDemandKind, form: TransportDemandForm): TruckPayload | LooseboardPayload {
|
||||
const failure = transportDemandError(kind, form)
|
||||
if (failure) throw new Error(failure)
|
||||
const common = commonPayload(form)
|
||||
return kind === 'truck'
|
||||
? { ...(({ packageCount: _packages, ...rest }) => rest)(common), palletCount: form.estimatedPalletCount, vehicleType: form.vehicleType as 'PALLET_15' | 'PALLET_30' }
|
||||
: { ...common, productName: form.productName.trim() || undefined, estimatedPalletCount: form.estimatedPalletCount }
|
||||
: { ...common, ...(form.deliveryMode === 'DELIVERY_TO_WAREHOUSE' ? { origin: '', originAddress: undefined, originContactName: '', originContactPhone: '' } : {}), deliveryMode: form.deliveryMode, operatingWarehouseId: form.operatingWarehouseId, deliveryContactName: form.deliveryMode === 'DELIVERY_TO_WAREHOUSE' ? form.deliveryContactName.trim() : undefined, deliveryContactPhone: form.deliveryMode === 'DELIVERY_TO_WAREHOUSE' ? form.deliveryContactPhone.trim() : undefined, estimatedArrivalAt: form.deliveryMode === 'DELIVERY_TO_WAREHOUSE' ? localMinuteInput(form.estimatedArrivalAt) : undefined, trackingNo: form.deliveryMode === 'DELIVERY_TO_WAREHOUSE' ? form.trackingNo.trim() || undefined : undefined, destinationType: form.destinationType, destinationPostalCode: form.destinationType === 'PRIVATE' ? form.destinationPostalCode.trim() : undefined, productName: form.productName.trim() || undefined, estimatedPalletCount: form.estimatedPalletCount }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
import { runInNewContext } from 'node:vm'
|
||||
import ts from 'typescript'
|
||||
import * as vue from 'vue'
|
||||
import { parse, compileScript } from 'vue/compiler-sfc'
|
||||
const source = readFileSync(new URL('./components/TransportPhotoGallery.vue', import.meta.url), 'utf8')
|
||||
const { descriptor } = parse(source)
|
||||
const code = ts.transpileModule(compileScript(descriptor, { id: 'transport-photos' }).content, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS } }).outputText
|
||||
const flush = async () => { await vue.nextTick(); await new Promise(resolve => setImmediate(resolve)); await vue.nextTick() }
|
||||
function harness(kind, overrides = {}) {
|
||||
const props = vue.reactive({ kind, orderNo: 'ORDER/1', refreshKey: 0 }), calls = [], disposers = [], created = [], revoked = []
|
||||
const photos = { pickupPhotos: [{ fileId: '71', fileName: 'pickup.png', contentType: 'image/png', sizeBytes: 5 }], arrivalPhotos: [{ fileId: '72', fileName: 'arrival.png', contentType: 'image/png', sizeBytes: 6 }] }
|
||||
const api = { getTransportPhotos: async (...args) => { calls.push(['list', ...args]); return photos }, readTransportPhoto: async (...args) => { calls.push(['file', ...args]); return {} }, ...overrides }
|
||||
const context = { exports: {}, Error, URL: { createObjectURL: () => { const url = `blob:${created.length}`; created.push(url); return url }, revokeObjectURL: url => revoked.push(url) }, require(name) {
|
||||
if (name === 'vue') return { ...vue, onBeforeUnmount: callback => disposers.push(callback) }
|
||||
if (name === '../api/businessOrders') return api
|
||||
throw Error(name)
|
||||
} }
|
||||
runInNewContext(code, context)
|
||||
const state = context.exports.default.setup(props, { expose() {} })
|
||||
return { state, props, calls, created, revoked, photos, dispose: () => disposers.forEach(fn => fn()) }
|
||||
}
|
||||
for (const kind of ['looseboard', 'trucking']) test(`${kind} loads both photo nodes and refreshes with URL cleanup`, async () => {
|
||||
const h = harness(kind); await flush()
|
||||
assert.deepEqual(h.calls, [['list', kind, 'ORDER/1'], ['file', kind, 'ORDER/1', 'PICKUP', '71'], ['file', kind, 'ORDER/1', 'ARRIVAL', '72']])
|
||||
assert.equal(h.state.previewUrls.value.length, 2)
|
||||
h.props.refreshKey++; await flush()
|
||||
assert.equal(h.revoked.length, 2)
|
||||
assert.equal(h.state.previewUrls.value.length, 2)
|
||||
h.dispose(); assert.equal(h.revoked.length, 4)
|
||||
})
|
||||
test('failed photo reads preserve the saved lists and allow retry', async () => {
|
||||
let failed = true
|
||||
const h = harness('looseboard', { readTransportPhoto: async () => { if (failed) throw Error('offline'); return {} } }); await flush()
|
||||
assert.match(h.state.error.value, /加载失败/); assert.equal(h.state.photos.value.pickupPhotos.length, 1)
|
||||
failed = false; await h.state.load(); await flush()
|
||||
assert.equal(h.state.error.value, ''); assert.equal(h.state.previewUrls.value.length, 2); h.dispose()
|
||||
})
|
||||
test('responses after unmount do not allocate preview URLs', async () => {
|
||||
let finish
|
||||
const response = new Promise(resolve => { finish = resolve })
|
||||
const h = harness('trucking', { readTransportPhoto: () => response }); await flush()
|
||||
h.dispose(); finish({}); await flush(); assert.equal(h.created.length, 0)
|
||||
})
|
||||
test('merchant photo API uses order and phase routes with authenticated blob requests', async () => {
|
||||
const api = readFileSync(new URL('./api/businessOrders.ts', import.meta.url), 'utf8')
|
||||
const compiled = ts.transpileModule(api, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS } }).outputText
|
||||
const calls = [], context = { exports: {}, require: () => ({ apiRequest: request => calls.push(request), apiBlobRequest: request => calls.push(request) }) }
|
||||
runInNewContext(compiled, context)
|
||||
for (const [kind, route] of [['looseboard', 'local-looseboard-orders'], ['trucking', 'us-truck-orders']]) {
|
||||
context.exports.getTransportPhotos(kind, 'ORDER/1')
|
||||
assert.equal(calls.at(-1).url, `/merchant/v1/last-leg/${route}/ORDER%2F1/transport-photos`)
|
||||
context.exports.readTransportPhoto(kind, 'ORDER/1', 'ARRIVAL', '72')
|
||||
assert.equal(calls.at(-1).url, `/merchant/v1/last-leg/${route}/ORDER%2F1/transport-photos/ARRIVAL/72/content`)
|
||||
}
|
||||
assert.match(source, /preview-teleported/)
|
||||
assert.match(source, /flex-wrap: wrap/)
|
||||
})
|
||||
@@ -164,7 +164,7 @@ onBeforeUnmount(() => { active = false; ++requestSequence })
|
||||
<header class="procurement-heading"><div><el-button link type="primary" @click="returnToList">← 返回列表</el-button><h2>{{ detail?.order.orderNo || '采购订单详情' }}</h2></div><div><el-tag v-if="detail" :type="statusTone(workflow?.status || detail.order.status)">{{ statusLabel(workflow?.status || detail.order.status, detail.order.procurementDirection) }}</el-tag><el-button :disabled="loading" @click="loadDetail">刷新</el-button></div></header>
|
||||
<OrderFundingStatus v-if="detail" kind="procurement" :order-no="detail.order.orderNo" :status="detail.order.status" :can-retry="canCreate" @changed="loadDetail" />
|
||||
<div v-if="error" class="procurement-error" role="alert"><span>{{ error }}</span><el-button :disabled="loading" @click="loadDetail">重新加载</el-button></div>
|
||||
<template v-else-if="detail && workflow"><el-alert v-if="workflow.rejectionReason" title="采购结果驳回原因" :description="workflow.rejectionReason" type="warning" :closable="false" /><ProcurementDetailPanel :detail="detail" :workflow="workflow" /></template><el-skeleton v-else :rows="8" animated />
|
||||
<template v-else-if="detail && workflow"><el-alert v-if="workflow.status === 'RECEIVING' && workflow.rejectionReason" title="采购结果驳回原因" :description="workflow.rejectionReason" type="warning" :closable="false" /><ProcurementDetailPanel :detail="detail" :workflow="workflow" /></template><el-skeleton v-else :rows="8" animated />
|
||||
</section>
|
||||
<section v-else class="procurement-create" aria-label="采购下单">
|
||||
<header class="procurement-create-heading">
|
||||
|
||||
@@ -55,12 +55,18 @@ test('adapts the Finance Java DTO contract and filters nested receipt allocation
|
||||
assert.doesNotMatch(adapterSource, /bill_no|customer_name|bill_date|total_amount|outstanding_amount|receipt_no|bill_id|applied_amount/)
|
||||
assert.doesNotMatch(adapterSource, /receipt\.billId|receipt\.appliedAmount/)
|
||||
assert.doesNotMatch(adapterSource, /voucherNo|voucher_no/)
|
||||
assert.match(viewSource, /name: 'finance-bill-detail', params: \{ billNo: row\.billNo \}/)
|
||||
assert.doesNotMatch(viewSource, /key: 'detail'|label: '查看'/)
|
||||
assert.match(detailViewSource, /getMerchantFinanceBill\(summary\.id\)/)
|
||||
assert.match(detailViewSource, /getMerchantFinanceReceipts\(summary\.id\)/)
|
||||
assert.doesNotMatch(detailViewSource, /getMerchantFinanceBill\(billNo\.value\)/)
|
||||
})
|
||||
|
||||
test('keeps bill numbers linked to business-number details independently of confirmation actions', () => {
|
||||
assert.match(viewSource, /label: '账单编号',\s*prop: 'billNo',\s*slotName: 'billNo'/)
|
||||
assert.match(viewSource, /<template #billNo="\{ row \}">\s*<RouterLink[^>]*:to="\{ name: 'finance-bill-detail', params: \{ billNo: row\.billNo \} \}"[^>]*>\{\{ row\.billNo \}\}<\/RouterLink>\s*<\/template>/)
|
||||
assert.doesNotMatch(viewSource.match(/<template #billNo[^]*?<\/template>/)?.[0] || '', /canConfirm|confirmationStatus|row\.id/)
|
||||
})
|
||||
|
||||
test('does not expose draft or voided bills returned by the server', () => {
|
||||
const bill = (id, status) => ({
|
||||
id,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { RefreshCcw } from '@lucide/vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { ApiError } from '@/api/http'
|
||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/components/common/QueryToolbar.vue'
|
||||
import DataTable, { type DataTableColumn } from '@/components/common/DataTable.vue'
|
||||
@@ -13,7 +13,6 @@ import type { FinanceBillQuery, FinanceBillStatus, FinanceBillSummary, FinanceCu
|
||||
|
||||
const loading = ref(false)
|
||||
const auth = useAuthStore()
|
||||
const router = useRouter()
|
||||
const errorMessage = ref('')
|
||||
const bills = ref<FinanceBillSummary[]>([])
|
||||
const confirmingBillId = ref('')
|
||||
@@ -38,7 +37,7 @@ const queryFields: QueryField[] = [
|
||||
]
|
||||
const tableColumns: DataTableColumn[] = [
|
||||
{ label: '操作', slotName: 'actions', width: 190, fixed: 'left' },
|
||||
{ label: '账单编号', prop: 'billNo', minWidth: 160 },
|
||||
{ label: '账单编号', prop: 'billNo', slotName: 'billNo', minWidth: 160 },
|
||||
{ label: '出账日期', slotName: 'issueDate', width: 120 },
|
||||
{ label: '到期日', slotName: 'dueDate', width: 120 },
|
||||
{ label: '账单金额', slotName: 'totalAmount', width: 145, align: 'right' },
|
||||
@@ -104,11 +103,9 @@ async function loadBills(nextPage = page.value) {
|
||||
}
|
||||
}
|
||||
|
||||
function openDetail(row: FinanceBillSummary) { void router.push({ name: 'finance-bill-detail', params: { billNo: row.billNo } }) }
|
||||
|
||||
function rowActions(row: object): TableAction[] {
|
||||
const statement = row as FinanceBillSummary
|
||||
const actions: TableAction[] = [{ key: 'detail', label: '查看', run: () => openDetail(statement) }]
|
||||
const actions: TableAction[] = []
|
||||
if (statement.customerConfirmationStatus === 'PENDING_CONFIRMATION' && canConfirm.value) {
|
||||
actions.push({
|
||||
key: 'confirm',
|
||||
@@ -181,6 +178,7 @@ onMounted(() => { void loadBills(1) })
|
||||
<section class="content-card">
|
||||
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="bills" :columns="tableColumns" :loading="loading" :total="total" :page-sizes="[20, 50, 100]" :row-actions="rowActions" row-key="billNo" empty-text="暂无账单">
|
||||
<template v-if="errorMessage" #error><div class="state-panel state-panel--error" role="alert"><p>{{ errorMessage }}</p><el-button @click="loadBills(1)"><RefreshCcw :size="15" />重试</el-button></div></template>
|
||||
<template #billNo="{ row }"><RouterLink class="bill-number-link" :to="{ name: 'finance-bill-detail', params: { billNo: row.billNo } }">{{ row.billNo }}</RouterLink></template>
|
||||
<template #issueDate="{ row }">{{ formatDate(row.issueDate) }}</template>
|
||||
<template #dueDate="{ row }">{{ formatDate(row.dueDate) }}</template>
|
||||
<template #totalAmount="{ row }">¥ {{ formatAmount(row.totalAmount) }}</template>
|
||||
@@ -196,6 +194,8 @@ onMounted(() => { void loadBills(1) })
|
||||
|
||||
<style scoped>
|
||||
.finance-bills-page { display: flex; flex-direction: column; gap: 12px; }
|
||||
.bill-number-link { color: var(--primary); text-decoration: none; }
|
||||
.bill-number-link:hover { text-decoration: underline; }
|
||||
.finance-summary { display: flex; flex-wrap: wrap; gap: 8px 26px; color: var(--muted); font-size: 13px; }.finance-summary strong { margin-left: 6px; color: var(--navy); font-size: 18px; }.amount-warning, .amount-warning strong { color: #b45309; }
|
||||
.content-card { min-width: 0; overflow: hidden; border: 1px solid var(--client-border); border-radius: var(--client-radius); background: var(--surface, #fff); }
|
||||
.state-panel { display: flex; min-height: 180px; align-items: center; justify-content: center; gap: 12px; flex-direction: column; color: var(--muted); text-align: center; }.state-panel p { margin: 0; }.state-panel--error { color: #b42318; background: #fff8f7; border: 1px solid #f5c2bd; border-radius: 6px; }
|
||||
|
||||
@@ -13,7 +13,7 @@ export const overseasWarehouseModule = {
|
||||
{
|
||||
path: 'overseas-warehouse/cargo-disposal/create', name: 'overseas-cargo-disposal-create',
|
||||
component: () => import('./views/WarehouseServiceView.vue'), props: { service: 'disposal', createPage: true }, release: 'active',
|
||||
meta: { title: '新增货物废弃申请', workspaceTitle: '货物废弃 · 新建', description: '填写并提交货物废弃申请', permission: 'merchant:overseas-warehouse:discard:create', keepAlive: false },
|
||||
meta: { title: '货物废弃', workspaceTitle: '货物废弃 · 新建', description: '填写并提交货物废弃申请', permission: 'merchant:overseas-warehouse:discard:create', keepAlive: false },
|
||||
},
|
||||
|
||||
{
|
||||
|
||||
@@ -252,7 +252,6 @@ async function printReturnLabel(receipt: ReturnReceipt | ReturnReceipt[]) {
|
||||
}
|
||||
function openDetail(returnNo: string) { void router.push({ name: 'overseas-return-relabel-detail', params: { returnNo } }) }
|
||||
function returnToList() { void router.push({ name: 'overseas-return-relabel' }) }
|
||||
function cancelCreate() { resetForm(); void router.push({ name: 'overseas-return-relabel' }) }
|
||||
function canApplyRelabel(order: { status?: unknown; needRelabel?: unknown; relabelNo?: unknown }) { return order.status === 'COMPLETED' && order.needRelabel === true && !order.relabelNo }
|
||||
function openRelabel(order: Pick<ReturnOrderSummary, 'returnNo' | 'overseasWarehouseId'>, cartonMark?: string) {
|
||||
void router.push({ name: 'overseas-labeling-create', query: { returnNo: order.returnNo, warehouseId: String(order.overseasWarehouseId), ...(cartonMark ? { cartonMark } : {}) } })
|
||||
@@ -354,7 +353,7 @@ onBeforeUnmount(clearPhotoUrls)
|
||||
</div>
|
||||
</section>
|
||||
</el-form>
|
||||
<footer class="return-create-actions"><el-button :disabled="creating" @click="cancelCreate">返回列表</el-button><el-button type="primary" :loading="creating" @click="submit">提交预报</el-button></footer>
|
||||
<footer class="return-create-actions"><el-button type="primary" :loading="creating" @click="submit">提交预报</el-button></footer>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -103,7 +103,7 @@ const listQueryModel = computed<QueryModel>({
|
||||
const statusOptions = computed(() => props.service === 'labeling'
|
||||
? [{ label: '待审核', value: 'PENDING_REVIEW' }, { label: '待处理', value: 'PENDING_PROCESSING' }, { label: '处理中', value: 'PROCESSING' }, { label: '换标完成', value: 'COMPLETED' }, { label: '已驳回', value: 'REJECTED' }]
|
||||
: props.service === 'disposal'
|
||||
? [{ label: '待审核', value: 'PENDING_REVIEW' }, { label: '待 PDA 废弃', value: 'APPROVED' }, { label: '待客户确认', value: 'PDA_COMPLETED' }, { label: '已完成', value: 'COMPLETED' }, { label: '已拒绝', value: 'REJECTED' }, { label: '已取消', value: 'CANCELLED' }]
|
||||
? [{ label: '待审核', value: 'PENDING_REVIEW' }, { label: '已审核', value: 'APPROVED' }, { label: '待客户确认', value: 'PDA_COMPLETED' }, { label: '已完成', value: 'COMPLETED' }, { label: '已驳回', value: 'REJECTED' }, { label: '已取消', value: 'CANCELLED' }]
|
||||
: props.service === 'transfer'
|
||||
? [{ label: '草稿', value: 'DRAFT' }, { label: '待审核', value: 'PENDING_REVIEW' },
|
||||
{ label: '待PDA收货', value: 'WAIT_PDA_RECEIVING' }, { label: '入库测量中', value: 'INBOUND_MEASURING' },
|
||||
@@ -1049,7 +1049,7 @@ function statusLabel(value: string) {
|
||||
|
||||
PENDING_PROCESSING: '待处理', PROCESSING: '处理中', CUSTOMER_CONFIRM_PENDING: '待客户确认',
|
||||
READY_FOR_OUTBOUND: '换标完成', COMPLETED: '已完成', CANCELLED: '已取消',
|
||||
DRAFT: '草稿', PENDING_REVIEW: '待审核', APPROVED: '待 PDA 废弃', PDA_COMPLETED: '待客户确认', REJECTED: '已拒绝', PENDING_ASSIGNMENT: '待分配',
|
||||
DRAFT: '草稿', PENDING_REVIEW: '待审核', APPROVED: '已审核', PDA_COMPLETED: '待客户确认', REJECTED: '已驳回', PENDING_ASSIGNMENT: '待分配',
|
||||
WAIT_PDA_RECEIVING: '待PDA收货', WAIT_CUSTOMER_CONFIRMATION: '待客户确认实测', WAIT_FBA_DESTINATION: '待确认目的地', WAIT_RELABEL: '待换标', READY_FOR_DISPATCH: '待排车', WAIT_OUTBOUND: '待出库',
|
||||
ALLOCATED: '已分配', OUTBOUNDED: '已出库', PARTIALLY_RECEIVED: '部分收货', RECEIVED: '已收货', INBOUND_COMPLETED: '入库完成', PENDING_DELIVERY: '待派送', DELIVERING: '派送中', DELIVERED: '派送完成',
|
||||
|
||||
@@ -1743,7 +1743,7 @@ function addReturnBoxBinding(source: RelabelInventoryCandidate) {
|
||||
</template>
|
||||
|
||||
<template v-else-if="mode === 'create' || mode === 'edit'">
|
||||
<div class="warehouse-page-heading"><h2>{{ mode === 'edit' ? `编辑转运草稿 ${editingTransferNo}` : config.createTitle }}</h2><el-button @click="returnToList"><ArrowLeft :size="16" />返回列表</el-button></div>
|
||||
<div class="warehouse-page-heading"><h2 v-if="props.service !== 'disposal'">{{ mode === 'edit' ? `编辑转运草稿 ${editingTransferNo}` : config.createTitle }}</h2><el-button @click="returnToList"><ArrowLeft :size="16" />返回列表</el-button></div>
|
||||
<component :is="isServiceCreate ? ElForm : 'form'" ref="labelingFormRef" :model="isLabelingCreate ? labelingFormModel : isTransferCreate ? transferFormModel : isDisposalCreate ? disposalFormModel : undefined" :rules="isLabelingCreate ? labelingFormRules : isTransferCreate ? transferFormRules : isDisposalCreate ? disposalFormRules : undefined" :disabled="isServiceCreate ? saving : undefined" label-position="top" :validate-on-rule-change="false" :scroll-to-error="isServiceCreate" :class="{ 'warehouse-transfer-form': props.service === 'transfer', 'warehouse-transfer-form--relabel': props.service === 'transfer' && form.relabel }" :style="props.service === 'transfer' ? { '--transfer-left': `${transferPaneShare}fr`, '--transfer-right': `${100 - transferPaneShare}fr` } : undefined" @submit.prevent="submit('submit')">
|
||||
<div id="transfer-main-pane" class="warehouse-card warehouse-form-section warehouse-transfer-main-card">
|
||||
<div v-if="isTransferCreate || isDisposalCreate" class="warehouse-order-info-heading"><h3>{{ isTransferCreate ? '转运信息' : '基础信息' }}</h3></div>
|
||||
@@ -1981,7 +1981,7 @@ function addReturnBoxBinding(source: RelabelInventoryCandidate) {
|
||||
<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="isTransferCreate ? '请输入仓库唛头' : ''" /></td><td><el-input :model-value="row.sku" readonly /></td><td><el-input v-model="row.newSkuCode" :placeholder="isTransferCreate ? '请输入新 SKU' : ''" /></td><td><el-input-number v-model="row.quantity" :min="1" :precision="0" controls-position="right" :placeholder="isTransferCreate ? '请输入数量' : ''" /></td><td><el-input v-model="row.fbaCartonMark" :placeholder="isTransferCreate ? '请输入新 FBA 箱唛' : ''" /></td><td><el-input v-model="row.remark" type="textarea" :rows="2" :placeholder="isTransferCreate ? '选填' : ''" /></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>
|
||||
<div class="warehouse-submit-bar warehouse-transfer-submit-bar"><el-button v-if="isServiceCreate" native-type="button" :disabled="saving" @click="returnToList">返回列表</el-button><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>
|
||||
<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>
|
||||
</component>
|
||||
</template>
|
||||
|
||||
@@ -2336,6 +2336,7 @@ function addReturnBoxBinding(source: RelabelInventoryCandidate) {
|
||||
font: 14px/1.5 "PingFang SC", "Microsoft YaHei", "Noto Sans SC", sans-serif;
|
||||
}
|
||||
.warehouse-create .warehouse-page-heading { margin-bottom: 12px; }
|
||||
.warehouse-create .warehouse-page-heading :deep(.el-button) { margin-left: auto; }
|
||||
.warehouse-create .warehouse-page-heading h2 { margin: 0; color: #1f2937; font-size: 26px; font-weight: 700; }
|
||||
.warehouse-create .warehouse-form-section { min-width: 0; margin-bottom: 12px; padding: 0; border: 0; background: transparent; box-shadow: none; }
|
||||
.warehouse-create .warehouse-order-info-heading,
|
||||
|
||||
Reference in New Issue
Block a user