优化
This commit is contained in:
@@ -199,6 +199,10 @@ describe('CustomersView', () => {
|
||||
})
|
||||
|
||||
it('saves customer data and selected qualifications with one action', async () => {
|
||||
vi.mocked(listTenantQualifications).mockResolvedValue([{
|
||||
id: 'qualification-legal', tenantId: fullTenant.id, qualificationType: 'LEGAL_PERSON_ID',
|
||||
certificateNo: null, documentUrl: '/files/legal-person.png', status: 'PENDING', reviewComment: null, validTo: null,
|
||||
}])
|
||||
mount(CustomersView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } })
|
||||
await flushPromises()
|
||||
tableButtonByText('编辑')?.click()
|
||||
|
||||
@@ -108,10 +108,7 @@ const rules: FormRules = {
|
||||
tenantName: [{ required: true, message: '请输入公司名称', trigger: 'blur' }],
|
||||
legalName: [{ required: true, message: '请输入营业执照上的企业名称', trigger: 'blur' }],
|
||||
legalRepresentative: [{ required: true, message: '请输入法定代表人姓名', trigger: 'blur' }],
|
||||
unifiedSocialCreditCode: [
|
||||
{ required: true, message: '请输入统一社会信用代码', trigger: 'blur' },
|
||||
{ pattern: /^[0-9A-Z]{18}$/, message: '请输入 18 位大写字母或数字', trigger: 'blur' },
|
||||
],
|
||||
unifiedSocialCreditCode: [{ pattern: /^[0-9A-Z]{18}$/, message: '请输入 18 位大写字母或数字', trigger: 'blur' }],
|
||||
contactName: [{ required: true, message: '请输入联系人姓名', trigger: 'blur' }],
|
||||
contactPhone: [{ required: true, message: '请输入联系电话', trigger: 'blur' }],
|
||||
contactEmail: [{ type: 'email', message: '请输入有效邮箱地址', trigger: 'blur' }],
|
||||
@@ -151,6 +148,8 @@ function newQualificationDrafts(): QualificationDraft[] {
|
||||
}))
|
||||
}
|
||||
const qualifications = ref<QualificationDraft[]>(newQualificationDrafts())
|
||||
const creditCodeEstablished = ref(false)
|
||||
const unifiedSocialCreditCodePattern = /^[0-9A-Z]{18}$/
|
||||
|
||||
const customerColumns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', minWidth: 190, slotName: 'actions' },
|
||||
@@ -174,6 +173,20 @@ const canUploadQualifications = computed(() => !editorStatus.value || ['PROFILE_
|
||||
const canSubmitReview = computed(() => Boolean(editingId.value) && ['PROFILE_REQUIRED', 'REJECTED'].includes(editorStatus.value || ''))
|
||||
const uploadingQualifications = computed(() => qualifications.value.some((item) => item.uploading))
|
||||
const hasSelectedQualifications = computed(() => qualifications.value.some((item) => item.file))
|
||||
const selectedBusinessLicense = computed(() => qualifications.value.find((item) => item.type === 'BUSINESS_LICENSE')?.file)
|
||||
const creditCodeReadyForSave = computed(() => creditCodeEstablished.value
|
||||
|| (Boolean(selectedBusinessLicense.value) && unifiedSocialCreditCodePattern.test(form.value.unifiedSocialCreditCode || '')))
|
||||
const requiredQualificationPackageComplete = computed(() => creditCodeReadyForSave.value
|
||||
&& qualifications.value.filter((item) => item.required).every((item) => item.uploaded || Boolean(item.file)))
|
||||
const missingQualificationPackageItems = computed(() => {
|
||||
const businessLicense = qualifications.value.find((item) => item.type === 'BUSINESS_LICENSE')
|
||||
const legalPersonId = qualifications.value.find((item) => item.type === 'LEGAL_PERSON_ID')
|
||||
const missing: string[] = []
|
||||
if (!businessLicense?.uploaded && !businessLicense?.file) missing.push('营业执照')
|
||||
if (!creditCodeReadyForSave.value) missing.push('18位统一社会信用代码(需随营业执照保存)')
|
||||
if (!legalPersonId?.uploaded && !legalPersonId?.file) missing.push('法人身份证')
|
||||
return missing
|
||||
})
|
||||
const hasUnsavedChanges = computed(() => Boolean(editingId.value) && JSON.stringify(form.value) !== savedFormSnapshot.value)
|
||||
const pendingReviewByTenantId = computed(() => new Map(pendingReviews.value.map((review) => [String(review.tenantId), review])))
|
||||
function displayText(value?: string | null) { return value?.trim() || '—' }
|
||||
@@ -302,7 +315,7 @@ function clearQualificationPreviews() {
|
||||
}
|
||||
function resetEditor() {
|
||||
clearQualificationPreviews()
|
||||
form.value = emptyForm(); qualifications.value = newQualificationDrafts(); qualificationLoadError.value = ''; savedFormSnapshot.value = ''
|
||||
form.value = emptyForm(); qualifications.value = newQualificationDrafts(); creditCodeEstablished.value = false; qualificationLoadError.value = ''; savedFormSnapshot.value = ''
|
||||
}
|
||||
function openCreate() {
|
||||
editorLoadSequence += 1
|
||||
@@ -352,6 +365,7 @@ async function openEdit(row: PlatformTenantResource) {
|
||||
if (sequence !== editorLoadSequence || editingId.value !== id || !editorOpen.value) return
|
||||
editorStatus.value = tenant.status
|
||||
form.value = mapTenantToForm(tenant)
|
||||
creditCodeEstablished.value = unifiedSocialCreditCodePattern.test(tenant.unifiedSocialCreditCode || '')
|
||||
savedFormSnapshot.value = JSON.stringify(form.value)
|
||||
await loadEditorQualifications(row.id, sequence)
|
||||
await nextTick(() => formRef.value?.clearValidate())
|
||||
@@ -430,6 +444,7 @@ async function uploadSelectedQualifications(tenantId: string) {
|
||||
draft.uploaded,
|
||||
)
|
||||
draft.uploaded = true
|
||||
if (draft.type === 'BUSINESS_LICENSE') creditCodeEstablished.value = true
|
||||
draft.uploadedName = draft.file?.name || '已登记文件'
|
||||
draft.documentUrl = uploaded.documentUrl
|
||||
draft.file = null
|
||||
@@ -442,6 +457,11 @@ async function uploadSelectedQualifications(tenantId: string) {
|
||||
|
||||
async function saveCustomer() {
|
||||
if (!formRef.value || !(await formRef.value.validate().catch(() => false))) return
|
||||
const qualificationPackageTouched = hasSelectedQualifications.value || Boolean(form.value.unifiedSocialCreditCode)
|
||||
if (qualificationPackageTouched && !requiredQualificationPackageComplete.value) {
|
||||
ElMessage.warning(`请完整保存必填业务项:${missingQualificationPackageItems.value.join('、')}`)
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
const hadSelectedQualifications = hasSelectedQualifications.value
|
||||
@@ -450,7 +470,7 @@ async function saveCustomer() {
|
||||
const { unifiedSocialCreditCode: _immutable, ...payload } = form.value
|
||||
saved = await updatePlatformTenant(editingId.value, payload)
|
||||
} else {
|
||||
saved = await createPlatformTenant(form.value)
|
||||
saved = await createPlatformTenant({ ...form.value, unifiedSocialCreditCode: undefined })
|
||||
editingId.value = String(saved.id)
|
||||
}
|
||||
editorStatus.value = saved.status
|
||||
@@ -459,7 +479,7 @@ async function saveCustomer() {
|
||||
if (failures.length) {
|
||||
ElMessage.warning(`客户资料已保存,但${failures.join('、')}上传失败;失败文件已保留,请修正后再次保存`)
|
||||
} else if (hadSelectedQualifications) {
|
||||
ElMessage.success('客户资料和已选资质已保存,尚未提交审核')
|
||||
ElMessage.success('营业执照、统一社会信用代码和法人身份证已完整保存,尚未提交审核')
|
||||
} else {
|
||||
ElMessage.success('客户资料已保存,尚未提交审核')
|
||||
}
|
||||
@@ -477,6 +497,10 @@ async function submitReview() {
|
||||
if (hasUnsavedChanges.value) { ElMessage.warning('客户资料有未保存的修改,请先保存资料'); return }
|
||||
if (uploadingQualifications.value) { ElMessage.warning('资质文件正在上传,请稍候再提交审核'); return }
|
||||
if (hasSelectedQualifications.value) { ElMessage.warning('存在尚未保存的已选文件,请先点击“保存资料和资质”'); return }
|
||||
if (!creditCodeEstablished.value || !unifiedSocialCreditCodePattern.test(form.value.unifiedSocialCreditCode || '')) {
|
||||
ElMessage.warning('请先完整保存营业执照、18位统一社会信用代码和法人身份证')
|
||||
return
|
||||
}
|
||||
const missing = qualifications.value.filter((item) => item.required && !item.uploaded)
|
||||
for (const item of missing) item.error = `${item.label}为提交审核必填文件`
|
||||
if (missing.length) { ElMessage.warning(`请先上传${missing.map((item) => item.label).join('、')}`); return }
|
||||
@@ -627,7 +651,6 @@ onMounted(async () => {
|
||||
<el-form-item label="公司名称" prop="tenantName" required><el-input v-model="form.tenantName" placeholder="请输入工商登记公司名称" maxlength="128" /></el-form-item>
|
||||
<el-form-item label="营业执照企业名称" prop="legalName" required><el-input v-model="form.legalName" placeholder="请输入营业执照上的企业名称" maxlength="128" /></el-form-item>
|
||||
<el-form-item label="法定代表人" prop="legalRepresentative" required><el-input v-model="form.legalRepresentative" placeholder="请输入法定代表人姓名" maxlength="64" /></el-form-item>
|
||||
<el-form-item label="统一社会信用代码" prop="unifiedSocialCreditCode" required><el-input v-model="form.unifiedSocialCreditCode" :disabled="Boolean(editingId)" placeholder="请输入18位大写字母或数字" maxlength="18" @input="form.unifiedSocialCreditCode = String($event).toUpperCase()" /></el-form-item>
|
||||
<el-form-item label="企业类型" prop="enterpriseType"><el-input v-model="form.enterpriseType" placeholder="如:有限责任公司" maxlength="64" /></el-form-item>
|
||||
<el-form-item label="成立日期" prop="establishedDate"><el-date-picker style="width: 100%;" v-model="form.establishedDate" type="date" value-format="YYYY-MM-DD" placeholder="请选择成立日期" :disabled-date="(date: Date) => date.getTime() > Date.now()" /></el-form-item>
|
||||
<el-form-item label="注册资本" prop="registeredCapital"><div class="capital-input"><el-input-number v-model="form.registeredCapital" :min="0" :precision="2" controls-position="right" /><el-select v-model="form.registeredCapitalCurrency" aria-label="注册资本币种"><el-option label="人民币 CNY" value="CNY" /><el-option label="美元 USD" value="USD" /><el-option label="港币 HKD" value="HKD" /></el-select></div></el-form-item>
|
||||
@@ -643,7 +666,8 @@ onMounted(async () => {
|
||||
<el-form-item label="账期(天)" prop="paymentTermDays" required><el-input-number v-model="form.paymentTermDays" :min="0" :max="365" controls-position="right" /></el-form-item>
|
||||
</div><el-form-item label="内部备注" prop="internalRemark"><el-input v-model="form.internalRemark" type="textarea" :rows="3" placeholder="请输入仅平台人员可见的客户备注" maxlength="1000" show-word-limit /></el-form-item></div>
|
||||
|
||||
<div class="form-section qualification-section"><div class="qualification-heading"><div><h3>资质文件</h3><p>先选择并上传文件;上传成功不会创建审核单,点击“提交审核”并确认后才会进入审核。</p></div><el-tag v-if="editorStatus" :type="statusMeta(editorStatus).type" effect="light">{{ statusMeta(editorStatus).label }}</el-tag></div>
|
||||
<div class="form-section qualification-section"><div class="qualification-heading"><div><h3>企业认证必填业务项</h3><p>营业执照、18位统一社会信用代码、法人身份证需共同完整保存;货代资质证书和银行开户许可证选填。</p></div><el-tag v-if="editorStatus" :type="statusMeta(editorStatus).type" effect="light">{{ statusMeta(editorStatus).label }}</el-tag></div>
|
||||
<el-form-item label="统一社会信用代码" prop="unifiedSocialCreditCode" required class="qualification-credit-code"><el-input v-model="form.unifiedSocialCreditCode" :disabled="creditCodeEstablished" placeholder="请输入18位大写字母或数字" maxlength="18" @input="form.unifiedSocialCreditCode = String($event).toUpperCase()" /><small>{{ creditCodeEstablished ? '已登记的统一社会信用代码不可直接修改。' : '必须与营业执照一致,并随营业执照文件共同保存。' }}</small></el-form-item>
|
||||
<div v-if="qualificationLoadError" class="qualification-load-error" role="alert">{{ qualificationLoadError }}<el-button v-if="editingId" link type="primary" @click="loadEditorQualifications(editingId)">重试</el-button></div>
|
||||
<div class="qualification-table" role="table" aria-label="客户资质文件">
|
||||
<div class="qualification-row qualification-header" role="row"><span role="columnheader">文件类型</span><span role="columnheader">上传文件</span><span role="columnheader">有效期</span></div>
|
||||
@@ -706,6 +730,7 @@ onMounted(async () => {
|
||||
.customer-company-cell { display:flex; min-width:0; align-items:center; gap:10px; } .customer-company-cell > span { display:grid; width:34px; height:34px; flex:0 0 auto; place-items:center; border-radius:7px; background:#eef4ff; color:var(--cc-primary); } .customer-company-cell strong { overflow:hidden; text-overflow:ellipsis; }
|
||||
.capital-input { display:grid; grid-template-columns:minmax(0,1fr) 130px; width:100%; gap:8px; } .capital-input :deep(.el-input-number),.capital-input :deep(.el-select) { width:100%; }
|
||||
.qualification-heading { display:flex; align-items:flex-start; justify-content:space-between; gap:16px; } .qualification-heading h3 { margin-bottom:4px; } .qualification-heading p,.qualification-hint { margin:0; color:var(--cc-muted); font-size:12px; line-height:1.6; } .qualification-hint { margin-top:12px; } .qualification-hint span,.qualification-label > span { color:var(--el-color-danger); }
|
||||
.qualification-credit-code { max-width:420px; margin-top:16px; } .qualification-credit-code small { display:block; margin-top:6px; color:var(--cc-muted); font-size:11px; line-height:1.5; }
|
||||
.qualification-table { overflow:hidden; margin-top:14px; border:1px solid var(--cc-border); border-radius:8px; } .qualification-row { display:grid; grid-template-columns:180px minmax(300px,1fr) 180px; min-width:0; border-top:1px solid var(--cc-border); } .qualification-row:first-child { border-top:0; } .qualification-row > * { display:flex; min-width:0; min-height:68px; align-items:center; padding:12px 14px; } .qualification-header { min-height:44px; background:#f7f8fc; color:var(--cc-muted); font-size:12px; font-weight:600; } .qualification-header > * { min-height:44px; }
|
||||
.qualification-label { gap:5px; font-weight:600; } .qualification-label svg { margin-left:auto; color:var(--el-color-success); }
|
||||
.qualification-optional { padding:2px 5px; border-radius:4px; background:var(--el-fill-color-light); color:var(--cc-muted); font-size:10px; font-weight:500; line-height:1.4; }
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
get: vi.fn(),
|
||||
post: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/api/http', () => ({
|
||||
http: mocks,
|
||||
unwrapApiResponse: <T>(response: { data: T }) => response.data,
|
||||
}))
|
||||
|
||||
import {
|
||||
createPlatformCarrierRateVersion,
|
||||
getPlatformCarrierRate,
|
||||
listPlatformCarrierRates,
|
||||
retirePlatformCarrierRate,
|
||||
} from './platformCarrierRates'
|
||||
|
||||
const payload = {
|
||||
id: '9007199254740995',
|
||||
tenantId: '10001',
|
||||
rateCode: 'RATE-US-WEST',
|
||||
rateVersion: 2,
|
||||
originWarehouseId: '20001',
|
||||
originWarehouseName: '义乌收货仓',
|
||||
businessType: 'FBA_SEA',
|
||||
channelCode: 'US-WEST-SEA',
|
||||
carrierId: '30001',
|
||||
carrierName: '示例船司',
|
||||
destinationCode: 'ONT8',
|
||||
chargeUnit: 'KG' as const,
|
||||
unitPrice: 12.5,
|
||||
currency: 'CNY',
|
||||
additionalFee: 80,
|
||||
effectiveFrom: '2026-08-01T00:00:00',
|
||||
effectiveTo: null,
|
||||
status: 'ACTIVE' as const,
|
||||
remark: null,
|
||||
createdBy: '10',
|
||||
createdAt: '2026-08-01T10:00:00',
|
||||
updatedBy: null,
|
||||
updatedAt: null,
|
||||
version: 1,
|
||||
}
|
||||
|
||||
describe('platformCarrierRates api', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it('queries the real carrier-rate path and keeps every long id as a string', async () => {
|
||||
mocks.get.mockResolvedValue({ data: [payload] })
|
||||
|
||||
const result = await listPlatformCarrierRates({ customerId: ' 10001 ', rateCode: ' RATE-US ' })
|
||||
|
||||
expect(mocks.get).toHaveBeenCalledWith('/platform/v1/fba-pricing/carrier-rates', {
|
||||
params: { tenantId: '10001', rateCode: 'RATE-US' },
|
||||
})
|
||||
expect(result[0]).toMatchObject({
|
||||
id: '9007199254740995',
|
||||
tenantId: '10001',
|
||||
originWarehouseId: '20001',
|
||||
carrierId: '30001',
|
||||
})
|
||||
})
|
||||
|
||||
it('omits the compatibility tenant parameter for an all-customer list query', async () => {
|
||||
mocks.get.mockResolvedValue({ data: [payload] })
|
||||
|
||||
await listPlatformCarrierRates()
|
||||
await listPlatformCarrierRates({ customerId: '', rateCode: ' ' })
|
||||
|
||||
expect(mocks.get).toHaveBeenNthCalledWith(1, '/platform/v1/fba-pricing/carrier-rates', { params: {} })
|
||||
expect(mocks.get).toHaveBeenNthCalledWith(2, '/platform/v1/fba-pricing/carrier-rates', { params: {} })
|
||||
})
|
||||
|
||||
it('loads a version detail with tenant scope', async () => {
|
||||
mocks.get.mockResolvedValue({ data: payload })
|
||||
await getPlatformCarrierRate('10001', '9001')
|
||||
expect(mocks.get).toHaveBeenCalledWith('/platform/v1/fba-pricing/carrier-rates/9001', {
|
||||
params: { tenantId: '10001' },
|
||||
})
|
||||
})
|
||||
|
||||
it('creates immutable versions and retires an active version', async () => {
|
||||
mocks.post.mockResolvedValue({ data: payload })
|
||||
mocks.delete.mockResolvedValue({ data: null })
|
||||
const request = {
|
||||
rateCode: 'RATE-US-WEST',
|
||||
originWarehouseId: '20001',
|
||||
originWarehouseName: '义乌收货仓',
|
||||
businessType: 'FBA_SEA',
|
||||
channelCode: 'US-WEST-SEA',
|
||||
carrierId: '30001',
|
||||
carrierName: '示例船司',
|
||||
destinationCode: 'ONT8',
|
||||
chargeUnit: 'KG' as const,
|
||||
unitPrice: 12.5,
|
||||
currency: 'CNY',
|
||||
additionalFee: 80,
|
||||
effectiveFrom: '2026-08-01T00:00:00',
|
||||
effectiveTo: null,
|
||||
remark: null,
|
||||
}
|
||||
|
||||
await createPlatformCarrierRateVersion('10001', request)
|
||||
await retirePlatformCarrierRate('10001', '9001')
|
||||
|
||||
expect(mocks.post).toHaveBeenCalledWith('/platform/v1/fba-pricing/carrier-rates/versions', request, {
|
||||
params: { tenantId: '10001' },
|
||||
})
|
||||
expect(mocks.delete).toHaveBeenCalledWith('/platform/v1/fba-pricing/carrier-rates/9001', {
|
||||
params: { tenantId: '10001' },
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,124 @@
|
||||
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
|
||||
export type CarrierRateChargeUnit = 'KG' | 'CBM'
|
||||
export type CarrierRateStatus = 'ACTIVE' | 'INACTIVE'
|
||||
|
||||
export interface PlatformCarrierRateResource {
|
||||
id: string
|
||||
tenantId: string
|
||||
rateCode: string
|
||||
rateVersion: number
|
||||
originWarehouseId: string
|
||||
originWarehouseName: string
|
||||
businessType: string
|
||||
channelCode: string
|
||||
carrierId: string
|
||||
carrierName: string
|
||||
destinationCode: string
|
||||
chargeUnit: CarrierRateChargeUnit
|
||||
unitPrice: number
|
||||
currency: string
|
||||
additionalFee: number
|
||||
effectiveFrom: string
|
||||
effectiveTo: string | null
|
||||
status: CarrierRateStatus
|
||||
remark: string | null
|
||||
createdBy: string | null
|
||||
createdAt: string | null
|
||||
updatedBy: string | null
|
||||
updatedAt: string | null
|
||||
version: number | null
|
||||
}
|
||||
|
||||
interface PlatformCarrierRatePayload extends Omit<PlatformCarrierRateResource,
|
||||
'id' | 'tenantId' | 'originWarehouseId' | 'carrierId' | 'createdBy' | 'updatedBy'> {
|
||||
id: string | number
|
||||
tenantId: string | number
|
||||
originWarehouseId: string | number
|
||||
carrierId: string | number
|
||||
createdBy?: string | number | null
|
||||
updatedBy?: string | number | null
|
||||
}
|
||||
|
||||
export interface CreatePlatformCarrierRateVersionRequest {
|
||||
rateCode: string
|
||||
originWarehouseId: string
|
||||
originWarehouseName: string
|
||||
businessType: string
|
||||
channelCode: string
|
||||
carrierId: string
|
||||
carrierName: string
|
||||
destinationCode: string
|
||||
chargeUnit: CarrierRateChargeUnit
|
||||
unitPrice: number
|
||||
currency: string
|
||||
additionalFee: number
|
||||
effectiveFrom: string
|
||||
effectiveTo: string | null
|
||||
remark: string | null
|
||||
}
|
||||
|
||||
export interface PlatformCarrierRateQuery {
|
||||
customerId?: string
|
||||
rateCode?: string
|
||||
}
|
||||
|
||||
const basePath = '/platform/v1/fba-pricing/carrier-rates'
|
||||
|
||||
function nullableId(value: string | number | null | undefined) {
|
||||
return value == null ? null : String(value)
|
||||
}
|
||||
|
||||
function normalizeCarrierRate(value: PlatformCarrierRatePayload): PlatformCarrierRateResource {
|
||||
return {
|
||||
...value,
|
||||
id: String(value.id),
|
||||
tenantId: String(value.tenantId),
|
||||
originWarehouseId: String(value.originWarehouseId),
|
||||
carrierId: String(value.carrierId),
|
||||
createdBy: nullableId(value.createdBy),
|
||||
updatedBy: nullableId(value.updatedBy),
|
||||
effectiveTo: value.effectiveTo ?? null,
|
||||
remark: value.remark ?? null,
|
||||
createdAt: value.createdAt ?? null,
|
||||
updatedAt: value.updatedAt ?? null,
|
||||
version: value.version ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
export async function listPlatformCarrierRates(query: PlatformCarrierRateQuery = {}) {
|
||||
const customerId = query.customerId?.trim()
|
||||
const rateCode = query.rateCode?.trim()
|
||||
const response = await http.get<ApiEnvelope<PlatformCarrierRatePayload[]>, ApiEnvelope<PlatformCarrierRatePayload[]>>(
|
||||
basePath,
|
||||
{ params: { ...(customerId ? { tenantId: customerId } : {}), ...(rateCode ? { rateCode } : {}) } },
|
||||
)
|
||||
return unwrapApiResponse(response).map(normalizeCarrierRate)
|
||||
}
|
||||
|
||||
export async function getPlatformCarrierRate(tenantId: string, id: string) {
|
||||
const response = await http.get<ApiEnvelope<PlatformCarrierRatePayload>, ApiEnvelope<PlatformCarrierRatePayload>>(
|
||||
`${basePath}/${id}`,
|
||||
{ params: { tenantId } },
|
||||
)
|
||||
return normalizeCarrierRate(unwrapApiResponse(response))
|
||||
}
|
||||
|
||||
export async function createPlatformCarrierRateVersion(
|
||||
tenantId: string,
|
||||
payload: CreatePlatformCarrierRateVersionRequest,
|
||||
) {
|
||||
const response = await http.post<ApiEnvelope<PlatformCarrierRatePayload>, ApiEnvelope<PlatformCarrierRatePayload>>(
|
||||
`${basePath}/versions`,
|
||||
payload,
|
||||
{ params: { tenantId } },
|
||||
)
|
||||
return normalizeCarrierRate(unwrapApiResponse(response))
|
||||
}
|
||||
|
||||
export async function retirePlatformCarrierRate(tenantId: string, id: string) {
|
||||
const response = await http.delete<ApiEnvelope<null>, ApiEnvelope<null>>(`${basePath}/${id}`, {
|
||||
params: { tenantId },
|
||||
})
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const source = readFileSync(join(process.cwd(), 'src/modules/fba-pricing/carrier-rates/pages/CarrierRatesView.vue'), 'utf8')
|
||||
const queryToolbarSource = readFileSync(join(process.cwd(), 'src/shared/components/QueryToolbar.vue'), 'utf8')
|
||||
|
||||
describe('CarrierRatesView source contract', () => {
|
||||
it('keeps operation column first and covers every required business field', () => {
|
||||
const customerColumn = "key: 'customer', label: '所属企业'"
|
||||
const destinationColumn = "key: 'destinationCode', prop: 'destinationCode'"
|
||||
expect(source.indexOf("key: 'actions'")).toBeLessThan(source.indexOf(customerColumn))
|
||||
expect(source.indexOf(customerColumn)).toBeLessThan(source.indexOf(destinationColumn))
|
||||
expect(source.indexOf(destinationColumn)).toBeLessThan(source.indexOf("key: 'rate'"))
|
||||
for (const label of ['起运仓', '业务类型', '渠道编码', '船司', 'FBA 目的仓', '计费单位', '单价', '币种', '固定附加费', '有效期', '备注', '生效时间', '结束时间']) {
|
||||
expect(source).toContain(label)
|
||||
}
|
||||
expect(source).toContain("placeholder: 'FBA 目的仓编码'")
|
||||
expect(source).toContain("placeholder: '渠道编码'")
|
||||
})
|
||||
|
||||
it('implements immutable version actions with exact backend permissions', () => {
|
||||
expect(source).toContain("firstleg:fba-pricing:rate:edit")
|
||||
expect(source).toContain('创建版本')
|
||||
expect(source).toContain('新版本')
|
||||
expect(source).toContain('停用')
|
||||
expect(source).not.toContain('编辑价格')
|
||||
})
|
||||
|
||||
it('keeps the customer company scope in the query toolbar without exposing tenant concepts', () => {
|
||||
expect(source).toContain("key: 'customerId'")
|
||||
expect(source.indexOf("key: 'customerId'")).toBeLessThan(source.indexOf("key: 'rateCode'"))
|
||||
expect(source).toContain("'aria-label': '所属企业'")
|
||||
expect(source).toContain('listPlatformTenants')
|
||||
expect(source).toContain('loadedCustomerId')
|
||||
expect(source).not.toContain('PlatformTenantSelector')
|
||||
expect(source).not.toContain('selectedTenantId')
|
||||
expect(source).not.toContain('租户')
|
||||
})
|
||||
|
||||
it('uses semantic compact filter widths and keeps the customer selection empty by default', () => {
|
||||
for (const field of [
|
||||
"key: 'customerId',\n type: 'select',\n width: 280",
|
||||
"key: 'rateCode', type: 'input', width: 220",
|
||||
"key: 'destinationCode', type: 'input', width: 220",
|
||||
"key: 'channelCode', type: 'input', width: 220",
|
||||
"key: 'chargeUnit', type: 'select', width: 180",
|
||||
"key: 'status', type: 'select', width: 160",
|
||||
]) expect(source).toContain(field)
|
||||
expect(source).toContain("customerId: ''")
|
||||
expect(source).toContain("placeholder: customerLoadError.value ? '企业列表加载失败' : '全部客户'")
|
||||
expect(source).not.toContain(':search-on-reset="false"')
|
||||
expect(source).not.toContain('请先选择所属企业')
|
||||
expect(source).not.toContain('请选择所属企业并查询价格数据')
|
||||
expect(source).not.toContain('customers.value[0]')
|
||||
})
|
||||
|
||||
it('loads all customers initially and after reset while preserving row scope for record actions', () => {
|
||||
expect(source).toContain('void loadRateData()')
|
||||
expect(source).toContain("listPlatformCarrierRates(customerId ? { customerId } : {})")
|
||||
expect(source).toContain('loadRateData(customerId)')
|
||||
expect(source).toContain('getPlatformCarrierRate(String(row.tenantId), row.id)')
|
||||
expect(source).toContain('retirePlatformCarrierRate(String(row.tenantId), row.id)')
|
||||
expect(source).toContain('customerLabel(detail.tenantId)')
|
||||
})
|
||||
|
||||
it('inherits QueryToolbar wrapping and full-width mobile controls', () => {
|
||||
expect(queryToolbarSource).toContain('flex-wrap: wrap')
|
||||
expect(queryToolbarSource).toContain('@media (max-width: 720px)')
|
||||
expect(queryToolbarSource).toContain('width: 100%')
|
||||
expect(source).not.toContain('--query-field-width')
|
||||
})
|
||||
|
||||
it('guards unsaved work, failures and narrow screens', () => {
|
||||
expect(source).toContain('useUnsavedWorkRegistration')
|
||||
expect(source).toContain('LoadErrorState')
|
||||
expect(source).toContain('ApiErrorMeta')
|
||||
expect(source).toContain('@media (max-width: 720px)')
|
||||
})
|
||||
|
||||
it('reuses the first-leg order list primitives for status, empty state and pagination', () => {
|
||||
expect(source).toContain("import StatusBadge from '@/shared/components/StatusBadge.vue'")
|
||||
expect(source).toContain('<StatusBadge :status="statusLabel(')
|
||||
expect(source).toContain('<template #empty><el-empty')
|
||||
expect(source).toContain('<div class="business-page">')
|
||||
expect(source).not.toContain('<QueryToolbar v-model="filters" class="surface-panel"')
|
||||
expect(source).not.toContain('<template #pagination-left>')
|
||||
expect(source).not.toContain('<el-tag')
|
||||
})
|
||||
|
||||
it('does not create route or navigation ownership in the page task', () => {
|
||||
expect(source).not.toContain('createRouter')
|
||||
expect(source).not.toContain('adminNavigation')
|
||||
for (const unsupportedCapability of ['导入', '导出', '全部船期', '参考时效', '最晚进仓时间']) {
|
||||
expect(source).not.toContain(unsupportedCapability)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,641 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
|
||||
import { CalendarClock, Eye, Plus, Power, Ship, Warehouse } from '@lucide/vue'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
|
||||
import { listPlatformTenants, type PlatformTenantResource as PlatformCustomerResource } from '@/modules/iam'
|
||||
import { listPlatformWarehouses, type PlatformWarehouseResource } from '@/modules/warehouses'
|
||||
import { listPlatformPartners, type PlatformPartnerResource } from '@/modules/master-data'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import AppDrawer from '@/shared/components/AppDrawer.vue'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||
import StatusBadge from '@/shared/components/StatusBadge.vue'
|
||||
import {
|
||||
createPlatformCarrierRateVersion,
|
||||
getPlatformCarrierRate,
|
||||
listPlatformCarrierRates,
|
||||
retirePlatformCarrierRate,
|
||||
type CarrierRateChargeUnit,
|
||||
type CarrierRateStatus,
|
||||
type CreatePlatformCarrierRateVersionRequest,
|
||||
type PlatformCarrierRateResource,
|
||||
} from '../api/platformCarrierRates'
|
||||
|
||||
type RateFilters = QueryModel & {
|
||||
customerId: string
|
||||
rateCode: string
|
||||
destinationCode: string
|
||||
channelCode: string
|
||||
chargeUnit: CarrierRateChargeUnit | ''
|
||||
status: CarrierRateStatus | ''
|
||||
}
|
||||
|
||||
interface RateForm {
|
||||
rateCode: string
|
||||
originWarehouseId: string
|
||||
businessType: string
|
||||
channelCode: string
|
||||
carrierId: string
|
||||
destinationCode: string
|
||||
chargeUnit: CarrierRateChargeUnit
|
||||
unitPrice: number | null
|
||||
currency: string
|
||||
additionalFee: number | null
|
||||
effectiveFrom: string
|
||||
effectiveTo: string
|
||||
remark: string
|
||||
}
|
||||
|
||||
const access = useAccessPreviewStore()
|
||||
const customers = ref<PlatformCustomerResource[]>([])
|
||||
const customersLoading = ref(false)
|
||||
const customerLoadError = ref<ApiErrorDetails | null>(null)
|
||||
const loadedCustomerId = ref<string | null>(null)
|
||||
const rows = ref<PlatformCarrierRateResource[]>([])
|
||||
const warehouses = ref<PlatformWarehouseResource[]>([])
|
||||
const carriers = ref<PlatformPartnerResource[]>([])
|
||||
const loading = ref(false)
|
||||
const masterDataLoading = ref(false)
|
||||
const loadError = ref<ApiErrorDetails | null>(null)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const editorOpen = ref(false)
|
||||
const detailOpen = ref(false)
|
||||
const detailLoading = ref(false)
|
||||
const saving = ref(false)
|
||||
const detail = ref<PlatformCarrierRateResource | null>(null)
|
||||
const retiringIds = ref(new Set<string>())
|
||||
const editorCustomerId = ref('')
|
||||
const formRef = ref<FormInstance>()
|
||||
let loadSequence = 0
|
||||
let masterDataSequence = 0
|
||||
|
||||
const emptyForm = (): RateForm => ({
|
||||
rateCode: '',
|
||||
originWarehouseId: '',
|
||||
businessType: '',
|
||||
channelCode: '',
|
||||
carrierId: '',
|
||||
destinationCode: '',
|
||||
chargeUnit: 'KG',
|
||||
unitPrice: null,
|
||||
currency: 'CNY',
|
||||
additionalFee: 0,
|
||||
effectiveFrom: '',
|
||||
effectiveTo: '',
|
||||
remark: '',
|
||||
})
|
||||
const form = reactive<RateForm>(emptyForm())
|
||||
const editorLabel = computed(() => form.rateCode ? `创建 ${form.rateCode} 的新版本` : '新增船司 FBA 价格版本')
|
||||
const requiredPermission = computed(() => 'firstleg:fba-pricing:rate:edit')
|
||||
const { captureBaseline } = useUnsavedWorkRegistration({
|
||||
key: 'carrier-rate-version-editor',
|
||||
label: editorLabel,
|
||||
isOpen: editorOpen,
|
||||
model: form,
|
||||
requiredPermission,
|
||||
})
|
||||
|
||||
const filters = ref<RateFilters>({ customerId: '', rateCode: '', destinationCode: '', channelCode: '', chargeUnit: '', status: '' })
|
||||
const appliedFilters = ref<RateFilters>({ ...filters.value })
|
||||
const customerSwitchDisabled = computed(() => editorOpen.value || detailOpen.value || saving.value || detailLoading.value || retiringIds.value.size > 0)
|
||||
const queryFields = computed<QueryField[]>(() => [
|
||||
{
|
||||
key: 'customerId',
|
||||
type: 'select',
|
||||
width: 280,
|
||||
placeholder: customerLoadError.value ? '企业列表加载失败' : '全部客户',
|
||||
props: { 'aria-label': '所属企业', filterable: true, disabled: customersLoading.value || Boolean(customerLoadError.value) || customerSwitchDisabled.value },
|
||||
options: customers.value.map((customer) => ({ value: String(customer.id), label: `${customer.tenantName}(${customer.tenantCode || customer.id})` })),
|
||||
},
|
||||
{ key: 'rateCode', type: 'input', width: 220, placeholder: '价格编码', props: { 'aria-label': '价格编码' } },
|
||||
{ key: 'destinationCode', type: 'input', width: 220, placeholder: 'FBA 目的仓编码', props: { 'aria-label': 'FBA 目的仓编码' } },
|
||||
{ key: 'channelCode', type: 'input', width: 220, placeholder: '渠道编码', props: { 'aria-label': '渠道编码' } },
|
||||
{
|
||||
key: 'chargeUnit', type: 'select', width: 180, placeholder: '全部计费单位', props: { 'aria-label': '计费单位' }, options: [
|
||||
{ label: 'KG', value: 'KG' },
|
||||
{ label: 'CBM', value: 'CBM' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'status', type: 'select', width: 160, placeholder: '全部状态', props: { 'aria-label': '价格状态' }, options: [
|
||||
{ label: '生效中', value: 'ACTIVE' },
|
||||
{ label: '已停用', value: 'INACTIVE' },
|
||||
],
|
||||
},
|
||||
])
|
||||
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 230, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'customer', label: '所属企业', minWidth: 200, slotName: 'customer' },
|
||||
{ key: 'destinationCode', prop: 'destinationCode', label: 'FBA 目的仓', minWidth: 135 },
|
||||
{ key: 'rate', label: '价格版本', minWidth: 180, slotName: 'rate' },
|
||||
{ key: 'originWarehouseName', prop: 'originWarehouseName', label: '起运仓', minWidth: 170, showOverflowTooltip: true },
|
||||
{ key: 'channelCode', prop: 'channelCode', label: '渠道编码', minWidth: 150, showOverflowTooltip: true },
|
||||
{ key: 'carrier', label: '船司', minWidth: 170, slotName: 'carrier' },
|
||||
{ key: 'businessType', prop: 'businessType', label: '业务类型', minWidth: 110 },
|
||||
{ key: 'chargeUnit', prop: 'chargeUnit', label: '计费单位', width: 95 },
|
||||
{ key: 'unitPrice', label: '单价', minWidth: 135, slotName: 'unitPrice' },
|
||||
{ key: 'additionalFee', label: '固定附加费', minWidth: 135, slotName: 'additionalFee' },
|
||||
{ key: 'currency', prop: 'currency', label: '币种', width: 80 },
|
||||
{ key: 'period', label: '有效期', minWidth: 210, slotName: 'period' },
|
||||
{ key: 'remark', prop: 'remark', label: '备注', minWidth: 190, showOverflowTooltip: true, formatter: (row: PlatformCarrierRateResource) => row.remark || '-' },
|
||||
{ key: 'status', label: '状态', width: 90, slotName: 'status' },
|
||||
{ key: 'updatedAt', label: '更新时间', minWidth: 150, formatter: (row: PlatformCarrierRateResource) => formatDateTime(row.updatedAt) },
|
||||
]
|
||||
|
||||
const originWarehouses = computed(() => warehouses.value.filter((item) => item.status === 'ACTIVE' && item.warehouseType === 'DOMESTIC'))
|
||||
const fbaWarehouses = computed(() => warehouses.value.filter((item) => item.status === 'ACTIVE' && item.warehouseType === 'FBA'))
|
||||
const activeCarriers = computed(() => carriers.value.filter((item) => item.status === 'ACTIVE'))
|
||||
const filteredRows = computed(() => rows.value.filter((row) => {
|
||||
const code = appliedFilters.value.rateCode.trim().toLowerCase()
|
||||
const destinationCode = appliedFilters.value.destinationCode.trim().toLowerCase()
|
||||
const channelCode = appliedFilters.value.channelCode.trim().toLowerCase()
|
||||
return (!code || row.rateCode.toLowerCase().includes(code))
|
||||
&& (!destinationCode || row.destinationCode.toLowerCase().includes(destinationCode))
|
||||
&& (!channelCode || row.channelCode.toLowerCase().includes(channelCode))
|
||||
&& (!appliedFilters.value.chargeUnit || row.chargeUnit === appliedFilters.value.chargeUnit)
|
||||
&& (!appliedFilters.value.status || row.status === appliedFilters.value.status)
|
||||
}))
|
||||
const pagedRows = computed(() => filteredRows.value.slice((page.value - 1) * pageSize.value, page.value * pageSize.value))
|
||||
const activeCount = computed(() => rows.value.filter((row) => row.status === 'ACTIVE').length)
|
||||
const loadErrorMessage = computed(() => loadError.value?.message || '')
|
||||
|
||||
function validateEffectiveTo(_rule: unknown, value: string, callback: (error?: Error) => void) {
|
||||
if (!value || !form.effectiveFrom || new Date(value).getTime() > new Date(form.effectiveFrom).getTime()) callback()
|
||||
else callback(new Error('结束时间必须晚于开始时间'))
|
||||
}
|
||||
|
||||
const rules: FormRules<RateForm> = {
|
||||
rateCode: [{ required: true, whitespace: true, message: '请输入价格编码', trigger: 'blur' }],
|
||||
originWarehouseId: [{ required: true, message: '请选择起运仓', trigger: 'change' }],
|
||||
businessType: [{ required: true, whitespace: true, message: '请输入业务类型', trigger: 'blur' }],
|
||||
channelCode: [{ required: true, whitespace: true, message: '请输入渠道编码', trigger: 'blur' }],
|
||||
carrierId: [{ required: true, message: '请选择船司', trigger: 'change' }],
|
||||
destinationCode: [{ required: true, whitespace: true, message: '请选择或输入 FBA 目的仓编码', trigger: 'change' }],
|
||||
chargeUnit: [{ required: true, message: '请选择计费单位', trigger: 'change' }],
|
||||
unitPrice: [{ required: true, type: 'number', min: 0.000001, message: '单价必须大于 0', trigger: 'change' }],
|
||||
currency: [
|
||||
{ required: true, whitespace: true, message: '请输入币种', trigger: 'blur' },
|
||||
{ pattern: /^[A-Z]{3}$/, message: '币种须为三个大写英文字母', trigger: 'blur' },
|
||||
],
|
||||
additionalFee: [{ required: true, type: 'number', min: 0, message: '附加费不能小于 0', trigger: 'change' }],
|
||||
effectiveFrom: [{ required: true, message: '请选择生效时间', trigger: 'change' }],
|
||||
effectiveTo: [{ validator: validateEffectiveTo, trigger: 'change' }],
|
||||
}
|
||||
|
||||
watch(() => filters.value.customerId, (customerId) => {
|
||||
if (customerId === loadedCustomerId.value) return
|
||||
masterDataSequence += 1
|
||||
warehouses.value = []
|
||||
carriers.value = []
|
||||
loadedCustomerId.value = null
|
||||
page.value = 1
|
||||
})
|
||||
|
||||
watch(
|
||||
() => access.can('firstleg:fba-pricing:rate:edit'),
|
||||
(canEdit) => {
|
||||
if (canEdit || !editorOpen.value) return
|
||||
editorOpen.value = false
|
||||
Object.assign(form, emptyForm())
|
||||
},
|
||||
{ flush: 'sync' },
|
||||
)
|
||||
|
||||
function formatDateTime(value: string | null | undefined) {
|
||||
return value ? value.replace('T', ' ').slice(0, 16) : '-'
|
||||
}
|
||||
|
||||
function formatMoney(value: number, currency: string) {
|
||||
return `${currency} ${Number(value).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 4 })}`
|
||||
}
|
||||
|
||||
function statusLabel(status: CarrierRateStatus) {
|
||||
return status === 'ACTIVE' ? '启用' : '停用'
|
||||
}
|
||||
|
||||
function customerLabel(customerId: string) {
|
||||
const customer = customers.value.find((item) => String(item.id) === String(customerId))
|
||||
return customer ? `${customer.tenantName}(${customer.tenantCode || '未设置编码'})` : '客户信息待加载'
|
||||
}
|
||||
|
||||
function nullable(value: string) {
|
||||
return value.trim() || null
|
||||
}
|
||||
|
||||
async function loadCustomers() {
|
||||
customersLoading.value = true
|
||||
customerLoadError.value = null
|
||||
try {
|
||||
const records: PlatformCustomerResource[] = []
|
||||
let currentPage = 1
|
||||
let pages = 1
|
||||
do {
|
||||
const result = await listPlatformTenants({ page: currentPage, size: 200, status: 'ACTIVE' })
|
||||
records.push(...result.records)
|
||||
pages = Math.max(1, result.pages)
|
||||
currentPage += 1
|
||||
} while (currentPage <= pages)
|
||||
customers.value = records
|
||||
} catch (error) {
|
||||
customers.value = []
|
||||
customerLoadError.value = getApiErrorDetails(error, '企业列表加载失败,暂时无法按所属企业筛选或显示企业名称;价格列表仍可查询')
|
||||
} finally {
|
||||
customersLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRateData(customerId = filters.value.customerId) {
|
||||
const sequence = ++loadSequence
|
||||
loading.value = true
|
||||
loadError.value = null
|
||||
try {
|
||||
const rateResult = await listPlatformCarrierRates(customerId ? { customerId } : {})
|
||||
if (sequence !== loadSequence) return
|
||||
rows.value = rateResult
|
||||
} catch (error) {
|
||||
if (sequence !== loadSequence) return
|
||||
rows.value = []
|
||||
loadError.value = getApiErrorDetails(error, '船司 FBA 价格加载失败,请稍后重试')
|
||||
} finally {
|
||||
if (sequence === loadSequence) {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCustomerMasterData(customerId: string) {
|
||||
if (!customerId) return false
|
||||
const sequence = ++masterDataSequence
|
||||
masterDataLoading.value = true
|
||||
try {
|
||||
const [warehouseResult, partnerResult] = await Promise.all([
|
||||
listPlatformWarehouses(customerId, 1, 200),
|
||||
listPlatformPartners(customerId, { page: 1, size: 200, type: 'CARRIER', status: 'ACTIVE' }),
|
||||
])
|
||||
if (sequence !== masterDataSequence) return false
|
||||
warehouses.value = warehouseResult.records.map((item) => ({ ...item, id: String(item.id), tenantId: String(item.tenantId) }))
|
||||
carriers.value = partnerResult.records.map((item) => ({ ...item, id: String(item.id), tenantId: String(item.tenantId), dataScopeId: String(item.dataScopeId) }))
|
||||
loadedCustomerId.value = customerId
|
||||
return true
|
||||
} catch (error) {
|
||||
if (sequence === masterDataSequence) {
|
||||
warehouses.value = []
|
||||
carriers.value = []
|
||||
loadedCustomerId.value = null
|
||||
ElMessage.error(getApiErrorDetails(error, '所属企业的仓库或船司资料加载失败').message)
|
||||
}
|
||||
return false
|
||||
} finally {
|
||||
if (sequence === masterDataSequence) masterDataLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function applyQuery(model: QueryModel) {
|
||||
const customerId = String(model.customerId || '')
|
||||
appliedFilters.value = {
|
||||
customerId,
|
||||
rateCode: String(model.rateCode || ''),
|
||||
destinationCode: String(model.destinationCode || ''),
|
||||
channelCode: String(model.channelCode || ''),
|
||||
chargeUnit: (model.chargeUnit || '') as CarrierRateChargeUnit | '',
|
||||
status: (model.status || '') as CarrierRateStatus | '',
|
||||
}
|
||||
page.value = 1
|
||||
await Promise.all([
|
||||
loadRateData(customerId),
|
||||
customerId && loadedCustomerId.value !== customerId ? loadCustomerMasterData(customerId) : Promise.resolve(true),
|
||||
])
|
||||
}
|
||||
|
||||
function resetQuery(model: QueryModel) {
|
||||
appliedFilters.value = {
|
||||
customerId: String(model.customerId || ''),
|
||||
rateCode: String(model.rateCode || ''),
|
||||
destinationCode: String(model.destinationCode || ''),
|
||||
channelCode: String(model.channelCode || ''),
|
||||
chargeUnit: (model.chargeUnit || '') as CarrierRateChargeUnit | '',
|
||||
status: (model.status || '') as CarrierRateStatus | '',
|
||||
}
|
||||
page.value = 1
|
||||
}
|
||||
|
||||
function resetForm(rate?: PlatformCarrierRateResource) {
|
||||
Object.assign(form, emptyForm(), rate ? {
|
||||
rateCode: rate.rateCode,
|
||||
originWarehouseId: rate.originWarehouseId,
|
||||
businessType: rate.businessType,
|
||||
channelCode: rate.channelCode,
|
||||
carrierId: rate.carrierId,
|
||||
destinationCode: rate.destinationCode,
|
||||
chargeUnit: rate.chargeUnit,
|
||||
unitPrice: rate.unitPrice,
|
||||
currency: rate.currency,
|
||||
additionalFee: rate.additionalFee,
|
||||
effectiveFrom: '',
|
||||
effectiveTo: '',
|
||||
remark: rate.remark || '',
|
||||
} : {})
|
||||
}
|
||||
|
||||
async function openCreate(rate?: PlatformCarrierRateResource) {
|
||||
const customerId = String(rate?.tenantId || filters.value.customerId || '')
|
||||
if (!customerId) {
|
||||
ElMessage.warning('新增价格版本需要先选择所属企业')
|
||||
return
|
||||
}
|
||||
if (loadedCustomerId.value !== customerId) {
|
||||
const loaded = await loadCustomerMasterData(customerId)
|
||||
if (!loaded) return
|
||||
}
|
||||
editorCustomerId.value = customerId
|
||||
resetForm(rate)
|
||||
captureBaseline()
|
||||
editorOpen.value = true
|
||||
requestAnimationFrame(() => formRef.value?.clearValidate())
|
||||
}
|
||||
|
||||
async function openDetail(row: PlatformCarrierRateResource) {
|
||||
detailOpen.value = true
|
||||
detailLoading.value = true
|
||||
detail.value = null
|
||||
try {
|
||||
detail.value = await getPlatformCarrierRate(String(row.tenantId), row.id)
|
||||
} catch (error) {
|
||||
const meta = getApiErrorDetails(error, '价格版本详情加载失败')
|
||||
ElMessage.error(meta.requestId ? `${meta.message}(requestId: ${meta.requestId})` : meta.message)
|
||||
detailOpen.value = false
|
||||
} finally {
|
||||
detailLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function selectedOriginWarehouse() {
|
||||
return originWarehouses.value.find((item) => String(item.id) === form.originWarehouseId)
|
||||
}
|
||||
|
||||
function selectedCarrier() {
|
||||
return activeCarriers.value.find((item) => String(item.id) === form.carrierId)
|
||||
}
|
||||
|
||||
function buildPayload(): CreatePlatformCarrierRateVersionRequest | null {
|
||||
const warehouse = selectedOriginWarehouse()
|
||||
const carrier = selectedCarrier()
|
||||
if (!warehouse || !carrier || form.unitPrice == null || form.additionalFee == null) return null
|
||||
return {
|
||||
rateCode: form.rateCode.trim(),
|
||||
originWarehouseId: String(warehouse.id),
|
||||
originWarehouseName: warehouse.warehouseName,
|
||||
businessType: form.businessType.trim(),
|
||||
channelCode: form.channelCode.trim(),
|
||||
carrierId: String(carrier.id),
|
||||
carrierName: carrier.partnerName,
|
||||
destinationCode: form.destinationCode.trim().toUpperCase(),
|
||||
chargeUnit: form.chargeUnit,
|
||||
unitPrice: form.unitPrice,
|
||||
currency: form.currency.trim().toUpperCase(),
|
||||
additionalFee: form.additionalFee,
|
||||
effectiveFrom: form.effectiveFrom,
|
||||
effectiveTo: nullable(form.effectiveTo),
|
||||
remark: nullable(form.remark),
|
||||
}
|
||||
}
|
||||
|
||||
async function saveVersion() {
|
||||
const customerId = editorCustomerId.value
|
||||
if (!customerId) return
|
||||
const valid = await formRef.value?.validate().catch(() => false)
|
||||
if (!valid) return
|
||||
const payload = buildPayload()
|
||||
if (!payload) {
|
||||
ElMessage.error('起运仓或船司主数据已变化,请重新选择')
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
await createPlatformCarrierRateVersion(customerId, payload)
|
||||
editorOpen.value = false
|
||||
ElMessage.success('船司 FBA 价格新版本已创建')
|
||||
await loadRateData(filters.value.customerId)
|
||||
} catch (error) {
|
||||
const meta = getApiErrorDetails(error, '价格版本创建失败,请检查有效期和匹配维度后重试')
|
||||
ElMessage.error(meta.requestId ? `${meta.message}(requestId: ${meta.requestId})` : meta.message)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function retireRate(row: PlatformCarrierRateResource) {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`停用“${row.rateCode} V${row.rateVersion}”后,新费用试算不会再匹配该版本,历史费用快照不受影响。`,
|
||||
'停用价格版本',
|
||||
{ type: 'warning', confirmButtonText: '确认停用', cancelButtonText: '取消' },
|
||||
)
|
||||
retiringIds.value = new Set(retiringIds.value).add(row.id)
|
||||
await retirePlatformCarrierRate(String(row.tenantId), row.id)
|
||||
ElMessage.success('价格版本已停用')
|
||||
await loadRateData(filters.value.customerId)
|
||||
} catch (error) {
|
||||
if (error === 'cancel' || error === 'close') return
|
||||
const meta = getApiErrorDetails(error, '价格版本停用失败,请重试')
|
||||
ElMessage.error(meta.requestId ? `${meta.message}(requestId: ${meta.requestId})` : meta.message)
|
||||
} finally {
|
||||
const next = new Set(retiringIds.value)
|
||||
next.delete(row.id)
|
||||
retiringIds.value = next
|
||||
}
|
||||
}
|
||||
|
||||
function closeEditor() {
|
||||
if (saving.value) return
|
||||
editorOpen.value = false
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void loadCustomers()
|
||||
void loadRateData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="business-page">
|
||||
<QueryToolbar
|
||||
v-model="filters"
|
||||
ariaLabel="船司 FBA 价格筛选"
|
||||
:fields="queryFields"
|
||||
@search="applyQuery"
|
||||
@reset="resetQuery"
|
||||
/>
|
||||
|
||||
<el-alert v-if="customerLoadError" type="warning" :closable="false" show-icon role="alert">
|
||||
<template #title><div class="api-error-alert-title"><span>{{ customerLoadError.message }}</span><ApiErrorMeta :details="customerLoadError" /></div></template>
|
||||
<el-button link type="warning" @click="loadCustomers">重新加载企业列表</el-button>
|
||||
</el-alert>
|
||||
|
||||
<LoadErrorState
|
||||
v-if="loadError"
|
||||
title="船司 FBA 价格加载失败"
|
||||
:message="loadErrorMessage"
|
||||
:loading="loading"
|
||||
@retry="loadRateData()"
|
||||
>
|
||||
<template #details><ApiErrorMeta :details="loadError" /></template>
|
||||
</LoadErrorState>
|
||||
|
||||
<section class="surface-panel table-panel" aria-live="polite">
|
||||
<div class="panel-heading">
|
||||
<div>
|
||||
<h2>价格版本</h2>
|
||||
<p>共 {{ rows.length }} 个版本 · {{ activeCount }} 个生效中</p>
|
||||
</div>
|
||||
<el-button
|
||||
v-if="access.can('firstleg:fba-pricing:rate:edit')"
|
||||
type="primary"
|
||||
:disabled="!filters.customerId || masterDataLoading"
|
||||
@click="openCreate()"
|
||||
><Plus :size="16" />新增价格版本</el-button>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
v-model:page="page"
|
||||
v-model:page-size="pageSize"
|
||||
:data="pagedRows"
|
||||
:columns="columns"
|
||||
:loading="loading"
|
||||
:total="filteredRows.length"
|
||||
:page-sizes="[10, 20, 50]"
|
||||
row-key="id"
|
||||
aria-label="船司 FBA 价格版本列表"
|
||||
empty-text="暂无符合条件的价格版本"
|
||||
>
|
||||
<template #actions="{ row }">
|
||||
<div class="table-actions">
|
||||
<el-button link type="primary" @click="openDetail(row as PlatformCarrierRateResource)"><Eye :size="15" />详情</el-button>
|
||||
<el-button
|
||||
v-if="access.can('firstleg:fba-pricing:rate:edit')"
|
||||
link
|
||||
type="primary"
|
||||
@click="openCreate(row as PlatformCarrierRateResource)"
|
||||
><Plus :size="15" />新版本</el-button>
|
||||
<el-button
|
||||
v-if="access.can('firstleg:fba-pricing:rate:edit') && (row as PlatformCarrierRateResource).status === 'ACTIVE'"
|
||||
link
|
||||
type="danger"
|
||||
:loading="retiringIds.has((row as PlatformCarrierRateResource).id)"
|
||||
@click="retireRate(row as PlatformCarrierRateResource)"
|
||||
><Power :size="15" />停用</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<template #customer="{ row }"><strong>{{ customerLabel(String((row as PlatformCarrierRateResource).tenantId)) }}</strong></template>
|
||||
<template #rate="{ row }">
|
||||
<div class="primary-cell"><strong>{{ (row as PlatformCarrierRateResource).rateCode }}</strong><span>版本 V{{ (row as PlatformCarrierRateResource).rateVersion }}</span></div>
|
||||
</template>
|
||||
<template #carrier="{ row }"><div class="primary-cell"><strong>{{ (row as PlatformCarrierRateResource).carrierName }}</strong><span>ID {{ (row as PlatformCarrierRateResource).carrierId }}</span></div></template>
|
||||
<template #unitPrice="{ row }"><strong>{{ formatMoney((row as PlatformCarrierRateResource).unitPrice, (row as PlatformCarrierRateResource).currency) }}</strong></template>
|
||||
<template #additionalFee="{ row }">{{ formatMoney((row as PlatformCarrierRateResource).additionalFee, (row as PlatformCarrierRateResource).currency) }}</template>
|
||||
<template #period="{ row }"><div class="primary-cell"><strong>{{ formatDateTime((row as PlatformCarrierRateResource).effectiveFrom) }}</strong><span>至 {{ formatDateTime((row as PlatformCarrierRateResource).effectiveTo) }}</span></div></template>
|
||||
<template #status="{ row }"><StatusBadge :status="statusLabel((row as PlatformCarrierRateResource).status)" /></template>
|
||||
<template #empty><el-empty description="暂无符合条件的价格版本" /></template>
|
||||
</DataTable>
|
||||
</section>
|
||||
|
||||
<AppDrawer
|
||||
v-model="editorOpen"
|
||||
:title="editorLabel"
|
||||
size="min(760px, 96vw)"
|
||||
:confirm-loading="saving"
|
||||
confirm-text="创建版本"
|
||||
@confirm="saveVersion"
|
||||
@cancel="closeEditor"
|
||||
>
|
||||
<el-alert type="info" :closable="false" show-icon class="version-note">
|
||||
价格版本创建后不可原地修改。相同匹配维度的生效时间不得重叠;需要变价时,请创建新版本并停用旧版本。
|
||||
</el-alert>
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-position="top" class="drawer-form" :disabled="saving">
|
||||
<section class="form-section">
|
||||
<h3>版本与运输匹配</h3>
|
||||
<div class="form-grid two-cols">
|
||||
<el-form-item label="价格编码" prop="rateCode"><el-input v-model="form.rateCode" maxlength="50" placeholder="例如 FBA-US-WEST" /></el-form-item>
|
||||
<el-form-item label="起运仓" prop="originWarehouseId">
|
||||
<el-select v-model="form.originWarehouseId" filterable placeholder="请选择启用的国内收货仓" :loading="masterDataLoading">
|
||||
<el-option v-for="warehouse in originWarehouses" :key="String(warehouse.id)" :label="`${warehouse.warehouseName}(${warehouse.warehouseCode})`" :value="String(warehouse.id)" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="业务类型" prop="businessType"><el-input v-model="form.businessType" maxlength="32" placeholder="请输入后端使用的业务类型" /></el-form-item>
|
||||
<el-form-item label="渠道编码" prop="channelCode"><el-input v-model="form.channelCode" maxlength="64" placeholder="请输入渠道编码" /></el-form-item>
|
||||
<el-form-item label="船司" prop="carrierId">
|
||||
<el-select v-model="form.carrierId" filterable placeholder="请选择启用的承运商" :loading="masterDataLoading">
|
||||
<el-option v-for="carrier in activeCarriers" :key="String(carrier.id)" :label="`${carrier.partnerName}(${carrier.partnerCode})`" :value="String(carrier.id)" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="FBA 目的仓" prop="destinationCode">
|
||||
<el-select v-model="form.destinationCode" filterable allow-create default-first-option placeholder="选择或输入 FBA 仓编码">
|
||||
<el-option v-for="warehouse in fbaWarehouses" :key="String(warehouse.id)" :label="`${warehouse.warehouseName}(${warehouse.warehouseCode})`" :value="warehouse.warehouseCode" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="form-section">
|
||||
<h3>计费口径</h3>
|
||||
<div class="form-grid two-cols">
|
||||
<el-form-item label="计费单位" prop="chargeUnit"><el-segmented v-model="form.chargeUnit" :options="['KG', 'CBM']" /></el-form-item>
|
||||
<el-form-item label="单价" prop="unitPrice"><el-input-number v-model="form.unitPrice" :min="0" :precision="4" :step="0.01" controls-position="right" /></el-form-item>
|
||||
<el-form-item label="币种" prop="currency"><el-input v-model="form.currency" maxlength="3" placeholder="CNY" @input="form.currency = String($event).toUpperCase()" /></el-form-item>
|
||||
<el-form-item label="固定附加费" prop="additionalFee"><el-input-number v-model="form.additionalFee" :min="0" :precision="2" :step="1" controls-position="right" /></el-form-item>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="form-section">
|
||||
<h3>有效期</h3>
|
||||
<div class="form-grid two-cols">
|
||||
<el-form-item label="生效时间" prop="effectiveFrom"><el-date-picker v-model="form.effectiveFrom" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="选择生效时间" /></el-form-item>
|
||||
<el-form-item label="结束时间" prop="effectiveTo"><el-date-picker v-model="form.effectiveTo" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="留空表示长期有效" /></el-form-item>
|
||||
</div>
|
||||
<el-form-item label="备注"><el-input v-model="form.remark" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="记录版本适用说明或变更原因" /></el-form-item>
|
||||
</section>
|
||||
</el-form>
|
||||
</AppDrawer>
|
||||
|
||||
<AppDrawer v-model="detailOpen" title="船司 FBA 价格详情" size="min(680px, 96vw)" :loading="detailLoading" :show-footer="false">
|
||||
<div v-if="detail" class="detail-content">
|
||||
<header class="detail-heading">
|
||||
<div><strong>{{ detail.rateCode }}</strong><span>版本 V{{ detail.rateVersion }}</span></div>
|
||||
<StatusBadge :status="statusLabel(detail.status)" />
|
||||
</header>
|
||||
<section class="detail-section"><h3><Warehouse :size="17" />运输匹配</h3><dl class="detail-grid"><div><dt>所属企业</dt><dd>{{ customerLabel(detail.tenantId) }}</dd></div><div><dt>起运仓</dt><dd>{{ detail.originWarehouseName }}</dd></div><div><dt>FBA 目的仓</dt><dd>{{ detail.destinationCode }}</dd></div><div><dt>业务类型</dt><dd>{{ detail.businessType }}</dd></div><div><dt>渠道编码</dt><dd>{{ detail.channelCode }}</dd></div><div><dt>船司</dt><dd>{{ detail.carrierName }}</dd></div><div><dt>船司 ID</dt><dd>{{ detail.carrierId }}</dd></div></dl></section>
|
||||
<section class="detail-section"><h3><Ship :size="17" />计费口径</h3><dl class="detail-grid"><div><dt>计费单位</dt><dd>{{ detail.chargeUnit }}</dd></div><div><dt>币种</dt><dd>{{ detail.currency }}</dd></div><div><dt>单价</dt><dd>{{ formatMoney(detail.unitPrice, detail.currency) }}</dd></div><div><dt>固定附加费</dt><dd>{{ formatMoney(detail.additionalFee, detail.currency) }}</dd></div></dl></section>
|
||||
<section class="detail-section"><h3><CalendarClock :size="17" />版本有效期</h3><dl class="detail-grid"><div><dt>生效时间</dt><dd>{{ formatDateTime(detail.effectiveFrom) }}</dd></div><div><dt>结束时间</dt><dd>{{ formatDateTime(detail.effectiveTo) }}</dd></div><div><dt>更新时间</dt><dd>{{ formatDateTime(detail.updatedAt) }}</dd></div><div><dt>记录版本</dt><dd>{{ detail.version ?? '-' }}</dd></div></dl><p v-if="detail.remark" class="detail-remark">{{ detail.remark }}</p></section>
|
||||
</div>
|
||||
</AppDrawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.version-note { margin-bottom: 18px; }
|
||||
.form-grid :deep(.el-select), .form-grid :deep(.el-date-editor), .form-grid :deep(.el-input-number), .form-grid :deep(.el-segmented) { width: 100%; }
|
||||
.detail-content { display: grid; gap: 18px; }
|
||||
.detail-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; padding-bottom: 16px; border-bottom: 1px solid var(--el-border-color-lighter); }
|
||||
.detail-heading > div { display: flex; min-width: 0; flex-direction: column; gap: 4px; }
|
||||
.detail-heading strong { font-size: 18px; overflow-wrap: anywhere; }
|
||||
.detail-heading span { color: var(--cc-muted); font-size: 12px; }
|
||||
.detail-section { padding: 2px 0 8px; }
|
||||
.detail-section h3 { display: flex; align-items: center; gap: 8px; margin: 0 0 14px; font-size: 14px; }
|
||||
.detail-section h3 svg { color: var(--cc-primary); }
|
||||
.detail-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px 24px; margin: 0; }
|
||||
.detail-grid div { min-width: 0; }
|
||||
.detail-grid dt { margin-bottom: 5px; color: var(--cc-muted); font-size: 12px; }
|
||||
.detail-grid dd { margin: 0; font-size: 13px; line-height: 1.55; overflow-wrap: anywhere; }
|
||||
.detail-remark { margin: 16px 0 0; padding: 12px 14px; border-radius: 10px; background: var(--el-fill-color-light); color: var(--el-text-color-regular); font-size: 13px; line-height: 1.65; white-space: pre-wrap; }
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.panel-heading .el-button { width: 100%; min-height: 44px; }
|
||||
.detail-grid { grid-template-columns: 1fr; gap: 12px; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,86 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const source = readFileSync('src/modules/fba-pricing/customer-rules/CustomerRulesView.vue', 'utf8')
|
||||
const queryToolbarSource = readFileSync('src/shared/components/QueryToolbar.vue', 'utf8')
|
||||
|
||||
describe('CustomerRulesView', () => {
|
||||
it('keeps the operation column first and exposes version-only actions', () => {
|
||||
const table = source.indexOf('const columns')
|
||||
const operation = source.indexOf("label: '操作'", table)
|
||||
const ruleCode = source.indexOf("label: '规则版本'", table)
|
||||
expect(operation).toBeGreaterThan(0)
|
||||
expect(operation).toBeLessThan(ruleCode)
|
||||
expect(source).toContain('新建版本')
|
||||
expect(source).toContain('停用规则')
|
||||
expect(source).not.toContain('编辑规则')
|
||||
})
|
||||
|
||||
it('covers customer scope, permission, unsaved work, error and responsive states', () => {
|
||||
expect(source).not.toContain('PlatformTenantSelector')
|
||||
expect(source).toContain("key: 'customerId'")
|
||||
expect(source).toContain("'aria-label': '所属企业'")
|
||||
expect(source).toContain('listPlatformTenants')
|
||||
expect(source).toContain("firstleg:fba-pricing:rule:edit")
|
||||
expect(source).toContain('useUnsavedWorkRegistration')
|
||||
expect(source).toContain('LoadErrorState')
|
||||
expect(source).toContain('detailError')
|
||||
expect(source).toContain('@retry="retryDetail"')
|
||||
expect(source).not.toContain('请选择客户')
|
||||
expect(source).not.toContain('请选择所属企业')
|
||||
expect(source).not.toContain('当前接口要求选择客户')
|
||||
expect(source).not.toContain('租户')
|
||||
expect(source).toContain('@media (max-width: 480px)')
|
||||
})
|
||||
|
||||
it('uses compact semantic filter widths and keeps the customer filter empty by default', () => {
|
||||
expect(source).toContain("customerId: '', ruleCode: '', customerName: '', chargeMode: '', status: ''")
|
||||
expect(source).toContain("width: 280,\n placeholder: customerLoadError.value ? '客户列表加载失败' : '全部客户'")
|
||||
expect(source).toContain("key: 'ruleCode', type: 'input', width: 220")
|
||||
expect(source).toContain("key: 'customerName', type: 'input', width: 260")
|
||||
expect(source).toContain("key: 'chargeMode', type: 'select', width: 180")
|
||||
expect(source).toContain("key: 'status', type: 'select', width: 160")
|
||||
expect(source).not.toContain('customers.value[0]')
|
||||
})
|
||||
|
||||
it('resets to all customers, uses full-width mobile fields and allows an empty customer filter', () => {
|
||||
expect(queryToolbarSource).toContain("for (const field of props.fields) next[field.key] = resetValue(field)")
|
||||
expect(queryToolbarSource).toContain("if (field.type === 'select') return '全部'")
|
||||
expect(queryToolbarSource).toContain('@media (max-width: 720px)')
|
||||
expect(queryToolbarSource).toContain('{ width: 100%; }')
|
||||
expect(source).not.toContain('if (!selectedCustomerId.value)')
|
||||
expect(source).toContain('...(selectedCustomerId.value ? { tenantId: selectedCustomerId.value } : {})')
|
||||
expect(source).toContain('void loadRules()')
|
||||
expect(source).toContain("const tableEmptyText = '暂无符合筛选条件的客户计费规则'")
|
||||
})
|
||||
|
||||
it('uses each record scope for single-rule actions', () => {
|
||||
expect(source).toContain('getCustomerPricingRule(row.tenantId, row.id)')
|
||||
expect(source).toContain('editorScopeCustomerId.value = source?.tenantId || selectedCustomerId.value')
|
||||
expect(source).toContain('disableCustomerPricingRule(row.tenantId, row.id)')
|
||||
})
|
||||
|
||||
it('renders the complete pricing rule contract', () => {
|
||||
for (const label of ['适用客户', '业务类型', '计费方式', '税务模式', '换算系数', '舍入规则', '币种', '有效期']) {
|
||||
expect(source).toContain(label)
|
||||
}
|
||||
expect(source).toContain('QueryToolbar')
|
||||
expect(source).toContain('DataTable')
|
||||
expect(source).toContain('AppDrawer')
|
||||
})
|
||||
|
||||
it('matches the backend status and field constraints', () => {
|
||||
expect(source).toContain("{ label: '已停用', value: 'INACTIVE' }")
|
||||
expect(source).not.toContain("value: 'DISABLED'")
|
||||
expect(source).toContain('maxlength="50"')
|
||||
})
|
||||
|
||||
it('uses the mature admin list skeleton and grouped detail sections', () => {
|
||||
expect(source).toContain('class="business-page customer-rules-page"')
|
||||
expect(source).toContain('class="surface-panel table-panel"')
|
||||
expect(source).toContain('class="panel-heading"')
|
||||
for (const heading of ['规则归属', '计费口径', '版本有效期']) {
|
||||
expect(source).toContain(`<h3>${heading}</h3>`)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,496 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { CircleSlash2, Eye, Plus } from '@lucide/vue'
|
||||
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
|
||||
import { listPlatformTenants, type PlatformTenantResource as PlatformCustomerContract } from '@/modules/iam'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import AppDrawer from '@/shared/components/AppDrawer.vue'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||
import {
|
||||
createCustomerPricingRuleVersion,
|
||||
disableCustomerPricingRule,
|
||||
getCustomerPricingRule,
|
||||
listCustomerPricingRules,
|
||||
} from './api'
|
||||
import type { CustomerPricingRule, CustomerRuleFormModel } from './types'
|
||||
|
||||
const access = useAccessPreviewStore()
|
||||
const canEdit = computed(() => access.can('firstleg:fba-pricing:rule:edit'))
|
||||
const customers = ref<Array<{ id: string; name: string; code: string }>>([])
|
||||
const customerLoadError = ref<ApiErrorDetails | null>(null)
|
||||
const rules = ref<CustomerPricingRule[]>([])
|
||||
const loading = ref(false)
|
||||
const error = ref<ApiErrorDetails | null>(null)
|
||||
const detailVisible = ref(false)
|
||||
const detailLoading = ref(false)
|
||||
const detailError = ref<ApiErrorDetails | null>(null)
|
||||
const selectedRule = ref<CustomerPricingRule | null>(null)
|
||||
const editorVisible = ref(false)
|
||||
const editorSaving = ref(false)
|
||||
const editorForm = ref<FormInstance>()
|
||||
const editorDirty = ref(false)
|
||||
const editorScopeCustomerId = ref('')
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
|
||||
type RuleFilters = QueryModel & {
|
||||
customerId: string
|
||||
ruleCode: string
|
||||
customerName: string
|
||||
chargeMode: '' | 'KG' | 'CBM'
|
||||
status: string
|
||||
}
|
||||
|
||||
const filters = ref<RuleFilters>({ customerId: '', ruleCode: '', customerName: '', chargeMode: '', status: '' })
|
||||
const appliedFilters = ref<RuleFilters>({ ...filters.value })
|
||||
const selectedCustomerId = computed(() => appliedFilters.value.customerId)
|
||||
const queryFields = computed<QueryField[]>(() => [
|
||||
{
|
||||
key: 'customerId',
|
||||
type: 'select',
|
||||
width: 280,
|
||||
placeholder: customerLoadError.value ? '客户列表加载失败' : '全部客户',
|
||||
props: { 'aria-label': '所属企业', filterable: true, disabled: Boolean(customerLoadError.value) },
|
||||
options: customers.value.map((customer) => ({
|
||||
value: customer.id,
|
||||
label: `${customer.name}(${customer.code || customer.id})`,
|
||||
})),
|
||||
},
|
||||
{ key: 'ruleCode', type: 'input', width: 220, placeholder: '规则编码' },
|
||||
{ key: 'customerName', type: 'input', width: 260, placeholder: '客户名称' },
|
||||
{
|
||||
key: 'chargeMode', type: 'select', width: 180, placeholder: '全部计费方式', options: [
|
||||
{ label: '按 KG', value: 'KG' },
|
||||
{ label: '按 CBM', value: 'CBM' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'status', type: 'select', width: 160, placeholder: '全部状态', options: [
|
||||
{ label: '生效中', value: 'ACTIVE' },
|
||||
{ label: '已停用', value: 'INACTIVE' },
|
||||
],
|
||||
},
|
||||
])
|
||||
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 210, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'rule', label: '规则版本', minWidth: 170, slotName: 'rule', showOverflowTooltip: true },
|
||||
{ key: 'customer', label: '适用客户', minWidth: 190, slotName: 'customer', showOverflowTooltip: true },
|
||||
{ key: 'businessType', prop: 'businessType', label: '业务类型', minWidth: 110, showOverflowTooltip: true },
|
||||
{ key: 'chargeMode', label: '计费方式', width: 105, slotName: 'chargeMode' },
|
||||
{ key: 'taxMode', label: '税务模式', width: 100, slotName: 'taxMode' },
|
||||
{ key: 'conversionFactor', prop: 'conversionFactor', label: '换算系数', width: 105 },
|
||||
{ key: 'rounding', label: '舍入规则', minWidth: 155, slotName: 'rounding' },
|
||||
{ key: 'currency', prop: 'currency', label: '币种', width: 80 },
|
||||
{ key: 'period', label: '有效期', minWidth: 225, slotName: 'period' },
|
||||
{ key: 'status', label: '状态', width: 90, slotName: 'status' },
|
||||
]
|
||||
|
||||
const filteredRules = computed(() => rules.value.filter((rule) => {
|
||||
const customerName = appliedFilters.value.customerName.trim().toLowerCase()
|
||||
return (!customerName || rule.customerName.toLowerCase().includes(customerName))
|
||||
&& (!appliedFilters.value.chargeMode || rule.chargeMode === appliedFilters.value.chargeMode)
|
||||
&& (!appliedFilters.value.status || rule.status === appliedFilters.value.status)
|
||||
}))
|
||||
const pagedRules = computed(() => filteredRules.value.slice((page.value - 1) * pageSize.value, page.value * pageSize.value))
|
||||
const tableEmptyText = '暂无符合筛选条件的客户计费规则'
|
||||
|
||||
const emptyForm = (): CustomerRuleFormModel => ({
|
||||
ruleCode: '',
|
||||
customerId: '',
|
||||
customerName: '',
|
||||
businessType: 'FBA',
|
||||
chargeMode: 'KG',
|
||||
taxMode: 'TAX_INCLUDED',
|
||||
conversionFactor: 167,
|
||||
roundingScale: 3,
|
||||
roundingMode: 'CEILING',
|
||||
currency: 'CNY',
|
||||
effectiveFrom: '',
|
||||
effectiveTo: null,
|
||||
remark: null,
|
||||
})
|
||||
const form = reactive<CustomerRuleFormModel>(emptyForm())
|
||||
|
||||
const formRules: FormRules<CustomerRuleFormModel> = {
|
||||
ruleCode: [{ required: true, message: '请输入规则编码', trigger: 'blur' }],
|
||||
customerId: [{ required: true, message: '请输入客户 ID', trigger: 'blur' }],
|
||||
customerName: [{ required: true, message: '请输入客户名称', trigger: 'blur' }],
|
||||
businessType: [{ required: true, message: '请输入业务类型', trigger: 'blur' }],
|
||||
chargeMode: [{ required: true, message: '请选择计费方式', trigger: 'change' }],
|
||||
taxMode: [{ required: true, message: '请选择税务模式', trigger: 'change' }],
|
||||
conversionFactor: [{ required: true, message: '请输入大于 0 的换算系数', trigger: 'blur', type: 'number', min: 0.000001 }],
|
||||
roundingScale: [{ required: true, message: '请选择保留位数', trigger: 'change', type: 'number', min: 0, max: 4 }],
|
||||
roundingMode: [{ required: true, message: '请选择舍入方式', trigger: 'change' }],
|
||||
currency: [{ required: true, message: '请选择币种', trigger: 'change' }],
|
||||
effectiveFrom: [{ required: true, message: '请选择生效时间', trigger: 'change' }],
|
||||
}
|
||||
|
||||
useUnsavedWorkRegistration({
|
||||
key: 'customer-pricing-rule-version',
|
||||
label: computed(() => '客户计费规则版本'),
|
||||
isOpen: computed(() => editorVisible.value),
|
||||
model: form,
|
||||
requiredPermission: computed(() => 'firstleg:fba-pricing:rule:edit'),
|
||||
})
|
||||
|
||||
watch(form, () => {
|
||||
if (editorVisible.value) editorDirty.value = true
|
||||
}, { deep: true })
|
||||
|
||||
async function loadCustomers() {
|
||||
customerLoadError.value = null
|
||||
try {
|
||||
const records: PlatformCustomerContract[] = []
|
||||
let currentPage = 1
|
||||
let pages = 1
|
||||
do {
|
||||
const result = await listPlatformTenants({ page: currentPage, size: 200 })
|
||||
records.push(...result.records)
|
||||
pages = Math.max(1, result.pages)
|
||||
currentPage += 1
|
||||
} while (currentPage <= pages)
|
||||
customers.value = records.map((customer) => ({
|
||||
id: String(customer.id),
|
||||
name: customer.tenantName,
|
||||
code: customer.tenantCode,
|
||||
}))
|
||||
} catch (cause) {
|
||||
customers.value = []
|
||||
customerLoadError.value = getApiErrorDetails(cause, '客户列表加载失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRules() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
rules.value = await listCustomerPricingRules({
|
||||
...(selectedCustomerId.value ? { tenantId: selectedCustomerId.value } : {}),
|
||||
ruleCode: appliedFilters.value.ruleCode.trim(),
|
||||
})
|
||||
} catch (cause) {
|
||||
error.value = getApiErrorDetails(cause, '客户计费规则加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function applyQuery(model: QueryModel) {
|
||||
const nextCustomerId = String(model.customerId || '')
|
||||
if (nextCustomerId !== selectedCustomerId.value) {
|
||||
rules.value = []
|
||||
selectedRule.value = null
|
||||
detailVisible.value = false
|
||||
detailError.value = null
|
||||
editorVisible.value = false
|
||||
editorDirty.value = false
|
||||
error.value = null
|
||||
}
|
||||
appliedFilters.value = {
|
||||
customerId: nextCustomerId,
|
||||
ruleCode: String(model.ruleCode || ''),
|
||||
customerName: String(model.customerName || ''),
|
||||
chargeMode: (model.chargeMode || '') as RuleFilters['chargeMode'],
|
||||
status: String(model.status || ''),
|
||||
}
|
||||
page.value = 1
|
||||
void loadRules()
|
||||
}
|
||||
|
||||
async function openDetail(row: CustomerPricingRule) {
|
||||
detailVisible.value = true
|
||||
detailLoading.value = true
|
||||
detailError.value = null
|
||||
selectedRule.value = row
|
||||
try {
|
||||
selectedRule.value = await getCustomerPricingRule(row.tenantId, row.id)
|
||||
} catch (cause) {
|
||||
detailError.value = getApiErrorDetails(cause, '规则详情加载失败')
|
||||
} finally {
|
||||
detailLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function retryDetail() {
|
||||
if (selectedRule.value) void openDetail(selectedRule.value)
|
||||
}
|
||||
|
||||
function asRule(row: unknown): CustomerPricingRule {
|
||||
return row as CustomerPricingRule
|
||||
}
|
||||
|
||||
function resetForm(source?: CustomerPricingRule) {
|
||||
Object.assign(form, emptyForm(), source ? {
|
||||
ruleCode: source.ruleCode,
|
||||
customerId: source.customerId,
|
||||
customerName: source.customerName,
|
||||
businessType: source.businessType,
|
||||
chargeMode: source.chargeMode,
|
||||
taxMode: source.taxMode,
|
||||
conversionFactor: source.conversionFactor,
|
||||
roundingScale: source.roundingScale,
|
||||
roundingMode: source.roundingMode,
|
||||
currency: source.currency,
|
||||
effectiveTo: source.effectiveTo,
|
||||
remark: source.remark,
|
||||
} : { customerId: selectedCustomerId.value })
|
||||
}
|
||||
|
||||
async function openEditor(source?: CustomerPricingRule) {
|
||||
editorScopeCustomerId.value = source?.tenantId || selectedCustomerId.value
|
||||
resetForm(source)
|
||||
editorDirty.value = false
|
||||
editorVisible.value = true
|
||||
await nextTick()
|
||||
editorForm.value?.clearValidate()
|
||||
}
|
||||
|
||||
async function closeEditor() {
|
||||
if (editorDirty.value) {
|
||||
try {
|
||||
await ElMessageBox.confirm('尚有未保存的规则版本,确定放弃吗?', '放弃编辑', {
|
||||
confirmButtonText: '放弃', cancelButtonText: '继续编辑', type: 'warning',
|
||||
})
|
||||
} catch {
|
||||
editorVisible.value = true
|
||||
return
|
||||
}
|
||||
}
|
||||
editorVisible.value = false
|
||||
editorDirty.value = false
|
||||
}
|
||||
|
||||
async function beforeEditorClose(done: () => void) {
|
||||
if (editorSaving.value) return
|
||||
if (!editorDirty.value) {
|
||||
done()
|
||||
return
|
||||
}
|
||||
try {
|
||||
await ElMessageBox.confirm('尚有未保存的规则版本,确定放弃吗?', '放弃编辑', {
|
||||
confirmButtonText: '放弃', cancelButtonText: '继续编辑', type: 'warning',
|
||||
})
|
||||
editorDirty.value = false
|
||||
done()
|
||||
} catch {
|
||||
editorVisible.value = true
|
||||
}
|
||||
}
|
||||
|
||||
async function saveVersion() {
|
||||
if (!await editorForm.value?.validate()) return
|
||||
editorSaving.value = true
|
||||
try {
|
||||
await createCustomerPricingRuleVersion(editorScopeCustomerId.value, {
|
||||
...form,
|
||||
ruleCode: form.ruleCode.trim(),
|
||||
customerId: String(form.customerId),
|
||||
customerName: form.customerName.trim(),
|
||||
businessType: form.businessType.trim(),
|
||||
effectiveTo: form.effectiveTo || null,
|
||||
remark: form.remark?.trim() || null,
|
||||
})
|
||||
editorDirty.value = false
|
||||
editorVisible.value = false
|
||||
ElMessage.success('客户计费规则新版本已创建')
|
||||
await loadRules()
|
||||
} catch (cause) {
|
||||
ElMessage.error(getApiErrorDetails(cause, '规则版本创建失败').message)
|
||||
} finally {
|
||||
editorSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function disableRule(row: CustomerPricingRule) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`停用规则“${row.ruleCode}”的当前版本?历史费用不会改变。`, '停用规则', {
|
||||
confirmButtonText: '确认停用', cancelButtonText: '取消', type: 'warning',
|
||||
})
|
||||
await disableCustomerPricingRule(row.tenantId, row.id)
|
||||
ElMessage.success('规则已停用')
|
||||
await loadRules()
|
||||
} catch (cause) {
|
||||
if (cause === 'cancel' || cause === 'close') return
|
||||
ElMessage.error(getApiErrorDetails(cause, '规则停用失败').message)
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(value: string | null) {
|
||||
if (!value) return '长期有效'
|
||||
return value.replace('T', ' ').slice(0, 16)
|
||||
}
|
||||
|
||||
function modeLabel(mode: string) {
|
||||
return mode === 'KG' ? '按 KG' : mode === 'CBM' ? '按 CBM' : mode
|
||||
}
|
||||
|
||||
function taxLabel(mode: string) {
|
||||
return mode === 'TAX_INCLUDED' ? '包税' : mode === 'TAX_EXCLUDED' ? '不包税' : mode
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
access.initializeAfterLogin()
|
||||
void loadCustomers()
|
||||
void loadRules()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="business-page customer-rules-page">
|
||||
<QueryToolbar v-model="filters" class="surface-panel" ariaLabel="客户计费规则筛选" :fields="queryFields" @search="applyQuery" />
|
||||
|
||||
<LoadErrorState v-if="customerLoadError" title="客户列表加载失败" :message="customerLoadError.message" compact @retry="loadCustomers" />
|
||||
<LoadErrorState v-if="error" title="客户计费规则加载失败" :message="error.message" @retry="loadRules" />
|
||||
<section v-else class="surface-panel table-panel" aria-live="polite" :aria-busy="loading">
|
||||
<div class="panel-heading">
|
||||
<div>
|
||||
<h2>客户计费规则</h2>
|
||||
<p>按企业维护 FBA 的 KG / CBM 计费版本 · 共 {{ filteredRules.length }} 个规则版本</p>
|
||||
</div>
|
||||
<el-button v-if="canEdit" type="primary" :disabled="loading || !selectedCustomerId" @click="openEditor()">
|
||||
<Plus :size="16" aria-hidden="true" />新建版本
|
||||
</el-button>
|
||||
</div>
|
||||
<DataTable
|
||||
v-model:page="page"
|
||||
v-model:page-size="pageSize"
|
||||
:data="pagedRules"
|
||||
:columns="columns"
|
||||
:loading="loading"
|
||||
:total="filteredRules.length"
|
||||
:page-sizes="[10, 20, 50]"
|
||||
row-key="id"
|
||||
:empty-text="tableEmptyText"
|
||||
>
|
||||
<template #actions="{ row }">
|
||||
<div class="row-actions">
|
||||
<el-button link type="primary" @click="openDetail(asRule(row))"><Eye :size="15" aria-hidden="true" />详情</el-button>
|
||||
<el-button v-if="canEdit" link type="primary" @click="openEditor(asRule(row))"><Plus :size="15" aria-hidden="true" />新版本</el-button>
|
||||
<el-button v-if="canEdit && row.status === 'ACTIVE'" link type="danger" @click="disableRule(asRule(row))"><CircleSlash2 :size="15" aria-hidden="true" />停用</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<template #rule="{ row }"><div class="primary-cell"><strong>{{ row.ruleCode }}</strong><small>版本 V{{ row.ruleVersion ?? '—' }}</small></div></template>
|
||||
<template #customer="{ row }"><strong>{{ row.customerName }}</strong><small class="subline">ID {{ row.customerId }}</small></template>
|
||||
<template #chargeMode="{ row }"><el-tag effect="plain">{{ modeLabel(row.chargeMode) }}</el-tag></template>
|
||||
<template #taxMode="{ row }">{{ taxLabel(row.taxMode) }}</template>
|
||||
<template #rounding="{ row }">保留 {{ row.roundingScale }} 位 · {{ row.roundingMode }}</template>
|
||||
<template #period="{ row }"><span>{{ formatDate(row.effectiveFrom) }}</span><small class="subline">至 {{ formatDate(row.effectiveTo) }}</small></template>
|
||||
<template #status="{ row }"><el-tag :type="row.status === 'ACTIVE' ? 'success' : 'info'" effect="light">{{ row.status === 'ACTIVE' ? '生效中' : '已停用' }}</el-tag></template>
|
||||
<template #pagination-left>共 {{ filteredRules.length }} 个规则版本</template>
|
||||
</DataTable>
|
||||
</section>
|
||||
|
||||
<AppDrawer v-model="detailVisible" title="客户计费规则详情" size="min(600px, 94vw)" :loading="detailLoading" :show-footer="false">
|
||||
<LoadErrorState v-if="detailError" title="规则详情加载失败" :message="detailError.message" compact @retry="retryDetail" />
|
||||
<div v-else-if="selectedRule" class="rule-detail">
|
||||
<section>
|
||||
<h3>规则归属</h3>
|
||||
<dl class="detail-grid">
|
||||
<div><dt>规则编码</dt><dd>{{ selectedRule.ruleCode }}</dd></div>
|
||||
<div><dt>规则版本</dt><dd>V{{ selectedRule.ruleVersion ?? '—' }}</dd></div>
|
||||
<div><dt>适用客户</dt><dd>{{ selectedRule.customerName }}</dd></div>
|
||||
<div><dt>客户 ID</dt><dd>{{ selectedRule.customerId }}</dd></div>
|
||||
<div><dt>业务类型</dt><dd>{{ selectedRule.businessType }}</dd></div>
|
||||
<div><dt>状态</dt><dd><el-tag :type="selectedRule.status === 'ACTIVE' ? 'success' : 'info'">{{ selectedRule.status === 'ACTIVE' ? '生效中' : '已停用' }}</el-tag></dd></div>
|
||||
</dl>
|
||||
</section>
|
||||
<section>
|
||||
<h3>计费口径</h3>
|
||||
<dl class="detail-grid">
|
||||
<div><dt>计费方式</dt><dd>{{ modeLabel(selectedRule.chargeMode) }}</dd></div>
|
||||
<div><dt>税务模式</dt><dd>{{ taxLabel(selectedRule.taxMode) }}</dd></div>
|
||||
<div><dt>换算系数</dt><dd>{{ selectedRule.conversionFactor }}</dd></div>
|
||||
<div><dt>舍入规则</dt><dd>保留 {{ selectedRule.roundingScale }} 位,{{ selectedRule.roundingMode }}</dd></div>
|
||||
<div><dt>币种</dt><dd>{{ selectedRule.currency }}</dd></div>
|
||||
</dl>
|
||||
</section>
|
||||
<section>
|
||||
<h3>版本有效期</h3>
|
||||
<dl class="detail-grid">
|
||||
<div><dt>生效时间</dt><dd>{{ formatDate(selectedRule.effectiveFrom) }}</dd></div>
|
||||
<div><dt>失效时间</dt><dd>{{ formatDate(selectedRule.effectiveTo) }}</dd></div>
|
||||
<div class="detail-wide"><dt>备注</dt><dd>{{ selectedRule.remark || '—' }}</dd></div>
|
||||
</dl>
|
||||
</section>
|
||||
</div>
|
||||
</AppDrawer>
|
||||
|
||||
<AppDrawer
|
||||
v-model="editorVisible"
|
||||
title="创建客户计费规则版本"
|
||||
size="min(760px, 94vw)"
|
||||
:confirm-loading="editorSaving"
|
||||
confirm-text="创建版本"
|
||||
:before-close="beforeEditorClose"
|
||||
@confirm="saveVersion"
|
||||
@cancel="closeEditor"
|
||||
>
|
||||
<el-form ref="editorForm" :model="form" :rules="formRules" label-position="top" class="rule-form">
|
||||
<div class="form-section">
|
||||
<h2>规则归属</h2>
|
||||
<div class="form-grid">
|
||||
<el-form-item label="规则编码" prop="ruleCode"><el-input v-model="form.ruleCode" maxlength="50" placeholder="例如 FBA-TAX-KG" /></el-form-item>
|
||||
<el-form-item label="客户 ID" prop="customerId"><el-input v-model="form.customerId" /></el-form-item>
|
||||
<el-form-item label="客户名称" prop="customerName"><el-input v-model="form.customerName" maxlength="128" /></el-form-item>
|
||||
<el-form-item label="业务类型" prop="businessType"><el-input v-model="form.businessType" maxlength="32" /></el-form-item>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-section">
|
||||
<h2>计费口径</h2>
|
||||
<div class="form-grid">
|
||||
<el-form-item label="计费方式" prop="chargeMode"><el-segmented v-model="form.chargeMode" :options="[{ label: '按 KG', value: 'KG' }, { label: '按 CBM', value: 'CBM' }]" /></el-form-item>
|
||||
<el-form-item label="税务模式" prop="taxMode"><el-select v-model="form.taxMode"><el-option label="包税" value="TAX_INCLUDED" /><el-option label="不包税" value="TAX_EXCLUDED" /></el-select></el-form-item>
|
||||
<el-form-item label="换算系数" prop="conversionFactor"><el-input-number v-model="form.conversionFactor" :min="0.000001" :precision="6" :controls="false" /></el-form-item>
|
||||
<el-form-item label="保留小数位" prop="roundingScale"><el-select v-model="form.roundingScale"><el-option v-for="scale in [0, 1, 2, 3, 4]" :key="scale" :label="`${scale} 位`" :value="scale" /></el-select></el-form-item>
|
||||
<el-form-item label="舍入方式" prop="roundingMode"><el-select v-model="form.roundingMode"><el-option label="向上取整 CEILING" value="CEILING" /><el-option label="向上舍入 UP" value="UP" /><el-option label="向下舍入 DOWN" value="DOWN" /><el-option label="向下取整 FLOOR" value="FLOOR" /><el-option label="四舍五入 HALF_UP" value="HALF_UP" /><el-option label="五舍六入 HALF_DOWN" value="HALF_DOWN" /><el-option label="银行家舍入 HALF_EVEN" value="HALF_EVEN" /></el-select></el-form-item>
|
||||
<el-form-item label="币种" prop="currency"><el-select v-model="form.currency" filterable allow-create default-first-option><el-option label="人民币 CNY" value="CNY" /><el-option label="美元 USD" value="USD" /></el-select></el-form-item>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-section">
|
||||
<h2>生效范围</h2>
|
||||
<div class="form-grid">
|
||||
<el-form-item label="生效时间" prop="effectiveFrom"><el-date-picker v-model="form.effectiveFrom" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="选择生效时间" /></el-form-item>
|
||||
<el-form-item label="失效时间"><el-date-picker v-model="form.effectiveTo" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="不填表示长期有效" /></el-form-item>
|
||||
<el-form-item label="备注" class="wide"><el-input v-model="form.remark" type="textarea" :rows="3" maxlength="500" show-word-limit /></el-form-item>
|
||||
</div>
|
||||
</div>
|
||||
</el-form>
|
||||
</AppDrawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.customer-rules-page { min-width: 0; }
|
||||
.row-actions .el-button { display: inline-flex; align-items: center; gap: 6px; }
|
||||
.row-actions { display: flex; align-items: center; gap: 3px; white-space: nowrap; }
|
||||
.primary-cell { display: flex; flex-direction: column; gap: 3px; }
|
||||
.primary-cell small, .subline { display: block; margin-top: 3px; color: var(--cc-muted); font-size: 11px; line-height: 1.45; }
|
||||
.rule-detail { display: grid; gap: 24px; }
|
||||
.rule-detail section + section { padding-top: 22px; border-top: 1px solid var(--cc-border); }
|
||||
.rule-detail h3 { margin: 0 0 14px; color: var(--cc-text); font-size: 15px; }
|
||||
.detail-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px 24px; margin: 0; }
|
||||
.detail-grid > div { min-width: 0; }
|
||||
.detail-grid dt { margin-bottom: 5px; color: var(--cc-muted); font-size: 12px; }
|
||||
.detail-grid dd { margin: 0; color: var(--cc-text); line-height: 1.55; overflow-wrap: anywhere; }
|
||||
.detail-wide { grid-column: 1 / -1; }
|
||||
.rule-form { display: grid; gap: 24px; }
|
||||
.form-section { display: grid; gap: 14px; }
|
||||
.form-section + .form-section { padding-top: 22px; border-top: 1px solid var(--cc-border); }
|
||||
.form-section h2 { margin: 0; color: var(--cc-text); font-size: 16px; line-height: 1.4; }
|
||||
.form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 2px 20px; }
|
||||
.wide { grid-column: 1 / -1; }
|
||||
.form-grid :deep(.el-select), .form-grid :deep(.el-input-number), .form-grid :deep(.el-date-editor) { width: 100%; }
|
||||
@media (max-width: 760px) {
|
||||
.form-grid { grid-template-columns: 1fr; }
|
||||
.wide { grid-column: auto; }
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
.detail-grid { grid-template-columns: 1fr; }
|
||||
.detail-wide { grid-column: auto; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,82 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const http = vi.hoisted(() => ({
|
||||
get: vi.fn(),
|
||||
post: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/api/http', () => ({
|
||||
http,
|
||||
unwrapApiResponse: <T>(response: { data: T }) => response.data,
|
||||
}))
|
||||
|
||||
import {
|
||||
createCustomerPricingRuleVersion,
|
||||
disableCustomerPricingRule,
|
||||
getCustomerPricingRule,
|
||||
listCustomerPricingRules,
|
||||
} from './api'
|
||||
|
||||
const rawRule = {
|
||||
id: 9007199254740993n.toString(),
|
||||
tenantId: 10001,
|
||||
ruleCode: 'FBA-TAX-KG',
|
||||
customerId: 20002,
|
||||
customerName: '测试企业',
|
||||
businessType: 'FBA',
|
||||
chargeMode: 'KG',
|
||||
taxMode: 'TAX_INCLUDED',
|
||||
conversionFactor: 167,
|
||||
roundingScale: 3,
|
||||
roundingMode: 'CEILING',
|
||||
currency: 'CNY',
|
||||
effectiveFrom: '2026-08-04T00:00:00',
|
||||
effectiveTo: null,
|
||||
ruleVersion: 1,
|
||||
status: 'ACTIVE',
|
||||
remark: null,
|
||||
createdAt: null,
|
||||
updatedAt: null,
|
||||
}
|
||||
|
||||
describe('customer pricing rules api', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it('omits an empty customer scope and rule code for the all-customer list', async () => {
|
||||
http.get.mockResolvedValue({ data: [rawRule] })
|
||||
|
||||
await listCustomerPricingRules({ tenantId: ' ', ruleCode: ' ' })
|
||||
|
||||
expect(http.get).toHaveBeenCalledWith('/platform/v1/fba-pricing/customer-rules', { params: {} })
|
||||
})
|
||||
|
||||
it('keeps every long id as a string and sends the real list query', async () => {
|
||||
http.get.mockResolvedValue({ data: [rawRule] })
|
||||
|
||||
const result = await listCustomerPricingRules({ tenantId: '10001', ruleCode: ' FBA ' })
|
||||
|
||||
expect(http.get).toHaveBeenCalledWith('/platform/v1/fba-pricing/customer-rules', {
|
||||
params: { tenantId: '10001', ruleCode: 'FBA' },
|
||||
})
|
||||
expect(result[0]).toMatchObject({ id: '9007199254740993', tenantId: '10001', customerId: '20002' })
|
||||
})
|
||||
|
||||
it('uses detail, version creation and disable endpoints from the controller', async () => {
|
||||
http.get.mockResolvedValue({ data: rawRule })
|
||||
http.post.mockResolvedValue({ data: rawRule })
|
||||
http.delete.mockResolvedValue({ data: null })
|
||||
|
||||
await getCustomerPricingRule('10001', '9')
|
||||
await createCustomerPricingRuleVersion('10001', {
|
||||
ruleCode: 'FBA-TAX-KG', customerId: '20002', customerName: '测试企业', businessType: 'FBA',
|
||||
chargeMode: 'KG', taxMode: 'TAX_INCLUDED', conversionFactor: 167, roundingScale: 3,
|
||||
roundingMode: 'CEILING', currency: 'CNY', effectiveFrom: '2026-08-04T00:00:00',
|
||||
})
|
||||
await disableCustomerPricingRule('10001', '9')
|
||||
|
||||
expect(http.get).toHaveBeenCalledWith('/platform/v1/fba-pricing/customer-rules/9', { params: { tenantId: '10001' } })
|
||||
expect(http.post).toHaveBeenCalledWith('/platform/v1/fba-pricing/customer-rules/versions', expect.any(Object), { params: { tenantId: '10001' } })
|
||||
expect(http.delete).toHaveBeenCalledWith('/platform/v1/fba-pricing/customer-rules/9', { params: { tenantId: '10001' } })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,59 @@
|
||||
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
import type {
|
||||
CreateCustomerRuleVersionRequest,
|
||||
CustomerPricingRule,
|
||||
CustomerRuleQuery,
|
||||
} from './types'
|
||||
|
||||
type RawRule = Omit<CustomerPricingRule, 'id' | 'tenantId' | 'customerId'> & {
|
||||
id: string | number
|
||||
tenantId: string | number
|
||||
customerId: string | number
|
||||
}
|
||||
|
||||
function normalizeRule(rule: RawRule): CustomerPricingRule {
|
||||
return {
|
||||
...rule,
|
||||
id: String(rule.id),
|
||||
tenantId: String(rule.tenantId),
|
||||
customerId: String(rule.customerId),
|
||||
}
|
||||
}
|
||||
|
||||
export async function listCustomerPricingRules(query: CustomerRuleQuery): Promise<CustomerPricingRule[]> {
|
||||
const tenantId = query.tenantId?.trim()
|
||||
const ruleCode = query.ruleCode?.trim()
|
||||
const response = await http.get<never, ApiEnvelope<RawRule[]>>('/platform/v1/fba-pricing/customer-rules', {
|
||||
params: {
|
||||
...(tenantId ? { tenantId } : {}),
|
||||
...(ruleCode ? { ruleCode } : {}),
|
||||
},
|
||||
})
|
||||
return unwrapApiResponse(response).map(normalizeRule)
|
||||
}
|
||||
|
||||
export async function getCustomerPricingRule(tenantId: string, id: string): Promise<CustomerPricingRule> {
|
||||
const response = await http.get<never, ApiEnvelope<RawRule>>(`/platform/v1/fba-pricing/customer-rules/${id}`, {
|
||||
params: { tenantId },
|
||||
})
|
||||
return normalizeRule(unwrapApiResponse(response))
|
||||
}
|
||||
|
||||
export async function createCustomerPricingRuleVersion(
|
||||
tenantId: string,
|
||||
request: CreateCustomerRuleVersionRequest,
|
||||
): Promise<CustomerPricingRule> {
|
||||
const response = await http.post<never, ApiEnvelope<RawRule>>(
|
||||
'/platform/v1/fba-pricing/customer-rules/versions',
|
||||
request,
|
||||
{ params: { tenantId } },
|
||||
)
|
||||
return normalizeRule(unwrapApiResponse(response))
|
||||
}
|
||||
|
||||
export async function disableCustomerPricingRule(tenantId: string, id: string): Promise<void> {
|
||||
const response = await http.delete<never, ApiEnvelope<unknown>>(`/platform/v1/fba-pricing/customer-rules/${id}`, {
|
||||
params: { tenantId },
|
||||
})
|
||||
unwrapApiResponse(response)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
export type CustomerRuleChargeMode = 'KG' | 'CBM'
|
||||
export type CustomerRuleStatus = 'ACTIVE' | 'INACTIVE' | string
|
||||
|
||||
export interface CustomerPricingRule {
|
||||
id: string
|
||||
tenantId: string
|
||||
ruleCode: string
|
||||
customerId: string
|
||||
customerName: string
|
||||
businessType: string
|
||||
chargeMode: CustomerRuleChargeMode
|
||||
taxMode: string
|
||||
conversionFactor: number
|
||||
roundingScale: number
|
||||
roundingMode: string
|
||||
currency: string
|
||||
effectiveFrom: string
|
||||
effectiveTo: string | null
|
||||
ruleVersion: number | null
|
||||
status: CustomerRuleStatus
|
||||
remark: string | null
|
||||
createdAt: string | null
|
||||
updatedAt: string | null
|
||||
}
|
||||
|
||||
export interface CustomerRuleQuery {
|
||||
tenantId?: string
|
||||
ruleCode?: string
|
||||
}
|
||||
|
||||
export interface CreateCustomerRuleVersionRequest {
|
||||
ruleCode: string
|
||||
customerId: string
|
||||
customerName: string
|
||||
businessType: string
|
||||
chargeMode: CustomerRuleChargeMode
|
||||
taxMode: string
|
||||
conversionFactor: number
|
||||
roundingScale: number
|
||||
roundingMode: string
|
||||
currency: string
|
||||
effectiveFrom: string
|
||||
effectiveTo?: string | null
|
||||
remark?: string | null
|
||||
}
|
||||
|
||||
export interface CustomerRuleFormModel extends CreateCustomerRuleVersionRequest {
|
||||
customerId: string
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
|
||||
export const fbaPricingRoutes = [
|
||||
{
|
||||
path: 'first-leg-pricing/customer-rules',
|
||||
name: 'fba-customer-rules',
|
||||
component: () => import('./customer-rules/CustomerRulesView.vue'),
|
||||
meta: {
|
||||
title: '客户计费规则',
|
||||
description: '维护 FBA 客户计费规则和生效版本',
|
||||
permission: 'firstleg:fba-pricing:rule:view',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'first-leg-pricing/carrier-rates',
|
||||
name: 'fba-carrier-rates',
|
||||
component: () => import('./carrier-rates/pages/CarrierRatesView.vue'),
|
||||
meta: {
|
||||
title: '船司 FBA 价格',
|
||||
description: '维护起运仓、船司和 FBA 目的仓价格版本',
|
||||
permission: 'firstleg:fba-pricing:rate:view',
|
||||
},
|
||||
},
|
||||
] satisfies RouteRecordRaw[]
|
||||
@@ -10,7 +10,7 @@ describe('PlatformFirstLegOrdersPanel review queue', () => {
|
||||
expect(source).toContain("tenantId: filters.value.tenantId || undefined")
|
||||
expect(source).toContain('listPlatformTenants')
|
||||
expect(source).toContain("key: 'tenantId'")
|
||||
expect(source).toContain('tenantName((row as PlatformFirstLegOrderResource).tenantId)')
|
||||
expect(source).toContain('customerDisplay(row as PlatformFirstLegOrderResource).name')
|
||||
expect(source).toContain('result.pages')
|
||||
})
|
||||
|
||||
@@ -34,6 +34,16 @@ describe('PlatformFirstLegOrdersPanel review queue', () => {
|
||||
expect(source).toContain("minWidth: 224, fixed: 'left'")
|
||||
})
|
||||
|
||||
it('prioritizes order status and removes noisy customer and time metadata', () => {
|
||||
const statusColumn = source.indexOf("{ key: 'status', label: '状态'")
|
||||
const orderColumn = source.indexOf("{ key: 'orderInfo', label: '订单信息'")
|
||||
expect(statusColumn).toBeGreaterThan(0)
|
||||
expect(statusColumn).toBeLessThan(orderColumn)
|
||||
expect(source).toContain("replace('T', ' ').slice(0, 16)")
|
||||
expect(source).toContain('客户编码 ${code}')
|
||||
expect(source).not.toContain('customerName }} · {{ (row as PlatformFirstLegOrderResource).tenantId')
|
||||
})
|
||||
|
||||
it('passes the order tenant through detail and lifecycle actions', () => {
|
||||
expect(source).toContain('tenantId: String(order.tenantId)')
|
||||
expect(source).toContain('getPlatformFirstLegOrder(tenantId, orderId)')
|
||||
|
||||
@@ -65,14 +65,15 @@ const queryFields = computed<QueryField[]>(() => [
|
||||
{
|
||||
key: 'tenantId',
|
||||
type: 'select',
|
||||
width: 160,
|
||||
placeholder: tenantLoadError.value ? '企业列表加载失败' : '全部企业',
|
||||
props: { 'aria-label': '企业客户', filterable: true, disabled: Boolean(tenantLoadError.value) },
|
||||
options: tenants.value.map((tenant) => ({ value: String(tenant.id), label: `${tenant.tenantName}(${tenant.tenantCode || tenant.id})` })),
|
||||
},
|
||||
{ key: 'keyword', type: 'input', placeholder: '订单号或发货客户名称', props: { 'aria-label': '订单关键词' } },
|
||||
{
|
||||
key: 'status',
|
||||
type: 'select',
|
||||
width: 120,
|
||||
placeholder: '全部状态',
|
||||
props: { 'aria-label': '订单状态' },
|
||||
options: [
|
||||
@@ -86,18 +87,19 @@ const queryFields = computed<QueryField[]>(() => [
|
||||
{ value: 'REJECTED', label: '已驳回(旧契约)' },
|
||||
],
|
||||
},
|
||||
{ key: 'keyword', type: 'input', placeholder: '订单号或发货客户名称', props: { 'aria-label': '订单关键词' } },
|
||||
{ key: 'dateRange', type: 'daterange', props: { 'aria-label': '订单创建日期' } },
|
||||
])
|
||||
|
||||
const orderColumns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', minWidth: 224, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'status', label: '状态', width: 108, slotName: 'status' },
|
||||
{ key: 'orderInfo', label: '订单信息', minWidth: 210, slotName: 'orderInfo' },
|
||||
{ key: 'customer', label: '客户', minWidth: 190, showOverflowTooltip: true, slotName: 'customer' },
|
||||
{ key: 'transport', label: '运输方案', minWidth: 180, slotName: 'transport' },
|
||||
{ key: 'destination', label: '目的地', minWidth: 150, slotName: 'destination' },
|
||||
{ key: 'receivingWarehouse', label: '平台收货仓', minWidth: 180, slotName: 'receivingWarehouse' },
|
||||
{ key: 'express', label: '履约快递', minWidth: 160, slotName: 'express' },
|
||||
{ key: 'status', label: '状态', width: 120, slotName: 'status' },
|
||||
]
|
||||
|
||||
const expressRules: FormRules = {
|
||||
@@ -145,11 +147,23 @@ function transportLabel(order: PlatformFirstLegOrderResource) {
|
||||
}
|
||||
|
||||
function formattedDate(value: string | null | undefined) {
|
||||
return value ? value.replace('T', ' ') : '-'
|
||||
return value ? value.replace('T', ' ').slice(0, 16) : '-'
|
||||
}
|
||||
|
||||
function tenantFor(tenantId: number | string) {
|
||||
return tenants.value.find((tenant) => String(tenant.id) === String(tenantId))
|
||||
}
|
||||
|
||||
function tenantName(tenantId: number | string) {
|
||||
return tenants.value.find((tenant) => String(tenant.id) === String(tenantId))?.tenantName || `企业 ${tenantId}`
|
||||
return tenantFor(tenantId)?.tenantName || `企业 ${tenantId}`
|
||||
}
|
||||
|
||||
function customerDisplay(order: PlatformFirstLegOrderResource) {
|
||||
const tenant = tenantFor(order.tenantId)
|
||||
const name = tenant?.legalName?.trim() || tenant?.tenantName?.trim() || order.customerName?.trim() || '未命名企业'
|
||||
const code = tenant?.tenantCode?.trim()
|
||||
const secondary = code ? `客户编码 ${code}` : order.customerName?.trim()
|
||||
return { name, secondary: secondary && secondary !== name ? secondary : '' }
|
||||
}
|
||||
|
||||
async function loadTenants() {
|
||||
@@ -535,13 +549,13 @@ onMounted(() => void loadTenants())
|
||||
</el-dropdown>
|
||||
</div>
|
||||
</template>
|
||||
<template #status="{ row }"><StatusBadge :status="statusLabel((row as PlatformFirstLegOrderResource).status)" /></template>
|
||||
<template #orderInfo="{ row }"><div class="primary-cell"><strong>{{ (row as PlatformFirstLegOrderResource).orderNo || '-' }}</strong><span>{{ (row as PlatformFirstLegOrderResource).referenceId || '无客户参考号' }} · {{ formattedDate((row as PlatformFirstLegOrderResource).createdAt) }}</span></div></template>
|
||||
<template #customer="{ row }"><div class="primary-cell"><strong>{{ tenantName((row as PlatformFirstLegOrderResource).tenantId) }}</strong><span>{{ (row as PlatformFirstLegOrderResource).customerName }} · {{ (row as PlatformFirstLegOrderResource).tenantId }}</span></div></template>
|
||||
<template #customer="{ row }"><div class="primary-cell"><strong>{{ customerDisplay(row as PlatformFirstLegOrderResource).name }}</strong><span v-if="customerDisplay(row as PlatformFirstLegOrderResource).secondary">{{ customerDisplay(row as PlatformFirstLegOrderResource).secondary }}</span></div></template>
|
||||
<template #transport="{ row }"><div class="primary-cell"><strong>{{ transportLabel(row as PlatformFirstLegOrderResource) }}</strong><span>{{ (row as PlatformFirstLegOrderResource).channelName || '未填写渠道' }}</span></div></template>
|
||||
<template #destination="{ row }"><div class="primary-cell"><strong>{{ (row as PlatformFirstLegOrderResource).destinationWarehouseName || '-' }}</strong><span>{{ (row as PlatformFirstLegOrderResource).destinationCountryCode || '-' }}</span></div></template>
|
||||
<template #receivingWarehouse="{ row }"><div class="primary-cell"><strong>{{ (row as PlatformFirstLegOrderResource).receivingWarehouseName || '-' }}</strong><span>{{ (row as PlatformFirstLegOrderResource).receivingWarehouseAddress || '-' }}</span></div></template>
|
||||
<template #express="{ row }"><div class="primary-cell"><strong>{{ (row as PlatformFirstLegOrderResource).expressCompany || '未维护' }}</strong><span>{{ (row as PlatformFirstLegOrderResource).expressTrackingNo || '—' }}</span></div></template>
|
||||
<template #status="{ row }"><StatusBadge :status="statusLabel((row as PlatformFirstLegOrderResource).status)" /></template>
|
||||
<template #empty><el-empty description="当前没有符合条件的头程订单" /></template>
|
||||
</DataTable>
|
||||
</section>
|
||||
|
||||
@@ -53,7 +53,7 @@ export interface CreatePlatformTenantRequest {
|
||||
tenantName: string
|
||||
legalName: string
|
||||
legalRepresentative: string
|
||||
unifiedSocialCreditCode: string
|
||||
unifiedSocialCreditCode?: string
|
||||
contactName: string
|
||||
contactPhone: string
|
||||
contactEmail: string
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/modules/inbounds/components/InboundWorkflowWorkspace.vue'), 'utf8')
|
||||
|
||||
describe('InboundWorkflowWorkspace', () => {
|
||||
it('assembles every phase-two inbound domain behind one server-backed workspace', () => {
|
||||
expect(source).toContain('InboundSummaryPanel')
|
||||
expect(source).toContain('InboundHistoryPanel')
|
||||
expect(source).toContain('InboundMeasurementPanel')
|
||||
expect(source).toContain('WeightOcrPanel')
|
||||
expect(source).toContain('MeasurementReviewPanel')
|
||||
expect(source).toContain('InboundFeePanel')
|
||||
expect(source).toContain('getPlatformInbound')
|
||||
expect(source).toContain('StatusBadge')
|
||||
expect(source).toContain('label="基本信息"')
|
||||
expect(source).toContain('label="货物与测量"')
|
||||
expect(source).toContain('label="测量审核"')
|
||||
expect(source).toContain('label="费用快照"')
|
||||
expect(source).toContain('label="托盘与记录"')
|
||||
})
|
||||
|
||||
it('uses the established first-leg detail density without nested framed surfaces', () => {
|
||||
expect(source).toContain('padding: 20px 24px')
|
||||
expect(source).toContain('border-bottom: 1px solid var(--cc-border)')
|
||||
expect(source).not.toContain('border-radius: 10px')
|
||||
expect(source).not.toContain('<el-tag')
|
||||
})
|
||||
|
||||
it('refreshes the aggregate and remounts domains after manual or child refresh', () => {
|
||||
expect(source).toContain('function handleDomainChanged()')
|
||||
expect(source).toContain('async function refreshWorkspace()')
|
||||
expect(source).toContain('revision.value += 1')
|
||||
expect(source).toContain('void refreshWorkspace()')
|
||||
expect(source).toContain('@click="refreshWorkspace"')
|
||||
expect(source).toContain('@changed="handleDomainChanged"')
|
||||
})
|
||||
|
||||
it('uses customer context in the page layer and maps it to compatible domain APIs', () => {
|
||||
expect(source).toContain('customerId: string')
|
||||
expect(source).toContain('getPlatformInbound(props.customerId, props.inboundId)')
|
||||
expect(source).toContain(':tenant-id="customerId"')
|
||||
expect(source).toContain('缺少所属企业信息')
|
||||
expect(source).not.toContain('请选择企业')
|
||||
expect(source).not.toContain('租户')
|
||||
})
|
||||
|
||||
it('does not mount the review domain without its required measurement data permission', () => {
|
||||
expect(source).toContain("access.can('platform:firstleg:inbound:measure:view')")
|
||||
expect(source).toContain('v-if="canViewMeasurement"')
|
||||
expect(source).toContain('无测量审核查看权限')
|
||||
})
|
||||
|
||||
it('keeps pallet and location results read-only in the overview', () => {
|
||||
expect(source).toContain(':pallet-locations="detail.palletLocations"')
|
||||
expect(source).toContain('label="托盘与记录"')
|
||||
expect(source).not.toContain('reserveLocation')
|
||||
expect(source).not.toContain('allocatePallet')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,213 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { RefreshCw } from '@lucide/vue'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import StatusBadge from '@/shared/components/StatusBadge.vue'
|
||||
import { getPlatformInbound } from '../core/api'
|
||||
import InboundHistoryPanel from '../core/InboundHistoryPanel.vue'
|
||||
import InboundSummaryPanel from '../core/InboundSummaryPanel.vue'
|
||||
import type { InboundOrderDetail } from '../core/types'
|
||||
import { InboundMeasurementPanel } from '../measurement'
|
||||
import { WeightOcrPanel } from '../weight'
|
||||
import { MeasurementReviewPanel } from '../review'
|
||||
import { InboundFeePanel } from '../fee'
|
||||
|
||||
const props = defineProps<{
|
||||
customerId: string
|
||||
inboundId: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
changed: []
|
||||
}>()
|
||||
|
||||
const access = useAccessPreviewStore()
|
||||
const activeTab = ref('overview')
|
||||
const loading = ref(false)
|
||||
const loadError = ref<ApiErrorDetails | null>(null)
|
||||
const detail = ref<InboundOrderDetail | null>(null)
|
||||
const revision = ref(0)
|
||||
let requestSerial = 0
|
||||
let disposed = false
|
||||
|
||||
const title = computed(() => detail.value?.summary.inboundCode || props.inboundId)
|
||||
const canViewMeasurement = computed(() => access.can('platform:firstleg:inbound:measure:view'))
|
||||
const statusLabel = computed(() => {
|
||||
const status = detail.value?.summary.processStatus || ''
|
||||
return ({
|
||||
WAIT_MEASURE: '待测量',
|
||||
MEASURING: '测量中',
|
||||
MEASURE_PENDING: '待审核',
|
||||
MEASURE_REJECTED: '测量已驳回',
|
||||
WAIT_PRICING: '待计费',
|
||||
FEE_CONFIRMING: '费用确认中',
|
||||
INBOUNDING: '入库处理中',
|
||||
INBOUNDED: '已入库',
|
||||
VOIDED: '已作废',
|
||||
} as Record<string, string>)[status] || status || '未知状态'
|
||||
})
|
||||
|
||||
async function loadDetail() {
|
||||
const serial = ++requestSerial
|
||||
if (!props.customerId || !props.inboundId) {
|
||||
detail.value = null
|
||||
loadError.value = null
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
loadError.value = null
|
||||
try {
|
||||
const response = await getPlatformInbound(props.customerId, props.inboundId)
|
||||
if (disposed || serial !== requestSerial) return
|
||||
detail.value = response
|
||||
} catch (error) {
|
||||
if (disposed || serial !== requestSerial) return
|
||||
detail.value = null
|
||||
loadError.value = getApiErrorDetails(error, '入库单详情加载失败')
|
||||
} finally {
|
||||
if (!disposed && serial === requestSerial) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshWorkspace() {
|
||||
revision.value += 1
|
||||
await loadDetail()
|
||||
}
|
||||
|
||||
function handleDomainChanged() {
|
||||
void refreshWorkspace()
|
||||
emit('changed')
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.customerId, props.inboundId],
|
||||
() => {
|
||||
activeTab.value = 'overview'
|
||||
detail.value = null
|
||||
loadError.value = null
|
||||
revision.value += 1
|
||||
void loadDetail()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
disposed = true
|
||||
requestSerial += 1
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="inbound-workspace" :aria-busy="loading">
|
||||
<header class="workspace-header">
|
||||
<div>
|
||||
<div class="workspace-title-row">
|
||||
<h2>入仓码:{{ title }}</h2>
|
||||
<StatusBadge v-if="detail" :status="statusLabel" />
|
||||
</div>
|
||||
<p>按基本信息、货物测量、审核、费用和托盘库位顺序处理,所有结果以服务端最新状态为准。</p>
|
||||
</div>
|
||||
<el-button :loading="loading" :disabled="!customerId || !inboundId" @click="refreshWorkspace">
|
||||
<RefreshCw :size="16" />刷新
|
||||
</el-button>
|
||||
</header>
|
||||
|
||||
<LoadErrorState
|
||||
v-if="loadError"
|
||||
class="workspace-state"
|
||||
title="入库作业详情暂时无法加载"
|
||||
:message="loadError.message"
|
||||
@retry="loadDetail"
|
||||
>
|
||||
<template #details><ApiErrorMeta :details="loadError" /></template>
|
||||
</LoadErrorState>
|
||||
|
||||
<div v-else-if="loading && !detail" class="workspace-state workspace-skeleton" aria-label="正在加载入库作业详情">
|
||||
<el-skeleton :rows="8" animated />
|
||||
</div>
|
||||
|
||||
<el-empty v-else-if="!detail" class="workspace-state" description="缺少所属企业信息,无法查看入库作业详情" />
|
||||
|
||||
<el-tabs v-else v-model="activeTab" class="workflow-tabs">
|
||||
<el-tab-pane label="基本信息" name="overview">
|
||||
<InboundSummaryPanel :summary="detail.summary" />
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="货物与测量" name="measurement" lazy>
|
||||
<div class="measurement-workflow">
|
||||
<InboundMeasurementPanel
|
||||
:key="`measurement-${revision}`"
|
||||
:tenant-id="customerId"
|
||||
:inbound-id="inboundId"
|
||||
@changed="handleDomainChanged"
|
||||
/>
|
||||
<WeightOcrPanel
|
||||
:key="`weight-${revision}`"
|
||||
:tenant-id="customerId"
|
||||
:inbound-id="inboundId"
|
||||
@changed="handleDomainChanged"
|
||||
/>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="测量审核" name="review" lazy>
|
||||
<MeasurementReviewPanel
|
||||
v-if="canViewMeasurement"
|
||||
:key="`review-${revision}`"
|
||||
:tenant-id="customerId"
|
||||
:inbound-id="inboundId"
|
||||
@changed="handleDomainChanged"
|
||||
/>
|
||||
<el-alert
|
||||
v-else
|
||||
title="无测量审核查看权限"
|
||||
description="当前账号缺少测量查看权限,无法读取审核所需的原单、箱规和称重数据。"
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
/>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="费用快照" name="fee" lazy>
|
||||
<InboundFeePanel
|
||||
:key="`fee-${revision}`"
|
||||
:tenant-id="customerId"
|
||||
:inbound-id="inboundId"
|
||||
@changed="handleDomainChanged"
|
||||
/>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="托盘与记录" name="records" lazy>
|
||||
<InboundHistoryPanel
|
||||
:pallet-locations="detail.palletLocations"
|
||||
:files="detail.files"
|
||||
:internal-notes="detail.internalNotes"
|
||||
:status-history="detail.statusHistory"
|
||||
:public-remark="detail.publicRemark"
|
||||
/>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.inbound-workspace { min-width: 0; }
|
||||
.workspace-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; padding: 20px 24px; border-bottom: 1px solid var(--cc-border); }
|
||||
.workspace-title-row { display: flex; min-width: 0; align-items: center; flex-wrap: wrap; gap: 10px; }
|
||||
.workspace-title-row h2 { margin: 0; overflow-wrap: anywhere; color: var(--cc-text); font-size: 18px; line-height: 1.35; }
|
||||
.workspace-header p { margin: 5px 0 0; color: var(--cc-muted); font-size: 12px; line-height: 1.5; }
|
||||
.workspace-state { margin: 24px; }
|
||||
.workspace-skeleton { padding: 0; }
|
||||
.workflow-tabs { min-width: 0; padding: 0 24px 24px; }
|
||||
.workflow-tabs :deep(.el-tabs__header) { margin-bottom: 16px; }
|
||||
.workflow-tabs :deep(.el-tabs__item) { min-height: 48px; }
|
||||
.measurement-workflow { display: grid; min-width: 0; gap: 28px; }
|
||||
@media (max-width: 720px) {
|
||||
.workspace-header { align-items: stretch; flex-direction: column; padding: 16px; }
|
||||
.workspace-header :deep(.el-button) { width: 100%; min-height: 44px; }
|
||||
.workspace-state { margin: 16px; }
|
||||
.workflow-tabs { padding: 0 16px 16px; }
|
||||
.workflow-tabs :deep(.el-tabs__nav-wrap) { overflow-x: auto; }
|
||||
.workflow-tabs :deep(.el-tabs__item) { min-height: 44px; padding-inline: 14px; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,76 @@
|
||||
<script setup lang="ts">
|
||||
import type { InboundFileSummary, InboundInternalNote, InboundPalletLocation, InboundStatusHistory } from './types'
|
||||
|
||||
defineProps<{
|
||||
palletLocations: InboundPalletLocation[]
|
||||
files: InboundFileSummary[]
|
||||
internalNotes: InboundInternalNote[]
|
||||
statusHistory: InboundStatusHistory[]
|
||||
publicRemark: string | null
|
||||
}>()
|
||||
|
||||
function value(value: string | number | null | undefined) { return value === null || value === undefined || value === '' ? '—' : String(value) }
|
||||
function number(value: number | null) { return value === null ? '—' : value.toFixed(3) }
|
||||
function date(value: string | null) {
|
||||
if (!value) return '—'
|
||||
const parsed = new Date(value)
|
||||
return Number.isNaN(parsed.getTime()) ? value : parsed.toLocaleString('zh-CN', { hour12: false })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="history-panel">
|
||||
<section class="history-section">
|
||||
<div class="section-heading"><h3>托盘与库位</h3><span>{{ palletLocations.length }} 条分配</span></div>
|
||||
<div v-if="palletLocations.length" class="allocation-list">
|
||||
<div v-for="item in palletLocations" :key="`${item.allocationNo}-${item.palletId}-${item.locationId}`" class="allocation-row">
|
||||
<div><strong>{{ value(item.palletCode) }}</strong><span>分配 {{ value(item.allocationNo) }}</span></div>
|
||||
<div><span>{{ number(item.allocatedVolumeCbm) }} CBM</span><span>{{ value(item.allocatedPackageCount) }} 箱</span></div>
|
||||
<div class="allocation-location">{{ value(item.locationCode) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="muted-empty">暂无托盘库位分配记录</p>
|
||||
</section>
|
||||
<section class="history-section">
|
||||
<div class="section-heading"><h3>文件记录</h3><span>{{ files.length }} 个文件</span></div>
|
||||
<div v-if="files.length" class="simple-list"><div v-for="file in files" :key="`${file.fileId}-${file.fileVersion}`"><strong>{{ value(file.fileNameSnapshot) }}</strong><span>{{ value(file.fileType) }} · {{ date(file.uploadedAt) }}</span></div></div>
|
||||
<p v-else class="muted-empty">暂无文件记录</p>
|
||||
</section>
|
||||
<section class="history-section">
|
||||
<div class="section-heading"><h3>内部备注</h3><span>{{ internalNotes.length }} 条</span></div>
|
||||
<div v-if="internalNotes.length" class="simple-list"><div v-for="note in internalNotes" :key="String(note.id)"><strong>{{ value(note.createdByName || note.createdBy) }}</strong><span>{{ value(note.content) }} · {{ date(note.createdAt) }}</span></div></div>
|
||||
<p v-else class="muted-empty">暂无内部备注</p>
|
||||
</section>
|
||||
<section class="history-section">
|
||||
<div class="section-heading"><h3>状态历史</h3><span>{{ statusHistory.length }} 条</span></div>
|
||||
<ol v-if="statusHistory.length" class="timeline"><li v-for="(history, index) in statusHistory" :key="`${history.occurredAt}-${index}`"><span class="timeline-dot" /><div><strong>{{ value(history.action) }}</strong><span>{{ value(history.fromStatus) }} → {{ value(history.toStatus) }} · {{ value(history.operatorName || history.operatorId) }}</span><small>{{ date(history.occurredAt) }}<template v-if="history.reason"> · {{ history.reason }}</template></small></div></li></ol>
|
||||
<p v-else class="muted-empty">暂无状态历史</p>
|
||||
</section>
|
||||
<section v-if="publicRemark" class="history-section remark-section"><div class="section-heading"><h3>公开备注</h3></div><p>{{ publicRemark }}</p></section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.history-panel { display: grid; gap: 22px; min-width: 0; }
|
||||
.history-section { min-width: 0; }
|
||||
.section-heading { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin-bottom: 10px; }
|
||||
.section-heading h3 { margin: 0; color: var(--cc-text); font-size: 14px; }
|
||||
.section-heading span { color: var(--cc-muted); font-size: 12px; }
|
||||
.allocation-list, .simple-list { overflow: hidden; border: 1px solid var(--cc-border); border-radius: 8px; }
|
||||
.allocation-row { display: grid; grid-template-columns: 1fr auto minmax(100px, 0.8fr); gap: 12px; align-items: center; padding: 11px 12px; border-bottom: 1px solid var(--cc-border); font-size: 12px; }
|
||||
.allocation-row:last-child, .simple-list > div:last-child { border-bottom: 0; }
|
||||
.allocation-row > div, .simple-list > div { min-width: 0; }
|
||||
.allocation-row span, .simple-list span { display: block; margin-top: 3px; color: var(--cc-muted); }
|
||||
.allocation-location { overflow-wrap: anywhere; color: var(--cc-primary); text-align: right; }
|
||||
.simple-list > div { padding: 11px 12px; border-bottom: 1px solid var(--cc-border); font-size: 12px; }
|
||||
.simple-list strong, .simple-list span { overflow-wrap: anywhere; }
|
||||
.muted-empty { margin: 0; padding: 18px 12px; border: 1px dashed var(--cc-border); border-radius: 8px; color: var(--cc-muted); font-size: 12px; text-align: center; }
|
||||
.timeline { display: grid; gap: 0; margin: 0; padding: 0; list-style: none; }
|
||||
.timeline li { display: grid; grid-template-columns: 16px 1fr; gap: 10px; min-width: 0; padding-bottom: 16px; }
|
||||
.timeline-dot { width: 8px; height: 8px; margin-top: 5px; border: 2px solid var(--cc-primary); border-radius: 50%; background: #fff; box-shadow: 0 0 0 3px color-mix(in srgb, var(--cc-primary) 12%, transparent); }
|
||||
.timeline li > div { min-width: 0; padding-bottom: 12px; border-bottom: 1px solid var(--cc-border); }
|
||||
.timeline strong, .timeline span, .timeline small { display: block; overflow-wrap: anywhere; }
|
||||
.timeline span, .timeline small { margin-top: 3px; color: var(--cc-muted); font-size: 12px; line-height: 1.5; }
|
||||
.remark-section p { margin: 0; padding: 12px; border-radius: 8px; background: #f8fafc; color: var(--cc-text); font-size: 13px; line-height: 1.6; white-space: pre-wrap; }
|
||||
@media (max-width: 480px) { .allocation-row { grid-template-columns: 1fr auto; } .allocation-location { grid-column: 1 / -1; text-align: left; } }
|
||||
</style>
|
||||
@@ -0,0 +1,23 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/modules/inbounds/core/InboundSummaryPanel.vue'), 'utf8')
|
||||
|
||||
describe('InboundSummaryPanel', () => {
|
||||
it('organizes the aggregate detail into the prototype-aligned backend-backed sections', () => {
|
||||
expect(source).toContain('基本信息')
|
||||
expect(source).toContain('仓库与运输')
|
||||
expect(source).toContain('测量与计费')
|
||||
expect(source).toContain('入库资源')
|
||||
expect(source).toContain('summary.deliveryWarehouseName')
|
||||
expect(source).toContain('summary.chargeableWeightKg')
|
||||
expect(source).toContain('summary.locationCodes')
|
||||
expect(source).toContain('summary.notificationStatus')
|
||||
})
|
||||
|
||||
it('does not restore excluded receiving review or customer fee confirmation actions', () => {
|
||||
expect(source).not.toContain('接收审核')
|
||||
expect(source).not.toContain('客户确认费用')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,124 @@
|
||||
<script setup lang="ts">
|
||||
import { Box, MapPin, Scale, Warehouse } from '@lucide/vue'
|
||||
import type { InboundOrderSummary } from './types'
|
||||
|
||||
defineProps<{ summary: InboundOrderSummary }>()
|
||||
|
||||
const processLabels: Record<string, string> = {
|
||||
WAIT_MEASURE: '待测量', MEASURING: '测量中', MEASURE_PENDING: '待审核', MEASURE_REJECTED: '测量驳回',
|
||||
WAIT_PRICING: '待计费', FEE_CONFIRMING: '待发送费用', INBOUNDING: '入库处理中', INBOUNDED: '已入库', VOIDED: '已作废',
|
||||
}
|
||||
|
||||
function processLabel(status: string) { return processLabels[status] || status || '未知状态' }
|
||||
function processType(status: string) {
|
||||
if (status === 'INBOUNDED') return 'success'
|
||||
if (status === 'MEASURE_REJECTED' || status === 'VOIDED') return 'danger'
|
||||
if (status === 'MEASURING' || status === 'INBOUNDING') return 'info'
|
||||
return 'warning'
|
||||
}
|
||||
function value(value: string | number | null | undefined) { return value === null || value === undefined || value === '' ? '—' : String(value) }
|
||||
function number(value: number | null, digits = 3) { return value === null ? '—' : value.toFixed(digits) }
|
||||
function date(value: string | null) {
|
||||
if (!value) return '—'
|
||||
const parsed = new Date(value)
|
||||
return Number.isNaN(parsed.getTime()) ? value : parsed.toLocaleString('zh-CN', { hour12: false })
|
||||
}
|
||||
function yesNo(value: boolean | null) { return value === null ? '—' : value ? '是' : '否' }
|
||||
const measureReviewLabels: Record<string, string> = { PENDING: '待审核', APPROVED: '审核通过', REJECTED: '审核驳回' }
|
||||
const feeLabels: Record<string, string> = { UNPRICED: '未计费', CALCULATED: '已试算', CONFIRMED: '已确认', SENT: '已发送', VOIDED: '已作废' }
|
||||
const notificationLabels: Record<string, string> = { NOT_CREATED: '未创建', PENDING: '待发送', SUCCESS: '发送成功', FAILED: '发送失败' }
|
||||
function label(value: string | null, labels: Record<string, string>) { return value ? labels[value] || value : '—' }
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="summary-panel" aria-label="入库单概览">
|
||||
<div class="summary-heading">
|
||||
<div>
|
||||
<h3>{{ summary.inboundCode || `入库单 ${summary.id}` }}</h3>
|
||||
<p class="summary-subtitle">订单 {{ value(summary.orderNo) }} · {{ value(summary.customerName) }}</p>
|
||||
</div>
|
||||
<el-tag :type="processType(summary.processStatus)" effect="light">{{ processLabel(summary.processStatus) }}</el-tag>
|
||||
</div>
|
||||
<p v-if="summary.holdFlag" class="hold-note" role="status">当前挂起:{{ summary.currentHoldReason || '待处理异常' }}</p>
|
||||
<div class="summary-sections">
|
||||
<section aria-labelledby="inbound-basic-heading">
|
||||
<h4 id="inbound-basic-heading">基本信息</h4>
|
||||
<dl class="summary-grid">
|
||||
<div><dt>头程订单号</dt><dd>{{ value(summary.orderNo) }}</dd></div>
|
||||
<div><dt>订单状态</dt><dd>{{ value(summary.orderStatusSnapshot) }}</dd></div>
|
||||
<div><dt>客户</dt><dd>{{ value(summary.customerName) }}</dd></div>
|
||||
<div><dt>公司</dt><dd>{{ value(summary.companyName) }}</dd></div>
|
||||
<div><dt>业务类型</dt><dd>{{ value(summary.businessType) }}</dd></div>
|
||||
<div><dt>渠道</dt><dd>{{ value(summary.channelName || summary.channelCode) }}</dd></div>
|
||||
<div><dt>计税模式</dt><dd>{{ value(summary.taxMode) }}</dd></div>
|
||||
<div><dt>装载模式</dt><dd>{{ value(summary.containerMode) }}</dd></div>
|
||||
<div><dt>商检验</dt><dd>{{ yesNo(summary.customsInspection) }}</dd></div>
|
||||
<div><dt>创建时间</dt><dd>{{ date(summary.createdAt) }}</dd></div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="inbound-route-heading">
|
||||
<h4 id="inbound-route-heading">仓库与运输</h4>
|
||||
<dl class="summary-grid">
|
||||
<div><dt><Warehouse :size="14" />收货仓</dt><dd>{{ value(summary.warehouseName) }}</dd></div>
|
||||
<div><dt>交货仓</dt><dd>{{ value(summary.deliveryWarehouseName) }}</dd></div>
|
||||
<div><dt><MapPin :size="14" />目的仓</dt><dd>{{ value(summary.destinationName || summary.destinationCode) }}</dd></div>
|
||||
<div><dt>目的仓类型</dt><dd>{{ value(summary.destinationType) }}</dd></div>
|
||||
<div><dt>集装箱号</dt><dd>{{ value(summary.containerNo) }}</dd></div>
|
||||
<div><dt>柜号</dt><dd>{{ value(summary.cabinetNo) }}</dd></div>
|
||||
<div><dt>船名 / 航次</dt><dd>{{ value(summary.vesselName) }} / {{ value(summary.voyageNo) }}</dd></div>
|
||||
<div><dt>配载状态</dt><dd>{{ value(summary.loadingStatus) }}</dd></div>
|
||||
<div><dt>预计到达</dt><dd>{{ date(summary.expectedArrivalAt) }}</dd></div>
|
||||
<div><dt>开船 / 到港</dt><dd>{{ date(summary.sailingAt) }} / {{ date(summary.portArrivalAt) }}</dd></div>
|
||||
<div><dt>快递公司</dt><dd>{{ value(summary.expressCompanyName || summary.expressCompanyCode) }}</dd></div>
|
||||
<div><dt>快递单号</dt><dd>{{ value(summary.expressTrackingNo) }}</dd></div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="inbound-measure-heading">
|
||||
<h4 id="inbound-measure-heading">测量与计费</h4>
|
||||
<dl class="summary-grid">
|
||||
<div><dt><Box :size="14" />总箱数</dt><dd>{{ value(summary.totalPackageCount) }} 箱</dd></div>
|
||||
<div><dt><Scale :size="14" />总毛重</dt><dd>{{ number(summary.actualGrossWeightKg) }} kg</dd></div>
|
||||
<div><dt>总净重</dt><dd>{{ number(summary.actualNetWeightKg) }} kg</dd></div>
|
||||
<div><dt>总体积</dt><dd>{{ number(summary.actualVolumeCbm) }} CBM</dd></div>
|
||||
<div><dt>总材积重</dt><dd>{{ number(summary.volumeWeightKg) }} kg</dd></div>
|
||||
<div><dt>计费重量</dt><dd>{{ number(summary.chargeableWeightKg) }} kg</dd></div>
|
||||
<div><dt>计费体积</dt><dd>{{ number(summary.chargeableVolumeCbm) }} CBM</dd></div>
|
||||
<div><dt>测量审核</dt><dd>{{ label(summary.measureReviewStatus, measureReviewLabels) }}</dd></div>
|
||||
<div><dt>费用状态</dt><dd>{{ label(summary.feeStatus, feeLabels) }}</dd></div>
|
||||
<div><dt>费用审核状态</dt><dd>{{ value(summary.feeReviewStatus) }}</dd></div>
|
||||
<div><dt>测量完成时间</dt><dd>{{ date(summary.measureCompletedAt) }}</dd></div>
|
||||
<div><dt>通知状态</dt><dd>{{ label(summary.notificationStatus, notificationLabels) }}</dd></div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="inbound-storage-heading">
|
||||
<h4 id="inbound-storage-heading">入库资源</h4>
|
||||
<dl class="summary-grid">
|
||||
<div><dt>总托盘数</dt><dd>{{ value(summary.palletCount) }}</dd></div>
|
||||
<div><dt>库位位置</dt><dd>{{ value(summary.locationCodes) }}</dd></div>
|
||||
<div><dt>入库完成时间</dt><dd>{{ date(summary.inboundedAt) }}</dd></div>
|
||||
<div><dt>最后更新时间</dt><dd>{{ date(summary.updatedAt) }}</dd></div>
|
||||
</dl>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.summary-panel { min-width: 0; }
|
||||
.summary-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; padding-bottom: 18px; border-bottom: 1px solid var(--cc-border); }
|
||||
h3 { margin: 0; overflow-wrap: anywhere; color: var(--cc-text); font-size: 18px; line-height: 1.3; }
|
||||
.summary-subtitle { margin: 6px 0 0; color: var(--cc-muted); font-size: 12px; }
|
||||
.summary-sections { display: grid; min-width: 0; gap: 22px; margin-top: 18px; }
|
||||
.summary-sections section { min-width: 0; }
|
||||
h4 { margin: 0 0 9px; color: var(--cc-text); font-size: 14px; line-height: 1.4; }
|
||||
.summary-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); margin: 0; border-top: 1px solid var(--cc-border); border-left: 1px solid var(--cc-border); }
|
||||
.summary-grid > div { min-width: 0; min-height: 66px; padding: 11px 12px; border-right: 1px solid var(--cc-border); border-bottom: 1px solid var(--cc-border); }
|
||||
dt { display: flex; align-items: center; gap: 6px; color: var(--cc-muted); font-size: 12px; }
|
||||
dd { margin: 5px 0 0; overflow-wrap: anywhere; color: var(--cc-text); font-size: 13px; line-height: 1.5; }
|
||||
.hold-note { margin: 16px 0 0; padding: 10px 12px; border: 1px solid #f5c26b; border-radius: 8px; background: #fffaeb; color: #93370d; font-size: 12px; line-height: 1.5; }
|
||||
@media (max-width: 900px) { .summary-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
||||
@media (max-width: 480px) { .summary-heading { align-items: stretch; flex-direction: column; } .summary-grid { grid-template-columns: 1fr; } }
|
||||
</style>
|
||||
@@ -0,0 +1,86 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { http } from '@/shared/api/http'
|
||||
import { getPlatformInbound, listPlatformInbounds } from './api'
|
||||
|
||||
vi.mock('@/shared/api/http', () => ({
|
||||
http: { get: vi.fn() },
|
||||
unwrapApiResponse: <T>(response: { data: T }) => response.data,
|
||||
}))
|
||||
|
||||
describe('platform inbound query API', () => {
|
||||
beforeEach(() => vi.mocked(http.get).mockReset())
|
||||
|
||||
it('omits the backend tenant filter for an all-customer list query', async () => {
|
||||
vi.mocked(http.get).mockResolvedValue({
|
||||
code: '200', message: 'success', data: { records: [], page: 1, size: 20, total: 0, pages: 0 },
|
||||
})
|
||||
|
||||
await listPlatformInbounds(undefined, { page: 1, size: 20 })
|
||||
|
||||
expect(http.get).toHaveBeenCalledWith('/platform/inbounds', { params: { page: 1, size: 20 } })
|
||||
})
|
||||
|
||||
it('uses the implemented non-v1 list path and normalizes page values and IDs', async () => {
|
||||
vi.mocked(http.get).mockResolvedValue({
|
||||
code: '200', message: 'success', data: {
|
||||
records: [{ id: 19001, orderId: 18001, customerId: 10001, totalPackageCount: '6', holdFlag: 'false' }],
|
||||
page: '1', size: '20', total: '1', pages: '1',
|
||||
},
|
||||
})
|
||||
|
||||
await expect(listPlatformInbounds('10001', {
|
||||
page: 1, size: 20, tab: 'WAIT_MEASURE', inboundCode: 'RK', holdFlag: false,
|
||||
})).resolves.toMatchObject({
|
||||
records: [{ id: '19001', orderId: '18001', customerId: '10001', totalPackageCount: 6, holdFlag: false }],
|
||||
total: 1, pages: 1,
|
||||
})
|
||||
expect(http.get).toHaveBeenCalledWith('/platform/inbounds', {
|
||||
params: { tenantId: '10001', page: 1, size: 20, tab: 'WAIT_MEASURE', inboundCode: 'RK', holdFlag: false },
|
||||
})
|
||||
})
|
||||
|
||||
it('loads a tenant-scoped aggregate detail and keeps nested IDs as strings', async () => {
|
||||
vi.mocked(http.get).mockResolvedValue({
|
||||
code: '200', message: 'success', data: {
|
||||
summary: { id: 19001, orderId: 18001 }, publicRemark: null,
|
||||
palletLocations: [{ palletId: 21001, locationId: 22001 }], files: [{ fileId: 23001, uploadedBy: 2 }],
|
||||
internalNotes: [{ id: 24001, createdBy: 2 }], statusHistory: [{ operatorId: 2 }],
|
||||
},
|
||||
})
|
||||
|
||||
await expect(getPlatformInbound('10001', '19001')).resolves.toMatchObject({
|
||||
summary: { id: '19001', orderId: '18001' },
|
||||
palletLocations: [{ palletId: '21001', locationId: '22001' }],
|
||||
files: [{ fileId: '23001', uploadedBy: '2' }],
|
||||
internalNotes: [{ id: '24001', createdBy: '2' }],
|
||||
statusHistory: [{ operatorId: '2' }],
|
||||
})
|
||||
expect(http.get).toHaveBeenCalledWith('/platform/inbounds/19001', { params: { tenantId: '10001' } })
|
||||
})
|
||||
|
||||
it('passes the implemented order, process, measurement and fee review filters without renaming them', async () => {
|
||||
vi.mocked(http.get).mockResolvedValue({
|
||||
code: '200', message: 'success', data: { records: [], page: 1, size: 20, total: 0, pages: 0 },
|
||||
})
|
||||
|
||||
await listPlatformInbounds('10001', {
|
||||
page: 1,
|
||||
size: 20,
|
||||
processStatus: 'MEASURE_PENDING',
|
||||
orderStatus: 'APPROVED',
|
||||
measureReviewStatus: 'PENDING',
|
||||
feeStatus: 'UNPRICED',
|
||||
feeReviewStatus: 'PENDING',
|
||||
warehouseId: '9007199254740993',
|
||||
deliveryWarehouseId: '9007199254740995',
|
||||
})
|
||||
|
||||
expect(http.get).toHaveBeenCalledWith('/platform/inbounds', {
|
||||
params: {
|
||||
tenantId: '10001', page: 1, size: 20, processStatus: 'MEASURE_PENDING',
|
||||
orderStatus: 'APPROVED', measureReviewStatus: 'PENDING', feeStatus: 'UNPRICED', feeReviewStatus: 'PENDING',
|
||||
warehouseId: '9007199254740993', deliveryWarehouseId: '9007199254740995',
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,127 @@
|
||||
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
import type {
|
||||
InboundFileSummary,
|
||||
InboundInternalNote,
|
||||
InboundListQuery,
|
||||
InboundOrderDetail,
|
||||
InboundOrderSummary,
|
||||
InboundPageResult,
|
||||
InboundPalletLocation,
|
||||
InboundStatusHistory,
|
||||
} from './types'
|
||||
|
||||
const basePath = '/platform/inbounds'
|
||||
|
||||
function id(value: unknown): string | null {
|
||||
return value === null || value === undefined ? null : String(value)
|
||||
}
|
||||
|
||||
function numberValue(value: unknown): number | null {
|
||||
if (value === null || value === undefined || value === '') return null
|
||||
const parsed = typeof value === 'number' ? value : Number(value)
|
||||
return Number.isFinite(parsed) ? parsed : null
|
||||
}
|
||||
|
||||
function integerValue(value: unknown): number | null {
|
||||
const parsed = numberValue(value)
|
||||
return parsed === null ? null : Math.trunc(parsed)
|
||||
}
|
||||
|
||||
function boolValue(value: unknown): boolean | null {
|
||||
if (value === null || value === undefined) return null
|
||||
if (typeof value === 'boolean') return value
|
||||
if (value === 'true' || value === 1 || value === '1') return true
|
||||
if (value === 'false' || value === 0 || value === '0') return false
|
||||
return null
|
||||
}
|
||||
|
||||
export function normalizeInboundSummary(raw: InboundOrderSummary): InboundOrderSummary {
|
||||
return {
|
||||
...raw,
|
||||
id: String(raw.id),
|
||||
orderId: id(raw.orderId),
|
||||
customerId: id(raw.customerId),
|
||||
companyId: id(raw.companyId),
|
||||
warehouseId: id(raw.warehouseId),
|
||||
deliveryWarehouseId: id(raw.deliveryWarehouseId),
|
||||
destinationWarehouseId: id(raw.destinationWarehouseId),
|
||||
version: integerValue(raw.version),
|
||||
customsInspection: boolValue(raw.customsInspection),
|
||||
holdFlag: boolValue(raw.holdFlag),
|
||||
totalPackageCount: integerValue(raw.totalPackageCount),
|
||||
actualGrossWeightKg: numberValue(raw.actualGrossWeightKg),
|
||||
actualNetWeightKg: numberValue(raw.actualNetWeightKg),
|
||||
actualVolumeCbm: numberValue(raw.actualVolumeCbm),
|
||||
volumeWeightKg: numberValue(raw.volumeWeightKg),
|
||||
chargeableWeightKg: numberValue(raw.chargeableWeightKg),
|
||||
chargeableVolumeCbm: numberValue(raw.chargeableVolumeCbm),
|
||||
palletCount: integerValue(raw.palletCount),
|
||||
}
|
||||
}
|
||||
|
||||
function normalizePage(raw: InboundPageResult): InboundPageResult {
|
||||
return {
|
||||
records: (raw.records || []).map(normalizeInboundSummary),
|
||||
page: integerValue(raw.page) || 1,
|
||||
size: integerValue(raw.size) || 20,
|
||||
total: integerValue(raw.total) || 0,
|
||||
pages: integerValue(raw.pages) || 0,
|
||||
}
|
||||
}
|
||||
|
||||
function normalizePallet(raw: InboundPalletLocation): InboundPalletLocation {
|
||||
return {
|
||||
...raw,
|
||||
allocationNo: integerValue(raw.allocationNo),
|
||||
warehouseId: id(raw.warehouseId),
|
||||
palletId: id(raw.palletId),
|
||||
locationId: id(raw.locationId),
|
||||
palletCapacityCbm: numberValue(raw.palletCapacityCbm),
|
||||
allocatedVolumeCbm: numberValue(raw.allocatedVolumeCbm),
|
||||
allocatedPackageCount: integerValue(raw.allocatedPackageCount),
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeFile(raw: InboundFileSummary): InboundFileSummary {
|
||||
return { ...raw, fileId: id(raw.fileId), uploadedBy: id(raw.uploadedBy), fileVersion: integerValue(raw.fileVersion) }
|
||||
}
|
||||
|
||||
function normalizeNote(raw: InboundInternalNote): InboundInternalNote {
|
||||
return { ...raw, id: id(raw.id), createdBy: id(raw.createdBy) }
|
||||
}
|
||||
|
||||
function normalizeHistory(raw: InboundStatusHistory): InboundStatusHistory {
|
||||
return { ...raw, operatorId: id(raw.operatorId) }
|
||||
}
|
||||
|
||||
function normalizeDetail(raw: InboundOrderDetail): InboundOrderDetail {
|
||||
return {
|
||||
...raw,
|
||||
summary: normalizeInboundSummary(raw.summary),
|
||||
palletLocations: (raw.palletLocations || []).map(normalizePallet),
|
||||
files: (raw.files || []).map(normalizeFile),
|
||||
internalNotes: (raw.internalNotes || []).map(normalizeNote),
|
||||
statusHistory: (raw.statusHistory || []).map(normalizeHistory),
|
||||
}
|
||||
}
|
||||
|
||||
function queryParams(tenantId: string | undefined, query: InboundListQuery) {
|
||||
return Object.fromEntries(
|
||||
Object.entries({ tenantId, ...query }).filter(([, value]) => value !== undefined && value !== ''),
|
||||
)
|
||||
}
|
||||
|
||||
export async function listPlatformInbounds(tenantId: string | undefined, query: InboundListQuery) {
|
||||
const response = await http.get<ApiEnvelope<InboundPageResult>, ApiEnvelope<InboundPageResult>>(basePath, {
|
||||
params: queryParams(tenantId, query),
|
||||
})
|
||||
return normalizePage(unwrapApiResponse(response))
|
||||
}
|
||||
|
||||
export async function getPlatformInbound(tenantId: string, inboundId: string) {
|
||||
const response = await http.get<ApiEnvelope<InboundOrderDetail>, ApiEnvelope<InboundOrderDetail>>(
|
||||
`${basePath}/${inboundId}`,
|
||||
{ params: { tenantId } },
|
||||
)
|
||||
return normalizeDetail(unwrapApiResponse(response))
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
export type InboundListTab = 'WAIT_MEASURE' | 'WAIT_REVIEW' | 'WAIT_FEE_SEND' | 'INBOUNDED'
|
||||
|
||||
export type InboundProcessStatus =
|
||||
| 'WAIT_MEASURE'
|
||||
| 'MEASURING'
|
||||
| 'MEASURE_PENDING'
|
||||
| 'MEASURE_REJECTED'
|
||||
| 'WAIT_PRICING'
|
||||
| 'FEE_CONFIRMING'
|
||||
| 'INBOUNDING'
|
||||
| 'INBOUNDED'
|
||||
| 'VOIDED'
|
||||
|
||||
export type InboundFeeStatus = 'UNPRICED' | 'CALCULATED' | 'CONFIRMED' | 'SENT' | 'VOIDED' | string
|
||||
|
||||
export interface InboundOrderSummary {
|
||||
id: string
|
||||
orderId: string | null
|
||||
orderNo: string | null
|
||||
inboundCode: string | null
|
||||
processStatus: InboundProcessStatus | string
|
||||
orderStatusSnapshot: string | null
|
||||
measureReviewStatus: string | null
|
||||
feeStatus: InboundFeeStatus | null
|
||||
feeReviewStatus: string | null
|
||||
notificationStatus: string | null
|
||||
version: number | null
|
||||
expectedArrivalAt: string | null
|
||||
customerId: string | null
|
||||
customerName: string | null
|
||||
companyId: string | null
|
||||
companyName: string | null
|
||||
warehouseId: string | null
|
||||
warehouseName: string | null
|
||||
deliveryWarehouseId: string | null
|
||||
deliveryWarehouseName: string | null
|
||||
businessType: string | null
|
||||
channelCode: string | null
|
||||
channelName: string | null
|
||||
taxMode: string | null
|
||||
containerMode: string | null
|
||||
customsInspection: boolean | null
|
||||
destinationType: string | null
|
||||
destinationWarehouseId: string | null
|
||||
destinationCode: string | null
|
||||
destinationName: string | null
|
||||
holdFlag: boolean | null
|
||||
currentHoldReason: string | null
|
||||
totalPackageCount: number | null
|
||||
actualGrossWeightKg: number | null
|
||||
actualNetWeightKg: number | null
|
||||
actualVolumeCbm: number | null
|
||||
volumeWeightKg: number | null
|
||||
chargeableWeightKg: number | null
|
||||
chargeableVolumeCbm: number | null
|
||||
palletCount: number | null
|
||||
locationCodes: string | null
|
||||
measureCompletedAt: string | null
|
||||
inboundedAt: string | null
|
||||
expressCompanyCode: string | null
|
||||
expressCompanyName: string | null
|
||||
expressTrackingNo: string | null
|
||||
loadingStatus: string | null
|
||||
containerNo: string | null
|
||||
cabinetNo: string | null
|
||||
vesselName: string | null
|
||||
voyageNo: string | null
|
||||
sailingAt: string | null
|
||||
portArrivalAt: string | null
|
||||
createdAt: string | null
|
||||
updatedAt: string | null
|
||||
}
|
||||
|
||||
export interface InboundPalletLocation {
|
||||
allocationNo: number | null
|
||||
warehouseId: string | null
|
||||
palletId: string | null
|
||||
palletCode: string | null
|
||||
palletCapacityCbm: number | null
|
||||
allocatedVolumeCbm: number | null
|
||||
allocatedPackageCount: number | null
|
||||
locationId: string | null
|
||||
locationCode: string | null
|
||||
reservationNo: string | null
|
||||
allocationStatus: string | null
|
||||
}
|
||||
|
||||
export interface InboundFileSummary {
|
||||
fileId: string | null
|
||||
fileType: string | null
|
||||
fileNameSnapshot: string | null
|
||||
visibility: string | null
|
||||
fileVersion: number | null
|
||||
uploadedBy: string | null
|
||||
uploadedAt: string | null
|
||||
}
|
||||
|
||||
export interface InboundInternalNote {
|
||||
id: string | null
|
||||
content: string | null
|
||||
createdBy: string | null
|
||||
createdByName: string | null
|
||||
createdAt: string | null
|
||||
}
|
||||
|
||||
export interface InboundStatusHistory {
|
||||
fromStatus: string | null
|
||||
toStatus: string | null
|
||||
action: string | null
|
||||
reason: string | null
|
||||
operatorId: string | null
|
||||
operatorName: string | null
|
||||
occurredAt: string | null
|
||||
}
|
||||
|
||||
export interface InboundOrderDetail {
|
||||
summary: InboundOrderSummary
|
||||
publicRemark: string | null
|
||||
palletLocations: InboundPalletLocation[]
|
||||
files: InboundFileSummary[]
|
||||
internalNotes: InboundInternalNote[]
|
||||
statusHistory: InboundStatusHistory[]
|
||||
}
|
||||
|
||||
export interface InboundPageResult {
|
||||
records: InboundOrderSummary[]
|
||||
page: number
|
||||
size: number
|
||||
total: number
|
||||
pages: number
|
||||
}
|
||||
|
||||
export interface InboundListQuery {
|
||||
page: number
|
||||
size: number
|
||||
tab?: InboundListTab
|
||||
inboundCode?: string
|
||||
orderNo?: string
|
||||
processStatus?: string
|
||||
orderStatus?: string
|
||||
measureReviewStatus?: string
|
||||
customerName?: string
|
||||
destinationCode?: string
|
||||
feeStatus?: string
|
||||
feeReviewStatus?: string
|
||||
customerId?: string
|
||||
holdFlag?: boolean
|
||||
companyId?: string
|
||||
warehouseId?: string
|
||||
deliveryWarehouseId?: string
|
||||
expectedArrivalFrom?: string
|
||||
expectedArrivalTo?: string
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
import ElementPlus from 'element-plus'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ApiResponseError } from '@/shared/api/http'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { useAuthStore } from '@/core/auth/auth'
|
||||
import {
|
||||
adjustPublishedInboundFee,
|
||||
getLatestInboundFee,
|
||||
} from './api'
|
||||
import type { InboundFeeSnapshot } from './types'
|
||||
import InboundFeePanel from './InboundFeePanel.vue'
|
||||
|
||||
vi.mock('./api', async () => {
|
||||
const actual = await vi.importActual<typeof import('./api')>('./api')
|
||||
return {
|
||||
...actual,
|
||||
getLatestInboundFee: vi.fn(),
|
||||
adjustPublishedInboundFee: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
const snapshot: InboundFeeSnapshot = {
|
||||
feeId: '9007199254740993', feeNo: 'FBAF-88-2', feeVersion: 2, feeStatus: 'SENT',
|
||||
inboundId: '88', inboundCode: 'RK202608040088',
|
||||
chargeRuleId: '101', ruleCode: 'RULE-FBA-KG', chargeRuleVersion: 3,
|
||||
carrierRateId: '202', rateCode: 'RATE-LAX', carrierRateVersion: 5,
|
||||
customerId: '303', customerName: '测试客户', originWarehouseId: '404', originWarehouseName: '义乌仓',
|
||||
businessType: 'FBA_SEA', channelCode: 'SEA-TAX', carrierId: '505', carrierName: '测试船司',
|
||||
destinationCode: 'LAX9', taxMode: 'TAX_INCLUDED', chargeMode: 'KG',
|
||||
conversionFactor: 167, roundingScale: 2, roundingMode: 'HALF_UP',
|
||||
actualWeightKg: 100, actualVolumeCbm: 0.8, convertedQuantity: 133.6, chargeableQuantity: 133.6,
|
||||
chargeUnit: 'KG', unitPrice: 8.5, subtotalAmount: 1135.6, additionalAmount: 100,
|
||||
totalAmount: 1235.6, currency: 'CNY',
|
||||
rateEffectiveFrom: '2026-08-01T00:00:00', rateEffectiveTo: null,
|
||||
calculatedAt: '2026-08-04T10:00:00', confirmedAt: '2026-08-04T10:01:00', sentAt: '2026-08-04T10:02:00',
|
||||
nextProcessStatus: 'INBOUNDED',
|
||||
}
|
||||
|
||||
enableAutoUnmount(afterEach)
|
||||
|
||||
function setupPermissions(permissions: string[]) {
|
||||
const pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
const auth = useAuthStore()
|
||||
auth.token = 'token'
|
||||
auth.permissions = permissions
|
||||
useAccessPreviewStore().initializeAfterLogin()
|
||||
return pinia
|
||||
}
|
||||
|
||||
function mountPanel(permissions = ['firstleg:fba-pricing:view', 'firstleg:fba-pricing:adjust']) {
|
||||
const pinia = setupPermissions(permissions)
|
||||
return mount(InboundFeePanel, {
|
||||
attachTo: document.body,
|
||||
props: { customerId: '10001', inboundId: '88' },
|
||||
global: { plugins: [pinia, ElementPlus] },
|
||||
})
|
||||
}
|
||||
|
||||
describe('InboundFeePanel', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
document.body.innerHTML = ''
|
||||
vi.mocked(getLatestInboundFee).mockResolvedValue(snapshot)
|
||||
vi.mocked(adjustPublishedInboundFee).mockResolvedValue({ ...snapshot, feeVersion: 3, feeNo: 'FBAF-88-3' })
|
||||
})
|
||||
|
||||
it('shows the immutable calculation basis, versions, amount, and timestamps', async () => {
|
||||
mountPanel()
|
||||
await flushPromises()
|
||||
|
||||
expect(getLatestInboundFee).toHaveBeenCalledWith('10001', '88')
|
||||
expect(document.body.textContent).not.toContain('租户')
|
||||
expect(document.body.textContent).toContain('对客费用总额 · CNY')
|
||||
expect(document.body.textContent).toContain('¥1,235.60')
|
||||
expect(document.body.textContent).toContain('基础运费小计 ¥1,135.60')
|
||||
expect(document.body.textContent).toContain('附加费 ¥100.00')
|
||||
expect(document.body.textContent).toContain('133.6 KG')
|
||||
expect(document.body.textContent).toContain('按重量计费')
|
||||
expect(document.body.textContent).toContain('¥8.50 / KG')
|
||||
expect(document.body.textContent).toContain('RULE-FBA-KG · V3')
|
||||
expect(document.body.textContent).toContain('RATE-LAX · V5')
|
||||
expect(document.body.textContent).toContain('发布给客户')
|
||||
expect(document.body.textContent).not.toContain('重新试算')
|
||||
expect(document.body.textContent).not.toContain('发送费用')
|
||||
})
|
||||
|
||||
it('follows the admin detail interaction pattern without list-only scaffolding', async () => {
|
||||
const wrapper = mountPanel()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.get('button[aria-label="刷新费用快照"]').classes()).toContain('is-circle')
|
||||
expect(wrapper.get('.version-status .el-tag').text()).toBe('已发布')
|
||||
expect(wrapper.find('.query-toolbar').exists()).toBe(false)
|
||||
expect(wrapper.find('.data-table').exists()).toBe(false)
|
||||
expect(wrapper.find('.surface-panel').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps the legacy integration prop compatible while the page layer uses customer context', async () => {
|
||||
const pinia = setupPermissions(['firstleg:fba-pricing:view'])
|
||||
mount(InboundFeePanel, {
|
||||
props: { tenantId: 'legacy-10001', inboundId: '88' },
|
||||
global: { plugins: [pinia, ElementPlus] },
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
expect(getLatestInboundFee).toHaveBeenCalledWith('legacy-10001', '88')
|
||||
expect(document.body.textContent).not.toContain('租户')
|
||||
})
|
||||
|
||||
it('keeps the prototype-aligned detail order and renders long values with pending timestamps', async () => {
|
||||
const longRuleCode = `RULE-${'LONG-'.repeat(20)}`
|
||||
const longCarrierName = `船司${'超长名称'.repeat(20)}`
|
||||
vi.mocked(getLatestInboundFee).mockResolvedValue({
|
||||
...snapshot,
|
||||
ruleCode: longRuleCode,
|
||||
carrierName: longCarrierName,
|
||||
confirmedAt: null,
|
||||
sentAt: null,
|
||||
})
|
||||
|
||||
const wrapper = mountPanel()
|
||||
await flushPromises()
|
||||
|
||||
const content = wrapper.find('.fee-content').text()
|
||||
expect(wrapper.findAll('.fee-summary > div > span').map((node) => node.text())).toEqual([
|
||||
'对客费用总额 · CNY',
|
||||
'最终计费量',
|
||||
'快照版本',
|
||||
])
|
||||
expect(wrapper.findAll('.calculation-flow > div > span').map((node) => node.text())).toEqual([
|
||||
'实际重量',
|
||||
'实际体积',
|
||||
'折算量',
|
||||
'取大后的计费量',
|
||||
])
|
||||
expect(wrapper.findAll('.fee-detail-grid h4').map((node) => node.text())).toEqual([
|
||||
'客户计费规则',
|
||||
'船司 FBA 价格',
|
||||
])
|
||||
expect(content).toContain(longRuleCode)
|
||||
expect(content).toContain(longCarrierName)
|
||||
const pendingTimelineItems = wrapper.findAll('.fee-timeline li.pending')
|
||||
expect(pendingTimelineItems).toHaveLength(2)
|
||||
expect(pendingTimelineItems.map((item) => item.find('strong').text())).toEqual(['—', '—'])
|
||||
})
|
||||
|
||||
it('does not request or expose fees without the view permission', async () => {
|
||||
mountPanel([])
|
||||
await flushPromises()
|
||||
|
||||
expect(getLatestInboundFee).not.toHaveBeenCalled()
|
||||
expect(document.body.textContent).toContain('没有查看入库费用的权限')
|
||||
})
|
||||
|
||||
it('treats A0404 as a neutral not-yet-generated state', async () => {
|
||||
vi.mocked(getLatestInboundFee).mockRejectedValue(new ApiResponseError('费用快照不存在', 'A0404'))
|
||||
mountPanel(['firstleg:fba-pricing:view'])
|
||||
await flushPromises()
|
||||
|
||||
expect(document.body.textContent).toContain('尚未生成费用快照')
|
||||
expect(document.body.textContent).toContain('测量审核完成后')
|
||||
expect(document.querySelector('[role="alert"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows adjustment only for authorized published inbound fees', async () => {
|
||||
mountPanel(['firstleg:fba-pricing:view'])
|
||||
await flushPromises()
|
||||
expect(document.querySelector('[data-testid="adjust-fee-button"]')).toBeNull()
|
||||
|
||||
document.body.innerHTML = ''
|
||||
mountPanel()
|
||||
await flushPromises()
|
||||
expect(document.querySelector('[data-testid="adjust-fee-button"]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('submits a reasoned adjustment and emits the refreshed snapshot', async () => {
|
||||
const wrapper = mountPanel()
|
||||
await flushPromises()
|
||||
;(document.querySelector('[data-testid="adjust-fee-button"]') as HTMLButtonElement).click()
|
||||
await flushPromises()
|
||||
|
||||
const numbers = [...document.querySelectorAll<HTMLInputElement>('.adjustment-form input')]
|
||||
numbers[0]!.value = '1200'
|
||||
numbers[0]!.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
numbers[0]!.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
numbers[1]!.value = '80'
|
||||
numbers[1]!.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
numbers[1]!.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
const reason = document.querySelector<HTMLTextAreaElement>('.adjustment-form textarea')!
|
||||
reason.value = '补充已确认的附加费用'
|
||||
reason.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
reason.dispatchEvent(new Event('blur', { bubbles: true }))
|
||||
|
||||
const confirm = [...document.querySelectorAll<HTMLButtonElement>('button')]
|
||||
.find((button) => button.textContent?.includes('确认调整并生成新版本'))
|
||||
confirm?.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(adjustPublishedInboundFee).toHaveBeenCalledWith(
|
||||
'10001',
|
||||
'88',
|
||||
{ subtotalAmount: 1200, additionalAmount: 80, reason: '补充已确认的附加费用' },
|
||||
expect.stringMatching(/^fee-adjust-/),
|
||||
)
|
||||
expect(wrapper.emitted('changed')?.[0]?.[0]).toMatchObject({ feeVersion: 3 })
|
||||
})
|
||||
|
||||
it('refreshes the latest snapshot after a conflict while retaining the dialog', async () => {
|
||||
vi.mocked(adjustPublishedInboundFee).mockRejectedValue(
|
||||
new ApiResponseError('费用已被其他操作员修改,请刷新后重试', 'A0409', 409),
|
||||
)
|
||||
mountPanel()
|
||||
await flushPromises()
|
||||
;(document.querySelector('[data-testid="adjust-fee-button"]') as HTMLButtonElement).click()
|
||||
await flushPromises()
|
||||
|
||||
const numbers = [...document.querySelectorAll<HTMLInputElement>('.adjustment-form input')]
|
||||
numbers[0]!.value = '1200'
|
||||
numbers[0]!.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
numbers[1]!.value = '80'
|
||||
numbers[1]!.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
const reason = document.querySelector<HTMLTextAreaElement>('.adjustment-form textarea')!
|
||||
reason.value = '并发场景价格修正'
|
||||
reason.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
reason.dispatchEvent(new Event('blur', { bubbles: true }))
|
||||
;[...document.querySelectorAll<HTMLButtonElement>('button')]
|
||||
.find((button) => button.textContent?.includes('确认调整并生成新版本'))?.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(getLatestInboundFee).toHaveBeenCalledTimes(2)
|
||||
expect(document.body.textContent).toContain('系统已刷新最新快照')
|
||||
expect(document.body.textContent).toContain('当前输入仍保留')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,477 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
||||
import { CircleDollarSign, PencilLine, RefreshCw } from '@lucide/vue'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import {
|
||||
ApiResponseError,
|
||||
getApiErrorDetails,
|
||||
type ApiErrorDetails,
|
||||
} from '@/shared/api/http'
|
||||
import {
|
||||
adjustPublishedInboundFee,
|
||||
createFeeAdjustmentIdempotencyKey,
|
||||
getLatestInboundFee,
|
||||
} from './api'
|
||||
import type { AdjustPublishedInboundFeeRequest, InboundFeeSnapshot } from './types'
|
||||
|
||||
const props = defineProps<{
|
||||
customerId?: string
|
||||
tenantId?: string
|
||||
inboundId: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
changed: [snapshot: InboundFeeSnapshot]
|
||||
}>()
|
||||
|
||||
const access = useAccessPreviewStore()
|
||||
const loading = ref(false)
|
||||
const adjusting = ref(false)
|
||||
const notFound = ref(false)
|
||||
const loadError = ref<ApiErrorDetails | null>(null)
|
||||
const adjustmentError = ref<ApiErrorDetails | null>(null)
|
||||
const conflictRefreshed = ref(false)
|
||||
const snapshot = ref<InboundFeeSnapshot | null>(null)
|
||||
const adjustOpen = ref(false)
|
||||
const adjustFormRef = ref<FormInstance>()
|
||||
let loadSequence = 0
|
||||
let adjustmentFingerprint = ''
|
||||
let adjustmentIdempotencyKey = ''
|
||||
|
||||
const adjustmentForm = reactive({
|
||||
subtotalAmount: 0,
|
||||
additionalAmount: 0,
|
||||
reason: '',
|
||||
})
|
||||
|
||||
const adjustmentRules: FormRules = {
|
||||
subtotalAmount: [{ required: true, message: '请输入调整后的基础运费', trigger: 'change' }],
|
||||
additionalAmount: [{ required: true, message: '请输入调整后的附加费', trigger: 'change' }],
|
||||
reason: [
|
||||
{ required: true, whitespace: true, message: '请填写费用调整原因', trigger: 'blur' },
|
||||
{ min: 2, max: 500, message: '调整原因需为 2–500 个字符', trigger: 'blur' },
|
||||
],
|
||||
}
|
||||
|
||||
const canView = computed(() => access.can('firstleg:fba-pricing:view'))
|
||||
const canAdjust = computed(() => access.can('firstleg:fba-pricing:adjust'))
|
||||
const customerContextId = computed(() => props.customerId || props.tenantId || '')
|
||||
const adjustmentEligible = computed(() => (
|
||||
snapshot.value?.feeStatus === 'SENT'
|
||||
&& snapshot.value.nextProcessStatus === 'INBOUNDED'
|
||||
))
|
||||
const chargeModeLabel = computed(() => snapshot.value?.chargeMode === 'KG' ? '按重量计费' : '按体积计费')
|
||||
const chargeUnitLabel = computed(() => snapshot.value?.chargeUnit === 'KG' ? 'KG' : 'CBM')
|
||||
const statusInfo = computed(() => feeStatusInfo(snapshot.value?.feeStatus))
|
||||
|
||||
function feeStatusInfo(status?: string) {
|
||||
const map: Record<string, { label: string; type: 'success' | 'warning' | 'info' }> = {
|
||||
CALCULATED: { label: '已试算', type: 'warning' },
|
||||
CONFIRMED: { label: '已确认', type: 'info' },
|
||||
SENT: { label: '已发布', type: 'success' },
|
||||
SUPERSEDED: { label: '历史版本', type: 'info' },
|
||||
}
|
||||
return map[status || ''] || { label: status || '未知状态', type: 'info' as const }
|
||||
}
|
||||
|
||||
function formatNumber(value: number | null | undefined, digits = 3) {
|
||||
if (value === null || value === undefined || !Number.isFinite(Number(value))) return '—'
|
||||
return new Intl.NumberFormat('zh-CN', { maximumFractionDigits: digits }).format(Number(value))
|
||||
}
|
||||
|
||||
function formatMoney(value: number | null | undefined, currency?: string) {
|
||||
if (value === null || value === undefined || !Number.isFinite(Number(value))) return '—'
|
||||
try {
|
||||
return new Intl.NumberFormat('zh-CN', {
|
||||
style: 'currency', currency: currency || 'CNY', minimumFractionDigits: 2,
|
||||
}).format(Number(value))
|
||||
} catch {
|
||||
return `${formatNumber(value, 2)} ${currency || ''}`.trim()
|
||||
}
|
||||
}
|
||||
|
||||
function formatDateTime(value: string | null | undefined) {
|
||||
if (!value) return '—'
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return value.replace('T', ' ')
|
||||
return new Intl.DateTimeFormat('zh-CN', {
|
||||
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit', hour12: false,
|
||||
}).format(date)
|
||||
}
|
||||
|
||||
function taxModeLabel(value: string) {
|
||||
return value === 'TAX_INCLUDED' ? '包税' : value === 'TAX_EXCLUDED' ? '不包税' : value
|
||||
}
|
||||
|
||||
function roundingModeLabel(value: string) {
|
||||
const labels: Record<string, string> = {
|
||||
UP: '远离零方向', DOWN: '向零方向', CEILING: '向上', FLOOR: '向下',
|
||||
HALF_UP: '四舍五入', HALF_DOWN: '五舍六入', HALF_EVEN: '银行家舍入',
|
||||
}
|
||||
return labels[value] || value
|
||||
}
|
||||
|
||||
async function loadFee(options: { preserveAdjustment?: boolean } = {}) {
|
||||
const sequence = ++loadSequence
|
||||
loadError.value = null
|
||||
notFound.value = false
|
||||
if (!customerContextId.value || !props.inboundId || !canView.value) {
|
||||
snapshot.value = null
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await getLatestInboundFee(customerContextId.value, props.inboundId)
|
||||
if (sequence !== loadSequence) return
|
||||
snapshot.value = result
|
||||
if (!options.preserveAdjustment && adjustOpen.value) fillAdjustmentForm(result)
|
||||
} catch (error) {
|
||||
if (sequence !== loadSequence) return
|
||||
snapshot.value = null
|
||||
if (error instanceof ApiResponseError && error.code === 'A0404') notFound.value = true
|
||||
else loadError.value = getApiErrorDetails(error, '费用快照加载失败,请稍后重试')
|
||||
} finally {
|
||||
if (sequence === loadSequence) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function fillAdjustmentForm(value: InboundFeeSnapshot) {
|
||||
adjustmentForm.subtotalAmount = Number(value.subtotalAmount)
|
||||
adjustmentForm.additionalAmount = Number(value.additionalAmount)
|
||||
adjustmentForm.reason = ''
|
||||
adjustmentError.value = null
|
||||
conflictRefreshed.value = false
|
||||
adjustmentFingerprint = ''
|
||||
adjustmentIdempotencyKey = ''
|
||||
}
|
||||
|
||||
function openAdjustment() {
|
||||
if (!snapshot.value || !adjustmentEligible.value) return
|
||||
fillAdjustmentForm(snapshot.value)
|
||||
adjustOpen.value = true
|
||||
}
|
||||
|
||||
function adjustmentPayload(): AdjustPublishedInboundFeeRequest {
|
||||
return {
|
||||
subtotalAmount: Number(adjustmentForm.subtotalAmount),
|
||||
additionalAmount: Number(adjustmentForm.additionalAmount),
|
||||
reason: adjustmentForm.reason.trim(),
|
||||
}
|
||||
}
|
||||
|
||||
function getAdjustmentKey(payload: AdjustPublishedInboundFeeRequest) {
|
||||
const fingerprint = JSON.stringify(payload)
|
||||
if (!adjustmentIdempotencyKey || fingerprint !== adjustmentFingerprint) {
|
||||
adjustmentFingerprint = fingerprint
|
||||
adjustmentIdempotencyKey = createFeeAdjustmentIdempotencyKey()
|
||||
}
|
||||
return adjustmentIdempotencyKey
|
||||
}
|
||||
|
||||
async function submitAdjustment() {
|
||||
if (!snapshot.value) return
|
||||
const valid = await adjustFormRef.value?.validate().catch(() => false)
|
||||
if (!valid) return
|
||||
const payload = adjustmentPayload()
|
||||
adjustmentError.value = null
|
||||
conflictRefreshed.value = false
|
||||
adjusting.value = true
|
||||
try {
|
||||
const result = await adjustPublishedInboundFee(
|
||||
customerContextId.value,
|
||||
props.inboundId,
|
||||
payload,
|
||||
getAdjustmentKey(payload),
|
||||
)
|
||||
snapshot.value = result
|
||||
adjustOpen.value = false
|
||||
ElMessage.success(`费用已调整为第 ${result.feeVersion} 版`)
|
||||
emit('changed', result)
|
||||
} catch (error) {
|
||||
adjustmentError.value = getApiErrorDetails(error, '费用调整失败,请检查后重试')
|
||||
if (error instanceof ApiResponseError && error.code === 'A0409') {
|
||||
await loadFee({ preserveAdjustment: true })
|
||||
conflictRefreshed.value = true
|
||||
adjustmentFingerprint = ''
|
||||
adjustmentIdempotencyKey = ''
|
||||
}
|
||||
} finally {
|
||||
adjusting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [customerContextId.value, props.inboundId, access.permissionSignature],
|
||||
() => {
|
||||
adjustOpen.value = false
|
||||
snapshot.value = null
|
||||
void loadFee()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
defineExpose({ reload: loadFee })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="inbound-fee-panel" :aria-busy="loading" aria-labelledby="inbound-fee-title">
|
||||
<header class="fee-heading">
|
||||
<div class="fee-heading-copy">
|
||||
<span class="fee-heading-icon" aria-hidden="true"><CircleDollarSign :size="20" /></span>
|
||||
<div>
|
||||
<h3 id="inbound-fee-title">入库费用快照</h3>
|
||||
<p>展示审核完成后锁定的计费依据和对客费用,不在此重复触发试算或发布。</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fee-heading-actions">
|
||||
<el-tooltip content="刷新费用快照" placement="top">
|
||||
<el-button
|
||||
circle
|
||||
aria-label="刷新费用快照"
|
||||
:loading="loading"
|
||||
:disabled="!customerContextId || !inboundId || !canView"
|
||||
@click="loadFee()"
|
||||
><RefreshCw :size="16" /></el-button>
|
||||
</el-tooltip>
|
||||
<el-button
|
||||
v-if="canAdjust && adjustmentEligible"
|
||||
data-testid="adjust-fee-button"
|
||||
type="primary"
|
||||
@click="openAdjustment"
|
||||
><PencilLine :size="16" />调整已发布费用</el-button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<el-alert v-if="!canView" type="warning" :closable="false" show-icon>
|
||||
<template #title>当前账号没有查看入库费用的权限</template>
|
||||
<p>需要权限:firstleg:fba-pricing:view</p>
|
||||
</el-alert>
|
||||
|
||||
<div v-else-if="loading && !snapshot" class="fee-loading" aria-live="polite">
|
||||
<el-skeleton :rows="6" animated />
|
||||
<span class="sr-only">正在加载费用快照</span>
|
||||
</div>
|
||||
|
||||
<el-alert v-else-if="loadError" type="error" :closable="false" show-icon role="alert">
|
||||
<template #title>费用快照加载失败</template>
|
||||
<p>{{ loadError.message }}</p>
|
||||
<ApiErrorMeta :details="loadError" />
|
||||
<el-button size="small" @click="loadFee()">重新加载</el-button>
|
||||
</el-alert>
|
||||
|
||||
<el-empty v-else-if="notFound" :image-size="64" description="尚未生成费用快照">
|
||||
<p class="empty-note">测量审核完成后,系统会自动生成并发布费用。</p>
|
||||
</el-empty>
|
||||
|
||||
<div v-else-if="snapshot" class="fee-content">
|
||||
<div class="fee-summary" aria-label="费用关键结果">
|
||||
<div class="fee-total">
|
||||
<span>对客费用总额 · {{ snapshot.currency }}</span>
|
||||
<strong>{{ formatMoney(snapshot.totalAmount, snapshot.currency) }}</strong>
|
||||
<small>基础运费小计 {{ formatMoney(snapshot.subtotalAmount, snapshot.currency) }} · 附加费 {{ formatMoney(snapshot.additionalAmount, snapshot.currency) }}</small>
|
||||
</div>
|
||||
<div class="fee-summary-item">
|
||||
<span>最终计费量</span>
|
||||
<strong>{{ formatNumber(snapshot.chargeableQuantity, snapshot.roundingScale) }} {{ chargeUnitLabel }}</strong>
|
||||
<small>{{ chargeModeLabel }}</small>
|
||||
</div>
|
||||
<div class="fee-summary-item">
|
||||
<span>快照版本</span>
|
||||
<div class="version-status"><strong>V{{ snapshot.feeVersion }}</strong><el-tag :type="statusInfo.type" effect="light">{{ statusInfo.label }}</el-tag></div>
|
||||
<small>{{ snapshot.feeNo }}</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="calculation-section" aria-labelledby="calculation-title">
|
||||
<div class="section-title">
|
||||
<div><h4 id="calculation-title">计费过程</h4><p>实际量与折算量取大后计费,金额以服务端快照为准。</p></div>
|
||||
<el-tag effect="plain">{{ taxModeLabel(snapshot.taxMode) }}</el-tag>
|
||||
</div>
|
||||
<div class="calculation-flow">
|
||||
<div><span>实际重量</span><strong>{{ formatNumber(snapshot.actualWeightKg, 3) }} KG</strong></div>
|
||||
<div><span>实际体积</span><strong>{{ formatNumber(snapshot.actualVolumeCbm, 4) }} CBM</strong></div>
|
||||
<div><span>折算量</span><strong>{{ formatNumber(snapshot.convertedQuantity, snapshot.roundingScale) }} {{ chargeUnitLabel }}</strong><small>系数 {{ formatNumber(snapshot.conversionFactor, 4) }}</small></div>
|
||||
<div class="is-chargeable"><span>取大后的计费量</span><strong>{{ formatNumber(snapshot.chargeableQuantity, snapshot.roundingScale) }} {{ chargeUnitLabel }}</strong></div>
|
||||
</div>
|
||||
<div class="amount-equation" aria-label="费用计算公式">
|
||||
<span>{{ formatNumber(snapshot.chargeableQuantity, snapshot.roundingScale) }} {{ chargeUnitLabel }}</span>
|
||||
<i>×</i>
|
||||
<span>{{ formatMoney(snapshot.unitPrice, snapshot.currency) }} / {{ chargeUnitLabel }}</span>
|
||||
<i>+</i>
|
||||
<span>附加费 {{ formatMoney(snapshot.additionalAmount, snapshot.currency) }}</span>
|
||||
<i>=</i>
|
||||
<strong>{{ formatMoney(snapshot.totalAmount, snapshot.currency) }}</strong>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="fee-detail-grid">
|
||||
<section aria-labelledby="rule-title">
|
||||
<div class="section-title"><div><h4 id="rule-title">客户计费规则</h4><p>{{ snapshot.ruleCode }} · V{{ snapshot.chargeRuleVersion }}</p></div></div>
|
||||
<dl>
|
||||
<div><dt>客户</dt><dd>{{ snapshot.customerName }}</dd></div>
|
||||
<div><dt>业务类型</dt><dd>{{ snapshot.businessType }}</dd></div>
|
||||
<div><dt>计费口径</dt><dd>{{ chargeModeLabel }}</dd></div>
|
||||
<div><dt>舍入规则</dt><dd>保留 {{ snapshot.roundingScale }} 位,{{ roundingModeLabel(snapshot.roundingMode) }}</dd></div>
|
||||
</dl>
|
||||
</section>
|
||||
<section aria-labelledby="rate-title">
|
||||
<div class="section-title"><div><h4 id="rate-title">船司 FBA 价格</h4><p>{{ snapshot.rateCode }} · V{{ snapshot.carrierRateVersion }}</p></div></div>
|
||||
<dl>
|
||||
<div><dt>起运仓</dt><dd>{{ snapshot.originWarehouseName }}</dd></div>
|
||||
<div><dt>渠道 / 船司</dt><dd>{{ snapshot.channelCode }} / {{ snapshot.carrierName }}</dd></div>
|
||||
<div><dt>FBA 目的仓</dt><dd>{{ snapshot.destinationCode }}</dd></div>
|
||||
<div><dt>价格有效期</dt><dd>{{ formatDateTime(snapshot.rateEffectiveFrom) }} 至 {{ formatDateTime(snapshot.rateEffectiveTo) }}</dd></div>
|
||||
</dl>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="fee-timeline" aria-labelledby="fee-timeline-title">
|
||||
<div class="section-title"><div><h4 id="fee-timeline-title">快照时间</h4><p>每个节点均由后端状态机记录。</p></div></div>
|
||||
<ol>
|
||||
<li><span>试算</span><strong>{{ formatDateTime(snapshot.calculatedAt) }}</strong></li>
|
||||
<li :class="{ pending: !snapshot.confirmedAt }"><span>确认</span><strong>{{ formatDateTime(snapshot.confirmedAt) }}</strong></li>
|
||||
<li :class="{ pending: !snapshot.sentAt }"><span>发布给客户</span><strong>{{ formatDateTime(snapshot.sentAt) }}</strong></li>
|
||||
</ol>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<AppDialog
|
||||
v-model="adjustOpen"
|
||||
title="调整已发布费用"
|
||||
width="min(560px, calc(100vw - 28px))"
|
||||
confirm-text="确认调整并生成新版本"
|
||||
confirm-type="warning"
|
||||
:confirm-loading="adjusting"
|
||||
@confirm="submitAdjustment"
|
||||
>
|
||||
<el-alert type="warning" :closable="false" show-icon title="调整会生成新的已发布版本,不会回退实物入库状态。" />
|
||||
<el-alert v-if="conflictRefreshed" class="adjustment-alert" type="warning" :closable="false" show-icon>
|
||||
<template #title>费用已被其他操作员更新,系统已刷新最新快照</template>
|
||||
<p>当前输入仍保留,请先核对最新版本后再提交。</p>
|
||||
</el-alert>
|
||||
<el-alert v-if="adjustmentError" class="adjustment-alert" type="error" :closable="false" show-icon role="alert">
|
||||
<template #title>费用调整失败</template>
|
||||
<p>{{ adjustmentError.message }}</p>
|
||||
<ApiErrorMeta :details="adjustmentError" />
|
||||
</el-alert>
|
||||
<el-form ref="adjustFormRef" :model="adjustmentForm" :rules="adjustmentRules" label-position="top" class="adjustment-form" :disabled="adjusting">
|
||||
<div class="adjustment-amounts">
|
||||
<el-form-item label="调整后基础运费" prop="subtotalAmount" required>
|
||||
<el-input-number v-model="adjustmentForm.subtotalAmount" aria-label="调整后基础运费" :min="0" :max="999999999999.99" :precision="2" :step="100" controls-position="right" />
|
||||
<span>{{ snapshot?.currency }}</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="调整后附加费" prop="additionalAmount" required>
|
||||
<el-input-number v-model="adjustmentForm.additionalAmount" aria-label="调整后附加费" :min="0" :max="999999999999.99" :precision="2" :step="10" controls-position="right" />
|
||||
<span>{{ snapshot?.currency }}</span>
|
||||
</el-form-item>
|
||||
</div>
|
||||
<div class="adjustment-total"><span>调整后总额</span><strong>{{ formatMoney(adjustmentForm.subtotalAmount + adjustmentForm.additionalAmount, snapshot?.currency) }}</strong></div>
|
||||
<el-form-item label="调整原因" prop="reason" required>
|
||||
<el-input v-model="adjustmentForm.reason" type="textarea" :rows="4" maxlength="500" show-word-limit placeholder="说明价格变更、补充费用或修正原因,记录将进入审计历史" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</AppDialog>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.inbound-fee-panel { min-width: 0; color: var(--cc-text); }
|
||||
.fee-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; padding-bottom: 16px; }
|
||||
.fee-heading-copy { display: flex; min-width: 0; align-items: flex-start; gap: 12px; }
|
||||
.fee-heading-icon { display: grid; width: 38px; height: 38px; flex: 0 0 38px; place-items: center; border-radius: 10px; background: #eef4ff; color: var(--cc-primary); }
|
||||
.fee-heading h3, .section-title h4 { margin: 0; }
|
||||
.fee-heading h3 { font-size: 16px; }
|
||||
.fee-heading p, .section-title p { margin: 5px 0 0; color: var(--cc-muted); font-size: 12px; line-height: 1.55; }
|
||||
.fee-heading-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; }
|
||||
.fee-heading-actions .el-button { margin-left: 0; }
|
||||
.fee-loading { padding: 20px 0; }
|
||||
.inbound-fee-panel :deep(.el-alert p) { margin: 5px 0 0; line-height: 1.55; }
|
||||
.inbound-fee-panel :deep(.el-alert .el-button) { margin-top: 8px; }
|
||||
.empty-note { margin: 0; color: var(--cc-muted); font-size: 12px; }
|
||||
.fee-content { border-top: 1px solid var(--cc-border); }
|
||||
.fee-summary { display: grid; grid-template-columns: minmax(260px, 1.35fr) repeat(2, minmax(180px, 1fr)); border-bottom: 1px solid var(--cc-border); background: #f8fafc; }
|
||||
.fee-total, .fee-summary-item { display: flex; min-width: 0; flex-direction: column; justify-content: center; gap: 6px; padding: 20px; }
|
||||
.fee-summary-item { border-left: 1px solid var(--cc-border); }
|
||||
.fee-summary span, .fee-summary small { color: var(--cc-muted); font-size: 11px; }
|
||||
.fee-summary strong { font-size: 18px; line-height: 1.25; overflow-wrap: anywhere; }
|
||||
.fee-total strong { color: #0f5c49; font-size: 27px; letter-spacing: -.02em; }
|
||||
.version-status { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
|
||||
.calculation-section, .fee-timeline { padding: 22px 0; border-bottom: 1px solid var(--cc-border); }
|
||||
.section-title { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; }
|
||||
.section-title h4 { font-size: 14px; }
|
||||
.calculation-flow { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); margin-top: 16px; border: 1px solid var(--cc-border); border-radius: 12px; overflow: hidden; }
|
||||
.calculation-flow > div { display: flex; min-width: 0; min-height: 88px; flex-direction: column; justify-content: center; gap: 6px; padding: 14px 16px; border-right: 1px solid var(--cc-border); }
|
||||
.calculation-flow > div:last-child { border-right: 0; }
|
||||
.calculation-flow span, .calculation-flow small { color: var(--cc-muted); font-size: 11px; }
|
||||
.calculation-flow strong { font-size: 15px; overflow-wrap: anywhere; }
|
||||
.calculation-flow .is-chargeable { background: #f0fdf9; }
|
||||
.calculation-flow .is-chargeable strong { color: #087d70; }
|
||||
.amount-equation { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; margin-top: 12px; padding: 12px 14px; border-radius: 8px; background: #f8fafc; color: #475467; font-size: 12px; }
|
||||
.amount-equation i { color: #98a2b3; font-style: normal; }
|
||||
.amount-equation strong { color: var(--cc-text); font-size: 15px; }
|
||||
.fee-detail-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); border-bottom: 1px solid var(--cc-border); }
|
||||
.fee-detail-grid > section { min-width: 0; padding: 22px 20px 22px 0; }
|
||||
.fee-detail-grid > section + section { padding-right: 0; padding-left: 20px; border-left: 1px solid var(--cc-border); }
|
||||
.fee-detail-grid dl { margin: 14px 0 0; }
|
||||
.fee-detail-grid dl > div { display: grid; grid-template-columns: 104px minmax(0, 1fr); gap: 12px; padding: 9px 0; border-top: 1px solid #edf0f4; font-size: 12px; }
|
||||
.fee-detail-grid dt { color: var(--cc-muted); }
|
||||
.fee-detail-grid dd { min-width: 0; margin: 0; font-weight: 600; overflow-wrap: anywhere; }
|
||||
.fee-timeline { border-bottom: 0; }
|
||||
.fee-timeline ol { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 0; margin: 18px 0 0; padding: 0; list-style: none; }
|
||||
.fee-timeline li { position: relative; display: flex; min-width: 0; flex-direction: column; gap: 5px; padding: 0 18px 0 20px; }
|
||||
.fee-timeline li::before { position: absolute; top: 4px; left: 0; width: 9px; height: 9px; border-radius: 50%; background: #12b76a; content: ''; }
|
||||
.fee-timeline li::after { position: absolute; top: 8px; right: 0; left: 10px; z-index: -1; height: 1px; background: var(--cc-border); content: ''; }
|
||||
.fee-timeline li:last-child::after { display: none; }
|
||||
.fee-timeline li.pending::before { background: #98a2b3; }
|
||||
.fee-timeline span { color: var(--cc-muted); font-size: 11px; }
|
||||
.fee-timeline strong { font-size: 12px; overflow-wrap: anywhere; }
|
||||
.adjustment-alert { margin-top: 12px; }
|
||||
.adjustment-form { margin-top: 18px; }
|
||||
.adjustment-amounts { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
|
||||
.adjustment-amounts :deep(.el-form-item__content) { flex-wrap: nowrap; }
|
||||
.adjustment-amounts :deep(.el-input-number) { width: 100%; }
|
||||
.adjustment-amounts :deep(.el-form-item__content > span) { margin-left: 8px; color: var(--cc-muted); font-size: 12px; }
|
||||
.adjustment-total { display: flex; align-items: baseline; justify-content: space-between; gap: 16px; margin: 0 0 18px; padding: 13px 14px; border-radius: 8px; background: #f8fafc; }
|
||||
.adjustment-total span { color: var(--cc-muted); font-size: 12px; }
|
||||
.adjustment-total strong { font-size: 18px; }
|
||||
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap; }
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.fee-summary { grid-template-columns: 1fr 1fr; }
|
||||
.fee-total { grid-column: 1 / -1; }
|
||||
.fee-summary-item:first-of-type { border-left: 0; }
|
||||
.calculation-flow { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.calculation-flow > div:nth-child(2) { border-right: 0; }
|
||||
.calculation-flow > div:nth-child(-n+2) { border-bottom: 1px solid var(--cc-border); }
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.fee-heading { align-items: stretch; flex-direction: column; }
|
||||
.fee-heading-actions { justify-content: flex-start; }
|
||||
.fee-heading-actions .el-button:not(.is-circle) { flex: 1; min-height: 44px; }
|
||||
.fee-summary, .calculation-flow, .fee-detail-grid, .fee-timeline ol, .adjustment-amounts { grid-template-columns: 1fr; }
|
||||
.fee-summary-item { border-top: 1px solid var(--cc-border); border-left: 0; }
|
||||
.calculation-flow > div { min-height: 76px; border-right: 0; border-bottom: 1px solid var(--cc-border); }
|
||||
.calculation-flow > div:last-child { border-bottom: 0; }
|
||||
.fee-detail-grid > section, .fee-detail-grid > section + section { padding: 20px 0; border-left: 0; }
|
||||
.fee-detail-grid > section + section { border-top: 1px solid var(--cc-border); }
|
||||
.fee-timeline ol { gap: 16px; }
|
||||
.fee-timeline li { padding-left: 20px; }
|
||||
.fee-timeline li::after { display: none; }
|
||||
}
|
||||
|
||||
@media (max-width: 360px) {
|
||||
.fee-heading-copy { gap: 8px; }
|
||||
.fee-heading-icon { width: 34px; height: 34px; flex-basis: 34px; }
|
||||
.fee-total, .fee-summary-item { padding: 16px 12px; }
|
||||
.fee-total strong { font-size: 22px; }
|
||||
.fee-detail-grid dl > div { grid-template-columns: 1fr; gap: 4px; }
|
||||
.amount-equation { align-items: flex-start; flex-direction: column; }
|
||||
.amount-equation i { display: none; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,73 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ApiResponseError, http } from '@/shared/api/http'
|
||||
import {
|
||||
adjustPublishedInboundFee,
|
||||
createFeeAdjustmentIdempotencyKey,
|
||||
getLatestInboundFee,
|
||||
} from './api'
|
||||
|
||||
vi.mock('@/shared/api/http', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/shared/api/http')>('@/shared/api/http')
|
||||
return { ...actual, http: { get: vi.fn(), put: vi.fn() } }
|
||||
})
|
||||
|
||||
const snapshot = {
|
||||
feeId: '9007199254740993',
|
||||
feeNo: 'FBAF-88-2',
|
||||
feeVersion: 2,
|
||||
feeStatus: 'SENT',
|
||||
inboundId: '88',
|
||||
totalAmount: 1280,
|
||||
}
|
||||
|
||||
describe('inbound fee API', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it('loads the latest immutable fee snapshot with string IDs', async () => {
|
||||
vi.mocked(http.get).mockResolvedValue({ code: '200', message: 'success', data: snapshot })
|
||||
|
||||
await expect(getLatestInboundFee('10001', '88')).resolves.toEqual(snapshot)
|
||||
expect(http.get).toHaveBeenCalledWith('/platform/v1/inbounds/88/fees/latest', {
|
||||
params: { tenantId: '10001' },
|
||||
})
|
||||
})
|
||||
|
||||
it('adjusts a published snapshot with the required idempotency key', async () => {
|
||||
vi.mocked(http.put).mockResolvedValue({ code: '200', message: 'success', data: { ...snapshot, feeVersion: 3 } })
|
||||
const payload = { subtotalAmount: 1200, additionalAmount: 80, reason: '补充已确认的附加费' }
|
||||
|
||||
await expect(adjustPublishedInboundFee('10001', '88', payload, 'fee-adjust-1'))
|
||||
.resolves.toMatchObject({ feeVersion: 3 })
|
||||
expect(http.put).toHaveBeenCalledWith(
|
||||
'/platform/v1/inbounds/88/fees/published',
|
||||
payload,
|
||||
{ params: { tenantId: '10001' }, headers: { 'Idempotency-Key': 'fee-adjust-1' } },
|
||||
)
|
||||
})
|
||||
|
||||
it('preserves backend conflict metadata for stale-version recovery', async () => {
|
||||
vi.mocked(http.put).mockResolvedValue({
|
||||
code: 'A0409', message: '费用已被其他操作员修改,请刷新后重试', data: null,
|
||||
requestId: 'request-fee-409', traceId: 'trace-fee-409',
|
||||
})
|
||||
|
||||
const error = await adjustPublishedInboundFee(
|
||||
'10001',
|
||||
'88',
|
||||
{ subtotalAmount: 1200, additionalAmount: 80, reason: '价格修正' },
|
||||
'fee-adjust-2',
|
||||
).catch((reason: unknown) => reason)
|
||||
|
||||
expect(error).toBeInstanceOf(ApiResponseError)
|
||||
expect(error).toMatchObject({ code: 'A0409', requestId: 'request-fee-409', traceId: 'trace-fee-409' })
|
||||
})
|
||||
|
||||
it('creates bounded unique keys for adjustment retries', () => {
|
||||
const first = createFeeAdjustmentIdempotencyKey()
|
||||
const second = createFeeAdjustmentIdempotencyKey()
|
||||
expect(first).toMatch(/^fee-adjust-/)
|
||||
expect(first.length).toBeLessThanOrEqual(128)
|
||||
expect(second).not.toBe(first)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
import type { AdjustPublishedInboundFeeRequest, InboundFeeSnapshot } from './types'
|
||||
|
||||
function feeBasePath(inboundId: string) {
|
||||
return `/platform/v1/inbounds/${encodeURIComponent(inboundId)}/fees`
|
||||
}
|
||||
|
||||
export async function getLatestInboundFee(tenantId: string, inboundId: string) {
|
||||
const response = await http.get<unknown, ApiEnvelope<InboundFeeSnapshot>>(
|
||||
`${feeBasePath(inboundId)}/latest`,
|
||||
{ params: { tenantId } },
|
||||
)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function adjustPublishedInboundFee(
|
||||
tenantId: string,
|
||||
inboundId: string,
|
||||
payload: AdjustPublishedInboundFeeRequest,
|
||||
idempotencyKey: string,
|
||||
) {
|
||||
const response = await http.put<unknown, ApiEnvelope<InboundFeeSnapshot>>(
|
||||
`${feeBasePath(inboundId)}/published`,
|
||||
payload,
|
||||
{ params: { tenantId }, headers: { 'Idempotency-Key': idempotencyKey } },
|
||||
)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export function createFeeAdjustmentIdempotencyKey() {
|
||||
const uuid = globalThis.crypto?.randomUUID?.()
|
||||
if (uuid) return `fee-adjust-${uuid}`
|
||||
return `fee-adjust-${Date.now()}-${Math.random().toString(36).slice(2, 12)}`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
export { default as InboundFeePanel } from './InboundFeePanel.vue'
|
||||
export {
|
||||
adjustPublishedInboundFee,
|
||||
createFeeAdjustmentIdempotencyKey,
|
||||
getLatestInboundFee,
|
||||
} from './api'
|
||||
export type {
|
||||
AdjustPublishedInboundFeeRequest,
|
||||
InboundChargeMode,
|
||||
InboundFeeSnapshot,
|
||||
InboundFeeStatus,
|
||||
} from './types'
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
export type InboundFeeStatus = 'CALCULATED' | 'CONFIRMED' | 'SENT' | 'SUPERSEDED' | string
|
||||
export type InboundChargeMode = 'KG' | 'CBM' | string
|
||||
|
||||
export interface InboundFeeSnapshot {
|
||||
feeId: string
|
||||
feeNo: string
|
||||
feeVersion: number
|
||||
feeStatus: InboundFeeStatus
|
||||
inboundId: string
|
||||
inboundCode: string | null
|
||||
chargeRuleId: string
|
||||
ruleCode: string
|
||||
chargeRuleVersion: number
|
||||
carrierRateId: string
|
||||
rateCode: string
|
||||
carrierRateVersion: number
|
||||
customerId: string
|
||||
customerName: string
|
||||
originWarehouseId: string
|
||||
originWarehouseName: string
|
||||
businessType: string
|
||||
channelCode: string
|
||||
carrierId: string
|
||||
carrierName: string
|
||||
destinationCode: string
|
||||
taxMode: string
|
||||
chargeMode: InboundChargeMode
|
||||
conversionFactor: number
|
||||
roundingScale: number
|
||||
roundingMode: string
|
||||
actualWeightKg: number
|
||||
actualVolumeCbm: number
|
||||
convertedQuantity: number
|
||||
chargeableQuantity: number
|
||||
chargeUnit: string
|
||||
unitPrice: number | null
|
||||
subtotalAmount: number
|
||||
additionalAmount: number
|
||||
totalAmount: number
|
||||
currency: string
|
||||
rateEffectiveFrom: string
|
||||
rateEffectiveTo: string | null
|
||||
calculatedAt: string
|
||||
confirmedAt: string | null
|
||||
sentAt: string | null
|
||||
nextProcessStatus: string | null
|
||||
}
|
||||
|
||||
export interface AdjustPublishedInboundFeeRequest {
|
||||
subtotalAmount: number
|
||||
additionalAmount: number
|
||||
reason: string
|
||||
}
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
import ElementPlus, { ElMessageBox } from 'element-plus'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
|
||||
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ApiResponseError } from '@/shared/api/http'
|
||||
import { useAuthStore } from '@/core/auth/auth'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import {
|
||||
getInboundMeasurement,
|
||||
saveInboundMeasurementDraft,
|
||||
submitInboundMeasurement,
|
||||
} from './api'
|
||||
import type { InboundMeasurementDetail } from './types'
|
||||
import InboundMeasurementPanel from './InboundMeasurementPanel.vue'
|
||||
|
||||
vi.mock('./api', () => ({
|
||||
getInboundMeasurement: vi.fn(),
|
||||
saveInboundMeasurementDraft: vi.fn(),
|
||||
submitInboundMeasurement: vi.fn(),
|
||||
}))
|
||||
|
||||
class ResizeObserverStub implements ResizeObserver {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
|
||||
function createMemoryStorage(): Storage {
|
||||
const values = new Map<string, string>()
|
||||
return {
|
||||
get length() { return values.size },
|
||||
clear: () => values.clear(),
|
||||
getItem: (key) => values.get(key) ?? null,
|
||||
key: (index) => [...values.keys()][index] ?? null,
|
||||
removeItem: (key) => values.delete(key),
|
||||
setItem: (key, value) => values.set(key, value),
|
||||
}
|
||||
}
|
||||
|
||||
function measurement(overrides: Partial<InboundMeasurementDetail> = {}): InboundMeasurementDetail {
|
||||
return {
|
||||
inboundId: '7001',
|
||||
inboundCode: 'RK202608040001',
|
||||
companyName: '测试企业',
|
||||
channelCode: 'SEA-FBA',
|
||||
channelName: '美西海运',
|
||||
processStatus: 'MEASURING',
|
||||
measureReviewStatus: null,
|
||||
recommendedPalletType: 'STANDARD',
|
||||
reviewReason: null,
|
||||
reviewerId: null,
|
||||
reviewerName: null,
|
||||
measurementId: '9001',
|
||||
sourceType: 'THIRD_PARTY',
|
||||
measurementStatus: 'RECEIVED',
|
||||
version: 2,
|
||||
originalDocumentNo: 'DOC-001',
|
||||
originalGrossWeightKg: 118.5,
|
||||
originalVolumeCbm: 0.8,
|
||||
actualPackageCount: 10,
|
||||
actualGrossWeightKg: 120,
|
||||
actualVolumeCbm: 0.6,
|
||||
volumeWeightKg: 100.2,
|
||||
actualGoodsName: '蓝牙音箱',
|
||||
items: [{
|
||||
id: '9101',
|
||||
itemNo: '001',
|
||||
boxSpecification: '50×40×30cm',
|
||||
packageCount: 10,
|
||||
lengthCm: 50,
|
||||
widthCm: 40,
|
||||
heightCm: 30,
|
||||
girthCm: 190,
|
||||
unitWeightKg: 12,
|
||||
unitVolumeCbm: 0.06,
|
||||
totalWeightKg: 120,
|
||||
totalVolumeCbm: 0.6,
|
||||
volumeWeightKg: 100.2,
|
||||
intelligentImageFileId: '88001',
|
||||
overLimitWarning: null,
|
||||
outerLengthCm: null,
|
||||
outerWidthCm: null,
|
||||
outerHeightCm: null,
|
||||
remark: '设备自动回传',
|
||||
}],
|
||||
weightResults: [],
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
async function mountPanel(permissions = [
|
||||
'platform:firstleg:inbound:measure:view',
|
||||
'platform:firstleg:inbound:measure:edit',
|
||||
'platform:firstleg:inbound:measure:submit',
|
||||
]) {
|
||||
const pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
const auth = useAuthStore()
|
||||
auth.token = 'token'
|
||||
auth.permissions = permissions
|
||||
useAccessPreviewStore().initializeAfterLogin()
|
||||
const wrapper = mount(InboundMeasurementPanel, {
|
||||
props: { customerId: '10001', inboundId: '7001' },
|
||||
attachTo: document.body,
|
||||
global: { plugins: [pinia, ElementPlus] },
|
||||
})
|
||||
await flushPromises()
|
||||
return wrapper
|
||||
}
|
||||
|
||||
enableAutoUnmount(afterEach)
|
||||
afterAll(() => vi.unstubAllGlobals())
|
||||
|
||||
describe('InboundMeasurementPanel', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
|
||||
vi.stubGlobal('localStorage', createMemoryStorage())
|
||||
vi.stubGlobal('sessionStorage', createMemoryStorage())
|
||||
document.body.innerHTML = ''
|
||||
vi.mocked(getInboundMeasurement).mockReset().mockResolvedValue(measurement())
|
||||
vi.mocked(saveInboundMeasurementDraft).mockReset().mockResolvedValue({ measurementId: '9001', version: 3, status: 'DRAFT' })
|
||||
vi.mocked(submitInboundMeasurement).mockReset().mockResolvedValue({
|
||||
measurementId: '9001', version: 2, processStatus: 'MEASURE_PENDING', measureReviewStatus: 'PENDING',
|
||||
recommendedPalletType: 'STANDARD', feeId: null, feeNo: null, feeStatus: null,
|
||||
})
|
||||
vi.spyOn(ElMessageBox, 'confirm').mockResolvedValue(undefined as never)
|
||||
})
|
||||
|
||||
it('loads third-party facts and renders server-calculated values separately', async () => {
|
||||
const wrapper = await mountPanel()
|
||||
|
||||
expect(getInboundMeasurement).toHaveBeenCalledWith('10001', '7001')
|
||||
expect(wrapper.text()).toContain('第三方设备')
|
||||
expect(wrapper.text()).toContain('RK202608040001')
|
||||
expect(wrapper.text()).toContain('箱规周长')
|
||||
expect(wrapper.text()).toContain('190 cm')
|
||||
expect(wrapper.text()).toContain('0.6 CBM')
|
||||
expect((wrapper.find('input[placeholder="请输入本箱规的补充说明"]').element as HTMLInputElement).value).toBe('设备自动回传')
|
||||
})
|
||||
|
||||
it('follows the prototype grouping, labels, and editable field order', async () => {
|
||||
const wrapper = await mountPanel()
|
||||
const text = wrapper.text()
|
||||
|
||||
expect(text).toContain('原单数据')
|
||||
expect(text).toContain('入库单测量数据明细')
|
||||
expect(text).toContain('实测总箱数')
|
||||
expect(text).toContain('实测总毛重')
|
||||
expect(text).toContain('实测总体积')
|
||||
expect(text).toContain('实测总材积重')
|
||||
expect(text).not.toContain('租户')
|
||||
|
||||
const labels = wrapper.findAll('.measurement-item__grid label > span').map((label) => label.text())
|
||||
expect(labels.slice(0, 8)).toEqual([
|
||||
'明细编号 *',
|
||||
'箱规 *',
|
||||
'箱规长(cm)*',
|
||||
'箱规宽(cm)*',
|
||||
'箱规高(cm)*',
|
||||
'箱数 *',
|
||||
'每箱毛重(kg)*',
|
||||
'智能图片文件 ID',
|
||||
])
|
||||
})
|
||||
|
||||
it('reuses the established first-leg detail surface and status patterns', async () => {
|
||||
const wrapper = await mountPanel()
|
||||
|
||||
expect(wrapper.classes()).toContain('surface-panel')
|
||||
expect(wrapper.find('.measurement-panel__heading').classes()).toContain('panel-heading')
|
||||
expect(wrapper.find('.status-badge').text()).toBe('测量中')
|
||||
})
|
||||
|
||||
it('saves only editable raw fields with the current measurement version', async () => {
|
||||
const wrapper = await mountPanel()
|
||||
await wrapper.find('input[placeholder="请输入实际货物名称"]').setValue('智能音箱')
|
||||
const save = wrapper.findAll('button').find((button) => button.text().includes('保存草稿'))
|
||||
await save?.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(saveInboundMeasurementDraft).toHaveBeenCalledOnce()
|
||||
expect(saveInboundMeasurementDraft).toHaveBeenCalledWith('10001', '7001', expect.objectContaining({
|
||||
measurementId: '9001',
|
||||
version: 2,
|
||||
actualGoodsName: '智能音箱',
|
||||
items: [expect.objectContaining({
|
||||
itemNo: '001', packageCount: 10, lengthCm: 50, intelligentImageFileId: '88001',
|
||||
})],
|
||||
}))
|
||||
const payload = vi.mocked(saveInboundMeasurementDraft).mock.calls[0][2]
|
||||
expect(payload.items[0]).not.toHaveProperty('totalVolumeCbm')
|
||||
})
|
||||
|
||||
it('preserves the edited form when the backend reports a version conflict', async () => {
|
||||
vi.mocked(saveInboundMeasurementDraft).mockRejectedValueOnce(
|
||||
new ApiResponseError('数据版本已变化,请刷新后重试', 'A0409', 200, null, 'req-conflict', 'trace-conflict'),
|
||||
)
|
||||
const wrapper = await mountPanel()
|
||||
const goodsInput = wrapper.find('input[placeholder="请输入实际货物名称"]')
|
||||
await goodsInput.setValue('保留我的修改')
|
||||
const save = wrapper.findAll('button').find((button) => button.text().includes('保存草稿'))
|
||||
await save?.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('数据版本已变化,当前修改尚未丢失')
|
||||
expect(wrapper.text()).toContain('req-conflict')
|
||||
expect((goodsInput.element as HTMLInputElement).value).toBe('保留我的修改')
|
||||
expect(getInboundMeasurement).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('requires the dirty draft to be saved before submitting for review', async () => {
|
||||
const wrapper = await mountPanel()
|
||||
await wrapper.find('input[placeholder="请输入实际货物名称"]').setValue('尚未保存')
|
||||
const submit = wrapper.findAll('button').find((button) => button.text().includes('提交审核'))
|
||||
await submit?.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(submitInboundMeasurement).not.toHaveBeenCalled()
|
||||
expect(ElMessageBox.confirm).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('submits the saved version and emits changed after confirmation', async () => {
|
||||
const wrapper = await mountPanel()
|
||||
const submit = wrapper.findAll('button').find((button) => button.text().includes('提交审核'))
|
||||
await submit?.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(ElMessageBox.confirm).toHaveBeenCalledWith(
|
||||
expect.stringContaining('准备托盘与库位占用'),
|
||||
'提交测量审核',
|
||||
expect.objectContaining({ confirmButtonText: '确认提交' }),
|
||||
)
|
||||
expect(submitInboundMeasurement).toHaveBeenCalledWith('10001', '7001', { measurementId: '9001', version: 2 })
|
||||
expect(wrapper.emitted('changed')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('clears the previous customer context before loading the next one', async () => {
|
||||
const wrapper = await mountPanel()
|
||||
vi.mocked(getInboundMeasurement).mockResolvedValueOnce(measurement({ inboundId: '7002', inboundCode: 'RK202608040002', companyName: '第二企业' }))
|
||||
|
||||
await wrapper.setProps({ customerId: '10002', inboundId: '7002' })
|
||||
await flushPromises()
|
||||
|
||||
expect(getInboundMeasurement).toHaveBeenLastCalledWith('10002', '7002')
|
||||
expect(wrapper.text()).toContain('RK202608040002')
|
||||
expect(wrapper.text()).not.toContain('测试企业')
|
||||
})
|
||||
|
||||
it('renders a permission-specific read-only state without requesting data', async () => {
|
||||
const wrapper = await mountPanel([])
|
||||
|
||||
expect(wrapper.text()).toContain('无测量查看权限')
|
||||
expect(getInboundMeasurement).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,635 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { AlertTriangle, Plus, RefreshCw, Ruler, Save, Send, Trash2 } from '@lucide/vue'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import StatusBadge from '@/shared/components/StatusBadge.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { getInboundMeasurement, saveInboundMeasurementDraft, submitInboundMeasurement } from './api'
|
||||
import type {
|
||||
InboundMeasurementDetail,
|
||||
InboundMeasurementItem,
|
||||
SaveInboundMeasurementDraftRequest,
|
||||
SaveInboundMeasurementItemRequest,
|
||||
} from './types'
|
||||
|
||||
const props = defineProps<{
|
||||
customerId?: string
|
||||
tenantId?: string
|
||||
inboundId: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
changed: []
|
||||
}>()
|
||||
|
||||
interface EditableMeasurementItem extends SaveInboundMeasurementItemRequest {
|
||||
clientKey: string
|
||||
serverValues: Pick<InboundMeasurementItem, 'girthCm' | 'unitVolumeCbm' | 'totalWeightKg' | 'totalVolumeCbm' | 'volumeWeightKg'>
|
||||
}
|
||||
|
||||
interface MeasurementFormModel {
|
||||
measurementId: string | null
|
||||
version: number
|
||||
originalDocumentNo: string
|
||||
originalGrossWeightKg: number | null
|
||||
originalVolumeCbm: number | null
|
||||
actualGoodsName: string
|
||||
items: EditableMeasurementItem[]
|
||||
}
|
||||
|
||||
const access = useAccessPreviewStore()
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const submitting = ref(false)
|
||||
const detail = ref<InboundMeasurementDetail | null>(null)
|
||||
const loadError = ref<ApiErrorDetails | null>(null)
|
||||
const operationError = ref<ApiErrorDetails | null>(null)
|
||||
const conflictDetected = ref(false)
|
||||
const validationErrors = ref<Record<string, string[]>>({})
|
||||
const baseline = ref('')
|
||||
let requestSequence = 0
|
||||
let itemSequence = 0
|
||||
|
||||
const form = reactive<MeasurementFormModel>({
|
||||
measurementId: null,
|
||||
version: 0,
|
||||
originalDocumentNo: '',
|
||||
originalGrossWeightKg: null,
|
||||
originalVolumeCbm: null,
|
||||
actualGoodsName: '',
|
||||
items: [],
|
||||
})
|
||||
|
||||
const canView = computed(() => access.can('platform:firstleg:inbound:measure:view'))
|
||||
const canEdit = computed(() => access.can('platform:firstleg:inbound:measure:edit'))
|
||||
const canSubmit = computed(() => access.can('platform:firstleg:inbound:measure:submit'))
|
||||
const customerId = computed(() => props.customerId || props.tenantId || '')
|
||||
const hasContext = computed(() => Boolean(customerId.value && props.inboundId))
|
||||
const editableProcess = computed(() => ['WAIT_MEASURE', 'MEASURING', 'MEASURE_REJECTED'].includes(detail.value?.processStatus || ''))
|
||||
const mayMaintain = computed(() => canEdit.value && editableProcess.value && !loading.value && !submitting.value)
|
||||
const maySubmit = computed(() => canSubmit.value && editableProcess.value && Boolean(form.measurementId) && !loading.value && !saving.value)
|
||||
const snapshot = computed(() => JSON.stringify(form))
|
||||
const isDirty = computed(() => Boolean(detail.value) && snapshot.value !== baseline.value)
|
||||
const registrationOpen = computed(() => hasContext.value && canView.value && Boolean(detail.value))
|
||||
const requiredPermission = computed(() => canEdit.value ? 'platform:firstleg:inbound:measure:edit' : null)
|
||||
const sourceLabel = computed(() => ({ THIRD_PARTY: '第三方设备', MANUAL: '平台人工', OPENAPI: '第三方设备' }[detail.value?.sourceType || ''] || detail.value?.sourceType || '尚未测量'))
|
||||
const sourceTagType = computed(() => detail.value?.sourceType === 'MANUAL' ? 'info' : 'primary')
|
||||
|
||||
const { captureBaseline } = useUnsavedWorkRegistration({
|
||||
key: 'p2-inbound-measurement-editor',
|
||||
label: computed(() => `入库单 ${detail.value?.inboundCode || props.inboundId} 的测量草稿`),
|
||||
isOpen: registrationOpen,
|
||||
model: form,
|
||||
requiredPermission,
|
||||
})
|
||||
|
||||
function optionalText(value: string) {
|
||||
const normalized = value.trim()
|
||||
return normalized || null
|
||||
}
|
||||
|
||||
function nextItemKey() {
|
||||
itemSequence += 1
|
||||
return `measurement-item-${itemSequence}`
|
||||
}
|
||||
|
||||
function emptyItem(): EditableMeasurementItem {
|
||||
return {
|
||||
clientKey: nextItemKey(),
|
||||
itemNo: '',
|
||||
boxSpecification: '',
|
||||
packageCount: 1,
|
||||
lengthCm: 0,
|
||||
widthCm: 0,
|
||||
heightCm: 0,
|
||||
unitWeightKg: 0,
|
||||
intelligentImageFileId: null,
|
||||
overLimitWarning: null,
|
||||
outerLengthCm: null,
|
||||
outerWidthCm: null,
|
||||
outerHeightCm: null,
|
||||
remark: null,
|
||||
serverValues: { girthCm: null, unitVolumeCbm: null, totalWeightKg: null, totalVolumeCbm: null, volumeWeightKg: null },
|
||||
}
|
||||
}
|
||||
|
||||
function editableItem(item: InboundMeasurementItem): EditableMeasurementItem {
|
||||
return {
|
||||
clientKey: nextItemKey(),
|
||||
itemNo: item.itemNo,
|
||||
boxSpecification: item.boxSpecification,
|
||||
packageCount: item.packageCount,
|
||||
lengthCm: item.lengthCm,
|
||||
widthCm: item.widthCm,
|
||||
heightCm: item.heightCm,
|
||||
unitWeightKg: item.unitWeightKg,
|
||||
intelligentImageFileId: item.intelligentImageFileId == null ? null : String(item.intelligentImageFileId),
|
||||
overLimitWarning: item.overLimitWarning,
|
||||
outerLengthCm: item.outerLengthCm,
|
||||
outerWidthCm: item.outerWidthCm,
|
||||
outerHeightCm: item.outerHeightCm,
|
||||
remark: item.remark,
|
||||
serverValues: {
|
||||
girthCm: item.girthCm,
|
||||
unitVolumeCbm: item.unitVolumeCbm,
|
||||
totalWeightKg: item.totalWeightKg,
|
||||
totalVolumeCbm: item.totalVolumeCbm,
|
||||
volumeWeightKg: item.volumeWeightKg,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function clearForm() {
|
||||
form.measurementId = null
|
||||
form.version = 0
|
||||
form.originalDocumentNo = ''
|
||||
form.originalGrossWeightKg = null
|
||||
form.originalVolumeCbm = null
|
||||
form.actualGoodsName = ''
|
||||
form.items.splice(0)
|
||||
baseline.value = JSON.stringify(form)
|
||||
captureBaseline()
|
||||
}
|
||||
|
||||
function applyDetail(resource: InboundMeasurementDetail) {
|
||||
detail.value = resource
|
||||
form.measurementId = resource.measurementId == null ? null : String(resource.measurementId)
|
||||
form.version = resource.version ?? 0
|
||||
form.originalDocumentNo = resource.originalDocumentNo || ''
|
||||
form.originalGrossWeightKg = resource.originalGrossWeightKg
|
||||
form.originalVolumeCbm = resource.originalVolumeCbm
|
||||
form.actualGoodsName = resource.actualGoodsName || ''
|
||||
form.items.splice(0, form.items.length, ...resource.items.map(editableItem))
|
||||
baseline.value = JSON.stringify(form)
|
||||
captureBaseline()
|
||||
validationErrors.value = {}
|
||||
conflictDetected.value = false
|
||||
}
|
||||
|
||||
async function loadMeasurement() {
|
||||
if (!hasContext.value || !canView.value) return
|
||||
const sequence = ++requestSequence
|
||||
loading.value = true
|
||||
loadError.value = null
|
||||
operationError.value = null
|
||||
try {
|
||||
const resource = await getInboundMeasurement(customerId.value, props.inboundId)
|
||||
if (sequence !== requestSequence) return
|
||||
applyDetail(resource)
|
||||
} catch (error) {
|
||||
if (sequence !== requestSequence) return
|
||||
detail.value = null
|
||||
clearForm()
|
||||
loadError.value = getApiErrorDetails(error, '测量数据加载失败,请稍后重试')
|
||||
} finally {
|
||||
if (sequence === requestSequence) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function reloadLatest() {
|
||||
if (isDirty.value) {
|
||||
try {
|
||||
await ElMessageBox.confirm('加载最新版本会丢弃当前尚未保存的修改。确定继续吗?', '重新加载测量数据', {
|
||||
type: 'warning',
|
||||
confirmButtonText: '加载最新版本',
|
||||
cancelButtonText: '继续编辑',
|
||||
})
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
await loadMeasurement()
|
||||
}
|
||||
|
||||
function addItem() {
|
||||
if (form.items.length >= 500) {
|
||||
ElMessage.warning('单个测量批次最多包含 500 条箱规')
|
||||
return
|
||||
}
|
||||
form.items.push(emptyItem())
|
||||
}
|
||||
|
||||
async function removeItem(index: number) {
|
||||
if (saving.value || submitting.value) return
|
||||
if (form.items.length === 1) {
|
||||
ElMessage.warning('测量草稿至少需要一条箱规')
|
||||
return
|
||||
}
|
||||
form.items.splice(index, 1)
|
||||
}
|
||||
|
||||
function isPositive(value: number | null) {
|
||||
return value != null && Number.isFinite(value) && value > 0
|
||||
}
|
||||
|
||||
function isNonNegative(value: number | null) {
|
||||
return value == null || (Number.isFinite(value) && value >= 0)
|
||||
}
|
||||
|
||||
function validateDraft() {
|
||||
const errors: Record<string, string[]> = {}
|
||||
const general: string[] = []
|
||||
if (!form.items.length) general.push('请至少添加一条箱规')
|
||||
if (form.items.length > 500) general.push('单个测量批次最多包含 500 条箱规')
|
||||
if (form.originalDocumentNo.trim().length > 64) general.push('原始单据号不能超过 64 个字符')
|
||||
if (form.actualGoodsName.trim().length > 255) general.push('实际品名不能超过 255 个字符')
|
||||
if (!isNonNegative(form.originalGrossWeightKg)) general.push('申报毛重不能小于 0')
|
||||
if (!isNonNegative(form.originalVolumeCbm)) general.push('申报体积不能小于 0')
|
||||
form.items.forEach((item, index) => {
|
||||
const itemErrors: string[] = []
|
||||
if (!item.itemNo.trim()) itemErrors.push('请输入箱规序号')
|
||||
else if (item.itemNo.trim().length > 32) itemErrors.push('箱规序号不能超过 32 个字符')
|
||||
if (!item.boxSpecification.trim()) itemErrors.push('请输入箱规描述')
|
||||
else if (item.boxSpecification.trim().length > 64) itemErrors.push('箱规描述不能超过 64 个字符')
|
||||
if (!Number.isInteger(item.packageCount) || item.packageCount < 1 || item.packageCount > 1_000_000) itemErrors.push('箱数必须是 1 至 1,000,000 的整数')
|
||||
if (!isPositive(item.lengthCm) || !isPositive(item.widthCm) || !isPositive(item.heightCm)) itemErrors.push('长、宽、高必须大于 0')
|
||||
if (!isPositive(item.unitWeightKg)) itemErrors.push('单箱重量必须大于 0')
|
||||
if (![item.outerLengthCm, item.outerWidthCm, item.outerHeightCm].every(isNonNegative)) itemErrors.push('外包装尺寸不能小于 0')
|
||||
if (item.intelligentImageFileId && !/^\d+$/.test(item.intelligentImageFileId)) itemErrors.push('测量图片文件 ID 必须是正整数')
|
||||
if ((item.overLimitWarning || '').length > 500 || (item.remark || '').length > 500) itemErrors.push('提示或备注不能超过 500 个字符')
|
||||
if (itemErrors.length) errors[item.clientKey] = itemErrors.map((message) => `第 ${index + 1} 条:${message}`)
|
||||
})
|
||||
if (general.length) errors.general = general
|
||||
validationErrors.value = errors
|
||||
return Object.keys(errors).length === 0
|
||||
}
|
||||
|
||||
function buildPayload(): SaveInboundMeasurementDraftRequest {
|
||||
return {
|
||||
measurementId: form.measurementId,
|
||||
version: form.version,
|
||||
originalDocumentNo: optionalText(form.originalDocumentNo),
|
||||
originalGrossWeightKg: form.originalGrossWeightKg,
|
||||
originalVolumeCbm: form.originalVolumeCbm,
|
||||
actualGoodsName: optionalText(form.actualGoodsName),
|
||||
items: form.items.map((item) => ({
|
||||
itemNo: item.itemNo.trim(),
|
||||
boxSpecification: item.boxSpecification.trim(),
|
||||
packageCount: item.packageCount,
|
||||
lengthCm: item.lengthCm,
|
||||
widthCm: item.widthCm,
|
||||
heightCm: item.heightCm,
|
||||
unitWeightKg: item.unitWeightKg,
|
||||
intelligentImageFileId: item.intelligentImageFileId || null,
|
||||
overLimitWarning: optionalText(item.overLimitWarning || ''),
|
||||
outerLengthCm: item.outerLengthCm,
|
||||
outerWidthCm: item.outerWidthCm,
|
||||
outerHeightCm: item.outerHeightCm,
|
||||
remark: optionalText(item.remark || ''),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
function setOperationError(error: unknown, fallback: string) {
|
||||
const details = getApiErrorDetails(error, fallback)
|
||||
operationError.value = details
|
||||
conflictDetected.value = details.code === 'A0409'
|
||||
|| details.code === 'CONFLICT'
|
||||
|| details.code === 'HTTP_409'
|
||||
|| details.message.includes('版本')
|
||||
|| details.message.includes('并发')
|
||||
return details
|
||||
}
|
||||
|
||||
async function saveDraft() {
|
||||
if (!mayMaintain.value || !validateDraft()) return
|
||||
saving.value = true
|
||||
operationError.value = null
|
||||
conflictDetected.value = false
|
||||
try {
|
||||
const saved = await saveInboundMeasurementDraft(customerId.value, props.inboundId, buildPayload())
|
||||
form.measurementId = String(saved.measurementId)
|
||||
form.version = saved.version
|
||||
ElMessage.success('测量草稿已保存,计算结果已由服务端更新')
|
||||
await loadMeasurement()
|
||||
emit('changed')
|
||||
} catch (error) {
|
||||
const details = setOperationError(error, '测量草稿保存失败,请检查后重试')
|
||||
if (!conflictDetected.value) ElMessage.error(details.message)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function submitForReview() {
|
||||
if (!maySubmit.value || !form.measurementId) return
|
||||
if (isDirty.value) {
|
||||
ElMessage.warning('请先保存当前修改,再提交审核')
|
||||
return
|
||||
}
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
'提交后将校验测量图片和全部称重结果,并由后端准备托盘与库位占用。提交成功后本批次不能继续编辑。',
|
||||
'提交测量审核',
|
||||
{ type: 'warning', confirmButtonText: '确认提交', cancelButtonText: '取消' },
|
||||
)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
operationError.value = null
|
||||
conflictDetected.value = false
|
||||
try {
|
||||
await submitInboundMeasurement(customerId.value, props.inboundId, {
|
||||
measurementId: form.measurementId,
|
||||
version: form.version,
|
||||
})
|
||||
ElMessage.success('测量数据已提交审核')
|
||||
await loadMeasurement()
|
||||
emit('changed')
|
||||
} catch (error) {
|
||||
const details = setOperationError(error, '测量数据提交失败,请检查图片和称重结果后重试')
|
||||
if (!conflictDetected.value) ElMessage.error(details.message)
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function formatNumber(value: number | null | undefined, maximumFractionDigits = 3) {
|
||||
if (value == null || !Number.isFinite(value)) return '—'
|
||||
return new Intl.NumberFormat('zh-CN', { maximumFractionDigits }).format(value)
|
||||
}
|
||||
|
||||
function processStatusLabel(status: string | undefined) {
|
||||
return ({
|
||||
WAIT_MEASURE: '待测量', MEASURING: '测量中', MEASURE_PENDING: '待审核', MEASURE_REJECTED: '审核驳回',
|
||||
WAIT_PRICING: '待计费', FEE_CONFIRMING: '费用确认中', INBOUNDING: '入库中', INBOUNDED: '已入库', VOIDED: '已作废',
|
||||
} as Record<string, string>)[status || ''] || status || '—'
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [customerId.value, props.inboundId, canView.value] as const,
|
||||
() => {
|
||||
requestSequence += 1
|
||||
detail.value = null
|
||||
loadError.value = null
|
||||
operationError.value = null
|
||||
conflictDetected.value = false
|
||||
validationErrors.value = {}
|
||||
clearForm()
|
||||
if (hasContext.value && canView.value) void loadMeasurement()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="surface-panel measurement-panel" aria-label="入库测量草稿">
|
||||
<header class="panel-heading measurement-panel__heading">
|
||||
<div>
|
||||
<span class="measurement-panel__icon"><Ruler :size="18" aria-hidden="true" /></span>
|
||||
<div>
|
||||
<h2>测量与箱规</h2>
|
||||
<p>维护箱规原始值;体积、材积重和汇总结果均由服务端计算。</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="measurement-panel__actions">
|
||||
<el-button :loading="loading" :disabled="!hasContext || !canView || saving || submitting" @click="reloadLatest">
|
||||
<RefreshCw :size="15" />刷新
|
||||
</el-button>
|
||||
<el-button v-if="canEdit" type="primary" plain :loading="saving" :disabled="!mayMaintain" @click="saveDraft">
|
||||
<Save :size="15" />保存草稿
|
||||
</el-button>
|
||||
<el-button v-if="canSubmit" type="primary" :loading="submitting" :disabled="!maySubmit" @click="submitForReview">
|
||||
<Send :size="15" />提交审核
|
||||
</el-button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<el-empty v-if="!hasContext" description="请选择所属企业和入库单后维护测量数据" />
|
||||
|
||||
<LoadErrorState
|
||||
v-else-if="!canView"
|
||||
title="无测量查看权限"
|
||||
message="当前账号缺少 platform:firstleg:inbound:measure:view 权限。"
|
||||
:show-retry="false"
|
||||
/>
|
||||
|
||||
<div v-else-if="loading && !detail" class="measurement-skeleton" aria-label="正在加载测量数据">
|
||||
<el-skeleton :rows="8" animated />
|
||||
</div>
|
||||
|
||||
<LoadErrorState
|
||||
v-else-if="loadError && !detail"
|
||||
title="测量数据加载失败"
|
||||
:message="loadError.message"
|
||||
:loading="loading"
|
||||
@retry="loadMeasurement"
|
||||
>
|
||||
<template #details><ApiErrorMeta :details="loadError" /></template>
|
||||
</LoadErrorState>
|
||||
|
||||
<template v-else-if="detail">
|
||||
<div class="measurement-context">
|
||||
<div class="measurement-context__identity">
|
||||
<div>
|
||||
<strong>{{ detail.inboundCode }}</strong>
|
||||
<span>{{ detail.companyName }} · {{ detail.channelName || detail.channelCode || '未设置渠道' }}</span>
|
||||
</div>
|
||||
<div class="measurement-tags">
|
||||
<el-tag :type="sourceTagType" effect="light">{{ sourceLabel }}</el-tag>
|
||||
<StatusBadge :status="processStatusLabel(detail.processStatus)" />
|
||||
<span>版本 {{ form.version }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<dl class="measurement-metrics">
|
||||
<div><dt>实测总箱数</dt><dd>{{ formatNumber(detail.actualPackageCount, 0) }}</dd></div>
|
||||
<div><dt>实测总毛重</dt><dd>{{ formatNumber(detail.actualGrossWeightKg) }} <small>T.K.GW</small></dd></div>
|
||||
<div><dt>实测总体积</dt><dd>{{ formatNumber(detail.actualVolumeCbm, 4) }} <small>T.CBM</small></dd></div>
|
||||
<div><dt>实测总材积重</dt><dd>{{ formatNumber(detail.volumeWeightKg) }} <small>KG</small></dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<el-alert v-if="!editableProcess" type="info" :closable="false" show-icon>
|
||||
<template #title>当前状态为“{{ processStatusLabel(detail.processStatus) }}”,测量数据仅可查看</template>
|
||||
测量草稿只允许在待测量、测量中或审核驳回状态维护。
|
||||
</el-alert>
|
||||
|
||||
<el-alert v-else-if="!canEdit" type="info" :closable="false" show-icon title="当前账号仅可查看测量数据" />
|
||||
|
||||
<el-alert v-if="conflictDetected" type="warning" :closable="false" show-icon class="measurement-alert">
|
||||
<template #title>数据版本已变化,当前修改尚未丢失</template>
|
||||
<p>{{ operationError?.message }}</p>
|
||||
<ApiErrorMeta v-if="operationError" :details="operationError" />
|
||||
<el-button size="small" @click="reloadLatest">加载最新版本</el-button>
|
||||
</el-alert>
|
||||
<el-alert v-else-if="operationError" type="error" :closable="false" show-icon class="measurement-alert">
|
||||
<template #title>操作未完成</template>
|
||||
<p>{{ operationError.message }}</p>
|
||||
<ApiErrorMeta :details="operationError" />
|
||||
</el-alert>
|
||||
|
||||
<el-alert v-if="Object.keys(validationErrors).length" type="error" :closable="false" show-icon class="measurement-alert">
|
||||
<template #title>请修正测量草稿中的问题</template>
|
||||
<ul>
|
||||
<li v-for="message in Object.values(validationErrors).flat()" :key="message">{{ message }}</li>
|
||||
</ul>
|
||||
</el-alert>
|
||||
|
||||
<form class="measurement-form" @submit.prevent="saveDraft">
|
||||
<fieldset :disabled="!mayMaintain || saving">
|
||||
<legend class="sr-only">测量草稿</legend>
|
||||
<section class="measurement-section">
|
||||
<div class="measurement-section__heading">
|
||||
<div><h3>原单数据</h3><p>原单值仅用于审核对比,不参与前端计算。</p></div>
|
||||
</div>
|
||||
<div class="measurement-form-grid">
|
||||
<label><span>原单据号码(CKW)</span><el-input v-model="form.originalDocumentNo" maxlength="64" placeholder="请输入原单据号码" /></label>
|
||||
<label><span>原单据毛重(T.K.GW)</span><el-input-number v-model="form.originalGrossWeightKg" :min="0" :precision="3" :controls="false" placeholder="0.000" /></label>
|
||||
<label><span>原单据总体积(T.CBM)</span><el-input-number v-model="form.originalVolumeCbm" :min="0" :precision="4" :controls="false" placeholder="0.0000" /></label>
|
||||
<label><span>实际品名</span><el-input v-model="form.actualGoodsName" maxlength="255" placeholder="请输入实际货物名称" /></label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="measurement-section measurement-items-section">
|
||||
<div class="measurement-section__heading">
|
||||
<div><h3>入库单测量数据明细</h3><p>最多 500 条;保存后由服务端重新计算箱规周长、单箱体积、总毛重、总体积和总材积重。</p></div>
|
||||
<el-button v-if="canEdit" :disabled="!mayMaintain || form.items.length >= 500" @click="addItem"><Plus :size="15" />新增箱规</el-button>
|
||||
</div>
|
||||
|
||||
<el-empty v-if="!form.items.length" description="暂无箱规明细">
|
||||
<el-button v-if="mayMaintain" type="primary" @click="addItem"><Plus :size="15" />添加第一条箱规</el-button>
|
||||
</el-empty>
|
||||
|
||||
<article v-for="(item, index) in form.items" :key="item.clientKey" class="measurement-item" :class="{ 'has-error': validationErrors[item.clientKey]?.length }">
|
||||
<header>
|
||||
<div><strong>箱规 {{ index + 1 }}</strong><span>{{ item.itemNo || '待填写序号' }} · {{ item.boxSpecification || '待填写箱规' }}</span></div>
|
||||
<el-button v-if="canEdit" link type="danger" :disabled="!mayMaintain || form.items.length === 1" @click="removeItem(index)"><Trash2 :size="15" />删除</el-button>
|
||||
</header>
|
||||
<div class="measurement-item__grid">
|
||||
<label><span>明细编号 *</span><el-input v-model="item.itemNo" maxlength="32" placeholder="例如 001" /></label>
|
||||
<label><span>箱规 *</span><el-input v-model="item.boxSpecification" maxlength="64" placeholder="例如 50×40×30cm" /></label>
|
||||
<label><span>箱规长(cm)*</span><el-input-number v-model="item.lengthCm" :min="0.001" :precision="3" :controls="false" /></label>
|
||||
<label><span>箱规宽(cm)*</span><el-input-number v-model="item.widthCm" :min="0.001" :precision="3" :controls="false" /></label>
|
||||
<label><span>箱规高(cm)*</span><el-input-number v-model="item.heightCm" :min="0.001" :precision="3" :controls="false" /></label>
|
||||
<label><span>箱数 *</span><el-input-number v-model="item.packageCount" :min="1" :max="1000000" :step="1" step-strictly controls-position="right" /></label>
|
||||
<label><span>每箱毛重(kg)*</span><el-input-number v-model="item.unitWeightKg" :min="0.001" :precision="3" :controls="false" /></label>
|
||||
<label><span>智能图片文件 ID</span><el-input v-model="item.intelligentImageFileId" inputmode="numeric" placeholder="文件服务返回的 ID" /></label>
|
||||
</div>
|
||||
<details class="measurement-item__advanced">
|
||||
<summary>外包装尺寸与补充说明</summary>
|
||||
<div class="measurement-item__grid">
|
||||
<label><span>外箱规长(cm)</span><el-input-number v-model="item.outerLengthCm" :min="0" :precision="3" :controls="false" /></label>
|
||||
<label><span>外箱规宽(cm)</span><el-input-number v-model="item.outerWidthCm" :min="0" :precision="3" :controls="false" /></label>
|
||||
<label><span>外箱规高(cm)</span><el-input-number v-model="item.outerHeightCm" :min="0" :precision="3" :controls="false" /></label>
|
||||
<label class="span-two"><span>超限提醒</span><el-input v-model="item.overLimitWarning" maxlength="500" placeholder="设备或人工发现的尺寸异常" /></label>
|
||||
<label class="span-two"><span>备注</span><el-input v-model="item.remark" maxlength="500" placeholder="请输入本箱规的补充说明" /></label>
|
||||
</div>
|
||||
</details>
|
||||
<dl class="measurement-item__results" aria-label="服务端计算结果">
|
||||
<div><dt>箱规周长</dt><dd>{{ formatNumber(item.serverValues.girthCm) }} cm</dd></div>
|
||||
<div><dt>单箱体积</dt><dd>{{ formatNumber(item.serverValues.unitVolumeCbm, 4) }} CBM</dd></div>
|
||||
<div><dt>总毛重</dt><dd>{{ formatNumber(item.serverValues.totalWeightKg) }} KG</dd></div>
|
||||
<div><dt>总体积</dt><dd>{{ formatNumber(item.serverValues.totalVolumeCbm, 4) }} CBM</dd></div>
|
||||
<div><dt>总材积重</dt><dd>{{ formatNumber(item.serverValues.volumeWeightKg) }} KG</dd></div>
|
||||
</dl>
|
||||
</article>
|
||||
</section>
|
||||
</fieldset>
|
||||
</form>
|
||||
|
||||
<footer v-if="canEdit || canSubmit" class="measurement-panel__footer">
|
||||
<span v-if="isDirty"><AlertTriangle :size="15" />有尚未保存的修改</span>
|
||||
<span v-else>当前显示版本 {{ form.version }}</span>
|
||||
<div>
|
||||
<el-button v-if="canEdit" type="primary" plain :loading="saving" :disabled="!mayMaintain" @click="saveDraft"><Save :size="15" />保存草稿</el-button>
|
||||
<el-button v-if="canSubmit" type="primary" :loading="submitting" :disabled="!maySubmit" @click="submitForReview"><Send :size="15" />提交审核</el-button>
|
||||
</div>
|
||||
</footer>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.measurement-panel { overflow: hidden; }
|
||||
.measurement-panel__heading > div:first-child { display: flex; min-width: 0; align-items: flex-start; gap: 11px; }
|
||||
.measurement-panel__icon { display: grid; width: 34px; height: 34px; flex: 0 0 34px; place-items: center; border-radius: 8px; background: #eef4ff; color: var(--cc-primary); }
|
||||
.measurement-panel h2, .measurement-panel h3 { margin: 0; color: var(--cc-text); }
|
||||
.measurement-panel h2 { font-size: 16px; }
|
||||
.measurement-panel h3 { font-size: 14px; }
|
||||
.measurement-panel__heading p, .measurement-section__heading p { margin: 4px 0 0; color: var(--cc-muted); font-size: 12px; line-height: 1.55; }
|
||||
.measurement-panel__actions, .measurement-panel__footer > div { display: flex; flex: 0 0 auto; flex-wrap: wrap; justify-content: flex-end; gap: 8px; }
|
||||
.measurement-panel__actions :deep(.el-button), .measurement-panel__footer :deep(.el-button) { margin-left: 0; }
|
||||
.measurement-skeleton { padding: 24px; }
|
||||
.measurement-context { padding: 18px 20px; background: #fbfcfe; }
|
||||
.measurement-context__identity { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; }
|
||||
.measurement-context__identity > div:first-child { display: flex; min-width: 0; flex-direction: column; gap: 4px; }
|
||||
.measurement-context__identity strong { overflow-wrap: anywhere; color: var(--cc-text); font-size: 15px; }
|
||||
.measurement-context__identity span { color: var(--cc-muted); font-size: 12px; line-height: 1.5; }
|
||||
.measurement-tags { display: flex; flex: 0 0 auto; flex-wrap: wrap; align-items: center; justify-content: flex-end; gap: 7px; }
|
||||
.measurement-tags > span:last-child { color: var(--cc-muted); font-size: 11px; }
|
||||
.measurement-metrics { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); margin: 16px 0 0; border: 1px solid var(--cc-border); border-radius: 8px; background: #fff; }
|
||||
.measurement-metrics > div { min-width: 0; padding: 13px 15px; border-right: 1px solid var(--cc-border); }
|
||||
.measurement-metrics > div:last-child { border-right: 0; }
|
||||
.measurement-metrics dt { color: var(--cc-muted); font-size: 11px; }
|
||||
.measurement-metrics dd { margin: 5px 0 0; overflow-wrap: anywhere; color: var(--cc-text); font-size: 17px; font-weight: 700; }
|
||||
.measurement-metrics small { color: var(--cc-muted); font-size: 10px; font-weight: 500; }
|
||||
.measurement-panel > :deep(.el-alert), .measurement-alert { margin: 16px 20px 0; width: auto; }
|
||||
.measurement-alert p { margin: 5px 0 0; line-height: 1.55; }
|
||||
.measurement-alert ul { margin: 7px 0 0; padding-left: 18px; font-size: 12px; line-height: 1.65; }
|
||||
.measurement-alert .el-button { margin-top: 9px; }
|
||||
.measurement-form fieldset { min-width: 0; margin: 0; padding: 0; border: 0; }
|
||||
.measurement-section { padding: 22px 20px; border-top: 1px solid var(--cc-border); }
|
||||
.measurement-section__heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 18px; margin-bottom: 16px; }
|
||||
.measurement-section__heading > div { min-width: 0; }
|
||||
.measurement-form-grid, .measurement-item__grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 15px; }
|
||||
.measurement-form label, .measurement-item label { display: flex; min-width: 0; flex-direction: column; gap: 7px; color: #344054; font-size: 12px; font-weight: 650; }
|
||||
.measurement-form :deep(.el-input-number), .measurement-form :deep(.el-input) { width: 100%; }
|
||||
.measurement-items-section { background: #fafbfc; }
|
||||
.measurement-item { overflow: hidden; margin-top: 14px; border: 1px solid var(--cc-border); border-radius: 9px; background: #fff; }
|
||||
.measurement-item.has-error { border-color: var(--el-color-danger-light-5); }
|
||||
.measurement-item > header { display: flex; align-items: center; justify-content: space-between; gap: 14px; padding: 12px 15px; border-bottom: 1px solid var(--cc-border); background: #f8fafc; }
|
||||
.measurement-item > header > div { display: flex; min-width: 0; flex-direction: column; gap: 3px; }
|
||||
.measurement-item > header strong { color: var(--cc-text); font-size: 13px; }
|
||||
.measurement-item > header span { overflow: hidden; color: var(--cc-muted); font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.measurement-item__grid { padding: 16px; }
|
||||
.measurement-item__advanced { margin: 0 16px 16px; border-top: 1px solid var(--cc-border); }
|
||||
.measurement-item__advanced summary { padding: 12px 0 0; color: var(--cc-primary); font-size: 12px; font-weight: 700; cursor: pointer; }
|
||||
.measurement-item__advanced .measurement-item__grid { grid-template-columns: repeat(3, minmax(0, 1fr)); padding: 14px 0 0; }
|
||||
.measurement-item__advanced .span-two { grid-column: span 3; }
|
||||
.measurement-item__results { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); margin: 0; border-top: 1px solid var(--cc-border); background: #fbfcfe; }
|
||||
.measurement-item__results > div { min-width: 0; padding: 11px 14px; border-right: 1px solid var(--cc-border); }
|
||||
.measurement-item__results > div:last-child { border-right: 0; }
|
||||
.measurement-item__results dt { color: var(--cc-muted); font-size: 10px; }
|
||||
.measurement-item__results dd { margin: 4px 0 0; overflow-wrap: anywhere; color: #344054; font-size: 12px; font-weight: 700; }
|
||||
.measurement-panel__footer { position: sticky; bottom: 0; z-index: 2; display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 13px 20px; border-top: 1px solid var(--cc-border); background: rgba(255, 255, 255, .97); box-shadow: 0 -8px 22px rgba(15, 23, 42, .05); }
|
||||
.measurement-panel__footer > span { display: inline-flex; align-items: center; gap: 6px; color: var(--cc-muted); font-size: 12px; }
|
||||
.measurement-panel__footer > span:has(svg) { color: #b54708; font-weight: 650; }
|
||||
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; }
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.measurement-form-grid, .measurement-item__grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.measurement-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.measurement-metrics > div:nth-child(2) { border-right: 0; }
|
||||
.measurement-metrics > div:nth-child(-n+2) { border-bottom: 1px solid var(--cc-border); }
|
||||
.measurement-item__results { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
.measurement-item__results > div:nth-child(3) { border-right: 0; }
|
||||
.measurement-item__results > div:nth-child(-n+3) { border-bottom: 1px solid var(--cc-border); }
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.measurement-panel__heading, .measurement-context__identity, .measurement-section__heading, .measurement-panel__footer { align-items: stretch; flex-direction: column; }
|
||||
.measurement-panel__actions, .measurement-panel__actions :deep(.el-button), .measurement-panel__footer > div, .measurement-panel__footer :deep(.el-button) { width: 100%; }
|
||||
.measurement-panel__actions :deep(.el-button), .measurement-panel__footer :deep(.el-button), .measurement-section__heading :deep(.el-button) { min-height: 44px; }
|
||||
.measurement-tags { justify-content: flex-start; }
|
||||
.measurement-context, .measurement-section { padding-right: 14px; padding-left: 14px; }
|
||||
.measurement-panel > :deep(.el-alert), .measurement-alert { margin-right: 14px; margin-left: 14px; }
|
||||
.measurement-form-grid, .measurement-item__grid, .measurement-item__advanced .measurement-item__grid { grid-template-columns: 1fr; }
|
||||
.measurement-item__advanced .span-two { grid-column: auto; }
|
||||
.measurement-item__results { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.measurement-item__results > div { border-right: 1px solid var(--cc-border); border-bottom: 1px solid var(--cc-border); }
|
||||
.measurement-item__results > div:nth-child(even) { border-right: 0; }
|
||||
.measurement-item__results > div:last-child { border-bottom: 0; }
|
||||
}
|
||||
|
||||
@media (max-width: 420px) {
|
||||
.measurement-metrics, .measurement-item__results { grid-template-columns: 1fr; }
|
||||
.measurement-metrics > div, .measurement-item__results > div { border-right: 0; border-bottom: 1px solid var(--cc-border); }
|
||||
.measurement-metrics > div:last-child, .measurement-item__results > div:last-child { border-bottom: 0; }
|
||||
.measurement-item > header { align-items: flex-start; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,75 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { http } from '@/shared/api/http'
|
||||
import { getInboundMeasurement, saveInboundMeasurementDraft, submitInboundMeasurement } from './api'
|
||||
import type { SaveInboundMeasurementDraftRequest } from './types'
|
||||
|
||||
vi.mock('@/shared/api/http', () => ({
|
||||
http: { get: vi.fn(), put: vi.fn(), post: vi.fn() },
|
||||
unwrapApiResponse: <T>(response: { data: T }) => response.data,
|
||||
}))
|
||||
|
||||
const draft: SaveInboundMeasurementDraftRequest = {
|
||||
measurementId: '9001',
|
||||
version: 2,
|
||||
originalDocumentNo: 'DOC-01',
|
||||
originalGrossWeightKg: 120,
|
||||
originalVolumeCbm: 1.2,
|
||||
actualGoodsName: '蓝牙音箱',
|
||||
items: [{
|
||||
itemNo: '001',
|
||||
boxSpecification: '50×40×30cm',
|
||||
packageCount: 10,
|
||||
lengthCm: 50,
|
||||
widthCm: 40,
|
||||
heightCm: 30,
|
||||
unitWeightKg: 12,
|
||||
intelligentImageFileId: '88001',
|
||||
overLimitWarning: null,
|
||||
outerLengthCm: null,
|
||||
outerWidthCm: null,
|
||||
outerHeightCm: null,
|
||||
remark: null,
|
||||
}],
|
||||
}
|
||||
|
||||
describe('inbound measurement API', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(http.get).mockReset()
|
||||
vi.mocked(http.put).mockReset()
|
||||
vi.mocked(http.post).mockReset()
|
||||
})
|
||||
|
||||
it('maps the selected customer to the backend tenant compatibility parameter', async () => {
|
||||
const detail = { inboundId: '7001', items: [], weightResults: [] }
|
||||
vi.mocked(http.get).mockResolvedValue({ code: '200', message: 'success', data: detail })
|
||||
|
||||
await expect(getInboundMeasurement('10001', '7001')).resolves.toEqual(detail)
|
||||
expect(http.get).toHaveBeenCalledWith('/platform/v1/first-leg/inbounds/7001/measurement', {
|
||||
params: { tenantId: '10001' },
|
||||
})
|
||||
})
|
||||
|
||||
it('saves a versioned draft without inventing an idempotency header', async () => {
|
||||
const saved = { measurementId: '9001', version: 3, status: 'DRAFT' }
|
||||
vi.mocked(http.put).mockResolvedValue({ code: '200', message: 'success', data: saved })
|
||||
|
||||
await expect(saveInboundMeasurementDraft('10001', '7001', draft)).resolves.toEqual(saved)
|
||||
expect(http.put).toHaveBeenCalledWith(
|
||||
'/platform/v1/first-leg/inbounds/7001/measurement/draft',
|
||||
draft,
|
||||
{ params: { tenantId: '10001' } },
|
||||
)
|
||||
})
|
||||
|
||||
it('submits the server measurement id and optimistic-lock version', async () => {
|
||||
const submitted = { measurementId: '9001', version: 3, processStatus: 'MEASURE_PENDING' }
|
||||
vi.mocked(http.post).mockResolvedValue({ code: '200', message: 'success', data: submitted })
|
||||
|
||||
await expect(submitInboundMeasurement('10001', '7001', { measurementId: '9001', version: 3 })).resolves.toEqual(submitted)
|
||||
expect(http.post).toHaveBeenCalledWith(
|
||||
'/platform/v1/first-leg/inbounds/7001/measurement/submit',
|
||||
{ measurementId: '9001', version: 3 },
|
||||
{ params: { tenantId: '10001' } },
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,46 @@
|
||||
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
import type {
|
||||
InboundMeasurementDetail,
|
||||
InboundMeasurementDraftSaved,
|
||||
InboundMeasurementSubmitted,
|
||||
SaveInboundMeasurementDraftRequest,
|
||||
SubmitInboundMeasurementRequest,
|
||||
} from './types'
|
||||
|
||||
function measurementPath(inboundId: string) {
|
||||
return `/platform/v1/first-leg/inbounds/${encodeURIComponent(inboundId)}/measurement`
|
||||
}
|
||||
|
||||
export async function getInboundMeasurement(customerId: string, inboundId: string) {
|
||||
const response = await http.get<ApiEnvelope<InboundMeasurementDetail>, ApiEnvelope<InboundMeasurementDetail>>(
|
||||
measurementPath(inboundId),
|
||||
{ params: { tenantId: customerId } },
|
||||
)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function saveInboundMeasurementDraft(
|
||||
customerId: string,
|
||||
inboundId: string,
|
||||
payload: SaveInboundMeasurementDraftRequest,
|
||||
) {
|
||||
const response = await http.put<ApiEnvelope<InboundMeasurementDraftSaved>, ApiEnvelope<InboundMeasurementDraftSaved>>(
|
||||
`${measurementPath(inboundId)}/draft`,
|
||||
payload,
|
||||
{ params: { tenantId: customerId } },
|
||||
)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function submitInboundMeasurement(
|
||||
customerId: string,
|
||||
inboundId: string,
|
||||
payload: SubmitInboundMeasurementRequest,
|
||||
) {
|
||||
const response = await http.post<ApiEnvelope<InboundMeasurementSubmitted>, ApiEnvelope<InboundMeasurementSubmitted>>(
|
||||
`${measurementPath(inboundId)}/submit`,
|
||||
payload,
|
||||
{ params: { tenantId: customerId } },
|
||||
)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export { default as InboundMeasurementPanel } from './InboundMeasurementPanel.vue'
|
||||
export * from './api'
|
||||
export type * from './types'
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
export type MeasurementIdentifier = string
|
||||
|
||||
export type InboundMeasurementProcessStatus =
|
||||
| 'WAIT_MEASURE'
|
||||
| 'MEASURING'
|
||||
| 'MEASURE_PENDING'
|
||||
| 'MEASURE_REJECTED'
|
||||
| 'WAIT_PRICING'
|
||||
| 'FEE_CONFIRMING'
|
||||
| 'INBOUNDING'
|
||||
| 'INBOUNDED'
|
||||
| 'VOIDED'
|
||||
|
||||
export interface InboundMeasurementItem {
|
||||
id: MeasurementIdentifier
|
||||
itemNo: string
|
||||
boxSpecification: string
|
||||
packageCount: number
|
||||
lengthCm: number
|
||||
widthCm: number
|
||||
heightCm: number
|
||||
girthCm: number | null
|
||||
unitWeightKg: number
|
||||
unitVolumeCbm: number | null
|
||||
totalWeightKg: number | null
|
||||
totalVolumeCbm: number | null
|
||||
volumeWeightKg: number | null
|
||||
intelligentImageFileId: MeasurementIdentifier | null
|
||||
overLimitWarning: string | null
|
||||
outerLengthCm: number | null
|
||||
outerWidthCm: number | null
|
||||
outerHeightCm: number | null
|
||||
remark: string | null
|
||||
}
|
||||
|
||||
export interface InboundMeasurementWeightResult {
|
||||
id: MeasurementIdentifier
|
||||
fileId: MeasurementIdentifier
|
||||
ocrProvider: string | null
|
||||
ocrRawText: string | null
|
||||
ocrWeightValue: number | null
|
||||
ocrWeightUnit: string | null
|
||||
confidence: number | null
|
||||
adjustedWeightValue: number | null
|
||||
adjustedWeightUnit: string | null
|
||||
finalWeightKg: number | null
|
||||
recognitionStatus: string
|
||||
adjustmentReason: string | null
|
||||
adjustedBy: MeasurementIdentifier | null
|
||||
adjustedAt: string | null
|
||||
confirmedBy: MeasurementIdentifier | null
|
||||
confirmedAt: string | null
|
||||
version: number
|
||||
}
|
||||
|
||||
export interface InboundMeasurementDetail {
|
||||
inboundId: MeasurementIdentifier
|
||||
inboundCode: string
|
||||
companyName: string
|
||||
channelCode: string | null
|
||||
channelName: string | null
|
||||
processStatus: InboundMeasurementProcessStatus
|
||||
measureReviewStatus: string | null
|
||||
recommendedPalletType: string | null
|
||||
reviewReason: string | null
|
||||
reviewerId: MeasurementIdentifier | null
|
||||
reviewerName: string | null
|
||||
measurementId: MeasurementIdentifier | null
|
||||
sourceType: string | null
|
||||
measurementStatus: string | null
|
||||
version: number | null
|
||||
originalDocumentNo: string | null
|
||||
originalGrossWeightKg: number | null
|
||||
originalVolumeCbm: number | null
|
||||
actualPackageCount: number | null
|
||||
actualGrossWeightKg: number | null
|
||||
actualVolumeCbm: number | null
|
||||
volumeWeightKg: number | null
|
||||
actualGoodsName: string | null
|
||||
items: InboundMeasurementItem[]
|
||||
weightResults: InboundMeasurementWeightResult[]
|
||||
}
|
||||
|
||||
export interface SaveInboundMeasurementItemRequest {
|
||||
itemNo: string
|
||||
boxSpecification: string
|
||||
packageCount: number
|
||||
lengthCm: number
|
||||
widthCm: number
|
||||
heightCm: number
|
||||
unitWeightKg: number
|
||||
intelligentImageFileId: MeasurementIdentifier | null
|
||||
overLimitWarning: string | null
|
||||
outerLengthCm: number | null
|
||||
outerWidthCm: number | null
|
||||
outerHeightCm: number | null
|
||||
remark: string | null
|
||||
}
|
||||
|
||||
export interface SaveInboundMeasurementDraftRequest {
|
||||
measurementId: MeasurementIdentifier | null
|
||||
version: number
|
||||
originalDocumentNo: string | null
|
||||
originalGrossWeightKg: number | null
|
||||
originalVolumeCbm: number | null
|
||||
actualGoodsName: string | null
|
||||
items: SaveInboundMeasurementItemRequest[]
|
||||
}
|
||||
|
||||
export interface InboundMeasurementDraftSaved {
|
||||
measurementId: MeasurementIdentifier
|
||||
version: number
|
||||
status: string
|
||||
}
|
||||
|
||||
export interface SubmitInboundMeasurementRequest {
|
||||
measurementId: MeasurementIdentifier
|
||||
version: number
|
||||
}
|
||||
|
||||
export interface InboundMeasurementSubmitted {
|
||||
measurementId: MeasurementIdentifier
|
||||
version: number
|
||||
processStatus: string
|
||||
measureReviewStatus: string
|
||||
recommendedPalletType: string | null
|
||||
feeId: MeasurementIdentifier | null
|
||||
feeNo: string | null
|
||||
feeStatus: string | null
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/modules/inbounds/pages/InboundOrderDetailView.vue'), 'utf8')
|
||||
|
||||
describe('InboundOrderDetailView', () => {
|
||||
it('maps the compatible route parameter to customer context without a detail-page selector', () => {
|
||||
expect(source).toContain('route.query.tenantId')
|
||||
expect(source).toContain('const customerId = computed')
|
||||
expect(source).toContain("name: 'inbound-orders'")
|
||||
expect(source).toContain(':customer-id="customerId"')
|
||||
expect(source).toContain(':inbound-id="inboundId"')
|
||||
expect(source).toContain('缺少所属企业信息,请从入库单列表重新打开')
|
||||
expect(source).not.toContain('PlatformTenantSelector')
|
||||
expect(source).not.toContain('租户')
|
||||
expect(source).toContain('<h1>入库详情</h1>')
|
||||
expect(source).toContain('货物测量、审核、费用及托盘库位记录')
|
||||
expect(source).toContain('class="surface-panel detail-page"')
|
||||
expect(source).toContain('class="detail-page-actions"')
|
||||
expect(source).toContain('返回入库单列表')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,65 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ArrowLeft } from '@lucide/vue'
|
||||
import InboundWorkflowWorkspace from '../components/InboundWorkflowWorkspace.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
function routeValue(value: unknown) {
|
||||
if (Array.isArray(value)) return String(value[0] || '')
|
||||
return value === null || value === undefined ? '' : String(value)
|
||||
}
|
||||
|
||||
const inboundId = computed(() => routeValue(route.params.inboundId))
|
||||
const customerId = computed(() => routeValue(route.query.tenantId))
|
||||
|
||||
function backToList() {
|
||||
void router.push({ name: 'inbound-orders', query: customerId.value ? { tenantId: customerId.value } : undefined })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="business-page inbound-detail-page">
|
||||
<section class="surface-panel detail-page">
|
||||
<header class="detail-page-header">
|
||||
<div class="detail-page-heading">
|
||||
<h1>入库详情</h1>
|
||||
<p>查看基本信息、货物测量、审核、费用及托盘库位记录。</p>
|
||||
</div>
|
||||
<div class="detail-page-actions">
|
||||
<el-button @click="backToList"><ArrowLeft :size="16" />返回入库单列表</el-button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="detail-page-content">
|
||||
<InboundWorkflowWorkspace
|
||||
v-if="inboundId && customerId"
|
||||
:customer-id="customerId"
|
||||
:inbound-id="inboundId"
|
||||
/>
|
||||
<el-empty
|
||||
v-else
|
||||
:description="inboundId ? '缺少所属企业信息,请从入库单列表重新打开' : '缺少有效的入库单标识'"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.inbound-detail-page { min-width: 0; }
|
||||
.detail-page { overflow: hidden; }
|
||||
.detail-page-header { display: flex; min-height: 88px; align-items: center; justify-content: space-between; gap: 18px; padding: 20px 24px; border-bottom: 1px solid var(--cc-border); }
|
||||
.detail-page-heading { min-width: 0; }
|
||||
.detail-page-heading h1 { margin: 0; color: var(--cc-text); font-size: 20px; line-height: 1.35; }
|
||||
.detail-page-heading p { margin: 5px 0 0; overflow-wrap: anywhere; color: var(--cc-muted); font-size: 12px; line-height: 1.5; }
|
||||
.detail-page-actions { display: flex; min-width: 0; align-items: center; justify-content: flex-end; gap: 12px; }
|
||||
.detail-page-content { min-width: 0; }
|
||||
@media (max-width: 720px) {
|
||||
.detail-page-header { align-items: flex-start; flex-direction: column; padding: 16px; }
|
||||
.detail-page-actions { width: 100%; align-items: stretch; flex-direction: column; }
|
||||
.detail-page-actions :deep(.el-button) { width: 100%; min-height: 44px; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,261 @@
|
||||
import ElementPlus from 'element-plus'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import InboundOrdersView from './InboundOrdersView.vue'
|
||||
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
|
||||
import QueryToolbar from '@/shared/components/QueryToolbar.vue'
|
||||
import DataTable from '@/shared/components/DataTable.vue'
|
||||
import { listPlatformWarehouses } from '@/modules/warehouses'
|
||||
import { getPlatformInbound, listPlatformInbounds } from '../core/api'
|
||||
import type { InboundOrderDetail, InboundOrderSummary } from '../core/types'
|
||||
|
||||
vi.mock('../core/api', () => ({
|
||||
getPlatformInbound: vi.fn(),
|
||||
listPlatformInbounds: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/modules/warehouses', () => ({
|
||||
listPlatformWarehouses: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/modules/iam', () => ({
|
||||
listPlatformTenants: vi.fn(),
|
||||
}))
|
||||
|
||||
const customers: PlatformTenantResource[] = [
|
||||
{ id: '10001', tenantCode: 'CUS-001', tenantName: '示例企业', status: 'ACTIVE', legalName: '示例企业有限公司', legalRepresentative: null, unifiedSocialCreditCode: null, contactName: null, contactPhone: null, contactEmail: null, servicePackage: null, contactWechat: null, salesOwnerName: null, paymentTermDays: null, enterpriseType: null, establishedDate: null, registeredCapital: null, registeredCapitalCurrency: null, registeredAddress: null, internalRemark: null, createdAt: null, approvedAt: null },
|
||||
{ id: '10002', tenantCode: 'CUS-002', tenantName: '第二客户', status: 'ACTIVE', legalName: null, legalRepresentative: null, unifiedSocialCreditCode: null, contactName: null, contactPhone: null, contactEmail: null, servicePackage: null, contactWechat: null, salesOwnerName: null, paymentTermDays: null, enterpriseType: null, establishedDate: null, registeredCapital: null, registeredCapitalCurrency: null, registeredAddress: null, internalRemark: null, createdAt: null, approvedAt: null },
|
||||
]
|
||||
|
||||
const summary: InboundOrderSummary = {
|
||||
id: '19001', orderId: '18001', orderNo: 'ORD-001', inboundCode: 'RK-001', processStatus: 'WAIT_MEASURE',
|
||||
orderStatusSnapshot: 'APPROVED', measureReviewStatus: 'PENDING', feeStatus: 'UNPRICED', feeReviewStatus: null,
|
||||
notificationStatus: null, version: 1, expectedArrivalAt: '2026-08-04T10:00:00', customerId: '10001', customerName: '示例客户',
|
||||
companyId: '10001', companyName: '示例企业', warehouseId: '20001', warehouseName: '义乌收货仓', deliveryWarehouseId: null,
|
||||
deliveryWarehouseName: null, businessType: 'FBA', channelCode: 'SEA', channelName: '海运', taxMode: 'DDP', containerMode: null,
|
||||
customsInspection: false, destinationType: 'FBA', destinationWarehouseId: '30001', destinationCode: 'ONT8', destinationName: 'ONT8',
|
||||
holdFlag: false, currentHoldReason: null, totalPackageCount: 6, actualGrossWeightKg: 120, actualNetWeightKg: 110,
|
||||
actualVolumeCbm: 1.2, volumeWeightKg: 200, chargeableWeightKg: 200, chargeableVolumeCbm: null, palletCount: 1, locationCodes: 'A-01',
|
||||
measureCompletedAt: null, inboundedAt: null, expressCompanyCode: null, expressCompanyName: null, expressTrackingNo: null,
|
||||
loadingStatus: null, containerNo: null, cabinetNo: null, vesselName: null, voyageNo: null, sailingAt: null, portArrivalAt: null,
|
||||
createdAt: '2026-08-01T10:00:00', updatedAt: '2026-08-01T10:00:00',
|
||||
}
|
||||
|
||||
const detail: InboundOrderDetail = {
|
||||
summary,
|
||||
publicRemark: '请优先安排测量',
|
||||
palletLocations: [{ allocationNo: 1, warehouseId: '20001', palletId: '21001', palletCode: 'P-001', palletCapacityCbm: 2, allocatedVolumeCbm: 1.2, allocatedPackageCount: 6, locationId: '22001', locationCode: 'A-01', reservationNo: 'RES-1', allocationStatus: 'PREPARED' }],
|
||||
files: [], internalNotes: [], statusHistory: [{ fromStatus: null, toStatus: 'WAIT_MEASURE', action: 'CREATE', reason: null, operatorId: '2', operatorName: '管理员', occurredAt: '2026-08-01T10:00:00' }],
|
||||
}
|
||||
|
||||
enableAutoUnmount(afterEach)
|
||||
|
||||
function mountPage() {
|
||||
return mount(InboundOrdersView, {
|
||||
attachTo: document.body,
|
||||
global: { plugins: [ElementPlus] },
|
||||
})
|
||||
}
|
||||
|
||||
async function selectCustomer(wrapper: ReturnType<typeof mountPage>, customerId = '10001') {
|
||||
await flushPromises()
|
||||
const toolbar = wrapper.findComponent(QueryToolbar)
|
||||
toolbar.vm.$emit('update:modelValue', { ...toolbar.props('modelValue'), customerId })
|
||||
await flushPromises()
|
||||
return toolbar
|
||||
}
|
||||
|
||||
describe('InboundOrdersView', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
setActivePinia(createPinia())
|
||||
vi.mocked(listPlatformInbounds).mockResolvedValue({ records: [summary], page: 1, size: 20, total: 1, pages: 1 })
|
||||
vi.mocked(getPlatformInbound).mockResolvedValue(detail)
|
||||
vi.mocked(listPlatformTenants).mockResolvedValue({ records: customers, page: 1, size: 200, total: 2, pages: 1 })
|
||||
vi.mocked(listPlatformWarehouses).mockResolvedValue({
|
||||
records: [
|
||||
{ id: '20001', tenantId: '10001', warehouseCode: 'YIWU', warehouseName: '义乌收货仓', warehouseType: 'DOMESTIC', countryCode: 'CN', timezone: 'Asia/Shanghai', contactName: null, contactPhone: null, contactEmail: null, status: 'ACTIVE', remark: null },
|
||||
{ id: 20002, tenantId: '10001', warehouseCode: 'B001', warehouseName: '交货仓 B001', warehouseType: 'DOMESTIC', countryCode: 'CN', timezone: 'Asia/Shanghai', contactName: null, contactPhone: null, contactEmail: null, status: 'ACTIVE', remark: null },
|
||||
],
|
||||
page: 1, size: 200, total: 2, pages: 1,
|
||||
})
|
||||
})
|
||||
|
||||
it('loads the customer-scoped list and renders the first-column view action', async () => {
|
||||
const wrapper = mountPage()
|
||||
await flushPromises()
|
||||
const toolbar = wrapper.findComponent(QueryToolbar)
|
||||
const customerField = (toolbar.props('fields') as Array<{ key: string; placeholder?: string }>).find((field) => field.key === 'customerId')
|
||||
expect(customerField?.placeholder).toBe('全部客户')
|
||||
expect((toolbar.props('modelValue') as { customerId: string }).customerId).toBe('')
|
||||
expect(listPlatformInbounds).toHaveBeenCalledWith(undefined, expect.objectContaining({ page: 1, size: 20 }))
|
||||
expect(listPlatformWarehouses).not.toHaveBeenCalled()
|
||||
const requestsBeforeSearch = vi.mocked(listPlatformInbounds).mock.calls.length
|
||||
toolbar.vm.$emit('search')
|
||||
await flushPromises()
|
||||
expect(vi.mocked(listPlatformInbounds).mock.calls).toHaveLength(requestsBeforeSearch + 1)
|
||||
expect(listPlatformInbounds).toHaveBeenLastCalledWith(undefined, expect.objectContaining({ page: 1, size: 20 }))
|
||||
|
||||
await selectCustomer(wrapper)
|
||||
expect(listPlatformInbounds).toHaveBeenCalledWith('10001', expect.objectContaining({ page: 1, size: 20 }))
|
||||
expect(listPlatformWarehouses).toHaveBeenCalledWith('10001', 1, 200)
|
||||
expect(listPlatformTenants).toHaveBeenCalledWith({ page: 1, size: 200 })
|
||||
expect(document.body.textContent).toContain('RK-001')
|
||||
expect(document.body.textContent).toContain('查看')
|
||||
expect(document.body.textContent).toContain('所属企业 示例企业有限公司')
|
||||
expect(document.body.textContent).not.toContain('租户')
|
||||
expect(wrapper.find('.inbound-tabs .is-active').text()).toContain('全部')
|
||||
})
|
||||
|
||||
it('uses backend-supported warehouse IDs from tenant-scoped options without coercing Long IDs', async () => {
|
||||
const wrapper = mountPage()
|
||||
const toolbar = await selectCustomer(wrapper)
|
||||
toolbar.vm.$emit('update:modelValue', {
|
||||
...toolbar.props('modelValue'),
|
||||
warehouseId: '20001',
|
||||
deliveryWarehouseId: '20002',
|
||||
})
|
||||
toolbar.vm.$emit('search')
|
||||
await flushPromises()
|
||||
expect(listPlatformInbounds).toHaveBeenLastCalledWith('10001', expect.objectContaining({
|
||||
warehouseId: '20001',
|
||||
deliveryWarehouseId: '20002',
|
||||
}))
|
||||
})
|
||||
|
||||
it('keeps common filters compact and clears hidden advanced filters when collapsed', async () => {
|
||||
const wrapper = mountPage()
|
||||
const toolbar = await selectCustomer(wrapper)
|
||||
const fieldKeys = () => (toolbar.props('fields') as Array<{ key: string }>).map((field) => field.key)
|
||||
const widths = Object.fromEntries((toolbar.props('fields') as Array<{ key: string; width?: number }>).map((field) => [field.key, field.width]))
|
||||
expect(widths.customerId).toBe(260)
|
||||
expect(widths.processStatus).toBe(160)
|
||||
expect(widths.inboundCode).toBe(220)
|
||||
expect(widths.expectedArrivalRange).toBe(320)
|
||||
expect(fieldKeys()).toContain('expectedArrivalRange')
|
||||
expect(fieldKeys()).not.toContain('feeReviewStatus')
|
||||
|
||||
const advancedButton = () => wrapper.findAll('button').find((button) => button.text().includes('更多筛选') || button.text().includes('收起筛选'))
|
||||
await advancedButton()?.trigger('click')
|
||||
expect(fieldKeys()).toContain('feeReviewStatus')
|
||||
|
||||
toolbar.vm.$emit('update:modelValue', {
|
||||
...toolbar.props('modelValue'),
|
||||
feeReviewStatus: 'PENDING',
|
||||
destinationCode: 'ONT8',
|
||||
})
|
||||
await flushPromises()
|
||||
await advancedButton()?.trigger('click')
|
||||
await flushPromises()
|
||||
expect(fieldKeys()).not.toContain('feeReviewStatus')
|
||||
expect(listPlatformInbounds).toHaveBeenLastCalledWith('10001', expect.objectContaining({
|
||||
feeReviewStatus: undefined,
|
||||
destinationCode: undefined,
|
||||
}))
|
||||
})
|
||||
|
||||
it('passes the selected backend tab and opens an aggregate read-only detail', async () => {
|
||||
const wrapper = mountPage()
|
||||
await selectCustomer(wrapper)
|
||||
;[...document.querySelectorAll<HTMLButtonElement>('.inbound-tab')].find((button) => button.textContent?.includes('待审核'))?.click()
|
||||
await flushPromises()
|
||||
expect(listPlatformInbounds).toHaveBeenLastCalledWith('10001', expect.objectContaining({ tab: 'WAIT_REVIEW', page: 1 }))
|
||||
;[...document.querySelectorAll<HTMLButtonElement>('button')].filter((button) => button.textContent?.trim() === '查看').at(-1)?.click()
|
||||
await flushPromises()
|
||||
expect(getPlatformInbound).toHaveBeenCalledWith('10001', '19001')
|
||||
expect(document.body.textContent).toContain('托盘与库位')
|
||||
expect(document.body.textContent).toContain('请优先安排测量')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('uses the app header as the page title and exposes compact accessible status tabs', async () => {
|
||||
const wrapper = mountPage()
|
||||
await flushPromises()
|
||||
expect(wrapper.find('.inbound-page-heading').exists()).toBe(false)
|
||||
expect(wrapper.text()).not.toContain('所有处理均进入单票工作台完成')
|
||||
const tabList = wrapper.find('[role="tablist"]')
|
||||
const tabButtons = tabList.findAll('[role="tab"]')
|
||||
const queryToolbar = wrapper.findComponent(QueryToolbar)
|
||||
expect(tabList.attributes('aria-label')).toBe('入库单处理状态')
|
||||
expect(tabList.element.parentElement?.classList.contains('table-toolbar')).toBe(true)
|
||||
expect(queryToolbar.element.compareDocumentPosition(tabList.element) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
|
||||
expect(tabButtons).toHaveLength(5)
|
||||
expect(wrapper.find('.table-toolbar').text()).toContain('共 1 条 · 所属企业 全部客户')
|
||||
expect(wrapper.find('.table-toolbar').text()).toContain('刷新')
|
||||
expect(wrapper.text()).not.toContain('全部入库单')
|
||||
expect(tabButtons[0].attributes('aria-selected')).toBe('true')
|
||||
expect(tabButtons[0].attributes('tabindex')).toBe('0')
|
||||
await tabButtons[0].trigger('keydown', { key: 'ArrowRight' })
|
||||
await flushPromises()
|
||||
expect(tabButtons[1].attributes('aria-selected')).toBe('true')
|
||||
expect(listPlatformInbounds).toHaveBeenLastCalledWith(undefined, expect.objectContaining({ tab: 'WAIT_MEASURE', page: 1 }))
|
||||
})
|
||||
|
||||
it('shows an actionable list error and recovers on retry', async () => {
|
||||
vi.mocked(listPlatformInbounds).mockRejectedValueOnce(new Error('network down')).mockResolvedValueOnce({ records: [summary], page: 1, size: 20, total: 1, pages: 1 })
|
||||
const wrapper = mountPage()
|
||||
await flushPromises()
|
||||
expect(document.body.textContent).toContain('入库单暂时无法加载')
|
||||
;[...document.querySelectorAll<HTMLButtonElement>('button')].find((button) => button.textContent?.trim() === '重新加载')?.click()
|
||||
await flushPromises()
|
||||
expect(listPlatformInbounds).toHaveBeenCalledTimes(2)
|
||||
expect(document.body.textContent).toContain('RK-001')
|
||||
})
|
||||
|
||||
it('clears the selected list and detail when the selected customer changes', async () => {
|
||||
const wrapper = mountPage()
|
||||
const toolbar = await selectCustomer(wrapper)
|
||||
toolbar.vm.$emit('update:modelValue', { ...toolbar.props('modelValue'), customerId: '10002' })
|
||||
await flushPromises()
|
||||
expect(listPlatformInbounds).toHaveBeenLastCalledWith('10002', expect.objectContaining({ page: 1 }))
|
||||
expect(listPlatformWarehouses).toHaveBeenLastCalledWith('10002', 1, 200)
|
||||
})
|
||||
|
||||
it('resets to all customers without sending an empty backend tenant ID', async () => {
|
||||
const wrapper = mountPage()
|
||||
const toolbar = await selectCustomer(wrapper)
|
||||
const requestsBeforeReset = vi.mocked(listPlatformInbounds).mock.calls.length
|
||||
const resetButton = wrapper.findAll('button').find((button) => button.text().includes('重置'))
|
||||
await resetButton?.trigger('click')
|
||||
await flushPromises()
|
||||
expect((toolbar.props('modelValue') as { customerId: string }).customerId).toBe('')
|
||||
expect(vi.mocked(listPlatformInbounds).mock.calls).toHaveLength(requestsBeforeReset + 1)
|
||||
expect(listPlatformInbounds).toHaveBeenLastCalledWith(undefined, expect.objectContaining({ page: 1 }))
|
||||
expect(document.body.textContent).not.toContain('请选择所属企业')
|
||||
expect(document.body.textContent).not.toContain('请选择客户')
|
||||
})
|
||||
|
||||
it('loads another full-data page without a customer filter', async () => {
|
||||
vi.mocked(listPlatformInbounds).mockResolvedValue({ records: [summary], page: 1, size: 20, total: 60, pages: 3 })
|
||||
const wrapper = mountPage()
|
||||
await flushPromises()
|
||||
wrapper.findComponent(DataTable).vm.$emit('update:page', 2)
|
||||
await flushPromises()
|
||||
expect(listPlatformInbounds).toHaveBeenLastCalledWith(undefined, expect.objectContaining({ page: 2, size: 20 }))
|
||||
})
|
||||
|
||||
it('keeps the operation column first and follows the prototype field order with backend-backed columns', () => {
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/modules/inbounds/pages/InboundOrdersView.vue'), 'utf8')
|
||||
const toolbarSource = readFileSync(resolve(process.cwd(), 'src/shared/components/QueryToolbar.vue'), 'utf8')
|
||||
const columnSource = source.slice(source.indexOf('const columns:'))
|
||||
const operation = columnSource.indexOf("key: 'actions'")
|
||||
const inboundCode = columnSource.indexOf("key: 'inboundCode'")
|
||||
const orderStatus = columnSource.indexOf("key: 'orderStatus'")
|
||||
const measureReview = columnSource.indexOf("key: 'measureReviewStatus'")
|
||||
const feeStatus = columnSource.indexOf("key: 'feeStatus'")
|
||||
const customer = columnSource.indexOf("key: 'customerName'")
|
||||
const warehouse = columnSource.indexOf("key: 'warehouseName'")
|
||||
expect(operation).toBeGreaterThan(-1)
|
||||
expect(operation).toBeLessThan(inboundCode)
|
||||
expect(inboundCode).toBeLessThan(orderStatus)
|
||||
expect(orderStatus).toBeLessThan(measureReview)
|
||||
expect(measureReview).toBeLessThan(feeStatus)
|
||||
expect(feeStatus).toBeLessThan(customer)
|
||||
expect(customer).toBeLessThan(warehouse)
|
||||
expect(columnSource).toContain("fixed: 'left'")
|
||||
expect(toolbarSource).toMatch(/@media \(max-width: 720px\)[\s\S]*width: 100%/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,474 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ChevronDown, ChevronUp, ClipboardCheck, Eye, RefreshCw, SlidersHorizontal } from '@lucide/vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import AppDrawer from '@/shared/components/AppDrawer.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
|
||||
import { listPlatformWarehouses } from '@/modules/warehouses'
|
||||
import { getPlatformInbound, listPlatformInbounds } from '../core/api'
|
||||
import InboundHistoryPanel from '../core/InboundHistoryPanel.vue'
|
||||
import InboundSummaryPanel from '../core/InboundSummaryPanel.vue'
|
||||
import type { InboundListTab, InboundOrderDetail, InboundOrderSummary } from '../core/types'
|
||||
|
||||
type FilterModel = QueryModel & {
|
||||
customerId: string
|
||||
inboundCode: string
|
||||
orderNo: string
|
||||
warehouseId: string
|
||||
deliveryWarehouseId: string
|
||||
processStatus: string
|
||||
orderStatus: string
|
||||
measureReviewStatus: string
|
||||
customerName: string
|
||||
destinationCode: string
|
||||
feeStatus: string
|
||||
feeReviewStatus: string
|
||||
holdFlag: string
|
||||
expectedArrivalRange: string[]
|
||||
}
|
||||
|
||||
const router = useRouter()
|
||||
const tab = ref<InboundListTab | ''>('')
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const loading = ref(false)
|
||||
const loadError = ref<ApiErrorDetails | null>(null)
|
||||
const detailOpen = ref(false)
|
||||
const detailLoading = ref(false)
|
||||
const detailError = ref<ApiErrorDetails | null>(null)
|
||||
const detail = ref<InboundOrderDetail | null>(null)
|
||||
const detailTarget = ref<InboundOrderSummary | null>(null)
|
||||
const result = ref({ records: [] as InboundOrderSummary[], total: 0 })
|
||||
const filters = ref<FilterModel>({ customerId: '', inboundCode: '', orderNo: '', warehouseId: '', deliveryWarehouseId: '', processStatus: '', orderStatus: '', measureReviewStatus: '', customerName: '', destinationCode: '', feeStatus: '', feeReviewStatus: '', holdFlag: '', expectedArrivalRange: [] })
|
||||
const selectedCustomerId = ref('')
|
||||
const customers = ref<PlatformTenantResource[]>([])
|
||||
const customersLoading = ref(false)
|
||||
const customerLoadError = ref<ApiErrorDetails | null>(null)
|
||||
const warehouseOptions = ref<Array<{ label: string; value: string }>>([])
|
||||
const warehousesLoading = ref(false)
|
||||
const warehousesUnavailable = ref(false)
|
||||
const showAdvancedFilters = ref(false)
|
||||
let loadRequestId = 0
|
||||
let detailRequestId = 0
|
||||
let customerRequestId = 0
|
||||
let warehouseRequestId = 0
|
||||
let disposed = false
|
||||
|
||||
const tabs: Array<{ value: InboundListTab | ''; label: string; description: string }> = [
|
||||
{ value: '', label: '全部', description: '全部入库单' },
|
||||
{ value: 'WAIT_MEASURE', label: '待测量', description: '等待测量或补正' },
|
||||
{ value: 'WAIT_REVIEW', label: '待审核', description: '等待测量审核' },
|
||||
{ value: 'WAIT_FEE_SEND', label: '待费用/入库', description: '等待费用编排完成' },
|
||||
{ value: 'INBOUNDED', label: '已入库', description: '费用已发布并完成入库' },
|
||||
]
|
||||
|
||||
const advancedFilterKeys = new Set(['orderStatus', 'feeReviewStatus', 'customerName', 'destinationCode', 'holdFlag'])
|
||||
|
||||
const allQueryFields = computed<QueryField[]>(() => [
|
||||
{
|
||||
key: 'customerId',
|
||||
type: 'select',
|
||||
width: 260,
|
||||
placeholder: customerLoadError.value ? '所属企业加载失败' : customersLoading.value ? '正在加载客户' : '全部客户',
|
||||
options: customers.value.map((customer) => ({
|
||||
value: String(customer.id),
|
||||
label: `${customer.legalName?.trim() || customer.tenantName}(${customer.tenantCode || '无客户编码'})`,
|
||||
})),
|
||||
props: { 'aria-label': '所属企业', loading: customersLoading.value, filterable: true, disabled: Boolean(customerLoadError.value) || customers.value.length === 0 },
|
||||
},
|
||||
{ key: 'warehouseId', type: 'select', width: 180, placeholder: warehousesUnavailable.value ? '定位仓库加载失败' : '定位仓库', options: warehouseOptions.value, props: { 'aria-label': '定位仓库', loading: warehousesLoading.value, filterable: true, disabled: !selectedCustomerId.value || warehousesUnavailable.value } },
|
||||
{ key: 'deliveryWarehouseId', type: 'select', width: 180, placeholder: warehousesUnavailable.value ? '交货仓加载失败' : '交货仓', options: warehouseOptions.value, props: { 'aria-label': '交货仓', loading: warehousesLoading.value, filterable: true, disabled: !selectedCustomerId.value || warehousesUnavailable.value } },
|
||||
{ key: 'inboundCode', type: 'input', width: 220, placeholder: '入仓码' },
|
||||
{ key: 'orderNo', type: 'input', width: 220, placeholder: '头程订单号' },
|
||||
{ key: 'processStatus', type: 'select', width: 160, placeholder: '流程状态', options: [
|
||||
{ label: '待测量', value: 'WAIT_MEASURE' }, { label: '测量中', value: 'MEASURING' },
|
||||
{ label: '待测量审核', value: 'MEASURE_PENDING' }, { label: '测量已驳回', value: 'MEASURE_REJECTED' },
|
||||
{ label: '待计费', value: 'WAIT_PRICING' }, { label: '费用处理中', value: 'FEE_CONFIRMING' },
|
||||
{ label: '入库处理中', value: 'INBOUNDING' }, { label: '已入库', value: 'INBOUNDED' }, { label: '已作废', value: 'VOIDED' },
|
||||
] },
|
||||
{ key: 'orderStatus', type: 'input', width: 200, placeholder: '订单状态码' },
|
||||
{ key: 'measureReviewStatus', type: 'select', width: 160, placeholder: '测量审核状态', options: [
|
||||
{ label: '待审核', value: 'PENDING' }, { label: '审核通过', value: 'APPROVED' }, { label: '审核驳回', value: 'REJECTED' },
|
||||
] },
|
||||
{ key: 'feeStatus', type: 'select', width: 160, placeholder: '费用状态', options: [
|
||||
{ label: '未计费', value: 'UNPRICED' }, { label: '已试算', value: 'CALCULATED' },
|
||||
{ label: '已确认', value: 'CONFIRMED' }, { label: '已发送', value: 'SENT' }, { label: '已作废', value: 'VOIDED' },
|
||||
] },
|
||||
{ key: 'feeReviewStatus', type: 'input', width: 200, placeholder: '费用审核状态码' },
|
||||
{ key: 'customerName', type: 'input', width: 260, placeholder: '客户名称' },
|
||||
{ key: 'destinationCode', type: 'input', width: 220, placeholder: '目的仓编码' },
|
||||
{ key: 'holdFlag', type: 'select', width: 160, placeholder: '挂起状态', options: [{ label: '已挂起', value: 'true' }, { label: '未挂起', value: 'false' }] },
|
||||
{ key: 'expectedArrivalRange', type: 'daterange', width: 320, placeholder: '预计到达日期' },
|
||||
])
|
||||
|
||||
const queryFields = computed(() => showAdvancedFilters.value
|
||||
? allQueryFields.value
|
||||
: allQueryFields.value.filter((field) => !advancedFilterKeys.has(field.key)))
|
||||
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 152, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'inboundCode', label: '入仓码', minWidth: 166, slotName: 'inbound' },
|
||||
{ key: 'orderStatus', label: '订单 / 流程状态', minWidth: 156, slotName: 'orderStatus' },
|
||||
{ key: 'measureReviewStatus', label: '测量数据审核状态', minWidth: 142, slotName: 'measureReviewStatus' },
|
||||
{ key: 'expectedArrivalAt', label: '预计到达时间', minWidth: 164, slotName: 'expectedArrivalAt' },
|
||||
{ key: 'feeStatus', label: '费用状态', minWidth: 124, slotName: 'feeStatus' },
|
||||
{ key: 'customerName', label: '客户', minWidth: 142, slotName: 'customerName' },
|
||||
{ key: 'warehouseName', label: '仓库', minWidth: 140, slotName: 'warehouseName' },
|
||||
{ key: 'deliveryWarehouseName', label: '交货仓', minWidth: 140, slotName: 'deliveryWarehouseName' },
|
||||
{ key: 'loadingStatus', label: '配载状态', minWidth: 110, slotName: 'loadingStatus' },
|
||||
{ key: 'holdFlag', label: '是否扣货', width: 96, slotName: 'holdFlag' },
|
||||
{ key: 'currentHoldReason', label: '扣货理由', minWidth: 150, slotName: 'currentHoldReason' },
|
||||
{ key: 'containerNo', label: '集装箱号', minWidth: 132, slotName: 'containerNo' },
|
||||
{ key: 'cabinetNo', label: '柜号', minWidth: 118, slotName: 'cabinetNo' },
|
||||
{ key: 'companyName', label: '公司', minWidth: 160, slotName: 'companyName' },
|
||||
{ key: 'businessType', label: '业务类型', minWidth: 112, slotName: 'businessType' },
|
||||
{ key: 'channelName', label: '渠道', minWidth: 138, slotName: 'channelName' },
|
||||
{ key: 'taxMode', label: '计税模式', minWidth: 108, slotName: 'taxMode' },
|
||||
{ key: 'containerMode', label: '装载模式', minWidth: 108, slotName: 'containerMode' },
|
||||
{ key: 'customsInspection', label: '商检验', width: 92, slotName: 'customsInspection' },
|
||||
{ key: 'sailingAt', label: '开船时间', minWidth: 164, slotName: 'sailingAt' },
|
||||
{ key: 'portArrivalAt', label: '到港时间', minWidth: 164, slotName: 'portArrivalAt' },
|
||||
{ key: 'totalPackageCount', label: '总箱数', width: 90, slotName: 'totalPackageCount' },
|
||||
{ key: 'actualGrossWeightKg', label: '总毛重(kg)', minWidth: 118, slotName: 'actualGrossWeightKg' },
|
||||
{ key: 'chargeableWeightKg', label: '计费重量(kg)', minWidth: 126, slotName: 'chargeableWeightKg' },
|
||||
{ key: 'actualVolumeCbm', label: '总体积(CBM)', minWidth: 122, slotName: 'actualVolumeCbm' },
|
||||
{ key: 'chargeableVolumeCbm', label: '计费体积(CBM)', minWidth: 132, slotName: 'chargeableVolumeCbm' },
|
||||
{ key: 'palletCount', label: '总托盘数', width: 100, slotName: 'palletCount' },
|
||||
{ key: 'locationCodes', label: '库位位置', minWidth: 124, slotName: 'locationCodes' },
|
||||
{ key: 'volumeWeightKg', label: '总材积重(kg)', minWidth: 126, slotName: 'volumeWeightKg' },
|
||||
{ key: 'actualNetWeightKg', label: '总净重(kg)', minWidth: 118, slotName: 'actualNetWeightKg' },
|
||||
{ key: 'destinationType', label: '目的仓类型', minWidth: 112, slotName: 'destinationType' },
|
||||
{ key: 'destinationName', label: '目的仓', minWidth: 132, slotName: 'destinationName' },
|
||||
{ key: 'measureCompletedAt', label: '测量完成时间', minWidth: 164, slotName: 'measureCompletedAt' },
|
||||
{ key: 'inboundedAt', label: '入库完成时间', minWidth: 164, slotName: 'inboundedAt' },
|
||||
{ key: 'expressCompanyName', label: '快递公司', minWidth: 120, slotName: 'expressCompanyName' },
|
||||
{ key: 'expressTrackingNo', label: '快递单号', minWidth: 150, slotName: 'expressTrackingNo' },
|
||||
{ key: 'createdAt', label: '创建日期', minWidth: 164, slotName: 'createdAt' },
|
||||
]
|
||||
|
||||
const selectedCustomerName = computed(() => {
|
||||
const customer = customers.value.find((item) => String(item.id) === selectedCustomerId.value)
|
||||
return customer?.legalName?.trim() || customer?.tenantName?.trim() || '全部客户'
|
||||
})
|
||||
const listEmptyText = '暂无符合条件的入库单'
|
||||
|
||||
function queryValue(value: string) { return value.trim() || undefined }
|
||||
function handleTabKeydown(event: KeyboardEvent, currentIndex: number) {
|
||||
const lastIndex = tabs.length - 1
|
||||
let nextIndex = currentIndex
|
||||
if (event.key === 'ArrowRight') nextIndex = currentIndex === lastIndex ? 0 : currentIndex + 1
|
||||
else if (event.key === 'ArrowLeft') nextIndex = currentIndex === 0 ? lastIndex : currentIndex - 1
|
||||
else if (event.key === 'Home') nextIndex = 0
|
||||
else if (event.key === 'End') nextIndex = lastIndex
|
||||
else return
|
||||
event.preventDefault()
|
||||
tab.value = tabs[nextIndex].value
|
||||
const tabButtons = (event.currentTarget as HTMLElement).parentElement?.querySelectorAll<HTMLElement>('[role="tab"]')
|
||||
tabButtons?.[nextIndex]?.focus()
|
||||
}
|
||||
function dateParam(value: string | undefined, end = false) {
|
||||
if (!value) return undefined
|
||||
return `${value}T${end ? '23:59:59' : '00:00:00'}`
|
||||
}
|
||||
|
||||
async function loadWarehouseOptions() {
|
||||
const requestId = ++warehouseRequestId
|
||||
warehouseOptions.value = []
|
||||
warehousesUnavailable.value = false
|
||||
if (!selectedCustomerId.value) {
|
||||
warehousesLoading.value = false
|
||||
return
|
||||
}
|
||||
warehousesLoading.value = true
|
||||
try {
|
||||
const response = await listPlatformWarehouses(selectedCustomerId.value, 1, 200)
|
||||
if (disposed || requestId !== warehouseRequestId) return
|
||||
warehouseOptions.value = response.records.map((warehouse) => ({
|
||||
label: `${warehouse.warehouseName} · ${warehouse.warehouseCode}`,
|
||||
value: String(warehouse.id),
|
||||
}))
|
||||
} catch {
|
||||
if (disposed || requestId !== warehouseRequestId) return
|
||||
warehousesUnavailable.value = true
|
||||
} finally {
|
||||
if (!disposed && requestId === warehouseRequestId) warehousesLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCustomers() {
|
||||
const requestId = ++customerRequestId
|
||||
customersLoading.value = true
|
||||
customerLoadError.value = null
|
||||
try {
|
||||
const records: PlatformTenantResource[] = []
|
||||
let currentPage = 1
|
||||
let pages = 1
|
||||
do {
|
||||
const response = await listPlatformTenants({ page: currentPage, size: 200 })
|
||||
records.push(...response.records)
|
||||
pages = Math.max(1, response.pages)
|
||||
currentPage += 1
|
||||
} while (currentPage <= pages)
|
||||
if (disposed || requestId !== customerRequestId) return
|
||||
customers.value = records
|
||||
} catch (error) {
|
||||
if (disposed || requestId !== customerRequestId) return
|
||||
customers.value = []
|
||||
customerLoadError.value = getApiErrorDetails(error, '客户列表加载失败,暂时无法按所属企业筛选;入库单仍按全部客户查询')
|
||||
} finally {
|
||||
if (!disposed && requestId === customerRequestId) customersLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadInbounds() {
|
||||
const requestId = ++loadRequestId
|
||||
loading.value = true
|
||||
loadError.value = null
|
||||
const range = filters.value.expectedArrivalRange
|
||||
try {
|
||||
const response = await listPlatformInbounds(selectedCustomerId.value || undefined, {
|
||||
page: page.value,
|
||||
size: pageSize.value,
|
||||
tab: tab.value || undefined,
|
||||
inboundCode: queryValue(filters.value.inboundCode),
|
||||
orderNo: queryValue(filters.value.orderNo),
|
||||
warehouseId: queryValue(filters.value.warehouseId),
|
||||
deliveryWarehouseId: queryValue(filters.value.deliveryWarehouseId),
|
||||
processStatus: queryValue(filters.value.processStatus),
|
||||
orderStatus: queryValue(filters.value.orderStatus),
|
||||
measureReviewStatus: queryValue(filters.value.measureReviewStatus),
|
||||
customerName: queryValue(filters.value.customerName),
|
||||
destinationCode: queryValue(filters.value.destinationCode),
|
||||
feeStatus: queryValue(filters.value.feeStatus),
|
||||
feeReviewStatus: queryValue(filters.value.feeReviewStatus),
|
||||
holdFlag: filters.value.holdFlag === '' ? undefined : filters.value.holdFlag === 'true',
|
||||
expectedArrivalFrom: dateParam(range?.[0]),
|
||||
expectedArrivalTo: dateParam(range?.[1], true),
|
||||
})
|
||||
if (disposed || requestId !== loadRequestId) return
|
||||
result.value = { records: response.records, total: response.total }
|
||||
} catch (error) {
|
||||
if (disposed || requestId !== loadRequestId) return
|
||||
loadError.value = getApiErrorDetails(error, '入库单加载失败')
|
||||
} finally {
|
||||
if (!disposed && requestId === loadRequestId) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function applyFilters() {
|
||||
if (page.value === 1) void loadInbounds()
|
||||
else page.value = 1
|
||||
}
|
||||
|
||||
function resetDetail() {
|
||||
detailRequestId += 1
|
||||
detail.value = null
|
||||
detailTarget.value = null
|
||||
detailError.value = null
|
||||
detailLoading.value = false
|
||||
}
|
||||
|
||||
async function openDetail(row: InboundOrderSummary) {
|
||||
const requestId = ++detailRequestId
|
||||
detailOpen.value = true
|
||||
detailTarget.value = row
|
||||
detailLoading.value = true
|
||||
detailError.value = null
|
||||
detail.value = null
|
||||
try {
|
||||
const response = await getPlatformInbound(recordCustomerId(row), row.id)
|
||||
if (disposed || requestId !== detailRequestId) return
|
||||
detail.value = response
|
||||
} catch (error) {
|
||||
if (disposed || requestId !== detailRequestId) return
|
||||
detailError.value = getApiErrorDetails(error, '入库单详情加载失败')
|
||||
} finally {
|
||||
if (!disposed && requestId === detailRequestId) detailLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function closeDetail() { resetDetail() }
|
||||
function recordCustomerId(row: InboundOrderSummary) { return row.customerId || row.companyId || selectedCustomerId.value }
|
||||
function openWorkspace(row: InboundOrderSummary) {
|
||||
const customerId = recordCustomerId(row)
|
||||
void router.push({ name: 'inbound-order-detail', params: { inboundId: row.id }, query: customerId ? { tenantId: customerId } : undefined })
|
||||
}
|
||||
function formatNumber(value: number | null, digits = 3) { return value === null ? '—' : value.toFixed(digits) }
|
||||
function formatDate(value: string | null) {
|
||||
if (!value) return '—'
|
||||
const date = new Date(value)
|
||||
return Number.isNaN(date.getTime()) ? value : date.toLocaleString('zh-CN', { hour12: false })
|
||||
}
|
||||
function text(value: string | number | null | undefined) { return value === null || value === undefined || value === '' ? '—' : String(value) }
|
||||
function statusLabel(status: string) {
|
||||
return ({ WAIT_MEASURE: '待测量', MEASURING: '测量中', MEASURE_PENDING: '待审核', MEASURE_REJECTED: '测量驳回', WAIT_PRICING: '待计费', FEE_CONFIRMING: '待发送费用', INBOUNDING: '入库处理中', INBOUNDED: '已入库', VOIDED: '已作废' } as Record<string, string>)[status] || status || '未知状态'
|
||||
}
|
||||
function statusType(status: string) {
|
||||
if (status === 'INBOUNDED') return 'success'
|
||||
if (status === 'MEASURE_REJECTED' || status === 'VOIDED') return 'danger'
|
||||
if (status === 'MEASURING' || status === 'INBOUNDING') return 'info'
|
||||
return 'warning'
|
||||
}
|
||||
function codeLabel(status: string | null | undefined, labels: Record<string, string>) { return status ? labels[status] || status : '—' }
|
||||
const measureReviewLabels: Record<string, string> = { PENDING: '待审核', APPROVED: '审核通过', REJECTED: '审核驳回' }
|
||||
const feeStatusLabels: Record<string, string> = { UNPRICED: '未计费', CALCULATED: '已试算', CONFIRMED: '已确认', SENT: '已发送', VOIDED: '已作废' }
|
||||
const loadingStatusLabels: Record<string, string> = { NOT_LOADED: '未配载', LOADING: '配载中', LOADED: '已配载' }
|
||||
const taxModeLabels: Record<string, string> = { TAX_INCLUDED: '包税', TAX_EXCLUDED: '不包税', DDP: '包税', DDU: '不包税' }
|
||||
const containerModeLabels: Record<string, string> = { FCL: '整柜', LCL: '拼箱' }
|
||||
|
||||
function resetFilters() {
|
||||
selectedCustomerId.value = ''
|
||||
showAdvancedFilters.value = false
|
||||
filters.value = { ...filters.value, customerId: '', warehouseId: '', deliveryWarehouseId: '' }
|
||||
warehouseOptions.value = []
|
||||
warehousesUnavailable.value = false
|
||||
resetDetail()
|
||||
detailOpen.value = false
|
||||
applyFilters()
|
||||
}
|
||||
|
||||
function toggleAdvancedFilters() {
|
||||
if (!showAdvancedFilters.value) {
|
||||
showAdvancedFilters.value = true
|
||||
return
|
||||
}
|
||||
showAdvancedFilters.value = false
|
||||
filters.value = {
|
||||
...filters.value,
|
||||
orderStatus: '',
|
||||
feeReviewStatus: '',
|
||||
customerName: '',
|
||||
destinationCode: '',
|
||||
holdFlag: '',
|
||||
}
|
||||
applyFilters()
|
||||
}
|
||||
|
||||
watch(() => filters.value.customerId, (customerId) => {
|
||||
if (customerId === selectedCustomerId.value) return
|
||||
selectedCustomerId.value = customerId
|
||||
page.value = 1
|
||||
tab.value = ''
|
||||
filters.value = { ...filters.value, warehouseId: '', deliveryWarehouseId: '' }
|
||||
resetDetail()
|
||||
detailOpen.value = false
|
||||
void loadWarehouseOptions()
|
||||
void loadInbounds()
|
||||
})
|
||||
watch(tab, () => { page.value = 1; void loadInbounds() })
|
||||
watch([page, pageSize], () => { void loadInbounds() })
|
||||
onMounted(() => { void loadCustomers(); void loadInbounds() })
|
||||
onBeforeUnmount(() => { disposed = true; loadRequestId += 1; detailRequestId += 1; customerRequestId += 1; warehouseRequestId += 1 })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="business-page inbound-orders-page">
|
||||
<QueryToolbar v-model="filters" class="surface-panel inbound-query-toolbar" ariaLabel="入库单筛选" :fields="queryFields" :search-on-reset="false" @search="applyFilters" @reset="resetFilters">
|
||||
<template #actions>
|
||||
<el-button :aria-expanded="showAdvancedFilters" @click="toggleAdvancedFilters">
|
||||
<SlidersHorizontal :size="16" />
|
||||
{{ showAdvancedFilters ? '收起筛选' : '更多筛选' }}
|
||||
<ChevronUp v-if="showAdvancedFilters" :size="15" />
|
||||
<ChevronDown v-else :size="15" />
|
||||
</el-button>
|
||||
</template>
|
||||
</QueryToolbar>
|
||||
|
||||
<el-alert v-if="customerLoadError" type="warning" :closable="false" show-icon role="alert">
|
||||
<template #title><div class="api-error-alert-title"><span>{{ customerLoadError.message }}</span><ApiErrorMeta :details="customerLoadError" /></div></template>
|
||||
<el-button link type="warning" :loading="customersLoading" @click="loadCustomers">重新加载客户列表</el-button>
|
||||
</el-alert>
|
||||
|
||||
<LoadErrorState v-if="loadError" class="inbound-load-error" :message="loadError.message" title="入库单暂时无法加载" @retry="loadInbounds"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
|
||||
<section v-else class="surface-panel table-panel" :aria-busy="loading">
|
||||
<header class="table-toolbar">
|
||||
<nav class="inbound-tabs" role="tablist" aria-label="入库单处理状态">
|
||||
<button v-for="(item, index) in tabs" :key="item.value || 'all'" type="button" role="tab" class="inbound-tab" :class="{ 'is-active': tab === item.value }" :aria-selected="tab === item.value" :tabindex="tab === item.value ? 0 : -1" :title="item.description" @click="tab = item.value" @keydown="handleTabKeydown($event, index)">{{ item.label }}</button>
|
||||
</nav>
|
||||
<div class="table-toolbar-actions">
|
||||
<span class="result-summary">共 {{ result.total }} 条 · 所属企业 {{ selectedCustomerName }}</span>
|
||||
<el-button :loading="loading" @click="loadInbounds"><RefreshCw :size="16" />刷新</el-button>
|
||||
</div>
|
||||
</header>
|
||||
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="result.records" :columns="columns" :loading="loading" :total="result.total" :page-sizes="[20, 50, 100]" row-key="id" :empty-text="listEmptyText" aria-label="入库单列表">
|
||||
<template #actions="{ row }"><div class="row-actions"><el-button link type="primary" @click="openWorkspace(row as InboundOrderSummary)"><ClipboardCheck :size="15" />处理</el-button><el-button link @click="openDetail(row as InboundOrderSummary)"><Eye :size="15" />查看</el-button></div></template>
|
||||
<template #inbound="{ row }"><div class="primary-cell"><strong>{{ text((row as InboundOrderSummary).inboundCode) }}</strong><span>订单 {{ text((row as InboundOrderSummary).orderNo) }}</span></div></template>
|
||||
<template #orderStatus="{ row }"><div class="status-stack"><el-tag :type="statusType((row as InboundOrderSummary).processStatus)" effect="light">{{ statusLabel((row as InboundOrderSummary).processStatus) }}</el-tag><span>订单:{{ text((row as InboundOrderSummary).orderStatusSnapshot) }}</span></div></template>
|
||||
<template #measureReviewStatus="{ row }">{{ codeLabel((row as InboundOrderSummary).measureReviewStatus, measureReviewLabels) }}</template>
|
||||
<template #expectedArrivalAt="{ row }">{{ formatDate((row as InboundOrderSummary).expectedArrivalAt) }}</template>
|
||||
<template #feeStatus="{ row }"><div class="status-stack"><span>{{ codeLabel((row as InboundOrderSummary).feeStatus, feeStatusLabels) }}</span><small v-if="(row as InboundOrderSummary).feeReviewStatus">审核:{{ text((row as InboundOrderSummary).feeReviewStatus) }}</small></div></template>
|
||||
<template #customerName="{ row }">{{ text((row as InboundOrderSummary).customerName) }}</template>
|
||||
<template #warehouseName="{ row }">{{ text((row as InboundOrderSummary).warehouseName) }}</template>
|
||||
<template #deliveryWarehouseName="{ row }">{{ text((row as InboundOrderSummary).deliveryWarehouseName) }}</template>
|
||||
<template #loadingStatus="{ row }">{{ codeLabel((row as InboundOrderSummary).loadingStatus, loadingStatusLabels) }}</template>
|
||||
<template #holdFlag="{ row }"><el-tag :type="(row as InboundOrderSummary).holdFlag ? 'danger' : 'info'" effect="plain">{{ (row as InboundOrderSummary).holdFlag ? '是' : '否' }}</el-tag></template>
|
||||
<template #currentHoldReason="{ row }">{{ text((row as InboundOrderSummary).currentHoldReason) }}</template>
|
||||
<template #containerNo="{ row }">{{ text((row as InboundOrderSummary).containerNo) }}</template>
|
||||
<template #cabinetNo="{ row }">{{ text((row as InboundOrderSummary).cabinetNo) }}</template>
|
||||
<template #companyName="{ row }">{{ text((row as InboundOrderSummary).companyName) }}</template>
|
||||
<template #businessType="{ row }">{{ text((row as InboundOrderSummary).businessType) }}</template>
|
||||
<template #channelName="{ row }"><div class="primary-cell"><strong>{{ text((row as InboundOrderSummary).channelName) }}</strong><span>{{ text((row as InboundOrderSummary).channelCode) }}</span></div></template>
|
||||
<template #taxMode="{ row }">{{ codeLabel((row as InboundOrderSummary).taxMode, taxModeLabels) }}</template>
|
||||
<template #containerMode="{ row }">{{ codeLabel((row as InboundOrderSummary).containerMode, containerModeLabels) }}</template>
|
||||
<template #customsInspection="{ row }">{{ (row as InboundOrderSummary).customsInspection === null ? '—' : (row as InboundOrderSummary).customsInspection ? '是' : '否' }}</template>
|
||||
<template #sailingAt="{ row }">{{ formatDate((row as InboundOrderSummary).sailingAt) }}</template>
|
||||
<template #portArrivalAt="{ row }">{{ formatDate((row as InboundOrderSummary).portArrivalAt) }}</template>
|
||||
<template #totalPackageCount="{ row }">{{ text((row as InboundOrderSummary).totalPackageCount) }}</template>
|
||||
<template #actualGrossWeightKg="{ row }">{{ formatNumber((row as InboundOrderSummary).actualGrossWeightKg) }}</template>
|
||||
<template #chargeableWeightKg="{ row }">{{ formatNumber((row as InboundOrderSummary).chargeableWeightKg) }}</template>
|
||||
<template #actualVolumeCbm="{ row }">{{ formatNumber((row as InboundOrderSummary).actualVolumeCbm) }}</template>
|
||||
<template #chargeableVolumeCbm="{ row }">{{ formatNumber((row as InboundOrderSummary).chargeableVolumeCbm) }}</template>
|
||||
<template #palletCount="{ row }">{{ text((row as InboundOrderSummary).palletCount) }}</template>
|
||||
<template #locationCodes="{ row }">{{ text((row as InboundOrderSummary).locationCodes) }}</template>
|
||||
<template #volumeWeightKg="{ row }">{{ formatNumber((row as InboundOrderSummary).volumeWeightKg) }}</template>
|
||||
<template #actualNetWeightKg="{ row }">{{ formatNumber((row as InboundOrderSummary).actualNetWeightKg) }}</template>
|
||||
<template #destinationType="{ row }">{{ text((row as InboundOrderSummary).destinationType) }}</template>
|
||||
<template #destinationName="{ row }"><div class="primary-cell"><strong>{{ text((row as InboundOrderSummary).destinationName) }}</strong><span>{{ text((row as InboundOrderSummary).destinationCode) }}</span></div></template>
|
||||
<template #measureCompletedAt="{ row }">{{ formatDate((row as InboundOrderSummary).measureCompletedAt) }}</template>
|
||||
<template #inboundedAt="{ row }">{{ formatDate((row as InboundOrderSummary).inboundedAt) }}</template>
|
||||
<template #expressCompanyName="{ row }">{{ text((row as InboundOrderSummary).expressCompanyName) }}</template>
|
||||
<template #expressTrackingNo="{ row }">{{ text((row as InboundOrderSummary).expressTrackingNo) }}</template>
|
||||
<template #createdAt="{ row }">{{ formatDate((row as InboundOrderSummary).createdAt) }}</template>
|
||||
<template #empty><el-empty :description="listEmptyText" /></template>
|
||||
</DataTable>
|
||||
</section>
|
||||
|
||||
<AppDrawer v-model="detailOpen" :title="detail ? `入库单详情 · ${detail.summary.inboundCode || detail.summary.id}` : '入库单详情'" size="min(720px, 94vw)" :loading="detailLoading" :show-footer="false" destroy-on-close @closed="closeDetail">
|
||||
<section v-if="detailError" class="detail-error" role="alert"><strong>入库单详情暂时无法加载</strong><p>{{ detailError.message }}</p><ApiErrorMeta :details="detailError" /><el-button type="primary" :disabled="!detailTarget" @click="detailTarget && openDetail(detailTarget)">重新加载</el-button></section>
|
||||
<div v-else-if="detail" class="detail-content"><InboundSummaryPanel :summary="detail.summary" /><InboundHistoryPanel :pallet-locations="detail.palletLocations" :files="detail.files" :internal-notes="detail.internalNotes" :status-history="detail.statusHistory" :public-remark="detail.publicRemark" /></div>
|
||||
</AppDrawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.inbound-orders-page { min-width: 0; }
|
||||
.inbound-tabs { display: flex; max-width: 100%; min-width: 0; overflow-x: auto; padding: 4px; border: 1px solid var(--cc-border); border-radius: 8px; background: #f7f9fc; scrollbar-width: none; }
|
||||
.inbound-tabs::-webkit-scrollbar { display: none; }
|
||||
.inbound-tab { min-height: 44px; flex: 0 0 auto; padding: 8px 18px; border: 1px solid transparent; border-radius: 6px; background: transparent; color: var(--cc-muted); cursor: pointer; font-size: 13px; line-height: 1.4; white-space: nowrap; transition: background-color .16s ease, border-color .16s ease, color .16s ease, box-shadow .16s ease; }
|
||||
.inbound-tab:hover { background: #fff; color: var(--cc-primary); }
|
||||
.inbound-tab:focus-visible { outline: 3px solid rgba(102, 126, 234, .2); outline-offset: 1px; }
|
||||
.inbound-tab.is-active { border-color: #d8e0f0; background: #fff; color: var(--cc-primary); box-shadow: 0 1px 3px rgba(16, 24, 40, .08); font-weight: 650; }
|
||||
.inbound-load-error { margin-top: 12px; }
|
||||
.inbound-query-toolbar { border-radius: 8px; }
|
||||
.row-actions { display: flex; align-items: center; gap: 2px; white-space: nowrap; }
|
||||
.row-actions :deep(.el-button) { margin-left: 0; }
|
||||
.table-toolbar { display: flex; min-width: 0; align-items: center; justify-content: space-between; gap: 16px; min-height: 68px; padding: 10px 14px; border-bottom: 1px solid var(--cc-border); }
|
||||
.table-toolbar-actions { display: flex; flex: 0 0 auto; align-items: center; gap: 14px; }
|
||||
.result-summary { color: var(--cc-muted); font-size: 12px; white-space: nowrap; }
|
||||
.table-toolbar :deep(.el-button) { min-height: 40px; margin-left: 0; }
|
||||
.primary-cell, .status-stack { display: grid; min-width: 0; justify-items: start; gap: 4px; }
|
||||
.primary-cell strong, .primary-cell span, .status-stack span, .status-stack small { overflow-wrap: anywhere; }
|
||||
.primary-cell span, .status-stack span, .status-stack small { color: var(--cc-muted); font-size: 12px; }
|
||||
.detail-content { display: grid; gap: 24px; min-width: 0; }
|
||||
.detail-error { display: flex; min-height: 240px; align-items: flex-start; justify-content: center; flex-direction: column; }
|
||||
.detail-error strong { color: var(--cc-danger); font-size: 15px; }
|
||||
.detail-error p { margin: 7px 0; color: var(--cc-text); font-size: 13px; }
|
||||
.detail-error :deep(.el-button) { margin-top: 14px; }
|
||||
@media (max-width: 900px) { .table-toolbar { align-items: stretch; flex-direction: column; } .inbound-tabs { width: 100%; } .table-toolbar-actions { justify-content: space-between; } }
|
||||
@media (max-width: 720px) { .inbound-tab { padding-inline: 14px; } .table-toolbar { padding: 10px; } .table-toolbar-actions { gap: 10px; } .result-summary { min-width: 0; overflow: hidden; text-overflow: ellipsis; } .table-toolbar :deep(.el-button) { min-height: 44px; } }
|
||||
</style>
|
||||
@@ -0,0 +1,94 @@
|
||||
import ElementPlus, { ElMessageBox } from 'element-plus'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { useAuthStore } from '@/core/auth/auth'
|
||||
import DataTable from '@/shared/components/DataTable.vue'
|
||||
import { approveMeasurement, getMeasurementReviewDetail, listMeasurementReviewHistory } from './api'
|
||||
import MeasurementReviewPanel from './MeasurementReviewPanel.vue'
|
||||
|
||||
vi.mock('./api', () => ({
|
||||
approveMeasurement: vi.fn(),
|
||||
getMeasurementReviewDetail: vi.fn(),
|
||||
listMeasurementReviewHistory: vi.fn(),
|
||||
rejectMeasurement: vi.fn(),
|
||||
releaseRejectedMeasurement: vi.fn(),
|
||||
}))
|
||||
|
||||
const detail = {
|
||||
inboundId: '90001', inboundCode: 'RK90001', companyName: '柏悦客户', channelCode: 'SEA', channelName: '海运',
|
||||
processStatus: 'MEASURE_PENDING', measureReviewStatus: 'PENDING', recommendedPalletType: 'STANDARD', reviewReason: null, reviewerId: null, reviewerName: null,
|
||||
measurementId: '70001', sourceType: 'DEVICE', measurementStatus: 'SUBMITTED', version: 4,
|
||||
originalDocumentNo: 'ORD-001', originalGrossWeightKg: 100, originalVolumeCbm: 2,
|
||||
actualPackageCount: 10, actualGrossWeightKg: 110, actualVolumeCbm: 2.2, volumeWeightKg: 367.4, actualGoodsName: '测试货物',
|
||||
items: [{ id: '1', itemNo: '001', boxSpecification: '60×40×30', packageCount: 10, lengthCm: 60, widthCm: 40, heightCm: 30, girthCm: 240, unitWeightKg: 11, unitVolumeCbm: 0.072, totalWeightKg: 110, totalVolumeCbm: 0.72, volumeWeightKg: 120.24, intelligentImageFileId: '30001', overLimitWarning: '周长超过渠道限制,请人工核验尺寸后再作出审核决定', outerLengthCm: 61, outerWidthCm: 41, outerHeightCm: 31, remark: '外箱存在轻微变形' }],
|
||||
weightResults: [{ id: '2', fileId: 'file-1', ocrProvider: 'OCR', ocrRawText: '设备屏幕原始识别内容 110.5 KG,包含一段需要自动换行展示的较长文本', ocrWeightValue: 110.5, ocrWeightUnit: 'KG', confidence: 0.98, adjustedWeightValue: 110, adjustedWeightUnit: 'KG', finalWeightKg: 110, recognitionStatus: 'CONFIRMED', adjustmentReason: '去除托盘重量后采用净值', adjustedBy: '20001', adjustedAt: '2026-08-04T09:58:00', confirmedBy: '20002', confirmedAt: '2026-08-04T10:00:00', version: 1 }],
|
||||
}
|
||||
|
||||
enableAutoUnmount(afterEach)
|
||||
|
||||
describe('MeasurementReviewPanel', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
setActivePinia(createPinia())
|
||||
const auth = useAuthStore()
|
||||
auth.token = 'token'
|
||||
auth.permissions = ['firstleg:inbound:measurement-review', 'firstleg:inbound:measurement-review-history', 'firstleg:inbound:exception-release']
|
||||
useAccessPreviewStore().initializeAfterLogin()
|
||||
vi.mocked(getMeasurementReviewDetail).mockResolvedValue(detail)
|
||||
vi.mocked(listMeasurementReviewHistory).mockResolvedValue({ records: [], page: 1, size: 20, total: 0, pages: 0 })
|
||||
})
|
||||
|
||||
it('loads and compares original, device and OCR data without exposing a normal fee action', async () => {
|
||||
const wrapper = mount(MeasurementReviewPanel, { props: { customerId: '10001', inboundId: '90001' }, global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('原单申报')
|
||||
expect(wrapper.text()).toContain('设备实测')
|
||||
expect(wrapper.text()).toContain('称重 OCR')
|
||||
expect(wrapper.text()).toContain('外箱长 × 宽 × 高(cm)')
|
||||
expect(wrapper.text()).toContain('周长超过渠道限制')
|
||||
expect(wrapper.text()).toContain('OCR 原文:设备屏幕原始识别内容')
|
||||
expect(wrapper.text()).toContain('调整原因:去除托盘重量后采用净值')
|
||||
expect(wrapper.findComponent(DataTable).exists()).toBe(true)
|
||||
expect(wrapper.text()).not.toContain('租户')
|
||||
expect(wrapper.text()).toContain('通过并完成入库')
|
||||
expect(wrapper.text()).not.toContain('确认费用')
|
||||
expect(getMeasurementReviewDetail).toHaveBeenCalledWith('10001', '90001')
|
||||
})
|
||||
|
||||
it('shows the rejected release action only when the permission and rejected state exist', async () => {
|
||||
vi.mocked(getMeasurementReviewDetail).mockResolvedValue({ ...detail, processStatus: 'MEASURE_REJECTED', measureReviewStatus: 'REJECTED', reviewReason: '重量图片不清晰' })
|
||||
const wrapper = mount(MeasurementReviewPanel, { props: { customerId: '10001', inboundId: '90001' }, global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
expect(wrapper.text()).toContain('异常放行')
|
||||
expect(wrapper.text()).toContain('重量图片不清晰')
|
||||
|
||||
const auth = useAuthStore()
|
||||
auth.permissions = ['firstleg:inbound:measurement-review-history']
|
||||
useAccessPreviewStore().initializeAfterLogin()
|
||||
await wrapper.vm.$nextTick()
|
||||
expect(wrapper.findAll('button').some((button) => button.text().includes('异常放行'))).toBe(false)
|
||||
})
|
||||
|
||||
it('emits changed after a successful review write', async () => {
|
||||
vi.mocked(approveMeasurement).mockResolvedValue({ feeId: '1', feeNo: 'FEE-1', feeVersion: 1, feeStatus: 'SENT', inboundId: '90001', totalAmount: 100, currency: 'CNY', nextProcessStatus: 'INBOUNDED' })
|
||||
vi.spyOn(ElMessageBox, 'confirm').mockResolvedValue(undefined as never)
|
||||
const wrapper = mount(MeasurementReviewPanel, { props: { customerId: '10001', inboundId: '90001' }, global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
const buttons = wrapper.findAll('button')
|
||||
const approve = buttons.find((button) => button.text().includes('通过并完成入库'))
|
||||
expect(approve).toBeDefined()
|
||||
await approve?.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(ElMessageBox.confirm).toHaveBeenCalledWith(
|
||||
expect.stringContaining('自动试算并发布费用'),
|
||||
'确认测量审核通过',
|
||||
expect.objectContaining({ confirmButtonText: '通过并完成入库' }),
|
||||
)
|
||||
expect(approveMeasurement).toHaveBeenCalledWith('10001', '90001', { measurementId: '70001', expectedVersion: 4 })
|
||||
expect(wrapper.emitted('changed')).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,342 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Check, CircleAlert, History, RotateCcw, ShieldAlert, X } from '@lucide/vue'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import StatusBadge from '@/shared/components/StatusBadge.vue'
|
||||
import {
|
||||
approveMeasurement,
|
||||
getMeasurementReviewDetail,
|
||||
listMeasurementReviewHistory,
|
||||
rejectMeasurement,
|
||||
releaseRejectedMeasurement,
|
||||
} from './api'
|
||||
import type { MeasurementReviewDetail, MeasurementReviewItem, MeasurementReviewRecord } from './types'
|
||||
|
||||
const props = defineProps<{
|
||||
customerId?: string
|
||||
/** @deprecated 仅用于兼容尚未完成 customerId 映射的上层集成。 */
|
||||
tenantId?: string
|
||||
inboundId: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
changed: []
|
||||
}>()
|
||||
|
||||
const access = useAccessPreviewStore()
|
||||
const detail = ref<MeasurementReviewDetail | null>(null)
|
||||
const history = ref<MeasurementReviewRecord[]>([])
|
||||
const loading = ref(false)
|
||||
const historyLoading = ref(false)
|
||||
const actionLoading = ref('')
|
||||
const loadError = ref<ApiErrorDetails | null>(null)
|
||||
const historyError = ref<ApiErrorDetails | null>(null)
|
||||
const reasonDialog = ref<'reject' | 'release' | null>(null)
|
||||
const reason = ref('')
|
||||
const requestSerial = ref(0)
|
||||
const reasonDialogOpen = computed({
|
||||
get: () => reasonDialog.value !== null,
|
||||
set: (open: boolean) => { if (!open) reasonDialog.value = null },
|
||||
})
|
||||
|
||||
const canReview = computed(() => access.can('firstleg:inbound:measurement-review'))
|
||||
const canRelease = computed(() => access.can('firstleg:inbound:exception-release'))
|
||||
const customerId = computed(() => props.customerId || props.tenantId || '')
|
||||
const isPending = computed(() => detail.value?.measureReviewStatus === 'PENDING' && detail.value?.processStatus === 'MEASURE_PENDING')
|
||||
const isRejected = computed(() => detail.value?.measureReviewStatus === 'REJECTED' && detail.value?.processStatus === 'MEASURE_REJECTED')
|
||||
const confirmedWeights = computed(() => detail.value?.weightResults.filter((item) => item.confirmedAt && item.finalWeightKg !== null).length ?? 0)
|
||||
const reviewStatusLabel = computed(() => {
|
||||
if (isPending.value) return '待审核'
|
||||
if (isRejected.value) return '已驳回'
|
||||
if (detail.value?.measureReviewStatus === 'APPROVED') return '已通过'
|
||||
return detail.value?.measureReviewStatus || '未提交'
|
||||
})
|
||||
|
||||
const measurementColumns: DataTableColumn[] = [
|
||||
{ key: 'boxSpecification', label: '箱规', minWidth: 130, slotName: 'boxSpecification' },
|
||||
{ key: 'packageCount', label: '箱数', width: 80, slotName: 'packageCount' },
|
||||
{ key: 'dimensions', label: '长 × 宽 × 高(cm)', minWidth: 170, slotName: 'dimensions' },
|
||||
{ key: 'outerDimensions', label: '外箱长 × 宽 × 高(cm)', minWidth: 190, slotName: 'outerDimensions' },
|
||||
{ key: 'girthCm', label: '周长(cm)', minWidth: 105, slotName: 'girthCm' },
|
||||
{ key: 'unitWeightKg', label: '每箱毛重(kg)', minWidth: 125, slotName: 'unitWeightKg' },
|
||||
{ key: 'unitVolumeCbm', label: '每箱体积(m³)', minWidth: 125, slotName: 'unitVolumeCbm' },
|
||||
{ key: 'totalWeightKg', label: '总毛重(kg)', minWidth: 110, slotName: 'totalWeightKg' },
|
||||
{ key: 'totalVolumeCbm', label: '总体积(m³)', minWidth: 110, slotName: 'totalVolumeCbm' },
|
||||
{ key: 'volumeWeightKg', label: '材积重(kg)', minWidth: 110, slotName: 'volumeWeightKg' },
|
||||
{ key: 'note', label: '超限提示 / 备注', minWidth: 200, slotName: 'note' },
|
||||
]
|
||||
|
||||
function formatNumber(value: number | null | undefined, unit = '') {
|
||||
if (value === null || value === undefined || Number.isNaN(value)) return '-'
|
||||
return `${value.toLocaleString('zh-CN', { maximumFractionDigits: 3 })}${unit}`
|
||||
}
|
||||
|
||||
function formatDate(value: string | null | undefined) {
|
||||
return value ? value.replace('T', ' ') : '-'
|
||||
}
|
||||
|
||||
function resultLabel(value: string | null | undefined) {
|
||||
if (value === 'APPROVED') return '审核通过'
|
||||
if (value === 'REJECTED') return '审核驳回'
|
||||
return value || '未知结果'
|
||||
}
|
||||
|
||||
function isConflict(error: unknown) {
|
||||
const details = getApiErrorDetails(error)
|
||||
return details.code === 'A0409' || details.code === '409' || /并发|版本|已被更新|状态已变化/.test(details.message)
|
||||
}
|
||||
|
||||
async function loadReview() {
|
||||
const serial = ++requestSerial.value
|
||||
loadError.value = null
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await getMeasurementReviewDetail(customerId.value, props.inboundId)
|
||||
if (serial === requestSerial.value) detail.value = result
|
||||
} catch (error) {
|
||||
if (serial === requestSerial.value) {
|
||||
detail.value = null
|
||||
loadError.value = getApiErrorDetails(error, '测量审核数据加载失败')
|
||||
}
|
||||
} finally {
|
||||
if (serial === requestSerial.value) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadHistory() {
|
||||
historyError.value = null
|
||||
historyLoading.value = true
|
||||
try {
|
||||
const result = await listMeasurementReviewHistory(customerId.value, props.inboundId)
|
||||
history.value = result.records
|
||||
} catch (error) {
|
||||
history.value = []
|
||||
historyError.value = getApiErrorDetails(error, '审核历史加载失败')
|
||||
} finally {
|
||||
historyLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function reload() {
|
||||
await Promise.all([loadReview(), loadHistory()])
|
||||
}
|
||||
|
||||
async function confirmApprove() {
|
||||
if (!detail.value || !isPending.value || !canReview.value) return
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
'审核通过后,系统会自动试算并发布费用,同时完成托盘库位占用和入库。客户无需再次确认费用。是否继续?',
|
||||
'确认测量审核通过',
|
||||
{ confirmButtonText: '通过并完成入库', cancelButtonText: '返回', type: 'warning' },
|
||||
)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
actionLoading.value = 'approve'
|
||||
try {
|
||||
await approveMeasurement(customerId.value, props.inboundId, {
|
||||
measurementId: detail.value.measurementId,
|
||||
expectedVersion: detail.value.version,
|
||||
})
|
||||
ElMessage.success('审核已通过,费用发布和入库正在完成')
|
||||
await reload()
|
||||
emit('changed')
|
||||
} catch (error) {
|
||||
ElMessage.error(isConflict(error) ? '数据已被更新,请刷新后重新审核' : getApiErrorDetails(error, '审核通过失败').message)
|
||||
if (isConflict(error)) await reload()
|
||||
} finally {
|
||||
actionLoading.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
function openReasonDialog(kind: 'reject' | 'release') {
|
||||
reason.value = ''
|
||||
reasonDialog.value = kind
|
||||
}
|
||||
|
||||
async function submitReason() {
|
||||
if (!detail.value || !reasonDialog.value) return
|
||||
const value = reason.value.trim()
|
||||
if (!value) {
|
||||
ElMessage.warning(reasonDialog.value === 'reject' ? '请填写驳回原因' : '请填写异常放行原因')
|
||||
return
|
||||
}
|
||||
const kind = reasonDialog.value
|
||||
actionLoading.value = kind
|
||||
try {
|
||||
if (kind === 'reject') {
|
||||
await rejectMeasurement(customerId.value, props.inboundId, {
|
||||
measurementId: detail.value.measurementId,
|
||||
expectedVersion: detail.value.version,
|
||||
reason: value,
|
||||
})
|
||||
ElMessage.success('测量已驳回,已退回测量修正')
|
||||
} else {
|
||||
await releaseRejectedMeasurement(customerId.value, props.inboundId, {
|
||||
measurementId: detail.value.measurementId,
|
||||
expectedVersion: detail.value.version,
|
||||
reason: value,
|
||||
})
|
||||
ElMessage.success('异常放行已提交,后续计费入库将继续处理')
|
||||
}
|
||||
reasonDialog.value = null
|
||||
await reload()
|
||||
emit('changed')
|
||||
} catch (error) {
|
||||
ElMessage.error(isConflict(error) ? '数据已被更新,请刷新后重试' : getApiErrorDetails(error, kind === 'reject' ? '测量驳回失败' : '异常放行失败').message)
|
||||
if (isConflict(error)) await reload()
|
||||
} finally {
|
||||
actionLoading.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => [customerId.value, props.inboundId], () => {
|
||||
if (customerId.value && props.inboundId) void reload()
|
||||
}, { immediate: true })
|
||||
|
||||
onMounted(() => {
|
||||
if (!customerId.value || !props.inboundId) loadError.value = { message: '缺少所属企业或入库单标识,无法加载审核数据' }
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="review-panel" aria-labelledby="measurement-review-title">
|
||||
<header class="review-header">
|
||||
<div>
|
||||
<h2 id="measurement-review-title">测量审核</h2>
|
||||
<p>核对原单、设备实测和称重 OCR 后,再决定是否进入自动计费入库。</p>
|
||||
</div>
|
||||
<div class="review-header-status">
|
||||
<StatusBadge v-if="detail" :status="reviewStatusLabel" />
|
||||
<el-button text :loading="loading || historyLoading" aria-label="刷新审核数据" @click="reload"><RotateCcw :size="16" />刷新</el-button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<el-alert v-if="loadError" type="error" :closable="false" show-icon role="alert">
|
||||
<template #title><div class="review-alert-title"><span>{{ loadError.message }}</span><ApiErrorMeta :details="loadError" /></div></template>
|
||||
<el-button link type="danger" @click="reload">重新加载</el-button>
|
||||
</el-alert>
|
||||
|
||||
<el-skeleton v-if="loading && !detail" :rows="8" animated />
|
||||
|
||||
<template v-else-if="detail">
|
||||
<div class="comparison-grid">
|
||||
<article class="comparison-card">
|
||||
<span class="comparison-label">原单申报</span>
|
||||
<strong>{{ detail.originalDocumentNo || detail.inboundCode || '-' }}</strong>
|
||||
<dl>
|
||||
<div><dt>申报毛重</dt><dd>{{ formatNumber(detail.originalGrossWeightKg, ' kg') }}</dd></div>
|
||||
<div><dt>申报体积</dt><dd>{{ formatNumber(detail.originalVolumeCbm, ' CBM') }}</dd></div>
|
||||
</dl>
|
||||
</article>
|
||||
<article class="comparison-card">
|
||||
<span class="comparison-label">设备实测</span>
|
||||
<strong>{{ formatNumber(detail.actualPackageCount, ' 箱') }}</strong>
|
||||
<dl>
|
||||
<div><dt>实测体积</dt><dd>{{ formatNumber(detail.actualVolumeCbm, ' CBM') }}</dd></div>
|
||||
<div><dt>材积重</dt><dd>{{ formatNumber(detail.volumeWeightKg, ' kg') }}</dd></div>
|
||||
</dl>
|
||||
</article>
|
||||
<article class="comparison-card">
|
||||
<span class="comparison-label">称重 OCR</span>
|
||||
<strong>{{ formatNumber(detail.actualGrossWeightKg, ' kg') }}</strong>
|
||||
<dl>
|
||||
<div><dt>已确认图片</dt><dd>{{ confirmedWeights }} / {{ detail.weightResults.length }}</dd></div>
|
||||
<div><dt>测量来源</dt><dd>{{ detail.sourceType || '-' }}</dd></div>
|
||||
</dl>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div v-if="detail.reviewReason" class="review-reason">
|
||||
<CircleAlert :size="17" /><span><strong>最近审核说明:</strong>{{ detail.reviewReason }}</span>
|
||||
</div>
|
||||
|
||||
<div class="review-section">
|
||||
<div class="section-heading"><div><h3>三组数据对比</h3><p>{{ detail.companyName || '-' }} · {{ detail.channelName || detail.channelCode || '-' }}</p></div><span class="section-count">{{ detail.items.length }} 条箱规</span></div>
|
||||
<DataTable :data="detail.items" :columns="measurementColumns" empty-text="暂无设备测量明细" border stripe>
|
||||
<template #boxSpecification="{ row }">{{ (row as MeasurementReviewItem).boxSpecification || (row as MeasurementReviewItem).itemNo || '-' }}</template>
|
||||
<template #packageCount="{ row }">{{ (row as MeasurementReviewItem).packageCount ?? '-' }}</template>
|
||||
<template #dimensions="{ row }">{{ [(row as MeasurementReviewItem).lengthCm, (row as MeasurementReviewItem).widthCm, (row as MeasurementReviewItem).heightCm].map((value) => formatNumber(value)).join(' × ') }}</template>
|
||||
<template #outerDimensions="{ row }">{{ [(row as MeasurementReviewItem).outerLengthCm, (row as MeasurementReviewItem).outerWidthCm, (row as MeasurementReviewItem).outerHeightCm].map((value) => formatNumber(value)).join(' × ') }}</template>
|
||||
<template #girthCm="{ row }">{{ formatNumber((row as MeasurementReviewItem).girthCm) }}</template>
|
||||
<template #unitWeightKg="{ row }">{{ formatNumber((row as MeasurementReviewItem).unitWeightKg) }}</template>
|
||||
<template #unitVolumeCbm="{ row }">{{ formatNumber((row as MeasurementReviewItem).unitVolumeCbm) }}</template>
|
||||
<template #totalWeightKg="{ row }">{{ formatNumber((row as MeasurementReviewItem).totalWeightKg) }}</template>
|
||||
<template #totalVolumeCbm="{ row }">{{ formatNumber((row as MeasurementReviewItem).totalVolumeCbm) }}</template>
|
||||
<template #volumeWeightKg="{ row }">{{ formatNumber((row as MeasurementReviewItem).volumeWeightKg) }}</template>
|
||||
<template #note="{ row }"><div class="measurement-note"><strong v-if="(row as MeasurementReviewItem).overLimitWarning">{{ (row as MeasurementReviewItem).overLimitWarning }}</strong><span>{{ (row as MeasurementReviewItem).remark || ((row as MeasurementReviewItem).overLimitWarning ? '' : '-') }}</span></div></template>
|
||||
<template #empty><el-empty description="暂无设备测量明细" :image-size="48" /></template>
|
||||
</DataTable>
|
||||
</div>
|
||||
|
||||
<div class="review-section">
|
||||
<div class="section-heading"><div><h3>称重图片识别结果</h3><p>原始 OCR 值不可覆盖,只有已确认且有效的结果会计入实际重量。</p></div><span class="section-count">{{ detail.weightResults.length }} 张</span></div>
|
||||
<div class="weight-list">
|
||||
<div v-for="item in detail.weightResults" :key="item.id" class="weight-row">
|
||||
<div><strong>{{ item.fileId || '未关联文件' }}</strong><span>{{ item.ocrProvider || 'OCR' }} · {{ item.recognitionStatus || '待确认' }}</span><p v-if="item.ocrRawText">OCR 原文:{{ item.ocrRawText }}</p><p v-if="item.adjustmentReason">调整原因:{{ item.adjustmentReason }}</p></div>
|
||||
<div><span>原始</span><strong>{{ formatNumber(item.ocrWeightValue, ` ${item.ocrWeightUnit || ''}`) }}</strong></div>
|
||||
<div><span>调整后</span><strong>{{ formatNumber(item.adjustedWeightValue, ` ${item.adjustedWeightUnit || ''}`) }}</strong></div>
|
||||
<div><span>最终</span><strong>{{ formatNumber(item.finalWeightKg, ' kg') }}</strong></div>
|
||||
<div><span>置信度</span><strong>{{ item.confidence === null ? '-' : `${Math.round(item.confidence * 100)}%` }}</strong></div>
|
||||
</div>
|
||||
<el-empty v-if="!detail.weightResults.length" description="暂无称重图片识别结果" :image-size="48" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="review-actions" :class="{ 'review-actions--disabled': !isPending && !isRejected }">
|
||||
<div><strong>审核结论</strong><span v-if="isPending">通过后将自动完成计费、费用发布和入库。</span><span v-else-if="isRejected">当前测量已驳回,可修正后重新提交,或按权限申请异常放行。</span><span v-else>当前记录不在可操作的测量审核状态。</span></div>
|
||||
<div class="action-buttons">
|
||||
<el-button v-if="isPending && canReview" type="danger" plain :loading="actionLoading === 'reject'" @click="openReasonDialog('reject')"><X :size="16" />驳回</el-button>
|
||||
<el-button v-if="isPending && canReview" type="primary" :loading="actionLoading === 'approve'" @click="confirmApprove"><Check :size="16" />通过并完成入库</el-button>
|
||||
<el-button v-if="isRejected && canRelease" type="warning" plain :loading="actionLoading === 'release'" @click="openReasonDialog('release')"><ShieldAlert :size="16" />异常放行</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="review-section history-section">
|
||||
<div class="section-heading"><div><h3><History :size="17" />审核历史</h3><p>每次审核均保留操作人、原因和放行开关快照。</p></div></div>
|
||||
<el-alert v-if="historyError" type="warning" :closable="false" show-icon><template #title><div class="review-alert-title"><span>{{ historyError.message }}</span><ApiErrorMeta :details="historyError" /></div></template><el-button link type="warning" @click="loadHistory">重试</el-button></el-alert>
|
||||
<el-skeleton v-else-if="historyLoading" :rows="3" animated />
|
||||
<el-empty v-else-if="!history.length" description="暂无审核历史" :image-size="48" />
|
||||
<ol v-else class="history-list">
|
||||
<li v-for="item in history" :key="item.id">
|
||||
<div class="history-marker" :class="{ 'history-marker--success': item.reviewResult === 'APPROVED', 'history-marker--danger': item.reviewResult === 'REJECTED' }" />
|
||||
<div class="history-content"><div><strong>{{ resultLabel(item.reviewResult) }}{{ item.exceptionRelease ? ' · 异常放行' : '' }}</strong><time>{{ formatDate(item.reviewedAt) }}</time></div><p>{{ item.releaseReason || item.reviewReason || '未填写说明' }}</p><span>{{ item.reviewerName || '系统' }} · {{ item.allowRejectedConfig ? '放行开关已开启' : '放行开关未开启' }}</span></div>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-dialog v-model="reasonDialogOpen" :title="reasonDialog === 'reject' ? '填写驳回原因' : '填写异常放行原因'" width="520px" destroy-on-close>
|
||||
<el-alert v-if="reasonDialog === 'release'" title="异常放行不会抹去驳回记录;请说明为什么允许该测量继续进入自动计费入库。" type="warning" :closable="false" show-icon />
|
||||
<el-input v-model="reason" type="textarea" :rows="5" maxlength="1000" show-word-limit :placeholder="reasonDialog === 'reject' ? '请说明需要修正的测量问题' : '请填写异常放行的业务原因'" />
|
||||
<template #footer><el-button :disabled="Boolean(actionLoading)" @click="reasonDialog = null">取消</el-button><el-button :type="reasonDialog === 'reject' ? 'danger' : 'warning'" :loading="Boolean(actionLoading)" @click="submitReason">确认{{ reasonDialog === 'reject' ? '驳回' : '放行' }}</el-button></template>
|
||||
</el-dialog>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.review-panel { display: grid; gap: 20px; color: var(--cc-text); }
|
||||
.review-header { display: flex; justify-content: space-between; align-items: flex-start; gap: 18px; padding-bottom: 4px; }
|
||||
.review-header h2 { margin: 0 0 5px; font-size: 22px; line-height: 1.25; }
|
||||
.review-header p, .section-heading p { margin: 0; color: var(--cc-muted); font-size: 13px; line-height: 1.55; }
|
||||
.review-header-status { display: flex; align-items: center; gap: 10px; white-space: nowrap; }
|
||||
.review-alert-title { display: inline-flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
.comparison-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; }
|
||||
.comparison-card { min-width: 0; padding: 16px; border: 1px solid var(--cc-border); border-radius: 8px; background: var(--cc-surface); }
|
||||
.comparison-card strong { display: block; margin: 8px 0 12px; font-size: 21px; line-height: 1.2; overflow-wrap: anywhere; }
|
||||
.comparison-label { color: var(--cc-muted); font-size: 12px; font-weight: 700; }
|
||||
dl { display: grid; gap: 7px; margin: 0; }.comparison-card dl div { display: flex; justify-content: space-between; gap: 12px; font-size: 12px; }.comparison-card dt { color: var(--cc-muted); }.comparison-card dd { margin: 0; font-weight: 600; text-align: right; }
|
||||
.review-reason { display: flex; gap: 8px; align-items: flex-start; padding: 12px 14px; border: 1px solid #f3d19e; border-radius: 8px; background: #fdf6ec; color: #8a5a00; font-size: 13px; line-height: 1.55; }
|
||||
.review-section { display: grid; gap: 13px; padding-top: 2px; }.section-heading { display: flex; align-items: flex-end; justify-content: space-between; gap: 12px; }.section-heading h3 { display: flex; align-items: center; gap: 7px; margin: 0 0 4px; font-size: 16px; }.section-count { color: var(--cc-muted); font-size: 12px; white-space: nowrap; }
|
||||
.measurement-note { overflow-wrap: anywhere; }.measurement-note strong, .measurement-note span { display: block; }.measurement-note strong { color: var(--el-color-warning-dark-2); }.measurement-note strong + span { margin-top: 4px; color: var(--cc-muted); }
|
||||
.weight-list { display: grid; gap: 8px; }.weight-row { display: grid; grid-template-columns: minmax(190px, 1.8fr) repeat(4, minmax(80px, 1fr)); gap: 12px; align-items: center; padding: 12px 14px; border: 1px solid var(--cc-border); border-radius: 8px; background: var(--cc-surface); }.weight-row > div { min-width: 0; }.weight-row strong, .weight-row span, .weight-row p { display: block; overflow-wrap: anywhere; }.weight-row span { margin-top: 3px; color: var(--cc-muted); font-size: 11px; }.weight-row p { margin: 5px 0 0; color: var(--cc-muted); font-size: 11px; line-height: 1.45; }.weight-row > div:not(:first-child) { text-align: right; }
|
||||
.review-actions { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 15px 16px; border: 1px solid #b3d8ff; border-radius: 10px; background: #ecf5ff; }.review-actions > div:first-child { display: grid; gap: 4px; min-width: 0; }.review-actions span { color: #606266; font-size: 12px; line-height: 1.5; }.review-actions--disabled { border-color: var(--cc-border); background: var(--cc-surface-muted); }.action-buttons { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; }
|
||||
.history-list { display: grid; gap: 0; margin: 0; padding: 0; list-style: none; }.history-list li { display: grid; grid-template-columns: 16px minmax(0, 1fr); gap: 10px; position: relative; padding-bottom: 16px; }.history-list li:not(:last-child)::after { content: ''; position: absolute; top: 16px; bottom: 0; left: 7px; width: 1px; background: var(--cc-border); }.history-marker { z-index: 1; width: 14px; height: 14px; margin-top: 2px; border: 3px solid #909399; border-radius: 50%; background: var(--cc-surface); }.history-marker--success { border-color: #67c23a; }.history-marker--danger { border-color: #f56c6c; }.history-content { min-width: 0; }.history-content > div { display: flex; justify-content: space-between; gap: 12px; }.history-content time, .history-content > span { color: var(--cc-muted); font-size: 11px; }.history-content p { margin: 5px 0 4px; color: var(--cc-text); font-size: 13px; overflow-wrap: anywhere; }
|
||||
.el-button { min-height: 36px; }.el-button :deep(svg) { flex: 0 0 auto; }.review-panel :deep(.el-dialog__body) { display: grid; gap: 14px; }
|
||||
@media (max-width: 720px) { .review-header, .section-heading, .review-actions { align-items: flex-start; flex-direction: column; }.review-header-status, .action-buttons { width: 100%; justify-content: space-between; }.comparison-grid { grid-template-columns: 1fr; }.weight-row { grid-template-columns: 1fr 1fr; }.weight-row > div:not(:first-child) { text-align: left; }.review-actions .action-buttons .el-button { flex: 1 1 140px; }.review-actions .action-buttons { justify-content: stretch; }.review-actions .action-buttons :deep(.el-button) { width: 100%; } }
|
||||
@media (max-width: 380px) { .review-header h2 { font-size: 19px; }.review-header p { font-size: 12px; }.comparison-card { padding: 13px; }.weight-row { grid-template-columns: 1fr; }.review-actions { padding: 13px; } }
|
||||
</style>
|
||||
@@ -0,0 +1,47 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { http } from '@/shared/api/http'
|
||||
import {
|
||||
approveMeasurement,
|
||||
getMeasurementReviewDetail,
|
||||
listMeasurementReviewHistory,
|
||||
rejectMeasurement,
|
||||
releaseRejectedMeasurement,
|
||||
} from './api'
|
||||
|
||||
vi.mock('@/shared/api/http', () => ({
|
||||
http: { get: vi.fn(), post: vi.fn() },
|
||||
unwrapApiResponse: <T>(response: { data: T }) => response.data,
|
||||
}))
|
||||
|
||||
describe('measurement review API', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(http.get).mockReset()
|
||||
vi.mocked(http.post).mockReset()
|
||||
})
|
||||
|
||||
it('keeps the measurement detail and review history prefixes from the backend controllers', async () => {
|
||||
vi.mocked(http.get)
|
||||
.mockResolvedValueOnce({ code: '200', message: 'success', data: { measurementId: '7' } })
|
||||
.mockResolvedValueOnce({ code: '200', message: 'success', data: { records: [], page: '1', size: '20', total: '0', pages: '0' } })
|
||||
|
||||
await getMeasurementReviewDetail('10001', '90001')
|
||||
await listMeasurementReviewHistory('10001', '90001')
|
||||
|
||||
expect(http.get).toHaveBeenNthCalledWith(1, '/platform/v1/first-leg/inbounds/90001/measurement', { params: { tenantId: '10001' } })
|
||||
expect(http.get).toHaveBeenNthCalledWith(2, '/platform/v1/inbounds/90001/measurement-review-history', { params: { tenantId: '10001', page: 1, size: 20 } })
|
||||
})
|
||||
|
||||
it('normalizes string pagination and sends idempotency headers for review writes', async () => {
|
||||
vi.mocked(http.get).mockResolvedValue({ code: '200', message: 'success', data: { records: [], page: '2', size: '20', total: '3', pages: '1' } })
|
||||
vi.mocked(http.post).mockResolvedValue({ code: '200', message: 'success', data: null })
|
||||
|
||||
await expect(listMeasurementReviewHistory('10001', '90001', 2, 20)).resolves.toMatchObject({ page: 2, total: 3 })
|
||||
await approveMeasurement('10001', '90001', { measurementId: '7', expectedVersion: 4 }, 'approve-key')
|
||||
await rejectMeasurement('10001', '90001', { measurementId: '7', expectedVersion: 4, reason: '箱规照片不清晰' }, 'reject-key')
|
||||
await releaseRejectedMeasurement('10001', '90001', { measurementId: '7', expectedVersion: 5, reason: '客户确认后放行' }, 'release-key')
|
||||
|
||||
expect(http.post).toHaveBeenNthCalledWith(1, '/platform/v1/inbounds/90001/measurements/approve', { measurementId: '7', expectedVersion: 4 }, { params: { tenantId: '10001' }, headers: { 'Idempotency-Key': 'approve-key' } })
|
||||
expect(http.post).toHaveBeenNthCalledWith(2, '/platform/v1/inbounds/90001/measurements/reject', { measurementId: '7', expectedVersion: 4, reason: '箱规照片不清晰' }, { params: { tenantId: '10001' }, headers: { 'Idempotency-Key': 'reject-key' } })
|
||||
expect(http.post).toHaveBeenNthCalledWith(3, '/platform/v1/inbounds/90001/measurements/exception-release', { measurementId: '7', expectedVersion: 5, reason: '客户确认后放行' }, { params: { tenantId: '10001' }, headers: { 'Idempotency-Key': 'release-key' } })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,100 @@
|
||||
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
import type {
|
||||
ExceptionReleaseRequest,
|
||||
FeeSnapshotAfterApprove,
|
||||
MeasurementReviewDetail,
|
||||
MeasurementReviewHistoryPage,
|
||||
MeasurementReviewHistoryPayload,
|
||||
MeasurementReviewRequest,
|
||||
} from './types'
|
||||
|
||||
async function getData<T>(path: string, params: Record<string, unknown>) {
|
||||
const response = await http.get<ApiEnvelope<T>, ApiEnvelope<T>>(path, { params })
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
function idempotencyKey() {
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') return crypto.randomUUID()
|
||||
return `review-${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||
}
|
||||
|
||||
async function postReview<T>(path: string, tenantId: string, body: unknown, key = idempotencyKey()) {
|
||||
const response = await http.post<ApiEnvelope<T>, ApiEnvelope<T>>(path, body, {
|
||||
params: { tenantId },
|
||||
headers: { 'Idempotency-Key': key },
|
||||
})
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
function paginationNumber(value: number | string, fallback: number) {
|
||||
const parsed = Number(value)
|
||||
return Number.isFinite(parsed) && parsed >= 0 ? Math.trunc(parsed) : fallback
|
||||
}
|
||||
|
||||
export function getMeasurementReviewDetail(tenantId: string, inboundId: string) {
|
||||
return getData<MeasurementReviewDetail>(
|
||||
`/platform/v1/first-leg/inbounds/${inboundId}/measurement`,
|
||||
{ tenantId },
|
||||
)
|
||||
}
|
||||
|
||||
export async function listMeasurementReviewHistory(
|
||||
tenantId: string,
|
||||
inboundId: string,
|
||||
page = 1,
|
||||
size = 20,
|
||||
) {
|
||||
const result = await getData<MeasurementReviewHistoryPayload>(
|
||||
`/platform/v1/inbounds/${inboundId}/measurement-review-history`,
|
||||
{ tenantId, page, size },
|
||||
)
|
||||
return {
|
||||
...result,
|
||||
page: paginationNumber(result.page, page),
|
||||
size: paginationNumber(result.size, size),
|
||||
total: paginationNumber(result.total, 0),
|
||||
pages: paginationNumber(result.pages, 0),
|
||||
} satisfies MeasurementReviewHistoryPage
|
||||
}
|
||||
|
||||
export function approveMeasurement(
|
||||
tenantId: string,
|
||||
inboundId: string,
|
||||
request: MeasurementReviewRequest,
|
||||
key?: string,
|
||||
) {
|
||||
return postReview<FeeSnapshotAfterApprove>(
|
||||
`/platform/v1/inbounds/${inboundId}/measurements/approve`,
|
||||
tenantId,
|
||||
request,
|
||||
key,
|
||||
)
|
||||
}
|
||||
|
||||
export function rejectMeasurement(
|
||||
tenantId: string,
|
||||
inboundId: string,
|
||||
request: MeasurementReviewRequest,
|
||||
key?: string,
|
||||
) {
|
||||
return postReview<void>(
|
||||
`/platform/v1/inbounds/${inboundId}/measurements/reject`,
|
||||
tenantId,
|
||||
request,
|
||||
key,
|
||||
)
|
||||
}
|
||||
|
||||
export function releaseRejectedMeasurement(
|
||||
tenantId: string,
|
||||
inboundId: string,
|
||||
request: ExceptionReleaseRequest,
|
||||
key?: string,
|
||||
) {
|
||||
return postReview<void>(
|
||||
`/platform/v1/inbounds/${inboundId}/measurements/exception-release`,
|
||||
tenantId,
|
||||
request,
|
||||
key,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { default as MeasurementReviewPanel } from './MeasurementReviewPanel.vue'
|
||||
export * from './api'
|
||||
export type * from './types'
|
||||
@@ -0,0 +1,123 @@
|
||||
export type MeasurementReviewStatus = 'PENDING' | 'APPROVED' | 'REJECTED' | string
|
||||
|
||||
export interface MeasurementReviewItem {
|
||||
id: string
|
||||
itemNo: string
|
||||
boxSpecification: string | null
|
||||
packageCount: number | null
|
||||
lengthCm: number | null
|
||||
widthCm: number | null
|
||||
heightCm: number | null
|
||||
girthCm: number | null
|
||||
unitWeightKg: number | null
|
||||
unitVolumeCbm: number | null
|
||||
totalWeightKg: number | null
|
||||
totalVolumeCbm: number | null
|
||||
volumeWeightKg: number | null
|
||||
intelligentImageFileId: string | null
|
||||
overLimitWarning: string | null
|
||||
outerLengthCm: number | null
|
||||
outerWidthCm: number | null
|
||||
outerHeightCm: number | null
|
||||
remark: string | null
|
||||
}
|
||||
|
||||
export interface MeasurementReviewWeightResult {
|
||||
id: string
|
||||
fileId: string | null
|
||||
ocrProvider: string | null
|
||||
ocrRawText: string | null
|
||||
ocrWeightValue: number | null
|
||||
ocrWeightUnit: string | null
|
||||
confidence: number | null
|
||||
adjustedWeightValue: number | null
|
||||
adjustedWeightUnit: string | null
|
||||
finalWeightKg: number | null
|
||||
recognitionStatus: string | null
|
||||
adjustmentReason: string | null
|
||||
adjustedBy: string | null
|
||||
adjustedAt: string | null
|
||||
confirmedBy: string | null
|
||||
confirmedAt: string | null
|
||||
version: number
|
||||
}
|
||||
|
||||
export interface MeasurementReviewDetail {
|
||||
inboundId: string
|
||||
inboundCode: string | null
|
||||
companyName: string | null
|
||||
channelCode: string | null
|
||||
channelName: string | null
|
||||
processStatus: string | null
|
||||
measureReviewStatus: MeasurementReviewStatus | null
|
||||
recommendedPalletType: string | null
|
||||
reviewReason: string | null
|
||||
reviewerId: string | null
|
||||
reviewerName: string | null
|
||||
measurementId: string
|
||||
sourceType: string | null
|
||||
measurementStatus: string | null
|
||||
version: number
|
||||
originalDocumentNo: string | null
|
||||
originalGrossWeightKg: number | null
|
||||
originalVolumeCbm: number | null
|
||||
actualPackageCount: number | null
|
||||
actualGrossWeightKg: number | null
|
||||
actualVolumeCbm: number | null
|
||||
volumeWeightKg: number | null
|
||||
actualGoodsName: string | null
|
||||
items: MeasurementReviewItem[]
|
||||
weightResults: MeasurementReviewWeightResult[]
|
||||
}
|
||||
|
||||
export interface MeasurementReviewRecord {
|
||||
id: string
|
||||
tenantId: string
|
||||
inboundId: string
|
||||
measurementId: string
|
||||
reviewResult: string | null
|
||||
reviewReason: string | null
|
||||
exceptionRelease: boolean
|
||||
releaseReason: string | null
|
||||
allowRejectedConfig: boolean
|
||||
reviewerName: string | null
|
||||
reviewedAt: string | null
|
||||
}
|
||||
|
||||
export interface MeasurementReviewHistoryPage {
|
||||
records: MeasurementReviewRecord[]
|
||||
page: number
|
||||
size: number
|
||||
total: number
|
||||
pages: number
|
||||
}
|
||||
|
||||
export interface MeasurementReviewHistoryPayload extends Omit<MeasurementReviewHistoryPage, 'page' | 'size' | 'total' | 'pages'> {
|
||||
page: number | string
|
||||
size: number | string
|
||||
total: number | string
|
||||
pages: number | string
|
||||
}
|
||||
|
||||
export interface MeasurementReviewRequest {
|
||||
measurementId: string
|
||||
expectedVersion: number
|
||||
reason?: string
|
||||
}
|
||||
|
||||
export interface ExceptionReleaseRequest {
|
||||
measurementId: string
|
||||
expectedVersion: number
|
||||
reason: string
|
||||
}
|
||||
|
||||
export interface FeeSnapshotAfterApprove {
|
||||
feeId: string | null
|
||||
feeNo: string | null
|
||||
feeVersion: number | null
|
||||
feeStatus: string | null
|
||||
inboundId: string | null
|
||||
totalAmount: number | null
|
||||
currency: string | null
|
||||
nextProcessStatus: string | null
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
|
||||
export const inboundRoutes = [
|
||||
{
|
||||
path: 'inbounds',
|
||||
name: 'inbound-orders',
|
||||
component: () => import('./pages/InboundOrdersView.vue'),
|
||||
meta: {
|
||||
title: '入库单',
|
||||
description: '查询头程入库单并跟进测量、审核、费用和入库进度',
|
||||
permission: 'platform:firstleg:inbound:list',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'inbounds/:inboundId',
|
||||
name: 'inbound-order-detail',
|
||||
component: () => import('./pages/InboundOrderDetailView.vue'),
|
||||
meta: {
|
||||
title: '入库作业详情',
|
||||
description: '处理测量、称重、审核并查看费用和托盘库位结果',
|
||||
permission: 'platform:firstleg:inbound:view',
|
||||
},
|
||||
},
|
||||
] satisfies RouteRecordRaw[]
|
||||
@@ -0,0 +1,100 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { http } from '@/shared/api/http'
|
||||
import {
|
||||
confirmWeightResult,
|
||||
correctWeightResult,
|
||||
createWeightResult,
|
||||
downloadWeightImage,
|
||||
getWeightWorkspace,
|
||||
retryWeightRecognition,
|
||||
uploadWeightImage,
|
||||
} from './weightOcr'
|
||||
|
||||
vi.mock('@/shared/api/http', () => ({
|
||||
http: { get: vi.fn(), post: vi.fn(), put: vi.fn() },
|
||||
ApiResponseError: class ApiResponseError extends Error {},
|
||||
unwrapApiResponse: <T>(response: { data: T }) => response.data,
|
||||
}))
|
||||
|
||||
describe('inbound weight OCR API', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(http.get).mockReset()
|
||||
vi.mocked(http.post).mockReset()
|
||||
vi.mocked(http.put).mockReset()
|
||||
})
|
||||
|
||||
it('loads the measurement workspace using the real firstleg path', async () => {
|
||||
const detail = { inboundId: '9007199254740993', weightResults: [] }
|
||||
vi.mocked(http.get).mockResolvedValue({ code: '200', message: 'success', data: detail })
|
||||
|
||||
await expect(getWeightWorkspace('10001', '9007199254740993')).resolves.toEqual(detail)
|
||||
expect(http.get).toHaveBeenCalledWith(
|
||||
'/platform/v1/first-leg/inbounds/9007199254740993/measurement',
|
||||
{ params: { tenantId: '10001' } },
|
||||
)
|
||||
})
|
||||
|
||||
it('uploads and retries against the file service without converting long IDs', async () => {
|
||||
const result = { fileId: '9007199254740995', recognitionStatus: 'SUCCESS' }
|
||||
vi.mocked(http.post).mockResolvedValue({ code: '200', message: 'success', data: result })
|
||||
const file = new File(['image'], 'weight.png', { type: 'image/png' })
|
||||
|
||||
await expect(uploadWeightImage('10001', '9007199254740993', file)).resolves.toEqual(result)
|
||||
const [uploadPath, body] = vi.mocked(http.post).mock.calls[0]!
|
||||
expect(uploadPath).toBe('/platform/v1/files/measurements/10001/9007199254740993')
|
||||
expect(body).toBeInstanceOf(FormData)
|
||||
expect((body as FormData).get('file')).toBe(file)
|
||||
|
||||
await retryWeightRecognition('10001', '9007199254740993', '9007199254740995')
|
||||
expect(http.post).toHaveBeenLastCalledWith(
|
||||
'/platform/v1/files/measurements/10001/9007199254740993/9007199254740995/recognize',
|
||||
)
|
||||
})
|
||||
|
||||
it('registers, corrects, and confirms a weight result using the implemented contracts', async () => {
|
||||
vi.mocked(http.post).mockResolvedValue({ code: '200', message: 'success', data: { id: '1' } })
|
||||
vi.mocked(http.put).mockResolvedValue({ code: '200', message: 'success', data: { id: '1', version: 1 } })
|
||||
|
||||
const createPayload = {
|
||||
fileId: '9007199254740995',
|
||||
ocrProvider: 'FILE_WEIGHT_OCR',
|
||||
ocrRawText: null,
|
||||
ocrWeightValue: 12.5,
|
||||
ocrWeightUnit: 'KG',
|
||||
confidence: 0.98,
|
||||
}
|
||||
await createWeightResult('10001', '20001', createPayload)
|
||||
expect(http.post).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'/platform/v1/first-leg/inbounds/20001/weight-results',
|
||||
createPayload,
|
||||
{ params: { tenantId: '10001' } },
|
||||
)
|
||||
|
||||
const correction = { version: 0, adjustedWeightValue: 12.8, adjustedWeightUnit: 'KG' as const, adjustmentReason: '读数模糊' }
|
||||
await correctWeightResult('10001', '20001', '30001', correction)
|
||||
expect(http.put).toHaveBeenCalledWith(
|
||||
'/platform/v1/first-leg/inbounds/20001/weight-results/30001',
|
||||
correction,
|
||||
{ params: { tenantId: '10001' } },
|
||||
)
|
||||
|
||||
await confirmWeightResult('10001', '20001', '30001', { version: 1 })
|
||||
expect(http.post).toHaveBeenLastCalledWith(
|
||||
'/platform/v1/first-leg/inbounds/20001/weight-results/30001/confirm',
|
||||
{ version: 1 },
|
||||
{ params: { tenantId: '10001' } },
|
||||
)
|
||||
})
|
||||
|
||||
it('downloads an authenticated image as a blob', async () => {
|
||||
const image = new Blob(['png'], { type: 'image/png' })
|
||||
vi.mocked(http.get).mockResolvedValue(image)
|
||||
await expect(downloadWeightImage('10001', '20001', '40001')).resolves.toBe(image)
|
||||
expect(http.get).toHaveBeenCalledWith(
|
||||
'/platform/v1/files/measurements/10001/20001/40001',
|
||||
{ responseType: 'blob' },
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import axios from 'axios'
|
||||
import { ApiResponseError, http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
import type {
|
||||
ConfirmWeightResultRequest,
|
||||
CorrectWeightResultRequest,
|
||||
CreateWeightResultRequest,
|
||||
WeightImageUploadResult,
|
||||
WeightResultResource,
|
||||
WeightWorkspaceResource,
|
||||
} from '../types'
|
||||
|
||||
const measurementBasePath = '/platform/v1/first-leg/inbounds'
|
||||
const imageBasePath = '/platform/v1/files/measurements'
|
||||
const maxErrorEnvelopeBytes = 1024 * 1024
|
||||
|
||||
function segment(value: string) {
|
||||
return encodeURIComponent(value)
|
||||
}
|
||||
|
||||
async function parseBlobError(blob: Blob, status?: number) {
|
||||
if (!blob.type.toLowerCase().includes('json')) return null
|
||||
if (blob.size > maxErrorEnvelopeBytes) {
|
||||
return new ApiResponseError('图片接口返回了超出限制的 JSON 响应', 'INVALID_IMAGE_RESPONSE', status)
|
||||
}
|
||||
try {
|
||||
const payload = JSON.parse(await blob.text()) as Partial<ApiEnvelope<unknown>>
|
||||
return new ApiResponseError(
|
||||
payload.code === '200' ? '图片接口未返回图片内容' : payload.message || '称重图片读取失败',
|
||||
payload.code === '200' ? 'INVALID_IMAGE_RESPONSE' : payload.code || (status ? `HTTP_${status}` : 'IMAGE_FAILED'),
|
||||
status,
|
||||
payload.data,
|
||||
payload.requestId,
|
||||
payload.traceId,
|
||||
)
|
||||
} catch {
|
||||
return new ApiResponseError('图片接口返回了无法识别的响应', 'INVALID_IMAGE_RESPONSE', status)
|
||||
}
|
||||
}
|
||||
|
||||
export async function getWeightWorkspace(tenantId: string, inboundId: string) {
|
||||
const response = await http.get<ApiEnvelope<WeightWorkspaceResource>, ApiEnvelope<WeightWorkspaceResource>>(
|
||||
`${measurementBasePath}/${segment(inboundId)}/measurement`,
|
||||
{ params: { tenantId } },
|
||||
)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function uploadWeightImage(tenantId: string, inboundId: string, file: File) {
|
||||
const body = new FormData()
|
||||
body.append('file', file)
|
||||
const response = await http.post<ApiEnvelope<WeightImageUploadResult>, ApiEnvelope<WeightImageUploadResult>>(
|
||||
`${imageBasePath}/${segment(tenantId)}/${segment(inboundId)}`,
|
||||
body,
|
||||
)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function retryWeightRecognition(tenantId: string, inboundId: string, fileId: string) {
|
||||
const response = await http.post<ApiEnvelope<WeightImageUploadResult>, ApiEnvelope<WeightImageUploadResult>>(
|
||||
`${imageBasePath}/${segment(tenantId)}/${segment(inboundId)}/${segment(fileId)}/recognize`,
|
||||
)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function downloadWeightImage(tenantId: string, inboundId: string, fileId: string) {
|
||||
try {
|
||||
const blob = await http.get<Blob, Blob>(
|
||||
`${imageBasePath}/${segment(tenantId)}/${segment(inboundId)}/${segment(fileId)}`,
|
||||
{ responseType: 'blob' },
|
||||
)
|
||||
const responseError = await parseBlobError(blob, 200)
|
||||
if (responseError) throw responseError
|
||||
return blob
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error) && error.response?.data instanceof Blob) {
|
||||
const responseError = await parseBlobError(error.response.data, error.response.status)
|
||||
if (responseError) throw responseError
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export async function createWeightResult(
|
||||
tenantId: string,
|
||||
inboundId: string,
|
||||
payload: CreateWeightResultRequest,
|
||||
) {
|
||||
const response = await http.post<ApiEnvelope<WeightResultResource>, ApiEnvelope<WeightResultResource>>(
|
||||
`${measurementBasePath}/${segment(inboundId)}/weight-results`,
|
||||
payload,
|
||||
{ params: { tenantId } },
|
||||
)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function correctWeightResult(
|
||||
tenantId: string,
|
||||
inboundId: string,
|
||||
resultId: string,
|
||||
payload: CorrectWeightResultRequest,
|
||||
) {
|
||||
const response = await http.put<ApiEnvelope<WeightResultResource>, ApiEnvelope<WeightResultResource>>(
|
||||
`${measurementBasePath}/${segment(inboundId)}/weight-results/${segment(resultId)}`,
|
||||
payload,
|
||||
{ params: { tenantId } },
|
||||
)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function confirmWeightResult(
|
||||
tenantId: string,
|
||||
inboundId: string,
|
||||
resultId: string,
|
||||
payload: ConfirmWeightResultRequest,
|
||||
) {
|
||||
const response = await http.post<ApiEnvelope<WeightResultResource>, ApiEnvelope<WeightResultResource>>(
|
||||
`${measurementBasePath}/${segment(inboundId)}/weight-results/${segment(resultId)}/confirm`,
|
||||
payload,
|
||||
{ params: { tenantId } },
|
||||
)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/modules/inbounds/weight/components/WeightOcrPanel.vue'), 'utf8')
|
||||
|
||||
describe('WeightOcrPanel contract', () => {
|
||||
it('uses customer language at the page boundary while retaining a compatibility input', () => {
|
||||
expect(source).toContain("defineProps<{ customerId?: string; tenantId?: string; inboundId: string }>()")
|
||||
expect(source).toContain("const customerId = computed(() => props.customerId || props.tenantId || '')")
|
||||
expect(source).toContain("defineEmits<{ changed: [] }>()")
|
||||
expect(source).toContain("defineExpose({ reload: loadWorkspace })")
|
||||
const template = source.match(/<template>([\s\S]*?)<\/template>/)?.[1] || ''
|
||||
expect(template).not.toContain('租户')
|
||||
})
|
||||
|
||||
it('covers upload, preview, OCR retry, correction, confirmation, and server totals', () => {
|
||||
expect(source).toContain('uploadWeightImage')
|
||||
expect(source).toContain('downloadWeightImage')
|
||||
expect(source).toContain('retryWeightRecognition')
|
||||
expect(source).toContain('correctWeightResult')
|
||||
expect(source).toContain('confirmWeightResult')
|
||||
expect(source).toContain('实际总毛重(服务端汇总)')
|
||||
expect(source).toContain('称重图片序号 #{{ index + 1 }}')
|
||||
expect(source).toContain('workspace.value?.actualGrossWeightKg')
|
||||
})
|
||||
|
||||
it('matches backend image limits and preserves missing OCR text honestly', () => {
|
||||
expect(source).toContain("['image/jpeg', 'image/png', 'image/bmp']")
|
||||
expect(source).toContain('5 * 1024 * 1024')
|
||||
expect(source).toContain('图片服务未返回 OCR 原文')
|
||||
expect(source).toContain("adjustmentReason: 'OCR 重试识别结果'")
|
||||
})
|
||||
|
||||
it('gates every action with the implemented permissions and editable statuses', () => {
|
||||
expect(source).toContain("platform:firstleg:inbound:measure:view")
|
||||
expect(source).toContain("platform:firstleg:inbound:weight:edit")
|
||||
expect(source).toContain("platform:firstleg:inbound:weight:confirm")
|
||||
expect(source).toContain("new Set(['WAIT_MEASURE', 'MEASURING', 'MEASURE_REJECTED'])")
|
||||
})
|
||||
|
||||
it('provides error tracking, empty, loading, and narrow-screen states', () => {
|
||||
expect(source).toContain('<ApiErrorMeta')
|
||||
expect(source).toContain('<LoadErrorState')
|
||||
expect(source).toContain('尚未上传称重图片')
|
||||
expect(source).toContain('v-loading="loading"')
|
||||
expect(source).toContain('@media (max-width: 360px)')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,530 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
||||
import {
|
||||
CheckCircle2,
|
||||
Eye,
|
||||
FileImage,
|
||||
ImagePlus,
|
||||
Pencil,
|
||||
RefreshCw,
|
||||
Scale,
|
||||
} from '@lucide/vue'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import {
|
||||
confirmWeightResult,
|
||||
correctWeightResult,
|
||||
createWeightResult,
|
||||
downloadWeightImage,
|
||||
getWeightWorkspace,
|
||||
retryWeightRecognition,
|
||||
uploadWeightImage,
|
||||
} from '../api/weightOcr'
|
||||
import type { WeightResultResource, WeightWorkspaceResource } from '../types'
|
||||
|
||||
const props = defineProps<{ customerId?: string; tenantId?: string; inboundId: string }>()
|
||||
const emit = defineEmits<{ changed: [] }>()
|
||||
|
||||
const access = useAccessPreviewStore()
|
||||
const fileInput = ref<HTMLInputElement>()
|
||||
const correctionFormRef = ref<FormInstance>()
|
||||
const workspace = ref<WeightWorkspaceResource | null>(null)
|
||||
const loading = ref(false)
|
||||
const uploading = ref(false)
|
||||
const loadError = ref<ApiErrorDetails | null>(null)
|
||||
const actionError = ref<ApiErrorDetails | null>(null)
|
||||
const activeAction = ref('')
|
||||
const correctionOpen = ref(false)
|
||||
const previewOpen = ref(false)
|
||||
const previewLoading = ref(false)
|
||||
const previewError = ref<ApiErrorDetails | null>(null)
|
||||
const previewUrl = ref('')
|
||||
const correction = reactive({
|
||||
resultId: '',
|
||||
version: 0,
|
||||
value: null as number | null,
|
||||
unit: 'KG' as 'KG' | 'LB',
|
||||
reason: '',
|
||||
})
|
||||
let loadSequence = 0
|
||||
|
||||
const editableStatuses = new Set(['WAIT_MEASURE', 'MEASURING', 'MEASURE_REJECTED'])
|
||||
const customerId = computed(() => props.customerId || props.tenantId || '')
|
||||
const canEdit = computed(() => access.can('platform:firstleg:inbound:weight:edit'))
|
||||
const canConfirm = computed(() => access.can('platform:firstleg:inbound:weight:confirm'))
|
||||
const canView = computed(() => access.can('platform:firstleg:inbound:measure:view'))
|
||||
const editable = computed(() => Boolean(workspace.value && editableStatuses.has(workspace.value.processStatus)))
|
||||
const rows = computed(() => workspace.value?.weightResults ?? [])
|
||||
const confirmedCount = computed(() => rows.value.filter((row) => row.recognitionStatus === 'CONFIRMED').length)
|
||||
const pendingCount = computed(() => rows.value.length - confirmedCount.value)
|
||||
const confirmedTotal = computed(() => workspace.value?.actualGrossWeightKg ?? null)
|
||||
|
||||
const correctionRules: FormRules<typeof correction> = {
|
||||
value: [
|
||||
{ required: true, message: '请输入有效重量', trigger: 'blur' },
|
||||
{
|
||||
validator: (_rule, value, callback) => Number(value) > 0 ? callback() : callback(new Error('重量必须大于 0')),
|
||||
trigger: 'blur',
|
||||
},
|
||||
],
|
||||
unit: [{ required: true, message: '请选择重量单位', trigger: 'change' }],
|
||||
reason: [{ required: true, message: '请填写人工修正原因', trigger: 'blur' }],
|
||||
}
|
||||
|
||||
function formatWeight(value: number | null, unit = 'KG') {
|
||||
return value == null ? '—' : `${Number(value).toFixed(3)} ${unit}`
|
||||
}
|
||||
|
||||
function formatConfidence(value: number | null) {
|
||||
return value == null ? '未返回' : `${(Number(value) * 100).toFixed(1)}%`
|
||||
}
|
||||
|
||||
function formatTime(value: string | null) {
|
||||
return value?.replace('T', ' ').slice(0, 16) || '—'
|
||||
}
|
||||
|
||||
function statusMeta(status: string) {
|
||||
if (status === 'CONFIRMED') return { label: '已确认', type: 'success' as const }
|
||||
if (status === 'PENDING_CONFIRM') return { label: '待确认', type: 'warning' as const }
|
||||
if (status === 'NEEDS_MANUAL') return { label: '需人工处理', type: 'danger' as const }
|
||||
return { label: status || '未知', type: 'info' as const }
|
||||
}
|
||||
|
||||
function actionKey(action: string, id: string) {
|
||||
return `${action}:${id}`
|
||||
}
|
||||
|
||||
function isActionLoading(action: string, id: string) {
|
||||
return activeAction.value === actionKey(action, id)
|
||||
}
|
||||
|
||||
async function loadWorkspace() {
|
||||
if (!customerId.value || !props.inboundId || !canView.value) {
|
||||
workspace.value = null
|
||||
return
|
||||
}
|
||||
const sequence = ++loadSequence
|
||||
loading.value = true
|
||||
loadError.value = null
|
||||
try {
|
||||
const result = await getWeightWorkspace(customerId.value, props.inboundId)
|
||||
if (sequence === loadSequence) workspace.value = result
|
||||
} catch (error) {
|
||||
if (sequence !== loadSequence) return
|
||||
workspace.value = null
|
||||
loadError.value = getApiErrorDetails(error, '称重与 OCR 数据加载失败,请稍后重试')
|
||||
} finally {
|
||||
if (sequence === loadSequence) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function chooseFile() {
|
||||
if (!editable.value) {
|
||||
ElMessage.warning('当前入库状态不允许上传称重图片')
|
||||
return
|
||||
}
|
||||
fileInput.value?.click()
|
||||
}
|
||||
|
||||
function validateImage(file: File) {
|
||||
if (!['image/jpeg', 'image/png', 'image/bmp'].includes(file.type)) return '称重图片仅支持 JPG、PNG、BMP'
|
||||
if (file.size > 5 * 1024 * 1024) return '称重图片不能超过 5MB'
|
||||
return ''
|
||||
}
|
||||
|
||||
async function handleFileChange(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
input.value = ''
|
||||
if (!file || uploading.value) return
|
||||
const validationError = validateImage(file)
|
||||
if (validationError) {
|
||||
ElMessage.warning(validationError)
|
||||
return
|
||||
}
|
||||
uploading.value = true
|
||||
actionError.value = null
|
||||
try {
|
||||
const upload = await uploadWeightImage(customerId.value, props.inboundId, file)
|
||||
await createWeightResult(customerId.value, props.inboundId, {
|
||||
fileId: String(upload.fileId),
|
||||
ocrProvider: 'FILE_WEIGHT_OCR',
|
||||
ocrRawText: null,
|
||||
ocrWeightValue: upload.weightValue,
|
||||
ocrWeightUnit: upload.weightUnit,
|
||||
confidence: upload.confidence,
|
||||
})
|
||||
ElMessage.success(upload.recognitionStatus === 'FAILED'
|
||||
? '图片已保存,OCR 未识别出重量,请人工修正或重试'
|
||||
: '称重图片已上传并完成识别')
|
||||
await loadWorkspace()
|
||||
emit('changed')
|
||||
} catch (error) {
|
||||
actionError.value = getApiErrorDetails(error, '称重图片上传失败,请检查后重试')
|
||||
} finally {
|
||||
uploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openCorrection(row: WeightResultResource) {
|
||||
correction.resultId = row.id
|
||||
correction.version = row.version
|
||||
correction.value = row.adjustedWeightValue ?? row.ocrWeightValue
|
||||
correction.unit = (row.adjustedWeightUnit ?? row.ocrWeightUnit) === 'LB' ? 'LB' : 'KG'
|
||||
correction.reason = ''
|
||||
correctionOpen.value = true
|
||||
void correctionFormRef.value?.clearValidate()
|
||||
}
|
||||
|
||||
async function submitCorrection() {
|
||||
if (!await correctionFormRef.value?.validate().catch(() => false) || correction.value == null) return
|
||||
const key = actionKey('correct', correction.resultId)
|
||||
activeAction.value = key
|
||||
actionError.value = null
|
||||
try {
|
||||
await correctWeightResult(customerId.value, props.inboundId, correction.resultId, {
|
||||
version: correction.version,
|
||||
adjustedWeightValue: correction.value,
|
||||
adjustedWeightUnit: correction.unit,
|
||||
adjustmentReason: correction.reason.trim(),
|
||||
})
|
||||
correctionOpen.value = false
|
||||
ElMessage.success('重量修正已保存,请确认后计入总重量')
|
||||
await loadWorkspace()
|
||||
emit('changed')
|
||||
} catch (error) {
|
||||
actionError.value = getApiErrorDetails(error, '重量修正失败,请刷新后重试')
|
||||
} finally {
|
||||
activeAction.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmResult(row: WeightResultResource) {
|
||||
const key = actionKey('confirm', row.id)
|
||||
activeAction.value = key
|
||||
actionError.value = null
|
||||
try {
|
||||
await confirmWeightResult(customerId.value, props.inboundId, row.id, { version: row.version })
|
||||
ElMessage.success('重量已确认并计入实际总重量')
|
||||
await loadWorkspace()
|
||||
emit('changed')
|
||||
} catch (error) {
|
||||
actionError.value = getApiErrorDetails(error, '重量确认失败,请刷新后重试')
|
||||
} finally {
|
||||
activeAction.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
async function retryOcr(row: WeightResultResource) {
|
||||
const key = actionKey('retry', row.id)
|
||||
activeAction.value = key
|
||||
actionError.value = null
|
||||
try {
|
||||
const result = await retryWeightRecognition(customerId.value, props.inboundId, row.fileId)
|
||||
if (result.weightValue == null || !result.weightUnit) {
|
||||
throw new Error(result.error || 'OCR 仍未识别出有效重量,可改用人工修正')
|
||||
}
|
||||
await correctWeightResult(customerId.value, props.inboundId, row.id, {
|
||||
version: row.version,
|
||||
adjustedWeightValue: result.weightValue,
|
||||
adjustedWeightUnit: result.weightUnit.toUpperCase().startsWith('LB') ? 'LB' : 'KG',
|
||||
adjustmentReason: 'OCR 重试识别结果',
|
||||
})
|
||||
ElMessage.success('OCR 重试成功,请核对并确认重量')
|
||||
await loadWorkspace()
|
||||
emit('changed')
|
||||
} catch (error) {
|
||||
actionError.value = getApiErrorDetails(error, 'OCR 重试失败,可稍后重试或人工修正')
|
||||
} finally {
|
||||
activeAction.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
function releasePreview() {
|
||||
if (previewUrl.value) URL.revokeObjectURL(previewUrl.value)
|
||||
previewUrl.value = ''
|
||||
}
|
||||
|
||||
async function openPreview(row: WeightResultResource) {
|
||||
releasePreview()
|
||||
previewError.value = null
|
||||
previewOpen.value = true
|
||||
previewLoading.value = true
|
||||
try {
|
||||
const blob = await downloadWeightImage(customerId.value, props.inboundId, row.fileId)
|
||||
previewUrl.value = URL.createObjectURL(blob)
|
||||
} catch (error) {
|
||||
previewError.value = getApiErrorDetails(error, '称重图片读取失败,请稍后重试')
|
||||
} finally {
|
||||
previewLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [customerId.value, props.inboundId, canView.value] as const,
|
||||
() => {
|
||||
++loadSequence
|
||||
workspace.value = null
|
||||
loadError.value = null
|
||||
actionError.value = null
|
||||
correctionOpen.value = false
|
||||
previewOpen.value = false
|
||||
releasePreview()
|
||||
void loadWorkspace()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(previewOpen, (open) => { if (!open) releasePreview() })
|
||||
onBeforeUnmount(releasePreview)
|
||||
|
||||
defineExpose({ reload: loadWorkspace })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="weight-workspace" aria-labelledby="weight-ocr-title">
|
||||
<header class="weight-heading">
|
||||
<div>
|
||||
<h3 id="weight-ocr-title"><Scale :size="18" aria-hidden="true" />称重图片与 OCR</h3>
|
||||
<p>逐张核对识别值并确认,服务端汇总所有已确认图片的实际总重量。</p>
|
||||
</div>
|
||||
<div class="weight-heading__actions">
|
||||
<input
|
||||
ref="fileInput"
|
||||
class="visually-hidden-input"
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/bmp"
|
||||
aria-label="选择称重图片"
|
||||
@change="handleFileChange"
|
||||
/>
|
||||
<el-button
|
||||
v-if="canEdit"
|
||||
type="primary"
|
||||
:loading="uploading"
|
||||
:disabled="loading || !editable"
|
||||
@click="chooseFile"
|
||||
><ImagePlus :size="16" />上传称重图片</el-button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<el-alert v-if="!canView" type="warning" :closable="false" show-icon>
|
||||
当前账号缺少入库测量查看权限。
|
||||
</el-alert>
|
||||
|
||||
<template v-else>
|
||||
<LoadErrorState
|
||||
v-if="loadError"
|
||||
title="称重数据加载失败"
|
||||
:message="loadError.message"
|
||||
:loading="loading"
|
||||
compact
|
||||
@retry="loadWorkspace"
|
||||
>
|
||||
<template #details><ApiErrorMeta :details="loadError" /></template>
|
||||
</LoadErrorState>
|
||||
|
||||
<template v-else>
|
||||
<div class="weight-metrics" aria-live="polite" :aria-busy="loading">
|
||||
<div><span>实际总毛重(服务端汇总)</span><strong>{{ formatWeight(confirmedTotal) }}</strong></div>
|
||||
<div><span>已确认图片</span><strong>{{ confirmedCount }}</strong></div>
|
||||
<div><span>待处理图片</span><strong :class="{ 'is-warning': pendingCount > 0 }">{{ pendingCount }}</strong></div>
|
||||
</div>
|
||||
|
||||
<el-alert v-if="workspace && !editable" type="info" :closable="false" show-icon class="weight-state-note">
|
||||
当前入库状态为 {{ workspace.processStatus }},称重数据仅供查看。
|
||||
</el-alert>
|
||||
|
||||
<LoadErrorState
|
||||
v-if="actionError"
|
||||
title="操作未完成"
|
||||
:message="actionError.message"
|
||||
:show-retry="false"
|
||||
compact
|
||||
class="weight-action-error"
|
||||
>
|
||||
<template #details><ApiErrorMeta :details="actionError" /></template>
|
||||
</LoadErrorState>
|
||||
|
||||
<div v-loading="loading" class="weight-list" aria-live="polite">
|
||||
<div v-if="!loading && rows.length === 0" class="weight-empty">
|
||||
<FileImage :size="28" aria-hidden="true" />
|
||||
<strong>尚未上传称重图片</strong>
|
||||
<p>上传 JPG、PNG 或 BMP 图片,单张不超过 5MB。</p>
|
||||
</div>
|
||||
|
||||
<article v-for="(row, index) in rows" :key="row.id" class="weight-row">
|
||||
<div class="weight-row__identity">
|
||||
<span class="weight-row__icon"><FileImage :size="18" aria-hidden="true" /></span>
|
||||
<div>
|
||||
<strong>称重图片序号 #{{ index + 1 }}</strong>
|
||||
<small>文件 ID {{ row.fileId }} · {{ row.ocrProvider || 'OCR 服务未标识' }}</small>
|
||||
</div>
|
||||
<el-tag :type="statusMeta(row.recognitionStatus).type" effect="light">
|
||||
{{ statusMeta(row.recognitionStatus).label }}
|
||||
</el-tag>
|
||||
</div>
|
||||
|
||||
<dl class="weight-row__facts">
|
||||
<div><dt>OCR 原值</dt><dd>{{ formatWeight(row.ocrWeightValue, row.ocrWeightUnit || '') }}</dd></div>
|
||||
<div><dt>置信度</dt><dd>{{ formatConfidence(row.confidence) }}</dd></div>
|
||||
<div><dt>人工修正</dt><dd>{{ formatWeight(row.adjustedWeightValue, row.adjustedWeightUnit || '') }}</dd></div>
|
||||
<div><dt>最终计入</dt><dd class="weight-final">{{ formatWeight(row.finalWeightKg) }}</dd></div>
|
||||
</dl>
|
||||
|
||||
<div class="weight-row__raw">
|
||||
<span>OCR 原文</span>
|
||||
<p>{{ row.ocrRawText || '图片服务未返回 OCR 原文' }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="row.adjustmentReason || row.confirmedAt" class="weight-row__audit">
|
||||
<span v-if="row.adjustmentReason">修正原因:{{ row.adjustmentReason }}</span>
|
||||
<span v-if="row.confirmedAt">确认时间:{{ formatTime(row.confirmedAt) }}</span>
|
||||
</div>
|
||||
|
||||
<footer class="weight-row__actions">
|
||||
<el-button link type="primary" @click="openPreview(row)"><Eye :size="15" />查看图片</el-button>
|
||||
<el-button
|
||||
v-if="canEdit && editable && row.recognitionStatus !== 'CONFIRMED'"
|
||||
link
|
||||
type="primary"
|
||||
:loading="isActionLoading('retry', row.id)"
|
||||
:disabled="Boolean(activeAction)"
|
||||
@click="retryOcr(row)"
|
||||
><RefreshCw :size="15" />重试 OCR</el-button>
|
||||
<el-button
|
||||
v-if="canEdit && editable"
|
||||
link
|
||||
type="primary"
|
||||
:disabled="Boolean(activeAction)"
|
||||
@click="openCorrection(row)"
|
||||
><Pencil :size="15" />人工修正</el-button>
|
||||
<el-button
|
||||
v-if="canConfirm && editable && row.recognitionStatus !== 'CONFIRMED'"
|
||||
link
|
||||
type="success"
|
||||
:loading="isActionLoading('confirm', row.id)"
|
||||
:disabled="Boolean(activeAction) || row.recognitionStatus === 'NEEDS_MANUAL'"
|
||||
@click="confirmResult(row)"
|
||||
><CheckCircle2 :size="15" />确认重量</el-button>
|
||||
</footer>
|
||||
</article>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<AppDialog
|
||||
v-model="correctionOpen"
|
||||
title="人工修正重量"
|
||||
confirm-text="保存修正"
|
||||
:confirm-loading="activeAction.startsWith('correct:')"
|
||||
@confirm="submitCorrection"
|
||||
>
|
||||
<el-form ref="correctionFormRef" :model="correction" :rules="correctionRules" label-position="top">
|
||||
<div class="correction-grid">
|
||||
<el-form-item label="修正重量" prop="value">
|
||||
<el-input-number v-model="correction.value" :min="0.001" :precision="3" :controls="false" />
|
||||
</el-form-item>
|
||||
<el-form-item label="单位" prop="unit">
|
||||
<el-segmented v-model="correction.unit" :options="['KG', 'LB']" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
<el-form-item label="修正原因" prop="reason">
|
||||
<el-input
|
||||
v-model="correction.reason"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
maxlength="500"
|
||||
show-word-limit
|
||||
placeholder="说明读数模糊、单位识别错误等具体原因"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</AppDialog>
|
||||
|
||||
<AppDialog
|
||||
v-model="previewOpen"
|
||||
title="称重图片"
|
||||
:loading="previewLoading"
|
||||
:show-footer="false"
|
||||
width="min(760px, calc(100vw - 28px))"
|
||||
>
|
||||
<LoadErrorState
|
||||
v-if="previewError"
|
||||
title="图片读取失败"
|
||||
:message="previewError.message"
|
||||
:show-retry="false"
|
||||
compact
|
||||
>
|
||||
<template #details><ApiErrorMeta :details="previewError" /></template>
|
||||
</LoadErrorState>
|
||||
<img v-else-if="previewUrl" class="weight-preview" :src="previewUrl" alt="当前入库单的称重图片" />
|
||||
</AppDialog>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.weight-workspace { min-width: 0; color: var(--el-text-color-primary); }
|
||||
.weight-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; padding-bottom: 18px; border-bottom: 1px solid var(--cc-border); }
|
||||
.weight-heading h3 { display: flex; align-items: center; gap: 8px; margin: 0; font-size: 15px; }
|
||||
.weight-heading h3 svg { color: var(--cc-primary); }
|
||||
.weight-heading p { margin: 6px 0 0; color: var(--cc-muted); font-size: 12px; line-height: 1.6; }
|
||||
.weight-heading__actions { flex: 0 0 auto; }
|
||||
.visually-hidden-input { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap; }
|
||||
.weight-metrics { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); margin: 18px 0; border: 1px solid var(--cc-border); border-radius: 8px; background: #f8fafc; }
|
||||
.weight-metrics > div { min-width: 0; padding: 15px 18px; border-right: 1px solid var(--cc-border); }
|
||||
.weight-metrics > div:last-child { border-right: 0; }
|
||||
.weight-metrics span { display: block; margin-bottom: 5px; color: var(--cc-muted); font-size: 11px; }
|
||||
.weight-metrics strong { font-size: 20px; line-height: 1.2; }
|
||||
.weight-metrics strong.is-warning { color: #b54708; }
|
||||
.weight-state-note, .weight-action-error { margin-bottom: 14px; }
|
||||
.weight-list { min-height: 120px; }
|
||||
.weight-empty { display: grid; min-height: 180px; place-items: center; align-content: center; gap: 8px; padding: 24px; color: var(--cc-muted); text-align: center; }
|
||||
.weight-empty svg { color: #98a2b3; }
|
||||
.weight-empty strong { color: #344054; font-size: 14px; }
|
||||
.weight-empty p { margin: 0; font-size: 12px; }
|
||||
.weight-row { padding: 18px 0; border-bottom: 1px solid var(--cc-border); }
|
||||
.weight-row:last-child { border-bottom: 0; }
|
||||
.weight-row__identity { display: grid; grid-template-columns: 36px minmax(0, 1fr) auto; align-items: center; gap: 10px; }
|
||||
.weight-row__icon { display: grid; width: 36px; height: 36px; place-items: center; border-radius: 7px; background: #eef4ff; color: var(--cc-primary); }
|
||||
.weight-row__identity strong, .weight-row__identity small { display: block; min-width: 0; overflow-wrap: anywhere; }
|
||||
.weight-row__identity strong { font-size: 13px; }
|
||||
.weight-row__identity small { margin-top: 3px; color: var(--cc-muted); font-size: 11px; }
|
||||
.weight-row__facts { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 1px; margin: 14px 0 0; background: var(--cc-border); }
|
||||
.weight-row__facts > div { min-width: 0; padding: 11px 12px; background: #f8fafc; }
|
||||
.weight-row__facts dt { color: var(--cc-muted); font-size: 10px; }
|
||||
.weight-row__facts dd { margin: 4px 0 0; font-size: 12px; font-weight: 650; overflow-wrap: anywhere; }
|
||||
.weight-row__facts .weight-final { color: #067647; }
|
||||
.weight-row__raw { display: grid; grid-template-columns: 78px minmax(0, 1fr); gap: 10px; margin-top: 12px; font-size: 11px; line-height: 1.55; }
|
||||
.weight-row__raw span { color: var(--cc-muted); }
|
||||
.weight-row__raw p { margin: 0; color: #475467; overflow-wrap: anywhere; white-space: pre-wrap; }
|
||||
.weight-row__audit { display: flex; flex-wrap: wrap; gap: 6px 16px; margin-top: 10px; color: var(--cc-muted); font-size: 11px; line-height: 1.5; }
|
||||
.weight-row__actions { display: flex; flex-wrap: wrap; gap: 2px 12px; margin-top: 12px; }
|
||||
.weight-row__actions :deep(.el-button) { margin-left: 0; }
|
||||
.correction-grid { display: grid; grid-template-columns: minmax(0, 1fr) 150px; gap: 16px; }
|
||||
.correction-grid :deep(.el-input-number), .correction-grid :deep(.el-segmented) { width: 100%; }
|
||||
.weight-preview { display: block; max-width: 100%; max-height: min(68vh, 720px); margin: 0 auto; object-fit: contain; }
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.weight-heading { flex-direction: column; }
|
||||
.weight-heading__actions, .weight-heading__actions :deep(.el-button) { width: 100%; }
|
||||
.weight-heading__actions :deep(.el-button), .weight-row__actions :deep(.el-button) { min-height: 44px; }
|
||||
.weight-metrics { grid-template-columns: 1fr; }
|
||||
.weight-metrics > div { border-right: 0; border-bottom: 1px solid var(--cc-border); }
|
||||
.weight-metrics > div:last-child { border-bottom: 0; }
|
||||
.weight-row__facts { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.weight-row__raw { grid-template-columns: 1fr; gap: 4px; }
|
||||
.weight-row__actions { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.weight-row__actions :deep(.el-button) { width: 100%; justify-content: flex-start; }
|
||||
.correction-grid { grid-template-columns: 1fr; gap: 0; }
|
||||
}
|
||||
|
||||
@media (max-width: 360px) {
|
||||
.weight-row__identity { grid-template-columns: 32px minmax(0, 1fr); }
|
||||
.weight-row__identity > .el-tag { grid-column: 2; justify-self: start; }
|
||||
.weight-row__facts, .weight-row__actions { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,3 @@
|
||||
export { default as WeightOcrPanel } from './components/WeightOcrPanel.vue'
|
||||
export type { WeightResultResource, WeightWorkspaceResource } from './types'
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
export type WeightRecognitionStatus = 'PENDING_CONFIRM' | 'NEEDS_MANUAL' | 'CONFIRMED' | string
|
||||
|
||||
export interface WeightResultResource {
|
||||
id: string
|
||||
fileId: string
|
||||
ocrProvider: string | null
|
||||
ocrRawText: string | null
|
||||
ocrWeightValue: number | null
|
||||
ocrWeightUnit: string | null
|
||||
confidence: number | null
|
||||
adjustedWeightValue: number | null
|
||||
adjustedWeightUnit: string | null
|
||||
finalWeightKg: number | null
|
||||
recognitionStatus: WeightRecognitionStatus
|
||||
adjustmentReason: string | null
|
||||
adjustedBy: string | null
|
||||
adjustedAt: string | null
|
||||
confirmedBy: string | null
|
||||
confirmedAt: string | null
|
||||
version: number
|
||||
}
|
||||
|
||||
export interface WeightWorkspaceResource {
|
||||
inboundId: string
|
||||
inboundCode: string
|
||||
processStatus: string
|
||||
actualGrossWeightKg: number | null
|
||||
weightResults: WeightResultResource[]
|
||||
}
|
||||
|
||||
export interface WeightImageUploadResult {
|
||||
fileId: string
|
||||
fileName: string
|
||||
contentType: string
|
||||
sizeBytes: number
|
||||
recognitionStatus: string
|
||||
weightValue: number | null
|
||||
weightUnit: string | null
|
||||
confidence: number | null
|
||||
error: string | null
|
||||
}
|
||||
|
||||
export interface CreateWeightResultRequest {
|
||||
fileId: string
|
||||
ocrProvider: string | null
|
||||
ocrRawText: string | null
|
||||
ocrWeightValue: number | null
|
||||
ocrWeightUnit: string | null
|
||||
confidence: number | null
|
||||
}
|
||||
|
||||
export interface CorrectWeightResultRequest {
|
||||
version: number
|
||||
adjustedWeightValue: number
|
||||
adjustedWeightUnit: 'KG' | 'LB'
|
||||
adjustmentReason: string
|
||||
}
|
||||
|
||||
export interface ConfirmWeightResultRequest {
|
||||
version: number
|
||||
}
|
||||
|
||||
@@ -96,7 +96,7 @@ function normalizePartner(resource: PlatformPartnerResource): PlatformPartnerRes
|
||||
}
|
||||
}
|
||||
|
||||
export async function listPlatformPartners(tenantId: number | string, query: PlatformPartnerQuery) {
|
||||
export async function listPlatformPartners(tenantId: number | string | undefined, query: PlatformPartnerQuery) {
|
||||
const response = await http.get<ApiEnvelope<PlatformPartnerPagePayload>, ApiEnvelope<PlatformPartnerPagePayload>>(basePath, {
|
||||
params: { tenantId, ...query },
|
||||
})
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export * from './api/platformPartners'
|
||||
@@ -8,8 +8,8 @@ import {
|
||||
} from '@/modules/master-data/counterparties/api/platformPartners'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { useAuthStore } from '@/core/auth/auth'
|
||||
import QueryToolbar from '@/shared/components/QueryToolbar.vue'
|
||||
import { listPlatformTenants, type PlatformTenantResource } from '../../../iam/api/platformIam'
|
||||
import PlatformTenantSelector from '../../../iam/components/PlatformTenantSelector.vue'
|
||||
import PartnersView from './PartnersView.vue'
|
||||
|
||||
vi.mock('@/modules/master-data/counterparties/api/platformPartners', () => ({
|
||||
@@ -111,12 +111,14 @@ describe('PartnersView real API integration', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('loads the fixed tenant page and renders the real partner fields', async () => {
|
||||
it('loads all enterprises by default and renders the real partner fields', async () => {
|
||||
const wrapper = await mountView()
|
||||
|
||||
expect(listPlatformPartners).toHaveBeenCalledWith(10001, {
|
||||
expect(listPlatformPartners).toHaveBeenCalledWith(undefined, {
|
||||
page: 1, size: 20, keyword: undefined, type: undefined, status: undefined,
|
||||
})
|
||||
expect(wrapper.find('input[aria-label="所属企业"]').exists()).toBe(true)
|
||||
expect(wrapper.text()).toContain('全部企业 · 共 1 个往来单位')
|
||||
expect(wrapper.text()).toContain('深圳示例贸易有限公司')
|
||||
expect(wrapper.text()).toContain('CUS-001')
|
||||
expect(wrapper.text()).toContain('contact@example.com')
|
||||
@@ -131,7 +133,10 @@ describe('PartnersView real API integration', () => {
|
||||
it('preserves a snowflake tenant id as a string when switching enterprises', async () => {
|
||||
const wrapper = await mountView()
|
||||
|
||||
wrapper.findComponent(PlatformTenantSelector).vm.$emit('update:modelValue', snowflakeTenantId)
|
||||
const toolbar = wrapper.findComponent(QueryToolbar)
|
||||
const model = { tenantId: snowflakeTenantId, keyword: '', type: '', status: '' }
|
||||
toolbar.vm.$emit('update:modelValue', model)
|
||||
toolbar.vm.$emit('search', model)
|
||||
await flushPromises()
|
||||
|
||||
expect(listPlatformPartners).toHaveBeenLastCalledWith(snowflakeTenantId, {
|
||||
@@ -153,13 +158,18 @@ describe('PartnersView real API integration', () => {
|
||||
await flushPromises()
|
||||
|
||||
expect(listPlatformPartners).toHaveBeenCalledTimes(2)
|
||||
expect(listPlatformPartners).toHaveBeenLastCalledWith(10001, {
|
||||
expect(listPlatformPartners).toHaveBeenLastCalledWith(undefined, {
|
||||
page: 1, size: 20, keyword: '深圳', type: undefined, status: undefined,
|
||||
})
|
||||
})
|
||||
|
||||
it('shows useful placeholders and visible required markers in the create form', async () => {
|
||||
const wrapper = await mountView()
|
||||
const toolbar = wrapper.findComponent(QueryToolbar)
|
||||
const model = { tenantId: '10001', keyword: '', type: '', status: '' }
|
||||
toolbar.vm.$emit('update:modelValue', model)
|
||||
toolbar.vm.$emit('search', model)
|
||||
await flushPromises()
|
||||
const createButton = wrapper.findAll('button').find((button) => button.text().trim() === '新增往来单位')
|
||||
expect(createButton).toBeDefined()
|
||||
await createButton?.trigger('click')
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox, type FormInstance, type FormRules, type UploadFile } from 'element-plus'
|
||||
import { Building2, Download, Eye, MapPin, Pencil, Plus, Power, Upload } from '@lucide/vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
type SavePlatformPartnerRequest,
|
||||
} from '@/modules/master-data/counterparties/api/platformPartners'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { PlatformTenantSelector } from '@/modules/iam'
|
||||
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
|
||||
|
||||
const pageSize = ref(20)
|
||||
const access = useAccessPreviewStore()
|
||||
@@ -42,25 +42,36 @@ const formRef = ref<FormInstance>()
|
||||
const rows = ref<PlatformPartnerResource[]>([])
|
||||
const page = ref(1)
|
||||
const total = ref(0)
|
||||
const selectedTenantId = ref('')
|
||||
const loadedTenantId = ref<string | null>(null)
|
||||
const tenants = ref<PlatformTenantResource[]>([])
|
||||
const tenantLoadError = ref<ApiErrorDetails | null>(null)
|
||||
const editorTenantId = ref('')
|
||||
const loadError = ref<ApiErrorDetails | null>(null)
|
||||
const statusLoadingIds = ref(new Set<string>())
|
||||
let loadSequence = 0
|
||||
|
||||
type PartnerFilters = QueryModel & {
|
||||
tenantId: string
|
||||
keyword: string
|
||||
type: PlatformPartnerType | ''
|
||||
status: PlatformPartnerStatus | ''
|
||||
}
|
||||
|
||||
const filters = ref<PartnerFilters>({
|
||||
tenantId: '',
|
||||
keyword: '',
|
||||
type: '',
|
||||
status: '',
|
||||
})
|
||||
const appliedFilters = ref<PartnerFilters>({ ...filters.value })
|
||||
const queryFields: QueryField[] = [
|
||||
const queryFields = computed<QueryField[]>(() => [
|
||||
{
|
||||
key: 'tenantId',
|
||||
type: 'select',
|
||||
width: 180,
|
||||
placeholder: tenantLoadError.value ? '企业列表加载失败' : '全部企业',
|
||||
props: { 'aria-label': '所属企业', filterable: true, disabled: Boolean(tenantLoadError.value) },
|
||||
options: tenants.value.map((tenant) => ({ value: String(tenant.id), label: `${tenant.tenantName}(${tenant.tenantCode || tenant.id})` })),
|
||||
},
|
||||
{ key: 'keyword', type: 'input', placeholder: '单位名称、编码、联系人或电话' },
|
||||
{
|
||||
key: 'type',
|
||||
@@ -84,7 +95,7 @@ const queryFields: QueryField[] = [
|
||||
{ label: '停用', value: 'INACTIVE' },
|
||||
],
|
||||
},
|
||||
]
|
||||
])
|
||||
|
||||
interface PartnerForm extends SavePlatformPartnerRequest {
|
||||
id: string
|
||||
@@ -119,16 +130,12 @@ const { captureBaseline } = useUnsavedWorkRegistration({
|
||||
requiredPermission: editorPermission,
|
||||
})
|
||||
|
||||
const tenantReady = computed(() => Boolean(selectedTenantId.value)
|
||||
&& loadedTenantId.value === selectedTenantId.value
|
||||
&& !loading.value)
|
||||
const tenantSwitchDisabled = computed(() => editorOpen.value
|
||||
|| detailOpen.value
|
||||
|| importOpen.value
|
||||
|| saving.value
|
||||
|| detailLoading.value
|
||||
|| importing.value
|
||||
|| statusLoadingIds.value.size > 0)
|
||||
const selectedEnterpriseId = computed(() => appliedFilters.value.tenantId)
|
||||
const targetEnterpriseId = computed(() => filters.value.tenantId)
|
||||
const selectedEnterpriseName = computed(() => {
|
||||
if (!selectedEnterpriseId.value) return '全部企业'
|
||||
return tenants.value.find((tenant) => String(tenant.id) === selectedEnterpriseId.value)?.tenantName || `企业 ${selectedEnterpriseId.value}`
|
||||
})
|
||||
|
||||
const rules: FormRules<PartnerForm> = {
|
||||
partnerCode: [{ required: true, whitespace: true, message: '请输入往来单位编码', trigger: 'blur' }],
|
||||
@@ -161,6 +168,7 @@ function partnerTypeLabel(type: PlatformPartnerLegacyType) {
|
||||
const partnerColumns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 210, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'partner', label: '往来单位', minWidth: 210, slotName: 'partner' },
|
||||
{ key: 'tenant', label: '所属企业', minWidth: 190, showOverflowTooltip: true, slotName: 'tenant' },
|
||||
{ key: 'type', label: '类型', minWidth: 115, slotName: 'type' },
|
||||
{ key: 'contact', label: '联系人', minWidth: 165, slotName: 'contact' },
|
||||
{ key: 'address', label: '地址', minWidth: 240, slotName: 'address' },
|
||||
@@ -188,30 +196,52 @@ function queryParams() {
|
||||
}
|
||||
}
|
||||
|
||||
function apiTenantId(value: string) {
|
||||
function apiTenantId(value: string | undefined) {
|
||||
if (!value) return undefined
|
||||
const numeric = Number(value)
|
||||
return Number.isSafeInteger(numeric) ? numeric : value
|
||||
}
|
||||
|
||||
function requireCurrentTenant() {
|
||||
if (!tenantReady.value) {
|
||||
ElMessage.warning('往来单位数据尚未加载完成,请稍后重试')
|
||||
function requireSelectedEnterprise(action: string) {
|
||||
if (!targetEnterpriseId.value) {
|
||||
ElMessage.warning(`请先在筛选条件中选择所属企业,再${action}`)
|
||||
return null
|
||||
}
|
||||
return apiTenantId(selectedTenantId.value)
|
||||
return apiTenantId(targetEnterpriseId.value)!
|
||||
}
|
||||
|
||||
async function loadPartners(tenantId = selectedTenantId.value) {
|
||||
if (!tenantId) return
|
||||
function tenantName(tenantId: number | string) {
|
||||
return tenants.value.find((tenant) => String(tenant.id) === String(tenantId))?.tenantName || `企业 ${tenantId}`
|
||||
}
|
||||
|
||||
async function loadTenants() {
|
||||
tenantLoadError.value = null
|
||||
try {
|
||||
const records: PlatformTenantResource[] = []
|
||||
let currentPage = 1
|
||||
let pages = 1
|
||||
do {
|
||||
const result = await listPlatformTenants({ page: currentPage, size: 200 })
|
||||
records.push(...result.records)
|
||||
pages = Math.max(1, result.pages)
|
||||
currentPage += 1
|
||||
} while (currentPage <= pages)
|
||||
tenants.value = records
|
||||
} catch (error) {
|
||||
tenants.value = []
|
||||
tenantLoadError.value = getApiErrorDetails(error, '企业列表加载失败,暂时无法按所属企业筛选')
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPartners() {
|
||||
const sequence = ++loadSequence
|
||||
loading.value = true
|
||||
loadError.value = null
|
||||
try {
|
||||
const result = await listPlatformPartners(apiTenantId(tenantId), queryParams())
|
||||
const result = await listPlatformPartners(apiTenantId(appliedFilters.value.tenantId), queryParams())
|
||||
if (sequence !== loadSequence) return
|
||||
rows.value = result.records
|
||||
total.value = result.total
|
||||
loadedTenantId.value = tenantId
|
||||
} catch (error) {
|
||||
if (sequence !== loadSequence) return
|
||||
rows.value = []
|
||||
@@ -224,6 +254,7 @@ async function loadPartners(tenantId = selectedTenantId.value) {
|
||||
|
||||
function searchPartners(model: QueryModel) {
|
||||
appliedFilters.value = {
|
||||
tenantId: String(model.tenantId || ''),
|
||||
keyword: String(model.keyword || ''),
|
||||
type: (model.type || '') as PlatformPartnerType | '',
|
||||
status: (model.status || '') as PlatformPartnerStatus | '',
|
||||
@@ -236,7 +267,9 @@ function searchPartners(model: QueryModel) {
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
if (!requireCurrentTenant()) return
|
||||
const tenantId = requireSelectedEnterprise('新增往来单位')
|
||||
if (!tenantId) return
|
||||
editorTenantId.value = String(tenantId)
|
||||
Object.assign(form, emptyForm())
|
||||
captureBaseline()
|
||||
editorOpen.value = true
|
||||
@@ -248,7 +281,7 @@ function nullable(value: string | null) {
|
||||
}
|
||||
|
||||
function openEdit(row: PlatformPartnerResource) {
|
||||
if (!requireCurrentTenant()) return
|
||||
editorTenantId.value = String(row.tenantId)
|
||||
Object.assign(form, {
|
||||
id: String(row.id),
|
||||
partnerCode: row.partnerCode,
|
||||
@@ -294,8 +327,7 @@ function savePayload(): SavePlatformPartnerRequest {
|
||||
}
|
||||
|
||||
async function openDetail(row: PlatformPartnerResource) {
|
||||
const tenantId = requireCurrentTenant()
|
||||
if (!tenantId) return
|
||||
const tenantId = apiTenantId(String(row.tenantId))!
|
||||
detailOpen.value = true
|
||||
detailLoading.value = true
|
||||
detail.value = null
|
||||
@@ -311,6 +343,7 @@ async function openDetail(row: PlatformPartnerResource) {
|
||||
}
|
||||
|
||||
function openImport() {
|
||||
if (!requireSelectedEnterprise('批量导入')) return
|
||||
importFile.value = null
|
||||
importResult.value = null
|
||||
importOpen.value = true
|
||||
@@ -343,7 +376,7 @@ async function downloadTemplate() {
|
||||
}
|
||||
|
||||
async function submitImport() {
|
||||
const tenantId = requireCurrentTenant()
|
||||
const tenantId = requireSelectedEnterprise('批量导入')
|
||||
if (!tenantId) return
|
||||
if (!importFile.value) {
|
||||
ElMessage.warning('请先选择 XLSX 文件')
|
||||
@@ -382,7 +415,7 @@ function downloadImportErrors() {
|
||||
}
|
||||
|
||||
async function savePartner() {
|
||||
const tenantId = requireCurrentTenant()
|
||||
const tenantId = form.id ? apiTenantId(editorTenantId.value) : requireSelectedEnterprise('新增往来单位')
|
||||
if (!tenantId) return
|
||||
const valid = await formRef.value?.validate().catch(() => false)
|
||||
if (!valid) return
|
||||
@@ -407,8 +440,7 @@ async function savePartner() {
|
||||
}
|
||||
|
||||
async function toggleStatus(row: PlatformPartnerResource) {
|
||||
const tenantId = requireCurrentTenant()
|
||||
if (!tenantId) return
|
||||
const tenantId = apiTenantId(String(row.tenantId))!
|
||||
const nextStatus: PlatformPartnerStatus = row.status === 'ACTIVE' ? 'INACTIVE' : 'ACTIVE'
|
||||
const action = nextStatus === 'ACTIVE' ? '启用' : '停用'
|
||||
try {
|
||||
@@ -441,26 +473,23 @@ function formatUpdatedAt(value?: string | null) {
|
||||
}
|
||||
|
||||
watch([page, pageSize], () => { void loadPartners() })
|
||||
watch(selectedTenantId, (tenantId) => {
|
||||
editorOpen.value = false
|
||||
detailOpen.value = false
|
||||
importOpen.value = false
|
||||
detail.value = null
|
||||
importFile.value = null
|
||||
importResult.value = null
|
||||
rows.value = []
|
||||
total.value = 0
|
||||
loadedTenantId.value = null
|
||||
page.value = 1
|
||||
void loadPartners(tenantId)
|
||||
onMounted(() => {
|
||||
void loadTenants()
|
||||
void loadPartners()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="business-page partners-page">
|
||||
<PlatformTenantSelector v-model="selectedTenantId" :disabled="tenantSwitchDisabled" />
|
||||
<QueryToolbar v-model="filters" class="surface-panel" ariaLabel="往来单位筛选" :fields="queryFields" @search="searchPartners" />
|
||||
|
||||
<el-alert v-if="tenantLoadError" type="warning" :closable="false" show-icon>
|
||||
<template #title>所属企业列表加载失败</template>
|
||||
<p>{{ tenantLoadError.message }};往来单位仍按全部企业展示。</p>
|
||||
<ApiErrorMeta :details="tenantLoadError" />
|
||||
<el-button link type="warning" @click="loadTenants">重新加载企业列表</el-button>
|
||||
</el-alert>
|
||||
|
||||
<section v-if="loadError" class="surface-panel partners-error" role="alert">
|
||||
<div>
|
||||
<strong>往来单位列表暂时无法加载</strong>
|
||||
@@ -474,11 +503,11 @@ watch(selectedTenantId, (tenantId) => {
|
||||
<div class="partners-table-header">
|
||||
<div class="partners-table-heading">
|
||||
<h2>往来单位列表</h2>
|
||||
<span>租户 {{ selectedTenantId || '—' }} · 共 {{ total }} 个往来单位</span>
|
||||
<span>{{ selectedEnterpriseName }} · 共 {{ total }} 个往来单位</span>
|
||||
</div>
|
||||
<div class="partners-primary-actions">
|
||||
<el-button v-if="access.can('platform:counterparty:import')" :disabled="loading || !tenantReady" @click="openImport"><Upload :size="16" />批量导入</el-button>
|
||||
<el-button v-if="access.can('platform:counterparty:create')" type="primary" :disabled="loading || !tenantReady" @click="openCreate"><Plus :size="16" />新增往来单位</el-button>
|
||||
<el-button v-if="access.can('platform:counterparty:import')" :disabled="loading" @click="openImport"><Upload :size="16" />批量导入</el-button>
|
||||
<el-button v-if="access.can('platform:counterparty:create')" type="primary" :disabled="loading" @click="openCreate"><Plus :size="16" />新增往来单位</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<DataTable
|
||||
@@ -496,11 +525,12 @@ watch(selectedTenantId, (tenantId) => {
|
||||
<template #actions="{ row }">
|
||||
<div class="table-actions">
|
||||
<el-button link type="primary" @click="openDetail(row as PlatformPartnerResource)"><Eye :size="15" />详情</el-button>
|
||||
<el-button v-if="access.can('platform:counterparty:edit')" link type="primary" :disabled="!tenantReady" @click="openEdit(row as PlatformPartnerResource)"><Pencil :size="15" />编辑</el-button>
|
||||
<el-button v-if="access.can('platform:counterparty:status')" link :disabled="!tenantReady" :type="(row as PlatformPartnerResource).status === 'ACTIVE' ? 'danger' : 'success'" :loading="statusLoadingIds.has(String((row as PlatformPartnerResource).id))" @click="toggleStatus(row as PlatformPartnerResource)"><Power :size="15" />{{ (row as PlatformPartnerResource).status === 'ACTIVE' ? '停用' : '启用' }}</el-button>
|
||||
<el-button v-if="access.can('platform:counterparty:edit')" link type="primary" :disabled="loading" @click="openEdit(row as PlatformPartnerResource)"><Pencil :size="15" />编辑</el-button>
|
||||
<el-button v-if="access.can('platform:counterparty:status')" link :disabled="loading" :type="(row as PlatformPartnerResource).status === 'ACTIVE' ? 'danger' : 'success'" :loading="statusLoadingIds.has(String((row as PlatformPartnerResource).id))" @click="toggleStatus(row as PlatformPartnerResource)"><Power :size="15" />{{ (row as PlatformPartnerResource).status === 'ACTIVE' ? '停用' : '启用' }}</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<template #partner="{ row }"><div class="partner-cell"><span><Building2 :size="18" /></span><div><strong>{{ (row as PlatformPartnerResource).partnerName }}</strong><small>{{ (row as PlatformPartnerResource).partnerCode }}</small></div></div></template>
|
||||
<template #tenant="{ row }"><span>{{ tenantName((row as PlatformPartnerResource).tenantId) }}</span></template>
|
||||
<template #type="{ row }"><div class="partner-types"><el-tag v-for="type in (row as PlatformPartnerResource).types" :key="type" effect="plain">{{ partnerTypeLabel(type) }}</el-tag></div></template>
|
||||
<template #contact="{ row }"><strong>{{ (row as PlatformPartnerResource).contactName }}</strong><span class="partner-subline">{{ (row as PlatformPartnerResource).contactPhone }}</span><span v-if="(row as PlatformPartnerResource).contactEmail" class="partner-subline">{{ (row as PlatformPartnerResource).contactEmail }}</span></template>
|
||||
<template #address="{ row }"><div class="partner-address"><MapPin :size="15" /><span>{{ [(row as PlatformPartnerResource).province, (row as PlatformPartnerResource).city, (row as PlatformPartnerResource).district, (row as PlatformPartnerResource).addressLine].filter(Boolean).join(' ') }}</span></div></template>
|
||||
|
||||
@@ -51,7 +51,7 @@ export interface PlatformExpressCompanyImportResult {
|
||||
const basePath = '/platform/v1/express-companies'
|
||||
|
||||
export async function listPlatformExpressCompanies(
|
||||
tenantId: number | string,
|
||||
tenantId: number | string | undefined,
|
||||
page = 1,
|
||||
size = 20,
|
||||
keyword = '',
|
||||
|
||||
@@ -5,8 +5,8 @@ import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vites
|
||||
import { listPlatformExpressCompanies, type PlatformExpressCompanyResource } from '@/modules/master-data/express-companies/api/platformExpressCompanies'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { useAuthStore } from '@/core/auth/auth'
|
||||
import QueryToolbar from '@/shared/components/QueryToolbar.vue'
|
||||
import { listPlatformTenants, type PlatformTenantResource } from '../../../iam/api/platformIam'
|
||||
import PlatformTenantSelector from '../../../iam/components/PlatformTenantSelector.vue'
|
||||
import ExpressCompaniesView from './ExpressCompaniesView.vue'
|
||||
|
||||
vi.mock('@/modules/master-data/express-companies/api/platformExpressCompanies', () => ({
|
||||
@@ -95,9 +95,12 @@ describe('ExpressCompaniesView query and table migration', () => {
|
||||
const wrapper = await mountView()
|
||||
|
||||
expect(wrapper.find('.query-toolbar').exists()).toBe(true)
|
||||
expect(wrapper.find('input[aria-label="所属企业"]').exists()).toBe(true)
|
||||
expect(wrapper.find('.data-table').exists()).toBe(true)
|
||||
expect(wrapper.text()).toContain('顺丰速运')
|
||||
expect(wrapper.text()).toContain('全部企业 · 共 1 家快递公司')
|
||||
expect(wrapper.find('.metric-strip').exists()).toBe(false)
|
||||
expect(listPlatformExpressCompanies).toHaveBeenCalledWith(undefined, 1, 20, '', '')
|
||||
})
|
||||
|
||||
it('does not request with edited filters until search is submitted', async () => {
|
||||
@@ -110,13 +113,16 @@ describe('ExpressCompaniesView query and table migration', () => {
|
||||
await searchButton?.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(listPlatformExpressCompanies).toHaveBeenLastCalledWith(10001, 1, 20, '顺丰', '')
|
||||
expect(listPlatformExpressCompanies).toHaveBeenLastCalledWith(undefined, 1, 20, '顺丰', '')
|
||||
})
|
||||
|
||||
it('preserves a snowflake tenant id as a string when switching enterprises', async () => {
|
||||
const wrapper = await mountView()
|
||||
|
||||
wrapper.findComponent(PlatformTenantSelector).vm.$emit('update:modelValue', snowflakeTenantId)
|
||||
const toolbar = wrapper.findComponent(QueryToolbar)
|
||||
const model = { tenantId: snowflakeTenantId, keyword: '', status: '' }
|
||||
toolbar.vm.$emit('update:modelValue', model)
|
||||
toolbar.vm.$emit('search', model)
|
||||
await flushPromises()
|
||||
|
||||
expect(listPlatformExpressCompanies).toHaveBeenLastCalledWith(snowflakeTenantId, 1, 20, '', '')
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
|
||||
import { Download, ExternalLink, FileSpreadsheet, Pencil, Plus, Power, Truck, Upload } from '@lucide/vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
type SavePlatformExpressCompanyRequest,
|
||||
} from '@/modules/master-data/express-companies/api/platformExpressCompanies'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { PlatformTenantSelector } from '@/modules/iam'
|
||||
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
|
||||
|
||||
const access = useAccessPreviewStore()
|
||||
const loading = ref(false)
|
||||
@@ -34,8 +34,9 @@ const rows = ref<PlatformExpressCompanyResource[]>([])
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const total = ref(0)
|
||||
const selectedTenantId = ref('')
|
||||
const loadedTenantId = ref<string | null>(null)
|
||||
const tenants = ref<PlatformTenantResource[]>([])
|
||||
const tenantLoadError = ref<ApiErrorDetails | null>(null)
|
||||
const editorTenantId = ref('')
|
||||
const loadError = ref<ApiErrorDetails | null>(null)
|
||||
const statusLoadingIds = ref(new Set<string>())
|
||||
const editLoadingIds = ref(new Set<string>())
|
||||
@@ -45,12 +46,21 @@ const importFile = ref<File | null>(null)
|
||||
const importResult = ref<PlatformExpressCompanyImportResult | null>(null)
|
||||
const importError = ref<ApiErrorDetails | null>(null)
|
||||
type ExpressCompanyFilters = QueryModel & {
|
||||
tenantId: string
|
||||
keyword: string
|
||||
status: PlatformExpressCompanyStatus | ''
|
||||
}
|
||||
const filters = ref<ExpressCompanyFilters>({ keyword: '', status: '' })
|
||||
const filters = ref<ExpressCompanyFilters>({ tenantId: '', keyword: '', status: '' })
|
||||
const appliedFilters = ref<ExpressCompanyFilters>({ ...filters.value })
|
||||
const queryFields: QueryField[] = [
|
||||
const queryFields = computed<QueryField[]>(() => [
|
||||
{
|
||||
key: 'tenantId',
|
||||
type: 'select',
|
||||
width: 180,
|
||||
placeholder: tenantLoadError.value ? '企业列表加载失败' : '全部企业',
|
||||
props: { 'aria-label': '所属企业', filterable: true, disabled: Boolean(tenantLoadError.value) },
|
||||
options: tenants.value.map((tenant) => ({ value: String(tenant.id), label: `${tenant.tenantName}(${tenant.tenantCode || tenant.id})` })),
|
||||
},
|
||||
{ key: 'keyword', type: 'input', placeholder: '快递公司名称或编码', props: { maxlength: 128, 'aria-label': '快递公司关键词' } },
|
||||
{
|
||||
key: 'status',
|
||||
@@ -62,7 +72,7 @@ const queryFields: QueryField[] = [
|
||||
{ label: '停用', value: 'INACTIVE' },
|
||||
],
|
||||
},
|
||||
]
|
||||
])
|
||||
let loadSequence = 0
|
||||
|
||||
const emptyForm = (): SavePlatformExpressCompanyRequest & { id: string } => ({
|
||||
@@ -93,15 +103,12 @@ const { captureBaseline } = useUnsavedWorkRegistration({
|
||||
requiredPermission: editorPermission,
|
||||
})
|
||||
|
||||
const tenantReady = computed(() => Boolean(selectedTenantId.value)
|
||||
&& loadedTenantId.value === selectedTenantId.value
|
||||
&& !loading.value)
|
||||
const tenantSwitchDisabled = computed(() => editorOpen.value
|
||||
|| importOpen.value
|
||||
|| saving.value
|
||||
|| importing.value
|
||||
|| editLoadingIds.value.size > 0
|
||||
|| statusLoadingIds.value.size > 0)
|
||||
const selectedEnterpriseId = computed(() => appliedFilters.value.tenantId)
|
||||
const targetEnterpriseId = computed(() => filters.value.tenantId)
|
||||
const selectedEnterpriseName = computed(() => {
|
||||
if (!selectedEnterpriseId.value) return '全部企业'
|
||||
return tenants.value.find((tenant) => String(tenant.id) === selectedEnterpriseId.value)?.tenantName || `企业 ${selectedEnterpriseId.value}`
|
||||
})
|
||||
|
||||
watch(
|
||||
() => access.can(editorPermission.value),
|
||||
@@ -135,6 +142,7 @@ function formatUpdatedAt(value?: string | null) {
|
||||
const expressCompanyColumns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 170, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'company', label: '快递公司', minWidth: 210, slotName: 'company' },
|
||||
{ key: 'tenant', label: '所属企业', minWidth: 190, showOverflowTooltip: true, slotName: 'tenant' },
|
||||
{ prop: 'servicePhone', label: '客服电话', minWidth: 130, formatter: (row: PlatformExpressCompanyResource) => row.servicePhone || '—' },
|
||||
{ key: 'website', label: '官方网站', minWidth: 210, slotName: 'website' },
|
||||
{ key: 'trackingUrlTemplate', label: '轨迹查询模板', minWidth: 260, slotName: 'trackingUrlTemplate' },
|
||||
@@ -143,27 +151,50 @@ const expressCompanyColumns: DataTableColumn[] = [
|
||||
{ key: 'status', label: '状态', width: 100, slotName: 'status' },
|
||||
]
|
||||
|
||||
function apiTenantId(value: string) {
|
||||
function apiTenantId(value: string | undefined) {
|
||||
if (!value) return undefined
|
||||
const numeric = Number(value)
|
||||
return Number.isSafeInteger(numeric) ? numeric : value
|
||||
}
|
||||
|
||||
function requireCurrentTenant() {
|
||||
if (!tenantReady.value) {
|
||||
ElMessage.warning('快递公司数据尚未加载完成,请稍后重试')
|
||||
function requireSelectedEnterprise(action: string) {
|
||||
if (!targetEnterpriseId.value) {
|
||||
ElMessage.warning(`请先在筛选条件中选择所属企业,再${action}`)
|
||||
return null
|
||||
}
|
||||
return apiTenantId(selectedTenantId.value)
|
||||
return apiTenantId(targetEnterpriseId.value)!
|
||||
}
|
||||
|
||||
async function loadExpressCompanies(tenantId = selectedTenantId.value) {
|
||||
if (!tenantId) return
|
||||
function tenantName(tenantId: number | string) {
|
||||
return tenants.value.find((tenant) => String(tenant.id) === String(tenantId))?.tenantName || `企业 ${tenantId}`
|
||||
}
|
||||
|
||||
async function loadTenants() {
|
||||
tenantLoadError.value = null
|
||||
try {
|
||||
const records: PlatformTenantResource[] = []
|
||||
let currentPage = 1
|
||||
let pages = 1
|
||||
do {
|
||||
const result = await listPlatformTenants({ page: currentPage, size: 200 })
|
||||
records.push(...result.records)
|
||||
pages = Math.max(1, result.pages)
|
||||
currentPage += 1
|
||||
} while (currentPage <= pages)
|
||||
tenants.value = records
|
||||
} catch (error) {
|
||||
tenants.value = []
|
||||
tenantLoadError.value = getApiErrorDetails(error, '企业列表加载失败,暂时无法按所属企业筛选')
|
||||
}
|
||||
}
|
||||
|
||||
async function loadExpressCompanies() {
|
||||
const sequence = ++loadSequence
|
||||
loading.value = true
|
||||
loadError.value = null
|
||||
try {
|
||||
const result = await listPlatformExpressCompanies(
|
||||
apiTenantId(tenantId),
|
||||
apiTenantId(appliedFilters.value.tenantId),
|
||||
page.value,
|
||||
pageSize.value,
|
||||
appliedFilters.value.keyword,
|
||||
@@ -172,7 +203,6 @@ async function loadExpressCompanies(tenantId = selectedTenantId.value) {
|
||||
if (sequence !== loadSequence) return
|
||||
rows.value = result.records
|
||||
total.value = result.total
|
||||
loadedTenantId.value = tenantId
|
||||
} catch (error) {
|
||||
if (sequence !== loadSequence) return
|
||||
rows.value = []
|
||||
@@ -185,6 +215,7 @@ async function loadExpressCompanies(tenantId = selectedTenantId.value) {
|
||||
|
||||
function applyFilters(model: QueryModel) {
|
||||
appliedFilters.value = {
|
||||
tenantId: String(model.tenantId || ''),
|
||||
keyword: String(model.keyword || ''),
|
||||
status: (model.status || '') as PlatformExpressCompanyStatus | '',
|
||||
}
|
||||
@@ -196,7 +227,9 @@ function applyFilters(model: QueryModel) {
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
if (!requireCurrentTenant()) return
|
||||
const tenantId = requireSelectedEnterprise('新增快递公司')
|
||||
if (!tenantId) return
|
||||
editorTenantId.value = String(tenantId)
|
||||
Object.assign(form, emptyForm())
|
||||
captureBaseline()
|
||||
editorOpen.value = true
|
||||
@@ -220,8 +253,8 @@ function assignEditor(row: PlatformExpressCompanyResource) {
|
||||
}
|
||||
|
||||
async function openEdit(row: PlatformExpressCompanyResource) {
|
||||
const tenantId = requireCurrentTenant()
|
||||
if (!tenantId) return
|
||||
const tenantId = apiTenantId(String(row.tenantId))!
|
||||
editorTenantId.value = String(row.tenantId)
|
||||
const id = String(row.id)
|
||||
const pending = new Set(editLoadingIds.value)
|
||||
pending.add(id)
|
||||
@@ -239,6 +272,7 @@ async function openEdit(row: PlatformExpressCompanyResource) {
|
||||
}
|
||||
|
||||
function openImport() {
|
||||
if (!requireSelectedEnterprise('批量导入')) return
|
||||
importFile.value = null
|
||||
importResult.value = null
|
||||
importError.value = null
|
||||
@@ -292,7 +326,7 @@ function selectImportFile(event: Event) {
|
||||
}
|
||||
|
||||
async function submitImport() {
|
||||
const tenantId = requireCurrentTenant()
|
||||
const tenantId = requireSelectedEnterprise('批量导入')
|
||||
if (!tenantId) return
|
||||
if (!importFile.value) {
|
||||
ElMessage.warning('请先选择填写完成的 XLSX 文件')
|
||||
@@ -340,7 +374,7 @@ function buildPayload(): SavePlatformExpressCompanyRequest {
|
||||
}
|
||||
|
||||
async function saveExpressCompany() {
|
||||
const tenantId = requireCurrentTenant()
|
||||
const tenantId = form.id ? apiTenantId(editorTenantId.value) : requireSelectedEnterprise('新增快递公司')
|
||||
if (!tenantId) return
|
||||
const valid = await formRef.value?.validate().catch(() => false)
|
||||
if (!valid) return
|
||||
@@ -361,8 +395,7 @@ async function saveExpressCompany() {
|
||||
}
|
||||
|
||||
async function toggleStatus(row: PlatformExpressCompanyResource) {
|
||||
const tenantId = requireCurrentTenant()
|
||||
if (!tenantId) return
|
||||
const tenantId = apiTenantId(String(row.tenantId))!
|
||||
const nextStatus: PlatformExpressCompanyStatus = row.status === 'ACTIVE' ? 'INACTIVE' : 'ACTIVE'
|
||||
const action = nextStatus === 'ACTIVE' ? '启用' : '停用'
|
||||
try {
|
||||
@@ -393,23 +426,20 @@ async function toggleStatus(row: PlatformExpressCompanyResource) {
|
||||
}
|
||||
|
||||
watch([page, pageSize], () => { void loadExpressCompanies() })
|
||||
watch(selectedTenantId, (tenantId) => {
|
||||
editorOpen.value = false
|
||||
importOpen.value = false
|
||||
importFile.value = null
|
||||
importResult.value = null
|
||||
importError.value = null
|
||||
rows.value = []
|
||||
total.value = 0
|
||||
loadedTenantId.value = null
|
||||
page.value = 1
|
||||
void loadExpressCompanies(tenantId)
|
||||
onMounted(() => {
|
||||
void loadTenants()
|
||||
void loadExpressCompanies()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="business-page express-company-page">
|
||||
<PlatformTenantSelector v-model="selectedTenantId" :disabled="tenantSwitchDisabled" />
|
||||
<el-alert v-if="tenantLoadError" type="warning" :closable="false" show-icon class="state-alert">
|
||||
<template #title>所属企业列表加载失败</template>
|
||||
<p>{{ tenantLoadError.message }};快递公司仍按全部企业展示。</p>
|
||||
<ApiErrorMeta :details="tenantLoadError" />
|
||||
<el-button size="small" @click="loadTenants">重新加载企业列表</el-button>
|
||||
</el-alert>
|
||||
<el-alert v-if="loadError" type="error" :closable="false" show-icon class="state-alert">
|
||||
<template #title>快递公司加载失败</template>
|
||||
<p>{{ loadError.message }}</p>
|
||||
@@ -423,11 +453,11 @@ watch(selectedTenantId, (tenantId) => {
|
||||
<div class="panel-heading">
|
||||
<div>
|
||||
<h2>快递公司列表</h2>
|
||||
<p>租户 {{ selectedTenantId || '—' }} · 共 {{ total }} 家快递公司</p>
|
||||
<p>{{ selectedEnterpriseName }} · 共 {{ total }} 家快递公司</p>
|
||||
</div>
|
||||
<div class="panel-actions">
|
||||
<el-button v-if="access.can('platform:express:import')" :disabled="!tenantReady" @click="openImport"><Upload :size="16" />批量导入</el-button>
|
||||
<el-button v-if="access.can('platform:express:create')" type="primary" :disabled="!tenantReady" @click="openCreate"><Plus :size="16" />新增快递公司</el-button>
|
||||
<el-button v-if="access.can('platform:express:import')" :disabled="loading" @click="openImport"><Upload :size="16" />批量导入</el-button>
|
||||
<el-button v-if="access.can('platform:express:create')" type="primary" :disabled="loading" @click="openCreate"><Plus :size="16" />新增快递公司</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<DataTable
|
||||
@@ -448,14 +478,14 @@ watch(selectedTenantId, (tenantId) => {
|
||||
v-if="access.can('platform:express:edit')"
|
||||
link
|
||||
type="primary"
|
||||
:disabled="!tenantReady"
|
||||
:disabled="loading"
|
||||
:loading="editLoadingIds.has(String((row as PlatformExpressCompanyResource).id))"
|
||||
@click="openEdit(row as PlatformExpressCompanyResource)"
|
||||
><Pencil :size="15" />编辑</el-button>
|
||||
<el-button
|
||||
v-if="access.can('platform:express:status')"
|
||||
link
|
||||
:disabled="!tenantReady"
|
||||
:disabled="loading"
|
||||
:type="(row as PlatformExpressCompanyResource).status === 'ACTIVE' ? 'danger' : 'success'"
|
||||
:loading="statusLoadingIds.has(String((row as PlatformExpressCompanyResource).id))"
|
||||
@click="toggleStatus(row as PlatformExpressCompanyResource)"
|
||||
@@ -464,6 +494,7 @@ watch(selectedTenantId, (tenantId) => {
|
||||
<span v-else class="muted-value">仅查看</span>
|
||||
</template>
|
||||
<template #company="{ row }"><div class="express-company-cell"><span><Truck :size="17" /></span><div><strong>{{ (row as PlatformExpressCompanyResource).companyName }}</strong><small>{{ (row as PlatformExpressCompanyResource).companyCode }}</small></div></div></template>
|
||||
<template #tenant="{ row }"><span>{{ tenantName((row as PlatformExpressCompanyResource).tenantId) }}</span></template>
|
||||
<template #website="{ row }">
|
||||
<a v-if="safeHttpUrl((row as PlatformExpressCompanyResource).website)" class="external-link" :href="safeHttpUrl((row as PlatformExpressCompanyResource).website)!" target="_blank" rel="noopener noreferrer">访问官网<ExternalLink :size="13" /></a>
|
||||
<span v-else class="muted-value">{{ (row as PlatformExpressCompanyResource).website || '—' }}</span>
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export * from './counterparties'
|
||||
@@ -0,0 +1,112 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { http } from '@/shared/api/http'
|
||||
import {
|
||||
changePlatformLocationStatus,
|
||||
createPlatformLocation,
|
||||
deletePlatformLocation,
|
||||
getPlatformLocation,
|
||||
listPlatformLocations,
|
||||
updatePlatformLocation,
|
||||
type SavePlatformLocationRequest,
|
||||
} from './platformLocations'
|
||||
|
||||
vi.mock('@/shared/api/http', () => ({
|
||||
http: { get: vi.fn(), post: vi.fn(), put: vi.fn(), patch: vi.fn(), delete: vi.fn() },
|
||||
unwrapApiResponse: <T>(response: { data: T }) => response.data,
|
||||
}))
|
||||
|
||||
const payload: SavePlatformLocationRequest = {
|
||||
warehouseId: '190000000000000101',
|
||||
zoneCode: 'A',
|
||||
zoneName: '常规区',
|
||||
locationCode: 'A1-01',
|
||||
locationName: 'A1 区 01 位',
|
||||
locationType: 'SHELF',
|
||||
maxWeightKg: 500,
|
||||
status: 'AVAILABLE',
|
||||
remark: null,
|
||||
version: 3,
|
||||
}
|
||||
|
||||
describe('platform location API', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(http.get).mockReset()
|
||||
vi.mocked(http.post).mockReset()
|
||||
vi.mocked(http.put).mockReset()
|
||||
vi.mocked(http.patch).mockReset()
|
||||
vi.mocked(http.delete).mockReset()
|
||||
})
|
||||
|
||||
it('uses the implemented paged list and detail contracts', async () => {
|
||||
const page = { records: [], page: '2', size: '20', total: '21', pages: '2' }
|
||||
const detail = { location: { id: '19001' }, warehouseCode: 'CN-YW-01', warehouseName: '义乌仓' }
|
||||
vi.mocked(http.get)
|
||||
.mockResolvedValueOnce({ code: '200', message: 'success', data: page })
|
||||
.mockResolvedValueOnce({ code: '200', message: 'success', data: detail })
|
||||
|
||||
await expect(listPlatformLocations({
|
||||
page: 2,
|
||||
size: 20,
|
||||
tenantId: '9223372036854775806',
|
||||
warehouseId: '190000000000000101',
|
||||
zoneCode: ' A ',
|
||||
locationCode: ' A1 ',
|
||||
locationType: 'SHELF',
|
||||
status: 'AVAILABLE',
|
||||
})).resolves.toEqual({ records: [], page: 2, size: 20, total: 21, pages: 2 })
|
||||
await expect(getPlatformLocation('9223372036854775806', '190000000000000101', '19001')).resolves.toEqual(detail)
|
||||
|
||||
expect(http.get).toHaveBeenNthCalledWith(1, '/platform/v1/locations', {
|
||||
params: {
|
||||
tenantId: '9223372036854775806', page: 2, size: 20, warehouseId: '190000000000000101',
|
||||
zoneCode: 'A', locationCode: 'A1', locationType: 'SHELF', status: 'AVAILABLE',
|
||||
},
|
||||
})
|
||||
expect(http.get).toHaveBeenNthCalledWith(2, '/platform/v1/locations/19001', {
|
||||
params: { tenantId: '9223372036854775806', warehouseId: '190000000000000101' },
|
||||
})
|
||||
})
|
||||
|
||||
it('omits empty customer, warehouse, and filter values from all-customer list requests', async () => {
|
||||
const page = { records: [], page: 1, size: 20, total: 0, pages: 0 }
|
||||
vi.mocked(http.get).mockResolvedValue({ code: '200', message: 'success', data: page })
|
||||
|
||||
await expect(listPlatformLocations({
|
||||
page: 1,
|
||||
size: 20,
|
||||
tenantId: '',
|
||||
warehouseId: '',
|
||||
zoneCode: '',
|
||||
locationCode: '',
|
||||
locationType: '',
|
||||
status: '',
|
||||
})).resolves.toEqual(page)
|
||||
|
||||
expect(http.get).toHaveBeenCalledWith('/platform/v1/locations', {
|
||||
params: { page: 1, size: 20 },
|
||||
})
|
||||
})
|
||||
|
||||
it('sends versioned create, update, status, and delete requests', async () => {
|
||||
vi.mocked(http.post).mockResolvedValue({ code: '200', message: 'success', data: '19001' })
|
||||
vi.mocked(http.put).mockResolvedValue({ code: '200', message: 'success', data: null })
|
||||
vi.mocked(http.patch).mockResolvedValue({ code: '200', message: 'success', data: null })
|
||||
vi.mocked(http.delete).mockResolvedValue({ code: '200', message: 'success', data: null })
|
||||
|
||||
await expect(createPlatformLocation(10001, payload)).resolves.toBe('19001')
|
||||
await expect(updatePlatformLocation(10001, payload.warehouseId, '19001', payload)).resolves.toBeNull()
|
||||
await expect(changePlatformLocationStatus(10001, payload.warehouseId, '19001', 'DISABLED', 3)).resolves.toBeNull()
|
||||
await expect(deletePlatformLocation(10001, payload.warehouseId, '19001', 4)).resolves.toBeNull()
|
||||
|
||||
expect(http.post).toHaveBeenCalledWith('/platform/v1/locations', payload, { params: { tenantId: 10001 } })
|
||||
expect(http.put).toHaveBeenCalledWith('/platform/v1/locations/19001', payload, {
|
||||
params: { tenantId: 10001, warehouseId: payload.warehouseId },
|
||||
})
|
||||
expect(http.patch).toHaveBeenCalledWith('/platform/v1/locations/19001/status', { status: 'DISABLED', version: 3 }, {
|
||||
params: { tenantId: 10001, warehouseId: payload.warehouseId },
|
||||
})
|
||||
expect(http.delete).toHaveBeenCalledWith('/platform/v1/locations/19001', {
|
||||
params: { tenantId: 10001, warehouseId: payload.warehouseId, version: 4 },
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,148 @@
|
||||
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
|
||||
export type PlatformLocationStatus = 'AVAILABLE' | 'RESERVED' | 'OCCUPIED' | 'DISABLED'
|
||||
export type EditablePlatformLocationStatus = 'AVAILABLE' | 'DISABLED'
|
||||
export type PlatformLocationType = 'SHELF' | 'PALLET' | 'FLOOR' | 'FLOW_RACK'
|
||||
|
||||
export interface PlatformLocationResource {
|
||||
id: number | string
|
||||
tenantId: number | string
|
||||
warehouseId: number | string
|
||||
warehouseCode?: string | null
|
||||
warehouseName?: string | null
|
||||
zoneCode: string
|
||||
zoneName: string
|
||||
locationCode: string
|
||||
locationName: string | null
|
||||
locationType: PlatformLocationType
|
||||
maxWeightKg: number | string | null
|
||||
sharedOverflow: boolean | null
|
||||
status: PlatformLocationStatus
|
||||
remark: string | null
|
||||
version: number
|
||||
createdAt?: string | null
|
||||
updatedAt?: string | null
|
||||
}
|
||||
|
||||
export interface PlatformLocationDetail {
|
||||
location: PlatformLocationResource
|
||||
warehouseCode: string
|
||||
warehouseName: string
|
||||
}
|
||||
|
||||
export interface PlatformLocationPage {
|
||||
records: PlatformLocationResource[]
|
||||
page: number
|
||||
size: number
|
||||
total: number
|
||||
pages: number
|
||||
}
|
||||
|
||||
export interface PlatformLocationQuery {
|
||||
page?: number
|
||||
size?: number
|
||||
tenantId?: number | string
|
||||
warehouseId?: number | string
|
||||
zoneCode?: string
|
||||
locationCode?: string
|
||||
locationType?: PlatformLocationType | ''
|
||||
status?: PlatformLocationStatus | ''
|
||||
}
|
||||
|
||||
export interface SavePlatformLocationRequest {
|
||||
warehouseId: number | string
|
||||
zoneCode: string
|
||||
zoneName: string
|
||||
locationCode: string
|
||||
locationName: string | null
|
||||
locationType: PlatformLocationType
|
||||
maxWeightKg: number | null
|
||||
status: EditablePlatformLocationStatus
|
||||
remark: string | null
|
||||
version?: number
|
||||
}
|
||||
|
||||
const basePath = '/platform/v1/locations'
|
||||
|
||||
export async function listPlatformLocations(query: PlatformLocationQuery) {
|
||||
const zoneCode = query.zoneCode?.trim()
|
||||
const locationCode = query.locationCode?.trim()
|
||||
const response = await http.get<ApiEnvelope<PlatformLocationPage>, ApiEnvelope<PlatformLocationPage>>(basePath, {
|
||||
params: {
|
||||
page: query.page ?? 1,
|
||||
size: query.size ?? 20,
|
||||
...(query.tenantId ? { tenantId: query.tenantId } : {}),
|
||||
...(query.warehouseId ? { warehouseId: query.warehouseId } : {}),
|
||||
...(zoneCode ? { zoneCode } : {}),
|
||||
...(locationCode ? { locationCode } : {}),
|
||||
...(query.locationType ? { locationType: query.locationType } : {}),
|
||||
...(query.status ? { status: query.status } : {}),
|
||||
},
|
||||
})
|
||||
const result = unwrapApiResponse(response)
|
||||
return {
|
||||
...result,
|
||||
page: Number(result.page),
|
||||
size: Number(result.size),
|
||||
total: Number(result.total),
|
||||
pages: Number(result.pages),
|
||||
}
|
||||
}
|
||||
|
||||
export async function getPlatformLocation(
|
||||
tenantId: number | string,
|
||||
warehouseId: number | string,
|
||||
id: number | string,
|
||||
) {
|
||||
const response = await http.get<ApiEnvelope<PlatformLocationDetail>, ApiEnvelope<PlatformLocationDetail>>(
|
||||
`${basePath}/${id}`,
|
||||
{ params: { tenantId, warehouseId } },
|
||||
)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function createPlatformLocation(tenantId: number | string, payload: SavePlatformLocationRequest) {
|
||||
const response = await http.post<ApiEnvelope<number | string>, ApiEnvelope<number | string>>(basePath, payload, {
|
||||
params: { tenantId },
|
||||
})
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function updatePlatformLocation(
|
||||
tenantId: number | string,
|
||||
warehouseId: number | string,
|
||||
id: number | string,
|
||||
payload: SavePlatformLocationRequest,
|
||||
) {
|
||||
const response = await http.put<ApiEnvelope<null>, ApiEnvelope<null>>(`${basePath}/${id}`, payload, {
|
||||
params: { tenantId, warehouseId },
|
||||
})
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function changePlatformLocationStatus(
|
||||
tenantId: number | string,
|
||||
warehouseId: number | string,
|
||||
id: number | string,
|
||||
status: EditablePlatformLocationStatus,
|
||||
version: number,
|
||||
) {
|
||||
const response = await http.patch<ApiEnvelope<null>, ApiEnvelope<null>>(
|
||||
`${basePath}/${id}/status`,
|
||||
{ status, version },
|
||||
{ params: { tenantId, warehouseId } },
|
||||
)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function deletePlatformLocation(
|
||||
tenantId: number | string,
|
||||
warehouseId: number | string,
|
||||
id: number | string,
|
||||
version: number,
|
||||
) {
|
||||
const response = await http.delete<ApiEnvelope<null>, ApiEnvelope<null>>(`${basePath}/${id}`, {
|
||||
params: { tenantId, warehouseId, version },
|
||||
})
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
import ElementPlus from 'element-plus'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { enableAutoUnmount, flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { useAuthStore } from '@/core/auth/auth'
|
||||
import {
|
||||
getPlatformLocation,
|
||||
listPlatformLocations,
|
||||
type PlatformLocationResource,
|
||||
} from '../api/platformLocations'
|
||||
import { listPlatformTenants, type PlatformTenantResource } from '../../../iam/api/platformIam'
|
||||
import { listPlatformWarehouses, type PlatformWarehouseResource } from '../../../warehouses/api/platformWarehouses'
|
||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||
import DataTable from '@/shared/components/DataTable.vue'
|
||||
import LocationsView from './LocationsView.vue'
|
||||
|
||||
vi.mock('../../../iam/api/platformIam', () => ({ listPlatformTenants: vi.fn() }))
|
||||
vi.mock('../../../warehouses/api/platformWarehouses', () => ({ listPlatformWarehouses: vi.fn() }))
|
||||
vi.mock('../api/platformLocations', () => ({
|
||||
listPlatformLocations: vi.fn(),
|
||||
getPlatformLocation: vi.fn(),
|
||||
createPlatformLocation: vi.fn(),
|
||||
updatePlatformLocation: vi.fn(),
|
||||
changePlatformLocationStatus: vi.fn(),
|
||||
deletePlatformLocation: vi.fn(),
|
||||
}))
|
||||
|
||||
class ResizeObserverStub implements ResizeObserver {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
|
||||
function createMemoryStorage(): Storage {
|
||||
const values = new Map<string, string>()
|
||||
return {
|
||||
get length() { return values.size },
|
||||
clear: () => values.clear(),
|
||||
getItem: (key) => values.get(key) ?? null,
|
||||
key: (index) => [...values.keys()][index] ?? null,
|
||||
removeItem: (key) => values.delete(key),
|
||||
setItem: (key, value) => values.set(key, value),
|
||||
}
|
||||
}
|
||||
|
||||
const tenant = (id: number | string): PlatformTenantResource => ({
|
||||
id,
|
||||
tenantCode: String(id),
|
||||
tenantName: '雪花企业',
|
||||
status: 'ACTIVE',
|
||||
legalName: '雪花企业',
|
||||
legalRepresentative: null,
|
||||
unifiedSocialCreditCode: null,
|
||||
contactName: null,
|
||||
contactPhone: null,
|
||||
contactEmail: null,
|
||||
servicePackage: null,
|
||||
contactWechat: null,
|
||||
salesOwnerName: null,
|
||||
paymentTermDays: null,
|
||||
enterpriseType: null,
|
||||
establishedDate: null,
|
||||
registeredCapital: null,
|
||||
registeredCapitalCurrency: null,
|
||||
registeredAddress: null,
|
||||
internalRemark: null,
|
||||
createdAt: null,
|
||||
approvedAt: null,
|
||||
})
|
||||
|
||||
const tenantId = '9223372036854775806'
|
||||
const warehouseId = '9223372036854775805'
|
||||
const warehouse: PlatformWarehouseResource = {
|
||||
id: warehouseId,
|
||||
tenantId,
|
||||
warehouseCode: 'CN-YW-01',
|
||||
warehouseName: '义乌收货仓',
|
||||
warehouseType: 'DOMESTIC',
|
||||
countryCode: 'CN',
|
||||
timezone: 'Asia/Shanghai',
|
||||
contactName: null,
|
||||
contactPhone: null,
|
||||
contactEmail: null,
|
||||
status: 'ACTIVE',
|
||||
remark: null,
|
||||
}
|
||||
|
||||
const availableLocation: PlatformLocationResource = {
|
||||
id: '9223372036854775804',
|
||||
tenantId,
|
||||
warehouseId,
|
||||
zoneCode: 'A',
|
||||
zoneName: '常规区',
|
||||
locationCode: 'A1-01',
|
||||
locationName: 'A1 区 01 位',
|
||||
locationType: 'SHELF',
|
||||
maxWeightKg: '500.000',
|
||||
sharedOverflow: false,
|
||||
status: 'AVAILABLE',
|
||||
remark: '靠近入库口',
|
||||
version: 3,
|
||||
updatedAt: '2026-08-04T10:20:00',
|
||||
}
|
||||
|
||||
const occupiedLocation: PlatformLocationResource = {
|
||||
...availableLocation,
|
||||
id: '9223372036854775803',
|
||||
locationCode: 'A1-02',
|
||||
locationName: 'A1 区 02 位',
|
||||
status: 'OCCUPIED',
|
||||
version: 5,
|
||||
}
|
||||
|
||||
async function mountView() {
|
||||
vi.stubGlobal('localStorage', createMemoryStorage())
|
||||
vi.stubGlobal('sessionStorage', createMemoryStorage())
|
||||
const pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
const auth = useAuthStore()
|
||||
auth.token = 'token'
|
||||
auth.permissions = [
|
||||
'platform:location:list',
|
||||
'platform:location:create',
|
||||
'platform:location:edit',
|
||||
'platform:location:status',
|
||||
'platform:location:delete',
|
||||
]
|
||||
useAccessPreviewStore().initializeAfterLogin()
|
||||
const wrapper = mount(LocationsView, {
|
||||
attachTo: document.body,
|
||||
global: { plugins: [pinia, ElementPlus] },
|
||||
})
|
||||
await flushPromises()
|
||||
return wrapper
|
||||
}
|
||||
|
||||
async function selectScope(wrapper: VueWrapper) {
|
||||
let toolbar = wrapper.findComponent(QueryToolbar)
|
||||
toolbar.vm.$emit('update:modelValue', { ...(toolbar.props('modelValue') as QueryModel), customerId: tenantId })
|
||||
await flushPromises()
|
||||
toolbar = wrapper.findComponent(QueryToolbar)
|
||||
toolbar.vm.$emit('update:modelValue', { ...(toolbar.props('modelValue') as QueryModel), warehouseId })
|
||||
await flushPromises()
|
||||
const searchButton = toolbar.findAll('button').find((button) => button.text().trim() === '查询')
|
||||
await searchButton!.trigger('click')
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
enableAutoUnmount(afterEach)
|
||||
afterAll(() => vi.unstubAllGlobals())
|
||||
|
||||
describe('LocationsView', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
|
||||
document.body.innerHTML = ''
|
||||
vi.mocked(listPlatformTenants).mockReset().mockResolvedValue({
|
||||
records: [tenant(tenantId)], page: 1, size: 200, total: 1, pages: 1,
|
||||
})
|
||||
vi.mocked(listPlatformWarehouses).mockReset().mockResolvedValue({
|
||||
records: [warehouse], page: 1, size: 200, total: 1, pages: 1,
|
||||
})
|
||||
vi.mocked(listPlatformLocations).mockReset().mockResolvedValue({
|
||||
records: [availableLocation, occupiedLocation], page: 1, size: 20, total: 2, pages: 1,
|
||||
})
|
||||
vi.mocked(getPlatformLocation).mockReset().mockResolvedValue({
|
||||
location: availableLocation, warehouseCode: warehouse.warehouseCode, warehouseName: warehouse.warehouseName,
|
||||
})
|
||||
})
|
||||
|
||||
it('starts with all customers and queries the full list without empty IDs', async () => {
|
||||
const wrapper = await mountView()
|
||||
const toolbar = wrapper.findComponent(QueryToolbar)
|
||||
const fields = toolbar.props('fields') as QueryField[]
|
||||
|
||||
expect(listPlatformTenants).toHaveBeenCalledWith({ page: 1, size: 200 })
|
||||
expect(wrapper.find('[aria-label="所属企业"]').exists()).toBe(true)
|
||||
expect(fields[0]?.placeholder).toBe('全部客户')
|
||||
expect((toolbar.props('modelValue') as QueryModel).customerId).toBe('')
|
||||
expect(fields.map(({ key, width }) => [key, width])).toEqual([
|
||||
['customerId', 260], ['warehouseId', 240], ['zoneCode', 210],
|
||||
['locationCode', 220], ['locationType', 170], ['status', 160],
|
||||
])
|
||||
expect(listPlatformWarehouses).not.toHaveBeenCalled()
|
||||
expect(listPlatformLocations).toHaveBeenCalledWith({
|
||||
page: 1,
|
||||
size: 20,
|
||||
zoneCode: '',
|
||||
locationCode: '',
|
||||
locationType: '',
|
||||
status: '',
|
||||
})
|
||||
const searchButton = toolbar.findAll('button').find((button) => button.text().trim() === '查询')
|
||||
await searchButton!.trigger('click')
|
||||
await flushPromises()
|
||||
expect(listPlatformLocations).toHaveBeenCalledTimes(2)
|
||||
expect(wrapper.text()).not.toContain('请选择客户')
|
||||
expect(wrapper.text()).not.toContain('请选择所属企业')
|
||||
expect(wrapper.text()).not.toContain('租户')
|
||||
expect(wrapper.text()).not.toContain('企业 ID')
|
||||
})
|
||||
|
||||
it('loads the selected customer, warehouse, and location hierarchy without narrowing snowflake IDs', async () => {
|
||||
const wrapper = await mountView()
|
||||
await selectScope(wrapper)
|
||||
|
||||
expect(wrapper.find('.table-panel .panel-heading').exists()).toBe(false)
|
||||
expect(listPlatformWarehouses).toHaveBeenCalledWith(tenantId, 1, 200)
|
||||
expect(listPlatformLocations).toHaveBeenLastCalledWith({
|
||||
page: 1,
|
||||
size: 20,
|
||||
tenantId,
|
||||
warehouseId,
|
||||
zoneCode: '',
|
||||
locationCode: '',
|
||||
locationType: '',
|
||||
status: '',
|
||||
})
|
||||
expect(wrapper.text()).toContain('义乌收货仓')
|
||||
expect(wrapper.text()).toContain('A1-01')
|
||||
expect(wrapper.findAll('thead th').map((item) => item.text().trim())).toEqual([
|
||||
'操作', '库位编码', '所属仓库', '库区', '库位类型', '最大承重(kg)', '状态', '备注',
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps the operation column first and hides edit actions for occupied resources', async () => {
|
||||
const wrapper = await mountView()
|
||||
await selectScope(wrapper)
|
||||
const headings = wrapper.findAll('thead th').map((item) => item.text().trim())
|
||||
expect(headings[0]).toBe('操作')
|
||||
|
||||
const bodyRows = wrapper.findAll('tbody tr')
|
||||
expect(bodyRows[0]?.text()).toContain('编辑')
|
||||
expect(bodyRows[1]?.text()).toContain('详情')
|
||||
expect(bodyRows[1]?.text()).not.toContain('编辑')
|
||||
expect(bodyRows[1]?.text()).not.toContain('更多')
|
||||
})
|
||||
|
||||
it('opens an authenticated detail view with warehouse context', async () => {
|
||||
const wrapper = await mountView()
|
||||
const detailButton = wrapper.find('.el-table__body-wrapper tbody tr button')
|
||||
expect(detailButton.text()).toBe('详情')
|
||||
await detailButton.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(getPlatformLocation).toHaveBeenCalledWith(tenantId, warehouseId, availableLocation.id)
|
||||
expect(document.body.textContent).toContain('库位详情')
|
||||
expect(document.body.textContent).toContain('义乌收货仓(CN-YW-01)')
|
||||
expect(document.body.textContent).toContain(`所属企业雪花企业(${tenantId})`)
|
||||
expect(document.body.textContent).not.toContain('企业 ID')
|
||||
expect(document.body.textContent).toContain('库位名称A1 区 01 位')
|
||||
expect(document.body.textContent).toContain('v3')
|
||||
})
|
||||
|
||||
it('presents occupied locations as read-only business resources', async () => {
|
||||
vi.mocked(getPlatformLocation).mockResolvedValueOnce({
|
||||
location: occupiedLocation,
|
||||
warehouseCode: warehouse.warehouseCode,
|
||||
warehouseName: warehouse.warehouseName,
|
||||
})
|
||||
const wrapper = await mountView()
|
||||
const occupiedRow = wrapper.findAll('tbody tr')[1]
|
||||
await occupiedRow!.find('button').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(document.body.textContent).toContain('状态已占用')
|
||||
expect(document.body.textContent).toContain('当前库位正在被业务使用,只允许查看。')
|
||||
})
|
||||
|
||||
it('clears customer and warehouse on reset and immediately restores the full query', async () => {
|
||||
const wrapper = await mountView()
|
||||
await selectScope(wrapper)
|
||||
const toolbar = wrapper.findComponent(QueryToolbar)
|
||||
const resetButton = toolbar.findAll('button').find((button) => button.text().trim() === '重置')
|
||||
|
||||
await resetButton!.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
const model = wrapper.findComponent(QueryToolbar).props('modelValue') as QueryModel
|
||||
expect(model.customerId).toBe('')
|
||||
expect(model.warehouseId).toBe('')
|
||||
expect(listPlatformLocations).toHaveBeenLastCalledWith({
|
||||
page: 1,
|
||||
size: 20,
|
||||
zoneCode: '',
|
||||
locationCode: '',
|
||||
locationType: '',
|
||||
status: '',
|
||||
})
|
||||
expect(wrapper.text()).not.toContain('请选择客户')
|
||||
})
|
||||
|
||||
it('paginates the all-customer list without adding customer or warehouse IDs', async () => {
|
||||
vi.mocked(listPlatformLocations).mockResolvedValue({
|
||||
records: [availableLocation, occupiedLocation], page: 1, size: 20, total: 40, pages: 2,
|
||||
})
|
||||
const wrapper = await mountView()
|
||||
wrapper.findComponent(DataTable).vm.$emit('update:page', 2)
|
||||
await flushPromises()
|
||||
|
||||
expect(listPlatformLocations).toHaveBeenLastCalledWith({
|
||||
page: 2,
|
||||
size: 20,
|
||||
zoneCode: '',
|
||||
locationCode: '',
|
||||
locationType: '',
|
||||
status: '',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,808 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
|
||||
import { Eye, MapPin, MoreHorizontal, Pencil, Plus, Power, Trash2 } from '@lucide/vue'
|
||||
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||
import {
|
||||
changePlatformLocationStatus,
|
||||
createPlatformLocation,
|
||||
deletePlatformLocation,
|
||||
getPlatformLocation,
|
||||
listPlatformLocations,
|
||||
updatePlatformLocation,
|
||||
type EditablePlatformLocationStatus,
|
||||
type PlatformLocationDetail,
|
||||
type PlatformLocationQuery,
|
||||
type PlatformLocationResource,
|
||||
type PlatformLocationStatus,
|
||||
type PlatformLocationType,
|
||||
type SavePlatformLocationRequest,
|
||||
} from '../api/platformLocations'
|
||||
import { listPlatformWarehouses, type PlatformWarehouseResource } from '../../../warehouses/api/platformWarehouses'
|
||||
|
||||
type DrawerMode = 'view' | 'create' | 'edit'
|
||||
type LocationFilters = QueryModel & {
|
||||
customerId: string
|
||||
warehouseId: string
|
||||
zoneCode: string
|
||||
locationCode: string
|
||||
locationType: PlatformLocationType | ''
|
||||
status: PlatformLocationStatus | ''
|
||||
}
|
||||
type AppliedLocationFilters = {
|
||||
zoneCode: string
|
||||
locationCode: string
|
||||
locationType: PlatformLocationType | ''
|
||||
status: PlatformLocationStatus | ''
|
||||
}
|
||||
|
||||
const access = useAccessPreviewStore()
|
||||
const customers = ref<PlatformTenantResource[]>([])
|
||||
const customerLoading = ref(false)
|
||||
const customerError = ref<ApiErrorDetails | null>(null)
|
||||
const loadedCustomerId = ref<string | null>(null)
|
||||
const warehouses = ref<PlatformWarehouseResource[]>([])
|
||||
const warehouseLoading = ref(false)
|
||||
const warehouseError = ref<ApiErrorDetails | null>(null)
|
||||
const loading = ref(false)
|
||||
const loadError = ref<ApiErrorDetails | null>(null)
|
||||
const rows = ref<PlatformLocationResource[]>([])
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const total = ref(0)
|
||||
const drawerOpen = ref(false)
|
||||
const drawerMode = ref<DrawerMode>('view')
|
||||
const drawerLoading = ref(false)
|
||||
const drawerError = ref<ApiErrorDetails | null>(null)
|
||||
const detail = ref<PlatformLocationDetail | null>(null)
|
||||
const saving = ref(false)
|
||||
const actionLoadingIds = ref(new Set<string>())
|
||||
const formRef = ref<FormInstance>()
|
||||
let customerLoadSequence = 0
|
||||
let locationLoadSequence = 0
|
||||
|
||||
const filters = ref<LocationFilters>({ customerId: '', warehouseId: '', zoneCode: '', locationCode: '', locationType: '', status: '' })
|
||||
const appliedFilters = ref<AppliedLocationFilters>({ zoneCode: '', locationCode: '', locationType: '', status: '' })
|
||||
const selectedCustomerId = computed(() => String(filters.value.customerId || ''))
|
||||
const selectedWarehouseId = computed(() => String(filters.value.warehouseId || ''))
|
||||
const queryFields = computed<QueryField[]>(() => [
|
||||
{
|
||||
key: 'customerId',
|
||||
type: 'select',
|
||||
width: 260,
|
||||
placeholder: customerError.value ? '客户列表加载失败' : '全部客户',
|
||||
props: { 'aria-label': '所属企业', filterable: true, disabled: customerLoading.value || Boolean(customerError.value) || selectionDisabled.value },
|
||||
options: customers.value.map((customer) => ({
|
||||
value: String(customer.id),
|
||||
label: `${customer.tenantName}(${customer.tenantCode || customer.id})`,
|
||||
})),
|
||||
},
|
||||
{
|
||||
key: 'warehouseId',
|
||||
type: 'select',
|
||||
width: 240,
|
||||
placeholder: warehouseLoading.value ? '仓库加载中' : '所属仓库',
|
||||
props: {
|
||||
'aria-label': '所属仓库',
|
||||
filterable: true,
|
||||
disabled: selectionDisabled.value || warehouseLoading.value || !customerReady.value || warehouses.value.length === 0,
|
||||
},
|
||||
options: warehouses.value.map((warehouse) => ({
|
||||
value: String(warehouse.id),
|
||||
label: `${warehouse.warehouseName}(${warehouse.warehouseCode})`,
|
||||
disabled: warehouse.status !== 'ACTIVE',
|
||||
})),
|
||||
},
|
||||
{ key: 'zoneCode', type: 'input', width: 210, placeholder: '库区编码', props: { maxlength: 50, 'aria-label': '库区编码' } },
|
||||
{ key: 'locationCode', type: 'input', width: 220, placeholder: '库位编码', props: { maxlength: 50, 'aria-label': '库位编码' } },
|
||||
{
|
||||
key: 'locationType', type: 'select', width: 170, placeholder: '全部类型', props: { 'aria-label': '库位类型' },
|
||||
options: [
|
||||
{ label: '货架位', value: 'SHELF' },
|
||||
{ label: '托盘位', value: 'PALLET' },
|
||||
{ label: '地堆位', value: 'FLOOR' },
|
||||
{ label: '流利架', value: 'FLOW_RACK' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'status', type: 'select', width: 160, placeholder: '全部状态', props: { 'aria-label': '库位状态' },
|
||||
options: [
|
||||
{ label: '可用', value: 'AVAILABLE' },
|
||||
{ label: '已预占', value: 'RESERVED' },
|
||||
{ label: '已占用', value: 'OCCUPIED' },
|
||||
{ label: '已停用', value: 'DISABLED' },
|
||||
],
|
||||
},
|
||||
])
|
||||
|
||||
const emptyForm = () => ({
|
||||
id: '',
|
||||
customerId: selectedCustomerId.value,
|
||||
warehouseId: selectedWarehouseId.value,
|
||||
zoneCode: '',
|
||||
zoneName: '',
|
||||
locationCode: '',
|
||||
locationName: '',
|
||||
locationType: 'SHELF' as PlatformLocationType,
|
||||
maxWeightKg: null as number | null,
|
||||
status: 'AVAILABLE' as EditablePlatformLocationStatus,
|
||||
remark: '',
|
||||
version: 0,
|
||||
})
|
||||
const form = reactive(emptyForm())
|
||||
const rules: FormRules<typeof form> = {
|
||||
zoneCode: [{ required: true, message: '请输入库区编码', trigger: 'blur' }],
|
||||
zoneName: [{ required: true, message: '请输入库区名称', trigger: 'blur' }],
|
||||
locationCode: [
|
||||
{ required: true, message: '请输入库位编码', trigger: 'blur' },
|
||||
{
|
||||
validator: (_rule, value: string, callback) => {
|
||||
if (value.trim().toUpperCase() === 'A0') callback(new Error('A0 是系统共享溢出位保留编码'))
|
||||
else callback()
|
||||
},
|
||||
trigger: 'blur',
|
||||
},
|
||||
],
|
||||
locationType: [{ required: true, message: '请选择库位类型', trigger: 'change' }],
|
||||
status: [{ required: true, message: '请选择状态', trigger: 'change' }],
|
||||
}
|
||||
|
||||
const selectedWarehouse = computed(() => warehouses.value.find((item) => String(item.id) === selectedWarehouseId.value) ?? null)
|
||||
const customerReady = computed(() => Boolean(selectedCustomerId.value) && loadedCustomerId.value === selectedCustomerId.value && !warehouseLoading.value)
|
||||
const createReady = computed(() => customerReady.value && Boolean(selectedWarehouse.value) && !loading.value)
|
||||
const editorOpen = computed(() => drawerOpen.value && drawerMode.value !== 'view')
|
||||
const editorTitle = computed(() => drawerMode.value === 'create' ? '新增库位' : '编辑库位')
|
||||
const drawerTitle = computed(() => drawerMode.value === 'view' ? '库位详情' : editorTitle.value)
|
||||
const editorPermission = computed(() => drawerMode.value === 'create' ? 'platform:location:create' : 'platform:location:edit')
|
||||
const selectionDisabled = computed(() => drawerOpen.value || saving.value || actionLoadingIds.value.size > 0)
|
||||
const filtered = computed(() => Object.values(appliedFilters.value).some((value) => String(value || '').trim()))
|
||||
const emptyDescription = computed(() => filtered.value ? '暂无符合筛选条件的库位' : '暂无库位数据')
|
||||
|
||||
const { captureBaseline } = useUnsavedWorkRegistration({
|
||||
key: 'location-editor',
|
||||
label: editorTitle,
|
||||
isOpen: editorOpen,
|
||||
model: form,
|
||||
requiredPermission: editorPermission,
|
||||
})
|
||||
|
||||
const statusMeta: Record<PlatformLocationStatus, { label: string; type: 'success' | 'warning' | 'danger' | 'info' }> = {
|
||||
AVAILABLE: { label: '可用', type: 'success' },
|
||||
RESERVED: { label: '已预占', type: 'warning' },
|
||||
OCCUPIED: { label: '已占用', type: 'danger' },
|
||||
DISABLED: { label: '已停用', type: 'info' },
|
||||
}
|
||||
const typeLabels: Record<PlatformLocationType, string> = {
|
||||
SHELF: '货架位',
|
||||
PALLET: '托盘位',
|
||||
FLOOR: '地堆位',
|
||||
FLOW_RACK: '流利架',
|
||||
}
|
||||
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 210, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'location', label: '库位编码', minWidth: 190, slotName: 'location' },
|
||||
{ key: 'warehouse', label: '所属仓库', minWidth: 210, slotName: 'warehouse' },
|
||||
{ key: 'zone', label: '库区', minWidth: 150, slotName: 'zone' },
|
||||
{ key: 'locationType', label: '库位类型', width: 120, slotName: 'locationType' },
|
||||
{ key: 'maxWeightKg', label: '最大承重(kg)', minWidth: 140, slotName: 'maxWeightKg' },
|
||||
{ key: 'status', label: '状态', width: 110, slotName: 'status' },
|
||||
{ key: 'remark', label: '备注', minWidth: 220, slotName: 'remark' },
|
||||
]
|
||||
|
||||
function apiId(value: string) {
|
||||
return value
|
||||
}
|
||||
|
||||
function optionalText(value: string) {
|
||||
return value.trim() || null
|
||||
}
|
||||
|
||||
function formatDateTime(value?: string | null) {
|
||||
return value?.replace('T', ' ').slice(0, 16) || '—'
|
||||
}
|
||||
|
||||
function formatWeight(value: number | string | null) {
|
||||
if (value === null || value === '') return '—'
|
||||
const numeric = Number(value)
|
||||
return Number.isFinite(numeric) ? `${numeric.toLocaleString('zh-CN', { maximumFractionDigits: 3 })} kg` : `${value} kg`
|
||||
}
|
||||
|
||||
function canEdit(row: PlatformLocationResource) {
|
||||
return access.can('platform:location:edit')
|
||||
&& !row.sharedOverflow
|
||||
&& (row.status === 'AVAILABLE' || row.status === 'DISABLED')
|
||||
}
|
||||
|
||||
function canChangeStatus(row: PlatformLocationResource) {
|
||||
return access.can('platform:location:status')
|
||||
&& !row.sharedOverflow
|
||||
&& (row.status === 'AVAILABLE' || row.status === 'DISABLED')
|
||||
}
|
||||
|
||||
function canDelete(row: PlatformLocationResource) {
|
||||
return access.can('platform:location:delete') && !row.sharedOverflow && row.status === 'DISABLED'
|
||||
}
|
||||
|
||||
function actionError(error: unknown, fallback: string) {
|
||||
const details = getApiErrorDetails(error, fallback)
|
||||
ElMessage.error(details.requestId ? `${details.message}(requestId: ${details.requestId})` : details.message)
|
||||
}
|
||||
|
||||
function requireCreateScope() {
|
||||
if (!createReady.value || !selectedCustomerId.value || !selectedWarehouseId.value) {
|
||||
ElMessage.warning('所属企业和仓库数据尚未加载完成,请稍后重试')
|
||||
return null
|
||||
}
|
||||
return { customerId: apiId(selectedCustomerId.value), warehouseId: apiId(selectedWarehouseId.value) }
|
||||
}
|
||||
|
||||
function recordScope(row: PlatformLocationResource) {
|
||||
return { customerId: apiId(String(row.tenantId)), warehouseId: apiId(String(row.warehouseId)) }
|
||||
}
|
||||
|
||||
function customerLabel(customerId: number | string) {
|
||||
const customer = customers.value.find((item) => String(item.id) === String(customerId))
|
||||
return customer ? `${customer.tenantName}(${customer.tenantCode || customer.id})` : String(customerId)
|
||||
}
|
||||
|
||||
async function loadCustomers() {
|
||||
customerLoading.value = true
|
||||
customerError.value = null
|
||||
try {
|
||||
const result = await listPlatformTenants({ page: 1, size: 200 })
|
||||
customers.value = result.records
|
||||
} catch (error) {
|
||||
customers.value = []
|
||||
customerError.value = getApiErrorDetails(error, '所属企业列表加载失败,请稍后重试')
|
||||
} finally {
|
||||
customerLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadWarehouses(customerId = selectedCustomerId.value) {
|
||||
if (!customerId) return
|
||||
const sequence = ++customerLoadSequence
|
||||
warehouseLoading.value = true
|
||||
warehouseError.value = null
|
||||
filters.value = { ...filters.value, warehouseId: '' }
|
||||
warehouses.value = []
|
||||
try {
|
||||
const result = await listPlatformWarehouses(apiId(customerId), 1, 200)
|
||||
if (sequence !== customerLoadSequence) return
|
||||
warehouses.value = result.records
|
||||
loadedCustomerId.value = customerId
|
||||
} catch (error) {
|
||||
if (sequence !== customerLoadSequence) return
|
||||
loadedCustomerId.value = null
|
||||
warehouseError.value = getApiErrorDetails(error, '仓库列表加载失败,请稍后重试')
|
||||
} finally {
|
||||
if (sequence === customerLoadSequence) warehouseLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadLocations() {
|
||||
const sequence = ++locationLoadSequence
|
||||
loading.value = true
|
||||
loadError.value = null
|
||||
try {
|
||||
const query: PlatformLocationQuery = {
|
||||
page: page.value,
|
||||
size: pageSize.value,
|
||||
zoneCode: appliedFilters.value.zoneCode,
|
||||
locationCode: appliedFilters.value.locationCode,
|
||||
locationType: appliedFilters.value.locationType,
|
||||
status: appliedFilters.value.status,
|
||||
}
|
||||
if (selectedCustomerId.value) query.tenantId = apiId(selectedCustomerId.value)
|
||||
if (selectedWarehouseId.value) query.warehouseId = apiId(selectedWarehouseId.value)
|
||||
const result = await listPlatformLocations(query)
|
||||
if (sequence !== locationLoadSequence) return
|
||||
rows.value = result.records
|
||||
total.value = result.total
|
||||
} catch (error) {
|
||||
if (sequence !== locationLoadSequence) return
|
||||
rows.value = []
|
||||
total.value = 0
|
||||
loadError.value = getApiErrorDetails(error, '库位列表加载失败,请稍后重试')
|
||||
} finally {
|
||||
if (sequence === locationLoadSequence) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function applyFilters(model: QueryModel) {
|
||||
appliedFilters.value = {
|
||||
zoneCode: String(model.zoneCode || ''),
|
||||
locationCode: String(model.locationCode || ''),
|
||||
locationType: (model.locationType || '') as PlatformLocationType | '',
|
||||
status: (model.status || '') as PlatformLocationStatus | '',
|
||||
}
|
||||
if (page.value === 1) void loadLocations()
|
||||
else page.value = 1
|
||||
}
|
||||
|
||||
function assignForm(resource: PlatformLocationResource) {
|
||||
Object.assign(form, {
|
||||
id: String(resource.id),
|
||||
customerId: String(resource.tenantId),
|
||||
warehouseId: String(resource.warehouseId),
|
||||
zoneCode: resource.zoneCode,
|
||||
zoneName: resource.zoneName,
|
||||
locationCode: resource.locationCode,
|
||||
locationName: resource.locationName ?? '',
|
||||
locationType: resource.locationType,
|
||||
maxWeightKg: resource.maxWeightKg === null ? null : Number(resource.maxWeightKg),
|
||||
status: resource.status as EditablePlatformLocationStatus,
|
||||
remark: resource.remark ?? '',
|
||||
version: resource.version,
|
||||
})
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
if (!requireCreateScope()) return
|
||||
Object.assign(form, emptyForm(), {
|
||||
status: selectedWarehouse.value?.status === 'ACTIVE' ? 'AVAILABLE' : 'DISABLED',
|
||||
})
|
||||
detail.value = null
|
||||
drawerError.value = null
|
||||
drawerMode.value = 'create'
|
||||
captureBaseline()
|
||||
drawerOpen.value = true
|
||||
void formRef.value?.clearValidate()
|
||||
}
|
||||
|
||||
async function loadDetail(row: PlatformLocationResource, mode: 'view' | 'edit') {
|
||||
const scope = recordScope(row)
|
||||
drawerMode.value = mode
|
||||
drawerOpen.value = true
|
||||
drawerLoading.value = true
|
||||
drawerError.value = null
|
||||
detail.value = null
|
||||
try {
|
||||
const result = await getPlatformLocation(scope.customerId, scope.warehouseId, row.id)
|
||||
detail.value = result
|
||||
if (mode === 'edit') {
|
||||
assignForm(result.location)
|
||||
captureBaseline()
|
||||
void formRef.value?.clearValidate()
|
||||
}
|
||||
} catch (error) {
|
||||
drawerError.value = getApiErrorDetails(error, '库位详情加载失败,请稍后重试')
|
||||
} finally {
|
||||
drawerLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function buildPayload(): SavePlatformLocationRequest {
|
||||
return {
|
||||
warehouseId: apiId(form.warehouseId),
|
||||
zoneCode: form.zoneCode.trim(),
|
||||
zoneName: form.zoneName.trim(),
|
||||
locationCode: form.locationCode.trim(),
|
||||
locationName: optionalText(form.locationName),
|
||||
locationType: form.locationType,
|
||||
maxWeightKg: form.maxWeightKg,
|
||||
status: form.status,
|
||||
remark: optionalText(form.remark),
|
||||
...(form.id ? { version: form.version } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
async function saveLocation() {
|
||||
const scope = form.id
|
||||
? { customerId: apiId(form.customerId), warehouseId: apiId(form.warehouseId) }
|
||||
: requireCreateScope()
|
||||
if (!scope) return
|
||||
const valid = await formRef.value?.validate().catch(() => false)
|
||||
if (!valid) return
|
||||
saving.value = true
|
||||
try {
|
||||
const payload = buildPayload()
|
||||
if (form.id) await updatePlatformLocation(scope.customerId, scope.warehouseId, form.id, payload)
|
||||
else await createPlatformLocation(scope.customerId, payload)
|
||||
drawerOpen.value = false
|
||||
ElMessage.success(form.id ? '库位资料已更新' : '库位已创建')
|
||||
await loadLocations()
|
||||
} catch (error) {
|
||||
actionError(error, '库位保存失败,请检查后重试')
|
||||
if (form.id) await loadLocations()
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function setActionLoading(id: number | string, active: boolean) {
|
||||
const next = new Set(actionLoadingIds.value)
|
||||
if (active) next.add(String(id))
|
||||
else next.delete(String(id))
|
||||
actionLoadingIds.value = next
|
||||
}
|
||||
|
||||
async function toggleStatus(row: PlatformLocationResource) {
|
||||
const scope = recordScope(row)
|
||||
if (!canChangeStatus(row)) return
|
||||
const nextStatus: EditablePlatformLocationStatus = row.status === 'AVAILABLE' ? 'DISABLED' : 'AVAILABLE'
|
||||
const action = nextStatus === 'AVAILABLE' ? '启用' : '停用'
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
nextStatus === 'DISABLED'
|
||||
? `确定停用“${row.locationCode}”吗?停用前后端会再次检查是否存在活动预占或占用。`
|
||||
: `确定重新启用“${row.locationCode}”吗?仅启用仓库中的库位可以恢复可用。`,
|
||||
`${action}库位`,
|
||||
{ type: nextStatus === 'AVAILABLE' ? 'success' : 'warning', confirmButtonText: `确认${action}`, cancelButtonText: '取消' },
|
||||
)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
setActionLoading(row.id, true)
|
||||
try {
|
||||
await changePlatformLocationStatus(scope.customerId, scope.warehouseId, row.id, nextStatus, row.version)
|
||||
ElMessage.success(`库位 ${row.locationCode} 已${action}`)
|
||||
await loadLocations()
|
||||
} catch (error) {
|
||||
actionError(error, `${action}库位失败`)
|
||||
await loadLocations()
|
||||
} finally {
|
||||
setActionLoading(row.id, false)
|
||||
}
|
||||
}
|
||||
|
||||
async function removeLocation(row: PlatformLocationResource) {
|
||||
const scope = recordScope(row)
|
||||
if (!canDelete(row)) return
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确定删除已停用库位“${row.locationCode}”吗?系统会保留审计记录,此操作不能直接恢复。`,
|
||||
'删除库位',
|
||||
{ type: 'warning', confirmButtonText: '确认删除', cancelButtonText: '取消' },
|
||||
)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
setActionLoading(row.id, true)
|
||||
try {
|
||||
await deletePlatformLocation(scope.customerId, scope.warehouseId, row.id, row.version)
|
||||
ElMessage.success(`库位 ${row.locationCode} 已删除`)
|
||||
await loadLocations()
|
||||
} catch (error) {
|
||||
actionError(error, '删除库位失败')
|
||||
await loadLocations()
|
||||
} finally {
|
||||
setActionLoading(row.id, false)
|
||||
}
|
||||
}
|
||||
|
||||
function handleMoreAction(row: PlatformLocationResource, command: string) {
|
||||
if (command === 'status') void toggleStatus(row)
|
||||
if (command === 'delete') void removeLocation(row)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => access.can(editorPermission.value),
|
||||
(allowed) => {
|
||||
if (allowed || !editorOpen.value) return
|
||||
drawerOpen.value = false
|
||||
},
|
||||
{ flush: 'sync' },
|
||||
)
|
||||
|
||||
watch([page, pageSize], () => { void loadLocations() })
|
||||
watch(selectedCustomerId, (customerId) => {
|
||||
drawerOpen.value = false
|
||||
loadedCustomerId.value = null
|
||||
warehouses.value = []
|
||||
filters.value = { ...filters.value, warehouseId: '' }
|
||||
if (customerId) void loadWarehouses(customerId)
|
||||
})
|
||||
watch(selectedWarehouseId, () => {
|
||||
drawerOpen.value = false
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
void loadCustomers()
|
||||
void loadLocations()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="business-page location-page">
|
||||
<QueryToolbar
|
||||
v-model="filters"
|
||||
ariaLabel="库位筛选"
|
||||
:fields="queryFields"
|
||||
@search="applyFilters"
|
||||
>
|
||||
<template #actions>
|
||||
<el-button
|
||||
v-if="access.can('platform:location:create')"
|
||||
type="primary"
|
||||
:disabled="!createReady"
|
||||
@click="openCreate"
|
||||
><Plus :size="16" />新增库位</el-button>
|
||||
</template>
|
||||
</QueryToolbar>
|
||||
|
||||
<el-alert v-if="customerError" type="warning" :closable="false" show-icon class="state-alert">
|
||||
<template #title>所属企业列表加载失败</template>
|
||||
<p>{{ customerError.message }}</p>
|
||||
<ApiErrorMeta :details="customerError" />
|
||||
<el-button size="small" @click="loadCustomers">重新加载所属企业</el-button>
|
||||
</el-alert>
|
||||
<el-alert v-if="warehouseError" type="error" :closable="false" show-icon class="state-alert">
|
||||
<template #title>仓库列表加载失败</template>
|
||||
<p>{{ warehouseError.message }}</p>
|
||||
<ApiErrorMeta :details="warehouseError" />
|
||||
<el-button size="small" @click="loadWarehouses()">重新加载仓库</el-button>
|
||||
</el-alert>
|
||||
<el-alert v-else-if="customerReady && warehouses.length === 0" type="info" :closable="false" show-icon title="当前所属企业暂无仓库">
|
||||
请先在仓库管理中建立仓库,再维护库位。
|
||||
</el-alert>
|
||||
<el-alert v-if="loadError" type="error" :closable="false" show-icon class="state-alert">
|
||||
<template #title>库位列表加载失败</template>
|
||||
<p>{{ loadError.message }}</p>
|
||||
<ApiErrorMeta :details="loadError" />
|
||||
<el-button size="small" @click="loadLocations()">重新查询</el-button>
|
||||
</el-alert>
|
||||
|
||||
<section class="surface-panel table-panel">
|
||||
<DataTable
|
||||
v-model:page="page"
|
||||
v-model:page-size="pageSize"
|
||||
:data="rows"
|
||||
:columns="columns"
|
||||
:loading="loading"
|
||||
:total="total"
|
||||
:page-sizes="[20, 50, 100, 200]"
|
||||
row-key="id"
|
||||
aria-label="库位列表"
|
||||
:empty-text="emptyDescription"
|
||||
>
|
||||
<template #actions="{ row }">
|
||||
<div class="table-actions location-actions">
|
||||
<el-button link type="primary" :disabled="loading" @click="loadDetail(row as PlatformLocationResource, 'view')">
|
||||
<Eye :size="15" />详情
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="canEdit(row as PlatformLocationResource)"
|
||||
link
|
||||
type="primary"
|
||||
:disabled="loading"
|
||||
@click="loadDetail(row as PlatformLocationResource, 'edit')"
|
||||
><Pencil :size="15" />编辑</el-button>
|
||||
<el-dropdown
|
||||
v-if="canChangeStatus(row as PlatformLocationResource) || canDelete(row as PlatformLocationResource)"
|
||||
trigger="click"
|
||||
:disabled="loading || actionLoadingIds.has(String((row as PlatformLocationResource).id))"
|
||||
@command="handleMoreAction(row as PlatformLocationResource, String($event))"
|
||||
>
|
||||
<el-button link type="primary" aria-label="更多库位操作"><MoreHorizontal :size="16" />更多</el-button>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item v-if="canChangeStatus(row as PlatformLocationResource)" command="status">
|
||||
<Power :size="15" />{{ (row as PlatformLocationResource).status === 'AVAILABLE' ? '停用' : '启用' }}
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item v-if="canDelete(row as PlatformLocationResource)" command="delete" divided>
|
||||
<Trash2 :size="15" />删除
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
</template>
|
||||
<template #location="{ row }">
|
||||
<div class="location-cell">
|
||||
<span><MapPin :size="17" /></span>
|
||||
<div>
|
||||
<strong>{{ (row as PlatformLocationResource).locationCode }}</strong>
|
||||
<small>{{ (row as PlatformLocationResource).locationName || '未命名库位' }}</small>
|
||||
</div>
|
||||
<el-tag v-if="(row as PlatformLocationResource).sharedOverflow" size="small" type="warning">系统位</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
<template #zone="{ row }">
|
||||
<div class="stacked-value"><strong>{{ (row as PlatformLocationResource).zoneName }}</strong><small>{{ (row as PlatformLocationResource).zoneCode }}</small></div>
|
||||
</template>
|
||||
<template #warehouse="{ row }">
|
||||
<div class="stacked-value">
|
||||
<strong>{{ (row as PlatformLocationResource).warehouseName || selectedWarehouse?.warehouseName || '—' }}</strong>
|
||||
<small>{{ (row as PlatformLocationResource).warehouseCode || selectedWarehouse?.warehouseCode || '—' }}</small>
|
||||
</div>
|
||||
</template>
|
||||
<template #locationType="{ row }">{{ typeLabels[(row as PlatformLocationResource).locationType] }}</template>
|
||||
<template #maxWeightKg="{ row }">{{ formatWeight((row as PlatformLocationResource).maxWeightKg) }}</template>
|
||||
<template #status="{ row }">
|
||||
<el-tag :type="statusMeta[(row as PlatformLocationResource).status].type" effect="light">
|
||||
{{ statusMeta[(row as PlatformLocationResource).status].label }}
|
||||
</el-tag>
|
||||
</template>
|
||||
<template #remark="{ row }"><span class="remark-cell">{{ (row as PlatformLocationResource).remark || '—' }}</span></template>
|
||||
<template #empty>
|
||||
<el-empty v-if="!loading" :description="emptyDescription">
|
||||
<el-button
|
||||
v-if="selectedWarehouseId && !filtered && access.can('platform:location:create')"
|
||||
type="primary"
|
||||
@click="openCreate"
|
||||
><Plus :size="16" />新增第一个库位</el-button>
|
||||
</el-empty>
|
||||
</template>
|
||||
<template #pagination-left="{ total: tableTotal }"><span>共 {{ tableTotal }} 个库位</span></template>
|
||||
</DataTable>
|
||||
</section>
|
||||
|
||||
<el-drawer
|
||||
v-model="drawerOpen"
|
||||
:title="drawerTitle"
|
||||
size="min(620px, 100vw)"
|
||||
class="business-drawer location-drawer"
|
||||
:close-on-click-modal="false"
|
||||
:close-on-press-escape="!saving"
|
||||
:show-close="!saving"
|
||||
>
|
||||
<div v-loading="drawerLoading" class="drawer-content">
|
||||
<el-alert v-if="drawerError" type="error" :closable="false" show-icon title="库位详情加载失败">
|
||||
<p>{{ drawerError.message }}</p>
|
||||
<ApiErrorMeta :details="drawerError" />
|
||||
</el-alert>
|
||||
|
||||
<template v-else-if="drawerMode === 'view' && detail">
|
||||
<section class="detail-identity">
|
||||
<div class="detail-identity__icon"><MapPin :size="24" /></div>
|
||||
<div>
|
||||
<h3>{{ detail.location.locationCode }}</h3>
|
||||
<p>{{ detail.location.locationName || '未命名库位' }}</p>
|
||||
</div>
|
||||
<el-tag :type="statusMeta[detail.location.status].type">{{ statusMeta[detail.location.status].label }}</el-tag>
|
||||
</section>
|
||||
<el-descriptions :column="2" border class="detail-descriptions">
|
||||
<el-descriptions-item label="所属企业">{{ customerLabel(detail.location.tenantId) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="所属仓库">{{ detail.warehouseName }}({{ detail.warehouseCode }})</el-descriptions-item>
|
||||
<el-descriptions-item label="库位编码">{{ detail.location.locationCode }}</el-descriptions-item>
|
||||
<el-descriptions-item label="库位名称">{{ detail.location.locationName || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="库区">{{ detail.location.zoneName }}({{ detail.location.zoneCode }})</el-descriptions-item>
|
||||
<el-descriptions-item label="库位类型">{{ typeLabels[detail.location.locationType] }}</el-descriptions-item>
|
||||
<el-descriptions-item label="最大承重">{{ formatWeight(detail.location.maxWeightKg) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="状态">{{ statusMeta[detail.location.status].label }}</el-descriptions-item>
|
||||
<el-descriptions-item label="资源版本">v{{ detail.location.version }}</el-descriptions-item>
|
||||
<el-descriptions-item label="更新时间">{{ formatDateTime(detail.location.updatedAt) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="系统共享位">{{ detail.location.sharedOverflow ? '是,仅系统自动分配' : '否' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="备注">{{ detail.location.remark || '—' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<el-alert
|
||||
v-if="detail.location.status === 'RESERVED' || detail.location.status === 'OCCUPIED'"
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
title="当前库位正在被业务使用,只允许查看。"
|
||||
/>
|
||||
<el-alert
|
||||
v-if="detail.location.sharedOverflow"
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
title="A0 是系统共享溢出位,不能编辑、停用或删除。"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<el-form
|
||||
v-else-if="drawerMode !== 'view'"
|
||||
ref="formRef"
|
||||
:model="form"
|
||||
:rules="rules"
|
||||
label-position="top"
|
||||
class="drawer-form"
|
||||
:disabled="saving"
|
||||
>
|
||||
<div class="editor-scope">
|
||||
<MapPin :size="18" />
|
||||
<div><strong>{{ detail?.warehouseName || selectedWarehouse?.warehouseName }}({{ detail?.warehouseCode || selectedWarehouse?.warehouseCode }})</strong><span>所属企业:{{ customerLabel(form.customerId) }} · 保存后不可跨仓移动</span></div>
|
||||
</div>
|
||||
<section class="form-section">
|
||||
<h3>库位标识</h3>
|
||||
<p class="section-description">库位编码在当前企业和仓库内唯一;编辑后不能移动到其他仓库。</p>
|
||||
<div class="form-grid two-cols">
|
||||
<el-form-item label="库位编码" prop="locationCode" required>
|
||||
<el-input v-model.trim="form.locationCode" maxlength="50" placeholder="例如 A1-01" />
|
||||
</el-form-item>
|
||||
<el-form-item label="库位名称">
|
||||
<el-input v-model.trim="form.locationName" maxlength="100" placeholder="例如 A1 区 01 位" />
|
||||
</el-form-item>
|
||||
<el-form-item label="库区编码" prop="zoneCode" required>
|
||||
<el-input v-model.trim="form.zoneCode" maxlength="50" placeholder="例如 A" />
|
||||
</el-form-item>
|
||||
<el-form-item label="库区名称" prop="zoneName" required>
|
||||
<el-input v-model.trim="form.zoneName" maxlength="100" placeholder="例如 常规区" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="form-section">
|
||||
<h3>资源属性</h3>
|
||||
<div class="form-grid two-cols">
|
||||
<el-form-item label="库位类型" prop="locationType" required>
|
||||
<el-select v-model="form.locationType" placeholder="请选择库位类型">
|
||||
<el-option label="货架位" value="SHELF" />
|
||||
<el-option label="托盘位" value="PALLET" />
|
||||
<el-option label="地堆位" value="FLOOR" />
|
||||
<el-option label="流利架" value="FLOW_RACK" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="最大承重(kg)">
|
||||
<el-input-number v-model="form.maxWeightKg" :min="0.001" :precision="3" :step="10" controls-position="right" placeholder="选填" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
<el-form-item v-if="drawerMode === 'create'" label="初始状态" prop="status" required>
|
||||
<el-radio-group v-model="form.status">
|
||||
<el-radio-button value="AVAILABLE" :disabled="selectedWarehouse?.status !== 'ACTIVE'">可用</el-radio-button>
|
||||
<el-radio-button value="DISABLED">停用</el-radio-button>
|
||||
</el-radio-group>
|
||||
<p v-if="selectedWarehouse?.status !== 'ACTIVE'" class="form-help">当前仓库已停用,新库位只能以停用状态创建。</p>
|
||||
</el-form-item>
|
||||
<div v-else class="readonly-status">
|
||||
<span>当前状态</span>
|
||||
<el-tag :type="statusMeta[form.status].type">{{ statusMeta[form.status].label }}</el-tag>
|
||||
<small>状态只能通过列表中的启停操作变更。</small>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="form-section">
|
||||
<h3>备注</h3>
|
||||
<el-form-item label="内部说明">
|
||||
<el-input v-model.trim="form.remark" type="textarea" :rows="4" maxlength="500" show-word-limit placeholder="请输入库位位置、使用限制或其他说明" />
|
||||
</el-form-item>
|
||||
</section>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="drawer-footer">
|
||||
<el-button :disabled="saving" @click="drawerOpen = false">{{ drawerMode === 'view' ? '关闭' : '取消' }}</el-button>
|
||||
<el-button v-if="drawerMode !== 'view'" type="primary" :loading="saving" @click="saveLocation">保存库位</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.state-alert p { margin: 6px 0 0; line-height: 1.55; }
|
||||
.state-alert .el-button { margin-top: 10px; }
|
||||
.location-actions { display: flex; align-items: center; gap: 4px; white-space: nowrap; }
|
||||
.location-actions :deep(.el-button) { margin-left: 0; }
|
||||
.location-cell { display: flex; min-width: 0; align-items: center; gap: 10px; }
|
||||
.location-cell > span { display: grid; width: 34px; height: 34px; flex: 0 0 34px; place-items: center; border-radius: 7px; background: #eef3fa; color: var(--cc-primary); }
|
||||
.location-cell > div, .stacked-value { display: flex; min-width: 0; flex-direction: column; gap: 3px; }
|
||||
.location-cell strong, .stacked-value strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.location-cell small, .stacked-value small { color: var(--cc-muted); font-size: 11px; }
|
||||
.remark-cell { display: -webkit-box; overflow: hidden; line-height: 1.5; -webkit-box-orient: vertical; -webkit-line-clamp: 2; }
|
||||
.drawer-content { min-height: 260px; }
|
||||
.drawer-content > .el-alert + .el-alert, .detail-descriptions + .el-alert { margin-top: 14px; }
|
||||
.detail-identity { display: grid; grid-template-columns: 48px minmax(0, 1fr) auto; align-items: center; gap: 12px; margin-bottom: 18px; }
|
||||
.detail-identity__icon { display: grid; width: 48px; height: 48px; place-items: center; border-radius: 10px; background: #eef3fa; color: var(--cc-primary); }
|
||||
.detail-identity h3 { margin: 0; font-size: 18px; }
|
||||
.detail-identity p { margin: 4px 0 0; color: var(--cc-muted); font-size: 12px; }
|
||||
.detail-descriptions :deep(.el-descriptions__label) { width: 118px; }
|
||||
.section-description { margin: -3px 0 14px; color: var(--cc-muted); font-size: 12px; line-height: 1.55; }
|
||||
.drawer-form :deep(.el-input-number), .drawer-form :deep(.el-select) { width: 100%; }
|
||||
.editor-scope { display: flex; align-items: center; gap: 10px; margin-bottom: 18px; padding: 12px; background: #f7f9fc; color: var(--cc-primary); }
|
||||
.editor-scope div { display: grid; min-width: 0; gap: 2px; }
|
||||
.editor-scope strong, .editor-scope span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.editor-scope span { color: var(--cc-muted); font-size: 12px; }
|
||||
.form-help { margin: 6px 0 0; color: var(--cc-muted); font-size: 12px; line-height: 1.55; }
|
||||
.readonly-status { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; padding: 12px; background: #f7f9fc; }
|
||||
.readonly-status > span { color: var(--cc-text); font-size: 13px; font-weight: 600; }
|
||||
.readonly-status small { color: var(--cc-muted); font-size: 11px; }
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.detail-identity { grid-template-columns: 44px minmax(0, 1fr); }
|
||||
.detail-identity > .el-tag { grid-column: 2; justify-self: start; }
|
||||
.detail-descriptions :deep(.el-descriptions__body), .detail-descriptions :deep(table), .detail-descriptions :deep(tbody) { display: block; width: 100%; }
|
||||
.detail-descriptions :deep(tr) { display: grid; grid-template-columns: 108px minmax(0, 1fr); }
|
||||
.detail-descriptions :deep(.el-descriptions__cell) { display: block; width: auto; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,77 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { http } from '@/shared/api/http'
|
||||
import {
|
||||
createPlatformPallet,
|
||||
deletePlatformPallet,
|
||||
getPlatformPallet,
|
||||
listPlatformPallets,
|
||||
updatePlatformPallet,
|
||||
type SavePlatformPalletRequest,
|
||||
} from './platformPallets'
|
||||
|
||||
vi.mock('@/shared/api/http', () => ({
|
||||
http: { get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn() },
|
||||
unwrapApiResponse: <T>(response: { data: T }) => response.data,
|
||||
}))
|
||||
|
||||
const payload: SavePlatformPalletRequest = {
|
||||
warehouseId: '19001',
|
||||
palletCode: ' TP-001 ',
|
||||
palletType: '木托盘',
|
||||
capacityCbm: 1.2,
|
||||
remark: null,
|
||||
}
|
||||
|
||||
describe('platform pallet API', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(http.get).mockReset()
|
||||
vi.mocked(http.post).mockReset()
|
||||
vi.mocked(http.put).mockReset()
|
||||
vi.mocked(http.delete).mockReset()
|
||||
})
|
||||
|
||||
it('uses the real page contract and normalizes long pagination fields', async () => {
|
||||
vi.mocked(http.get).mockResolvedValue({
|
||||
code: '200', message: 'success', data: { records: [], page: '2', size: '20', total: '41', pages: '3' },
|
||||
})
|
||||
|
||||
await expect(listPlatformPallets({
|
||||
tenantId: '9223372036854775806', page: 2, size: 20, warehouseId: '19001', palletCode: ' TP ', palletType: ' 木托盘 ',
|
||||
})).resolves.toEqual({ records: [], page: 2, size: 20, total: 41, pages: 3 })
|
||||
expect(http.get).toHaveBeenCalledWith('/platform/v1/pallets', {
|
||||
params: {
|
||||
tenantId: '9223372036854775806', page: 2, size: 20, warehouseId: '19001', palletCode: 'TP', palletType: '木托盘',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('omits the customer parameter for an all-customer page query', async () => {
|
||||
vi.mocked(http.get).mockResolvedValue({
|
||||
code: '200', message: 'success', data: { records: [], page: 1, size: 20, total: 0, pages: 0 },
|
||||
})
|
||||
|
||||
await listPlatformPallets({ page: 2, size: 20 })
|
||||
|
||||
expect(http.get).toHaveBeenCalledWith('/platform/v1/pallets', {
|
||||
params: { page: 2, size: 20 },
|
||||
})
|
||||
expect(vi.mocked(http.get).mock.calls[0]?.[1]?.params).not.toHaveProperty('tenantId')
|
||||
})
|
||||
|
||||
it('uses tenant and warehouse scope for detail, update, and delete', async () => {
|
||||
vi.mocked(http.get).mockResolvedValue({ code: '200', data: { id: '20001' } })
|
||||
vi.mocked(http.post).mockResolvedValue({ code: '200', data: '20001' })
|
||||
vi.mocked(http.put).mockResolvedValue({ code: '200', data: null })
|
||||
vi.mocked(http.delete).mockResolvedValue({ code: '200', data: null })
|
||||
|
||||
await expect(getPlatformPallet(10001, '19001', '20001')).resolves.toEqual({ id: '20001' })
|
||||
await expect(createPlatformPallet(10001, payload)).resolves.toBe('20001')
|
||||
await expect(updatePlatformPallet(10001, '19001', '20001', payload)).resolves.toBeNull()
|
||||
await expect(deletePlatformPallet(10001, '19001', '20001')).resolves.toBeNull()
|
||||
|
||||
expect(http.get).toHaveBeenCalledWith('/platform/v1/pallets/20001', { params: { tenantId: 10001, warehouseId: '19001' } })
|
||||
expect(http.post).toHaveBeenCalledWith('/platform/v1/pallets', payload, { params: { tenantId: 10001 } })
|
||||
expect(http.put).toHaveBeenCalledWith('/platform/v1/pallets/20001', payload, { params: { tenantId: 10001, warehouseId: '19001' } })
|
||||
expect(http.delete).toHaveBeenCalledWith('/platform/v1/pallets/20001', { params: { tenantId: 10001, warehouseId: '19001' } })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,103 @@
|
||||
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
|
||||
export interface PlatformPalletResource {
|
||||
id: number | string
|
||||
tenantId: number | string
|
||||
warehouseId: number | string
|
||||
palletCode: string
|
||||
palletType: string
|
||||
capacityCbm: number | string
|
||||
remark: string | null
|
||||
createdAt: string | null
|
||||
updatedAt: string | null
|
||||
}
|
||||
|
||||
export interface PlatformPalletPage {
|
||||
records: PlatformPalletResource[]
|
||||
page: number
|
||||
size: number
|
||||
total: number
|
||||
pages: number
|
||||
}
|
||||
|
||||
export interface PlatformPalletQuery {
|
||||
tenantId?: number | string
|
||||
page: number
|
||||
size: number
|
||||
warehouseId?: number | string
|
||||
palletCode?: string
|
||||
palletType?: string
|
||||
}
|
||||
|
||||
export interface SavePlatformPalletRequest {
|
||||
warehouseId: number | string
|
||||
palletCode: string
|
||||
palletType: string
|
||||
capacityCbm: number
|
||||
remark: string | null
|
||||
}
|
||||
|
||||
const basePath = '/platform/v1/pallets'
|
||||
|
||||
export async function listPlatformPallets(query: PlatformPalletQuery) {
|
||||
const response = await http.get<ApiEnvelope<PlatformPalletPage>, ApiEnvelope<PlatformPalletPage>>(basePath, {
|
||||
params: {
|
||||
...(query.tenantId === undefined || query.tenantId === '' ? {} : { tenantId: query.tenantId }),
|
||||
page: query.page,
|
||||
size: query.size,
|
||||
...(query.warehouseId === undefined || query.warehouseId === '' ? {} : { warehouseId: query.warehouseId }),
|
||||
...(query.palletCode?.trim() ? { palletCode: query.palletCode.trim() } : {}),
|
||||
...(query.palletType?.trim() ? { palletType: query.palletType.trim() } : {}),
|
||||
},
|
||||
})
|
||||
const result = unwrapApiResponse(response)
|
||||
return {
|
||||
...result,
|
||||
page: Number(result.page),
|
||||
size: Number(result.size),
|
||||
total: Number(result.total),
|
||||
pages: Number(result.pages),
|
||||
}
|
||||
}
|
||||
|
||||
export async function getPlatformPallet(
|
||||
tenantId: number | string,
|
||||
warehouseId: number | string,
|
||||
id: number | string,
|
||||
) {
|
||||
const response = await http.get<ApiEnvelope<PlatformPalletResource>, ApiEnvelope<PlatformPalletResource>>(
|
||||
`${basePath}/${id}`,
|
||||
{ params: { tenantId, warehouseId } },
|
||||
)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function createPlatformPallet(tenantId: number | string, payload: SavePlatformPalletRequest) {
|
||||
const response = await http.post<ApiEnvelope<number | string>, ApiEnvelope<number | string>>(basePath, payload, {
|
||||
params: { tenantId },
|
||||
})
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function updatePlatformPallet(
|
||||
tenantId: number | string,
|
||||
warehouseId: number | string,
|
||||
id: number | string,
|
||||
payload: SavePlatformPalletRequest,
|
||||
) {
|
||||
const response = await http.put<ApiEnvelope<null>, ApiEnvelope<null>>(`${basePath}/${id}`, payload, {
|
||||
params: { tenantId, warehouseId },
|
||||
})
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function deletePlatformPallet(
|
||||
tenantId: number | string,
|
||||
warehouseId: number | string,
|
||||
id: number | string,
|
||||
) {
|
||||
const response = await http.delete<ApiEnvelope<null>, ApiEnvelope<null>>(`${basePath}/${id}`, {
|
||||
params: { tenantId, warehouseId },
|
||||
})
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
import ElementPlus from 'element-plus'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { enableAutoUnmount, flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { useAuthStore } from '@/core/auth/auth'
|
||||
import { listPlatformTenants as listPlatformCustomers, type PlatformTenantResource as PlatformCustomerResource } from '@/modules/iam'
|
||||
import { listPlatformWarehouses, type PlatformWarehouseResource } from '@/modules/warehouses'
|
||||
import DataTable from '@/shared/components/DataTable.vue'
|
||||
import QueryToolbar from '@/shared/components/QueryToolbar.vue'
|
||||
import {
|
||||
createPlatformPallet,
|
||||
getPlatformPallet,
|
||||
listPlatformPallets,
|
||||
type PlatformPalletResource,
|
||||
} from '../api/platformPallets'
|
||||
import PalletsView from './PalletsView.vue'
|
||||
|
||||
vi.mock('@/modules/iam', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/modules/iam')>()
|
||||
const listPlatformTenants = vi.fn<typeof actual.listPlatformTenants>()
|
||||
return { ...actual, listPlatformTenants }
|
||||
})
|
||||
vi.mock('@/modules/warehouses', () => ({ listPlatformWarehouses: vi.fn() }))
|
||||
vi.mock('../api/platformPallets', () => ({
|
||||
listPlatformPallets: vi.fn(),
|
||||
getPlatformPallet: vi.fn(),
|
||||
createPlatformPallet: vi.fn(),
|
||||
updatePlatformPallet: vi.fn(),
|
||||
deletePlatformPallet: vi.fn(),
|
||||
}))
|
||||
|
||||
class ResizeObserverStub implements ResizeObserver {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
|
||||
function createMemoryStorage(): Storage {
|
||||
const values = new Map<string, string>()
|
||||
return {
|
||||
get length() { return values.size },
|
||||
clear: () => values.clear(),
|
||||
getItem: (key) => values.get(key) ?? null,
|
||||
key: (index) => [...values.keys()][index] ?? null,
|
||||
removeItem: (key) => values.delete(key),
|
||||
setItem: (key, value) => values.set(key, value),
|
||||
}
|
||||
}
|
||||
|
||||
const customer = (id: number | string, tenantName: string): PlatformCustomerResource => ({
|
||||
id, tenantCode: String(id), tenantName, status: 'ACTIVE', legalName: tenantName,
|
||||
legalRepresentative: null, unifiedSocialCreditCode: null, contactName: null,
|
||||
contactPhone: null, contactEmail: null, servicePackage: null, contactWechat: null,
|
||||
salesOwnerName: null, paymentTermDays: null, enterpriseType: null, establishedDate: null,
|
||||
registeredCapital: null, registeredCapitalCurrency: null, registeredAddress: null,
|
||||
internalRemark: null, createdAt: null, approvedAt: null,
|
||||
})
|
||||
|
||||
const warehouse: PlatformWarehouseResource = {
|
||||
id: '19001', tenantId: 10001, warehouseCode: 'CN-YW-01', warehouseName: '义乌收货仓',
|
||||
warehouseType: 'DOMESTIC', countryCode: 'CN', timezone: 'Asia/Shanghai', contactName: null,
|
||||
contactPhone: null, contactEmail: null, status: 'ACTIVE', remark: null,
|
||||
}
|
||||
|
||||
const pallet: PlatformPalletResource = {
|
||||
id: '20001', tenantId: 10001, warehouseId: '19001', palletCode: 'TP-001', palletType: '木托盘',
|
||||
capacityCbm: '1.200000', remark: '标准川字托盘', createdAt: '2026-08-04T09:00:00', updatedAt: '2026-08-04T10:00:00',
|
||||
}
|
||||
|
||||
async function mountView(permissions = [
|
||||
'platform:pallet:list', 'platform:pallet:create', 'platform:pallet:edit', 'platform:pallet:delete',
|
||||
]) {
|
||||
vi.stubGlobal('localStorage', createMemoryStorage())
|
||||
vi.stubGlobal('sessionStorage', createMemoryStorage())
|
||||
const pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
const auth = useAuthStore()
|
||||
auth.token = 'token'
|
||||
auth.permissions = permissions
|
||||
useAccessPreviewStore().initializeAfterLogin()
|
||||
const wrapper = mount(PalletsView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } })
|
||||
await flushPromises()
|
||||
return wrapper
|
||||
}
|
||||
|
||||
async function setFilterContext(wrapper: VueWrapper, customerId = '10001', warehouseId = '') {
|
||||
wrapper.findComponent(QueryToolbar).vm.$emit('update:modelValue', {
|
||||
customerId,
|
||||
warehouseId: '',
|
||||
palletCode: '',
|
||||
palletType: '',
|
||||
})
|
||||
await flushPromises()
|
||||
if (!warehouseId) return
|
||||
wrapper.findComponent(QueryToolbar).vm.$emit('update:modelValue', {
|
||||
customerId,
|
||||
warehouseId,
|
||||
palletCode: '',
|
||||
palletType: '',
|
||||
})
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
async function loadRows(wrapper: VueWrapper, warehouseId = '') {
|
||||
await setFilterContext(wrapper, '10001', warehouseId)
|
||||
wrapper.findComponent(QueryToolbar).vm.$emit('search', {
|
||||
customerId: '10001',
|
||||
warehouseId,
|
||||
palletCode: '',
|
||||
palletType: '',
|
||||
})
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
enableAutoUnmount(afterEach)
|
||||
afterAll(() => vi.unstubAllGlobals())
|
||||
|
||||
describe('PalletsView', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
|
||||
document.body.innerHTML = ''
|
||||
vi.mocked(listPlatformCustomers).mockReset().mockResolvedValue({
|
||||
records: [customer(10001, '默认企业'), customer('9223372036854775806', '雪花企业')], page: 1, size: 200, total: 2, pages: 1,
|
||||
})
|
||||
vi.mocked(listPlatformWarehouses).mockReset().mockResolvedValue({ records: [warehouse], page: 1, size: 200, total: 1, pages: 1 })
|
||||
vi.mocked(listPlatformPallets).mockReset().mockResolvedValue({ records: [pallet], page: 1, size: 20, total: 1, pages: 1 })
|
||||
vi.mocked(getPlatformPallet).mockReset().mockResolvedValue(pallet)
|
||||
vi.mocked(createPlatformPallet).mockReset().mockResolvedValue('20002')
|
||||
})
|
||||
|
||||
it('starts with all customers and uses compact semantic filter widths', async () => {
|
||||
const wrapper = await mountView()
|
||||
const toolbar = wrapper.findComponent(QueryToolbar)
|
||||
const fields = toolbar.props('fields')
|
||||
|
||||
expect(toolbar.props('modelValue')).toMatchObject({ customerId: '', warehouseId: '' })
|
||||
expect(fields.map((field) => [field.key, field.width])).toEqual([
|
||||
['customerId', 260],
|
||||
['warehouseId', 240],
|
||||
['palletCode', 240],
|
||||
['palletType', 180],
|
||||
])
|
||||
expect(wrapper.find('.pallet-filter-toolbar').exists()).toBe(true)
|
||||
expect(fields.find((field) => field.key === 'customerId')?.placeholder).toBe('全部客户')
|
||||
expect(listPlatformWarehouses).not.toHaveBeenCalled()
|
||||
expect(listPlatformPallets).toHaveBeenCalledWith({
|
||||
page: 1, size: 20,
|
||||
})
|
||||
})
|
||||
|
||||
it('loads warehouses after customer selection and omits an empty warehouse id when searching', async () => {
|
||||
const wrapper = await mountView()
|
||||
await loadRows(wrapper)
|
||||
|
||||
expect(listPlatformWarehouses).toHaveBeenCalledWith(10001, 1, 200)
|
||||
expect(listPlatformPallets).toHaveBeenCalledWith({
|
||||
page: 1, size: 20, tenantId: 10001,
|
||||
})
|
||||
expect(wrapper.text()).toContain('TP-001')
|
||||
expect(wrapper.text()).toContain('木托盘')
|
||||
expect(wrapper.text()).toContain('1.2 m³')
|
||||
expect(wrapper.text()).toContain('义乌收货仓')
|
||||
expect(wrapper.text()).toContain('托盘列表')
|
||||
expect(wrapper.text()).not.toContain('启停托盘')
|
||||
expect(wrapper.text()).not.toContain('全部状态')
|
||||
expect(wrapper.text()).toContain('全部托盘类型')
|
||||
expect(wrapper.find('input[aria-label="所属企业"]').exists()).toBe(true)
|
||||
expect(wrapper.find('input[aria-label="所属仓库"]').exists()).toBe(true)
|
||||
expect(wrapper.find('.platform-tenant-selector').exists()).toBe(false)
|
||||
expect(wrapper.text()).not.toContain('租户')
|
||||
expect(wrapper.find('.data-table').exists()).toBe(true)
|
||||
expect(wrapper.find('.table-actions.pallet-row-actions').exists()).toBe(true)
|
||||
expect(wrapper.findAll('thead th').map((item) => item.text().trim())[0]).toBe('操作')
|
||||
})
|
||||
|
||||
it('queries all customers explicitly without adding a customer id', async () => {
|
||||
vi.mocked(listPlatformPallets).mockResolvedValue({ records: [pallet], page: 1, size: 20, total: 41, pages: 3 })
|
||||
const wrapper = await mountView()
|
||||
const toolbar = wrapper.findComponent(QueryToolbar)
|
||||
|
||||
toolbar.vm.$emit('search', { customerId: '', warehouseId: '', palletCode: '', palletType: '' })
|
||||
await flushPromises()
|
||||
expect(listPlatformPallets).toHaveBeenLastCalledWith({
|
||||
page: 1, size: 20,
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
it('resets customer and dependent warehouse filters without sending an empty id', async () => {
|
||||
const wrapper = await mountView()
|
||||
await setFilterContext(wrapper, '10001', '19001')
|
||||
const toolbar = wrapper.findComponent(QueryToolbar)
|
||||
|
||||
toolbar.vm.reset()
|
||||
await flushPromises()
|
||||
|
||||
expect(toolbar.props('modelValue')).toMatchObject({ customerId: '', warehouseId: '' })
|
||||
expect(listPlatformPallets).toHaveBeenCalledTimes(2)
|
||||
expect(listPlatformPallets).toHaveBeenLastCalledWith({
|
||||
page: 1, size: 20,
|
||||
})
|
||||
expect(toolbar.props('fields').find((field) => field.key === 'customerId')?.placeholder).toBe('全部客户')
|
||||
})
|
||||
|
||||
it('preserves a snowflake customer id when switching the enterprise filter', async () => {
|
||||
const wrapper = await mountView()
|
||||
const snowflakeId = '9223372036854775806'
|
||||
|
||||
wrapper.findComponent(QueryToolbar).vm.$emit('update:modelValue', {
|
||||
customerId: snowflakeId,
|
||||
warehouseId: '',
|
||||
palletCode: '',
|
||||
palletType: '',
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
expect(listPlatformWarehouses).toHaveBeenLastCalledWith(snowflakeId, 1, 200)
|
||||
})
|
||||
|
||||
it('applies pallet filters only after search', async () => {
|
||||
const wrapper = await mountView()
|
||||
await setFilterContext(wrapper, '10001', '19001')
|
||||
await wrapper.find('input[placeholder="托盘编码(模糊查询)"]').setValue('TP-00')
|
||||
expect(listPlatformPallets).toHaveBeenCalledTimes(1)
|
||||
|
||||
wrapper.findComponent(QueryToolbar).vm.$emit('search', {
|
||||
customerId: '10001', warehouseId: '19001', palletCode: 'TP-00', palletType: '木托盘',
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
expect(listPlatformPallets).toHaveBeenLastCalledWith({
|
||||
page: 1, size: 20, tenantId: 10001, warehouseId: 19001, palletCode: 'TP-00', palletType: '木托盘',
|
||||
})
|
||||
})
|
||||
|
||||
it('creates a pallet in the selected warehouse with the real request fields', async () => {
|
||||
const wrapper = await mountView()
|
||||
await setFilterContext(wrapper, '10001', '19001')
|
||||
const createButton = wrapper.findAll('button').find((button) => button.text().trim() === '新增托盘')
|
||||
await createButton?.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.find('input[placeholder="例如 TP-001"]').setValue('TP-002')
|
||||
const palletTypeItem = wrapper.findAll('.el-form-item').find((item) => item.text().includes('托盘类型'))
|
||||
palletTypeItem?.findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', '塑料托盘')
|
||||
const capacity = wrapper.findComponent({ name: 'ElInputNumber' })
|
||||
capacity.vm.$emit('update:modelValue', 1.5)
|
||||
await wrapper.find('textarea[placeholder="可填写规格、材质或现场识别说明"]').setValue('蓝色标准托盘')
|
||||
const saveButton = wrapper.findAll('button').find((button) => button.text().trim() === '保存托盘')
|
||||
await saveButton?.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(createPlatformPallet).toHaveBeenCalledWith(10001, {
|
||||
warehouseId: 19001, palletCode: 'TP-002', palletType: '塑料托盘', capacityCbm: 1.5, remark: '蓝色标准托盘',
|
||||
})
|
||||
})
|
||||
|
||||
it('hides all write actions without write permissions', async () => {
|
||||
const wrapper = await mountView(['platform:pallet:list'])
|
||||
|
||||
expect(wrapper.find('.panel-heading button').exists()).toBe(false)
|
||||
const actions = wrapper.find('.pallet-row-actions').text()
|
||||
expect(actions).not.toContain('编辑')
|
||||
expect(actions).not.toContain('删除')
|
||||
expect(actions).toContain('查看')
|
||||
})
|
||||
|
||||
it('shows a recoverable list failure instead of demo data', async () => {
|
||||
vi.mocked(listPlatformPallets).mockRejectedValueOnce(new Error('Gateway unavailable'))
|
||||
const wrapper = await mountView()
|
||||
|
||||
expect(wrapper.text()).toContain('托盘列表暂时无法加载')
|
||||
expect(wrapper.text()).toContain('Gateway unavailable')
|
||||
expect(wrapper.text()).toContain('重新加载')
|
||||
expect(wrapper.text()).not.toContain('TP-001')
|
||||
})
|
||||
|
||||
it('stops dependent requests when the enterprise filter cannot load', async () => {
|
||||
vi.mocked(listPlatformCustomers).mockRejectedValueOnce(new Error('Gateway unavailable'))
|
||||
const wrapper = await mountView()
|
||||
|
||||
expect(wrapper.text()).toContain('所属企业列表暂时无法加载')
|
||||
expect(wrapper.text()).toContain('重新加载')
|
||||
expect(listPlatformWarehouses).not.toHaveBeenCalled()
|
||||
expect(listPlatformPallets).toHaveBeenCalledWith({
|
||||
page: 1, size: 20,
|
||||
})
|
||||
})
|
||||
|
||||
it('shows only the pallet fields exposed by the backend contract', async () => {
|
||||
const crossCustomerPallet = { ...pallet, tenantId: '9223372036854775806' }
|
||||
vi.mocked(listPlatformPallets).mockResolvedValue({ records: [crossCustomerPallet], page: 1, size: 20, total: 1, pages: 1 })
|
||||
vi.mocked(getPlatformPallet).mockResolvedValue(crossCustomerPallet)
|
||||
const wrapper = await mountView()
|
||||
await loadRows(wrapper)
|
||||
expect(wrapper.findComponent(DataTable).props('data')).toEqual([crossCustomerPallet])
|
||||
const tableBody = wrapper.find('.el-table__body-wrapper')
|
||||
const viewButton = tableBody.findAll('.pallet-row-actions button').find((button) => button.text().trim() === '查看')
|
||||
await viewButton?.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(getPlatformPallet).toHaveBeenCalledWith('9223372036854775806', 19001, '20001')
|
||||
expect(document.body.textContent).toContain('所属企业')
|
||||
expect(document.body.textContent).toContain('雪花企业(9223372036854775806)')
|
||||
expect(document.body.textContent).toContain('托盘编码')
|
||||
expect(document.body.textContent).toContain('托盘类型')
|
||||
expect(document.body.textContent).toContain('托盘容积')
|
||||
expect(document.body.textContent).toContain('归属信息')
|
||||
expect(document.body.textContent).toContain('托盘规格')
|
||||
expect(document.body.textContent).toContain('记录信息')
|
||||
expect(document.body.textContent).not.toContain('企业 ID')
|
||||
expect(document.body.textContent).not.toContain('租户')
|
||||
expect(document.body.textContent).not.toContain('托盘状态')
|
||||
expect(document.body.textContent).not.toContain('最大承重')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,565 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
|
||||
import { Boxes, Eye, Pencil, Plus, Trash2, Warehouse as WarehouseIcon } from '@lucide/vue'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
|
||||
import { listPlatformTenants as listPlatformCustomers, type PlatformTenantResource as PlatformCustomerResource } from '@/modules/iam'
|
||||
import { listPlatformWarehouses, type PlatformWarehouseResource } from '@/modules/warehouses'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||
import {
|
||||
createPlatformPallet,
|
||||
deletePlatformPallet,
|
||||
getPlatformPallet,
|
||||
listPlatformPallets,
|
||||
updatePlatformPallet,
|
||||
type PlatformPalletResource,
|
||||
type SavePlatformPalletRequest,
|
||||
} from '../api/platformPallets'
|
||||
|
||||
const access = useAccessPreviewStore()
|
||||
const customers = ref<PlatformCustomerResource[]>([])
|
||||
const customerLoading = ref(false)
|
||||
const customerError = ref<ApiErrorDetails | null>(null)
|
||||
const selectedCustomerId = ref('')
|
||||
const selectedWarehouseId = ref('')
|
||||
const warehouses = ref<PlatformWarehouseResource[]>([])
|
||||
const warehouseLoading = ref(false)
|
||||
const warehouseError = ref<ApiErrorDetails | null>(null)
|
||||
const rows = ref<PlatformPalletResource[]>([])
|
||||
const loading = ref(false)
|
||||
const loadError = ref<ApiErrorDetails | null>(null)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const total = ref(0)
|
||||
const editorOpen = ref(false)
|
||||
const detailOpen = ref(false)
|
||||
const detail = ref<PlatformPalletResource | null>(null)
|
||||
const detailLoading = ref(false)
|
||||
const saving = ref(false)
|
||||
const deletingIds = ref(new Set<string>())
|
||||
const formRef = ref<FormInstance>()
|
||||
let warehouseSequence = 0
|
||||
let palletSequence = 0
|
||||
|
||||
type PalletFilters = QueryModel & { customerId: string; warehouseId: string; palletCode: string; palletType: string }
|
||||
type AppliedPalletFilters = Pick<PalletFilters, 'palletCode' | 'palletType'>
|
||||
const filters = ref<PalletFilters>({ customerId: '', warehouseId: '', palletCode: '', palletType: '' })
|
||||
const appliedFilters = ref<AppliedPalletFilters>({ palletCode: '', palletType: '' })
|
||||
const palletTypeOptions = ['木托盘', '塑料托盘', '金属托盘', '纸托盘', '免熏蒸托盘']
|
||||
const queryFields = computed<QueryField[]>(() => [
|
||||
{
|
||||
key: 'customerId',
|
||||
type: 'select',
|
||||
width: 260,
|
||||
placeholder: customerLoading.value ? '正在加载客户' : customerError.value ? '客户列表加载失败' : '全部客户',
|
||||
options: customers.value.map((customer) => ({
|
||||
label: `${customer.tenantName}(${customer.tenantCode || customer.id})`,
|
||||
value: String(customer.id),
|
||||
})),
|
||||
props: { filterable: true, disabled: contextLocked.value || customerLoading.value || Boolean(customerError.value), 'aria-label': '所属企业' },
|
||||
},
|
||||
{
|
||||
key: 'warehouseId',
|
||||
type: 'select',
|
||||
width: 240,
|
||||
placeholder: warehouseLoading.value ? '正在加载所属仓库' : warehouseError.value ? '所属仓库加载失败' : '全部仓库',
|
||||
options: warehouses.value.map((warehouse) => ({
|
||||
label: `${warehouse.warehouseName}(${warehouse.warehouseCode})`,
|
||||
value: String(warehouse.id),
|
||||
disabled: warehouse.status !== 'ACTIVE',
|
||||
})),
|
||||
props: {
|
||||
filterable: true,
|
||||
disabled: contextLocked.value || !selectedCustomerId.value || warehouseLoading.value || Boolean(warehouseError.value),
|
||||
'aria-label': '所属仓库',
|
||||
},
|
||||
},
|
||||
{ key: 'palletCode', type: 'input', width: 240, placeholder: '托盘编码(模糊查询)', props: { maxlength: 50, 'aria-label': '托盘编码' } },
|
||||
{
|
||||
key: 'palletType',
|
||||
type: 'select',
|
||||
width: 180,
|
||||
placeholder: '全部托盘类型',
|
||||
options: palletTypeOptions.map((value) => ({ label: value, value })),
|
||||
props: { filterable: true, allowCreate: true, defaultFirstOption: true, 'aria-label': '托盘类型' },
|
||||
},
|
||||
])
|
||||
|
||||
const emptyForm = () => ({ id: '', customerId: '', warehouseId: '', palletCode: '', palletType: '', capacityCbm: null as number | null, remark: '' })
|
||||
const form = reactive(emptyForm())
|
||||
const rules: FormRules<typeof form> = {
|
||||
palletCode: [
|
||||
{ required: true, message: '请输入托盘编码', trigger: 'blur' },
|
||||
{ max: 50, message: '托盘编码不能超过 50 个字符', trigger: 'blur' },
|
||||
],
|
||||
palletType: [
|
||||
{ required: true, message: '请输入托盘类型', trigger: 'blur' },
|
||||
{ max: 32, message: '托盘类型不能超过 32 个字符', trigger: 'blur' },
|
||||
],
|
||||
capacityCbm: [{
|
||||
validator: (_rule, value, callback) => {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) callback(new Error('请输入大于 0 的托盘容积'))
|
||||
else callback()
|
||||
},
|
||||
trigger: 'change',
|
||||
}],
|
||||
remark: [{ max: 500, message: '备注不能超过 500 个字符', trigger: 'blur' }],
|
||||
}
|
||||
|
||||
const currentWarehouse = computed(() => warehouses.value.find((item) => String(item.id) === selectedWarehouseId.value) || null)
|
||||
const editorWarehouse = computed(() => warehouses.value.find((item) => String(item.id) === form.warehouseId) || null)
|
||||
const currentCustomer = computed(() => customers.value.find((item) => String(item.id) === selectedCustomerId.value) || null)
|
||||
const contextReady = computed(() => Boolean(selectedCustomerId.value && selectedWarehouseId.value && currentWarehouse.value))
|
||||
const editorTitle = computed(() => form.id ? '编辑托盘' : '新增托盘')
|
||||
const editorPermission = computed(() => form.id ? 'platform:pallet:edit' : 'platform:pallet:create')
|
||||
const contextLocked = computed(() => editorOpen.value || detailOpen.value || saving.value || deletingIds.value.size > 0)
|
||||
const filtered = computed(() => Object.values(appliedFilters.value).some((value) => String(value || '').trim()))
|
||||
|
||||
const { captureBaseline } = useUnsavedWorkRegistration({
|
||||
key: 'pallet-editor',
|
||||
label: editorTitle,
|
||||
isOpen: editorOpen,
|
||||
model: form,
|
||||
requiredPermission: editorPermission,
|
||||
})
|
||||
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', minWidth: 224, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'pallet', label: '托盘编码', minWidth: 190, slotName: 'pallet' },
|
||||
{ prop: 'palletType', label: '托盘类型', minWidth: 140 },
|
||||
{ key: 'capacity', label: '托盘容积(m³)', minWidth: 160, slotName: 'capacity' },
|
||||
{ key: 'warehouse', label: '所属仓库', minWidth: 210, slotName: 'warehouse' },
|
||||
{ key: 'remark', label: '备注', minWidth: 220, slotName: 'remark' },
|
||||
{ prop: 'updatedAt', label: '更新时间', minWidth: 160, formatter: (row: PlatformPalletResource) => formatDateTime(row.updatedAt) },
|
||||
]
|
||||
|
||||
function apiId(value: string) {
|
||||
const numeric = Number(value)
|
||||
return Number.isSafeInteger(numeric) ? numeric : value
|
||||
}
|
||||
|
||||
function formatDateTime(value?: string | null) {
|
||||
return value?.replace('T', ' ').slice(0, 16) || '—'
|
||||
}
|
||||
|
||||
function formatCapacity(value: number | string) {
|
||||
const numeric = Number(value)
|
||||
if (!Number.isFinite(numeric)) return String(value)
|
||||
return `${numeric.toLocaleString('zh-CN', { maximumFractionDigits: 6 })} m³`
|
||||
}
|
||||
|
||||
function warehouseFor(warehouseId: number | string) {
|
||||
return warehouses.value.find((item) => String(item.id) === String(warehouseId)) || null
|
||||
}
|
||||
|
||||
function customerLabel(customerId: number | string) {
|
||||
const customer = customers.value.find((item) => String(item.id) === String(customerId))
|
||||
return customer ? `${customer.tenantName}(${customer.tenantCode || customer.id})` : '—'
|
||||
}
|
||||
|
||||
function errorMessage(details: ApiErrorDetails) {
|
||||
return details.requestId ? `${details.message}(requestId: ${details.requestId})` : details.message
|
||||
}
|
||||
|
||||
async function loadCustomers() {
|
||||
customerLoading.value = true
|
||||
customerError.value = null
|
||||
try {
|
||||
const records: PlatformCustomerResource[] = []
|
||||
let currentPage = 1
|
||||
let pages = 1
|
||||
do {
|
||||
const result = await listPlatformCustomers({ page: currentPage, size: 200 })
|
||||
records.push(...result.records)
|
||||
pages = Math.max(1, result.pages)
|
||||
currentPage += 1
|
||||
} while (currentPage <= pages)
|
||||
customers.value = records
|
||||
} catch (error) {
|
||||
customers.value = []
|
||||
customerError.value = getApiErrorDetails(error, '所属企业列表加载失败,请稍后重试')
|
||||
} finally {
|
||||
customerLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadWarehouses(customerId = selectedCustomerId.value) {
|
||||
const sequence = ++warehouseSequence
|
||||
selectedWarehouseId.value = ''
|
||||
filters.value = { ...filters.value, warehouseId: '' }
|
||||
warehouses.value = []
|
||||
rows.value = []
|
||||
total.value = 0
|
||||
warehouseError.value = null
|
||||
if (!customerId) return
|
||||
warehouseLoading.value = true
|
||||
try {
|
||||
const result = await listPlatformWarehouses(apiId(customerId), 1, 200)
|
||||
if (sequence !== warehouseSequence) return
|
||||
warehouses.value = result.records
|
||||
} catch (error) {
|
||||
if (sequence !== warehouseSequence) return
|
||||
warehouseError.value = getApiErrorDetails(error, '仓库列表加载失败,请稍后重试')
|
||||
} finally {
|
||||
if (sequence === warehouseSequence) warehouseLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPallets() {
|
||||
const customerId = selectedCustomerId.value
|
||||
const warehouseId = selectedWarehouseId.value
|
||||
const sequence = ++palletSequence
|
||||
rows.value = []
|
||||
total.value = 0
|
||||
loadError.value = null
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await listPlatformPallets({
|
||||
page: page.value,
|
||||
size: pageSize.value,
|
||||
...(customerId ? { tenantId: apiId(customerId) } : {}),
|
||||
...(warehouseId ? { warehouseId: apiId(warehouseId) } : {}),
|
||||
...(appliedFilters.value.palletCode ? { palletCode: appliedFilters.value.palletCode } : {}),
|
||||
...(appliedFilters.value.palletType ? { palletType: appliedFilters.value.palletType } : {}),
|
||||
})
|
||||
if (sequence !== palletSequence) return
|
||||
rows.value = result.records
|
||||
total.value = result.total
|
||||
} catch (error) {
|
||||
if (sequence !== palletSequence) return
|
||||
loadError.value = getApiErrorDetails(error, '托盘列表加载失败,请稍后重试')
|
||||
} finally {
|
||||
if (sequence === palletSequence) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function applyFilters(model: QueryModel) {
|
||||
selectedCustomerId.value = String(model.customerId || '')
|
||||
selectedWarehouseId.value = String(model.warehouseId || '')
|
||||
appliedFilters.value = { palletCode: String(model.palletCode || ''), palletType: String(model.palletType || '') }
|
||||
if (page.value === 1) void loadPallets()
|
||||
else page.value = 1
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
selectedCustomerId.value = ''
|
||||
selectedWarehouseId.value = ''
|
||||
appliedFilters.value = { palletCode: '', palletType: '' }
|
||||
if (page.value === 1) void loadPallets()
|
||||
else page.value = 1
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
if (!contextReady.value) {
|
||||
ElMessage.warning('请先选择所属企业和仓库')
|
||||
return
|
||||
}
|
||||
Object.assign(form, emptyForm())
|
||||
form.customerId = selectedCustomerId.value
|
||||
form.warehouseId = selectedWarehouseId.value
|
||||
editorOpen.value = true
|
||||
captureBaseline()
|
||||
void formRef.value?.clearValidate()
|
||||
}
|
||||
|
||||
function assignForm(resource: PlatformPalletResource) {
|
||||
Object.assign(form, {
|
||||
id: String(resource.id),
|
||||
customerId: String(resource.tenantId),
|
||||
warehouseId: String(resource.warehouseId),
|
||||
palletCode: resource.palletCode,
|
||||
palletType: resource.palletType,
|
||||
capacityCbm: Number(resource.capacityCbm),
|
||||
remark: resource.remark || '',
|
||||
})
|
||||
editorOpen.value = true
|
||||
captureBaseline()
|
||||
void formRef.value?.clearValidate()
|
||||
}
|
||||
|
||||
async function fetchDetail(row: PlatformPalletResource) {
|
||||
return getPlatformPallet(apiId(String(row.tenantId)), apiId(String(row.warehouseId)), row.id)
|
||||
}
|
||||
|
||||
async function openDetail(row: PlatformPalletResource) {
|
||||
detailLoading.value = true
|
||||
detailOpen.value = true
|
||||
detail.value = null
|
||||
try {
|
||||
detail.value = await fetchDetail(row)
|
||||
} catch (error) {
|
||||
detailOpen.value = false
|
||||
ElMessage.error(errorMessage(getApiErrorDetails(error, '托盘详情加载失败,请稍后重试')))
|
||||
} finally {
|
||||
detailLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function openEdit(row: PlatformPalletResource) {
|
||||
try {
|
||||
assignForm(await fetchDetail(row))
|
||||
} catch (error) {
|
||||
ElMessage.error(errorMessage(getApiErrorDetails(error, '托盘详情加载失败,请稍后重试')))
|
||||
}
|
||||
}
|
||||
|
||||
async function savePallet() {
|
||||
if ((!form.id && !contextReady.value) || !form.customerId || !form.warehouseId || !await formRef.value?.validate().catch(() => false) || form.capacityCbm === null) return
|
||||
saving.value = true
|
||||
const payload: SavePlatformPalletRequest = {
|
||||
warehouseId: apiId(form.warehouseId),
|
||||
palletCode: form.palletCode.trim(),
|
||||
palletType: form.palletType.trim(),
|
||||
capacityCbm: form.capacityCbm,
|
||||
remark: form.remark.trim() || null,
|
||||
}
|
||||
try {
|
||||
const customerId = apiId(form.customerId)
|
||||
if (form.id) await updatePlatformPallet(customerId, apiId(form.warehouseId), form.id, payload)
|
||||
else await createPlatformPallet(customerId, payload)
|
||||
ElMessage.success(form.id ? '托盘已更新' : '托盘已新增')
|
||||
editorOpen.value = false
|
||||
await loadPallets()
|
||||
} catch (error) {
|
||||
ElMessage.error(errorMessage(getApiErrorDetails(error, '托盘保存失败,请稍后重试')))
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function removePallet(row: PlatformPalletResource) {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确定删除托盘“${row.palletCode}”吗?已预占或已占用的托盘无法删除。`,
|
||||
'删除托盘',
|
||||
{ confirmButtonText: '删除', cancelButtonText: '取消', type: 'warning', confirmButtonClass: 'el-button--danger' },
|
||||
)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
const id = String(row.id)
|
||||
deletingIds.value = new Set([...deletingIds.value, id])
|
||||
try {
|
||||
await deletePlatformPallet(apiId(String(row.tenantId)), apiId(String(row.warehouseId)), row.id)
|
||||
ElMessage.success('托盘已删除')
|
||||
if (rows.value.length === 1 && page.value > 1) page.value -= 1
|
||||
else await loadPallets()
|
||||
} catch (error) {
|
||||
ElMessage.error(errorMessage(getApiErrorDetails(error, '托盘删除失败,请稍后重试')))
|
||||
} finally {
|
||||
const next = new Set(deletingIds.value)
|
||||
next.delete(id)
|
||||
deletingIds.value = next
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => filters.value.customerId, (customerId) => {
|
||||
selectedCustomerId.value = String(customerId || '')
|
||||
page.value = 1
|
||||
void loadWarehouses(selectedCustomerId.value)
|
||||
})
|
||||
watch(() => filters.value.warehouseId, (warehouseId) => {
|
||||
selectedWarehouseId.value = String(warehouseId || '')
|
||||
page.value = 1
|
||||
rows.value = []
|
||||
total.value = 0
|
||||
loadError.value = null
|
||||
})
|
||||
watch([page, pageSize], ([nextPage, nextSize], [previousPage, previousSize]) => {
|
||||
if (nextPage === previousPage && nextSize === previousSize) return
|
||||
void loadPallets()
|
||||
})
|
||||
watch(() => access.can(editorPermission.value), (canWrite) => {
|
||||
if (!canWrite && editorOpen.value) editorOpen.value = false
|
||||
}, { flush: 'sync' })
|
||||
onMounted(() => {
|
||||
void loadCustomers()
|
||||
void loadPallets()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="pallet-page">
|
||||
<LoadErrorState
|
||||
v-if="customerError"
|
||||
title="所属企业列表暂时无法加载"
|
||||
:message="customerError.message"
|
||||
:loading="customerLoading"
|
||||
@retry="loadCustomers"
|
||||
/>
|
||||
|
||||
<QueryToolbar
|
||||
v-model="filters"
|
||||
class="surface-panel pallet-filter-toolbar"
|
||||
ariaLabel="托盘筛选"
|
||||
:fields="queryFields"
|
||||
:search-on-reset="false"
|
||||
@search="applyFilters"
|
||||
@reset="resetFilters"
|
||||
/>
|
||||
|
||||
<LoadErrorState
|
||||
v-if="warehouseError"
|
||||
title="仓库列表暂时无法加载"
|
||||
:message="warehouseError.message"
|
||||
:loading="warehouseLoading"
|
||||
@retry="loadWarehouses()"
|
||||
/>
|
||||
|
||||
<LoadErrorState
|
||||
v-if="loadError"
|
||||
title="托盘列表暂时无法加载"
|
||||
:message="loadError.message"
|
||||
:loading="loading"
|
||||
@retry="loadPallets"
|
||||
/>
|
||||
|
||||
<section class="table-panel surface-panel" aria-label="托盘列表区域">
|
||||
<div class="panel-heading">
|
||||
<div><h2>托盘列表</h2><p>{{ currentCustomer?.tenantName || '全部客户' }} · {{ currentWarehouse?.warehouseName || '全部仓库' }} · 共 {{ total }} 个托盘</p></div>
|
||||
<el-button v-if="access.can('platform:pallet:create')" type="primary" :disabled="!contextReady" @click="openCreate"><Plus :size="16" />新增托盘</el-button>
|
||||
</div>
|
||||
<DataTable
|
||||
v-model:page="page"
|
||||
v-model:page-size="pageSize"
|
||||
:data="rows"
|
||||
:columns="columns"
|
||||
:loading="loading || warehouseLoading"
|
||||
:total="total"
|
||||
row-key="id"
|
||||
:empty-text="filtered ? '暂无符合筛选条件的托盘' : selectedCustomerId ? '当前客户暂无托盘' : '暂无托盘数据'"
|
||||
aria-label="托盘列表"
|
||||
>
|
||||
<template #actions="{ row }">
|
||||
<div class="table-actions pallet-row-actions">
|
||||
<el-button link type="primary" title="查看托盘" @click="openDetail(row as PlatformPalletResource)"><Eye :size="15" />查看</el-button>
|
||||
<el-button v-if="access.can('platform:pallet:edit')" link type="primary" title="编辑托盘" @click="openEdit(row as PlatformPalletResource)"><Pencil :size="15" />编辑</el-button>
|
||||
<el-button
|
||||
v-if="access.can('platform:pallet:delete')"
|
||||
link
|
||||
type="danger"
|
||||
title="删除托盘"
|
||||
:loading="deletingIds.has(String((row as PlatformPalletResource).id))"
|
||||
@click="removePallet(row as PlatformPalletResource)"
|
||||
><Trash2 :size="15" />删除</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<template #pallet="{ row }">
|
||||
<div class="pallet-cell"><span><Boxes :size="18" /></span><strong>{{ (row as PlatformPalletResource).palletCode }}</strong></div>
|
||||
</template>
|
||||
<template #capacity="{ row }"><strong>{{ formatCapacity((row as PlatformPalletResource).capacityCbm) }}</strong></template>
|
||||
<template #warehouse="{ row }"><span>{{ warehouseFor((row as PlatformPalletResource).warehouseId)?.warehouseName || '—' }}</span></template>
|
||||
<template #remark="{ row }"><span class="remark-text">{{ (row as PlatformPalletResource).remark || '—' }}</span></template>
|
||||
</DataTable>
|
||||
</section>
|
||||
|
||||
<el-drawer v-model="detailOpen" title="托盘详情" size="520px" class="business-drawer">
|
||||
<div v-loading="detailLoading" class="detail-body">
|
||||
<template v-if="detail">
|
||||
<div class="detail-hero"><span><Boxes :size="22" /></span><div><h2>{{ detail.palletCode }}</h2><p>{{ detail.palletType }}</p></div></div>
|
||||
<h3 class="detail-section-title">归属信息</h3>
|
||||
<el-descriptions :column="1" border>
|
||||
<el-descriptions-item label="所属企业">{{ customerLabel(detail.tenantId) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="所属仓库">{{ warehouseFor(detail.warehouseId)?.warehouseName || '—' }}({{ warehouseFor(detail.warehouseId)?.warehouseCode || '—' }})</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<h3 class="detail-section-title">托盘规格</h3>
|
||||
<el-descriptions :column="1" border>
|
||||
<el-descriptions-item label="托盘编码">{{ detail.palletCode }}</el-descriptions-item>
|
||||
<el-descriptions-item label="托盘类型">{{ detail.palletType }}</el-descriptions-item>
|
||||
<el-descriptions-item label="托盘容积">{{ formatCapacity(detail.capacityCbm) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="备注">{{ detail.remark || '—' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<h3 class="detail-section-title">记录信息</h3>
|
||||
<el-descriptions :column="1" border>
|
||||
<el-descriptions-item label="创建时间">{{ formatDateTime(detail.createdAt) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="更新时间">{{ formatDateTime(detail.updatedAt) }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</template>
|
||||
</div>
|
||||
<template #footer><el-button @click="detailOpen = false">关闭</el-button></template>
|
||||
</el-drawer>
|
||||
|
||||
<el-drawer v-model="editorOpen" :title="editorTitle" size="520px" class="business-drawer" :close-on-click-modal="false">
|
||||
<div class="editor-context">
|
||||
<WarehouseIcon :size="18" />
|
||||
<div><strong>{{ editorWarehouse?.warehouseName || '当前仓库' }}({{ editorWarehouse?.warehouseCode || form.warehouseId }})</strong><span>所属企业 {{ customerLabel(form.customerId) }} · 托盘保存后不可跨仓移动</span></div>
|
||||
</div>
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-position="top" class="editor-form">
|
||||
<el-form-item label="托盘编码" prop="palletCode" required>
|
||||
<el-input v-model="form.palletCode" maxlength="50" show-word-limit placeholder="例如 TP-001" />
|
||||
</el-form-item>
|
||||
<el-form-item label="托盘类型" prop="palletType" required>
|
||||
<el-select
|
||||
v-model="form.palletType"
|
||||
filterable
|
||||
allow-create
|
||||
default-first-option
|
||||
placeholder="请选择或输入托盘类型"
|
||||
aria-label="编辑托盘类型"
|
||||
>
|
||||
<el-option v-for="type in palletTypeOptions" :key="type" :label="type" :value="type" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="托盘容积(m³)" prop="capacityCbm" required>
|
||||
<el-input-number v-model="form.capacityCbm" :min="0.000001" :max="999999999999.999999" :precision="6" :step="0.1" controls-position="right" aria-label="标准容积" />
|
||||
<p class="field-help">用于服务端按总体积计算所需托盘数量;请输入托盘实际可用容积。</p>
|
||||
</el-form-item>
|
||||
<el-form-item label="备注" prop="remark">
|
||||
<el-input v-model="form.remark" type="textarea" :rows="4" maxlength="500" show-word-limit placeholder="可填写规格、材质或现场识别说明" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<div class="drawer-footer"><el-button :disabled="saving" @click="editorOpen = false">取消</el-button><el-button type="primary" :loading="saving" @click="savePallet">保存托盘</el-button></div>
|
||||
</template>
|
||||
</el-drawer>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.pallet-page { display: grid; min-width: 0; gap: 16px; }
|
||||
.surface-panel { overflow: hidden; border: 1px solid var(--cc-border); border-radius: 8px; background: #fff; }
|
||||
.table-panel { min-width: 0; }
|
||||
.panel-heading { display: flex; align-items: center; justify-content: space-between; gap: 20px; padding: 14px 18px; border-bottom: 1px solid var(--cc-border); }
|
||||
.panel-heading h2 { margin: 0; font-size: 16px; }
|
||||
.panel-heading p { margin: 4px 0 0; color: var(--cc-muted); font-size: 12px; }
|
||||
.context-empty { padding: 56px 20px; color: var(--cc-muted); text-align: center; }
|
||||
.pallet-row-actions { gap: 8px; white-space: nowrap; }
|
||||
.pallet-row-actions :deep(.el-button + .el-button) { margin-left: 0; }
|
||||
.pallet-cell { display: flex; align-items: center; gap: 10px; }
|
||||
.pallet-cell > span, .detail-hero > span { display: inline-flex; align-items: center; justify-content: center; flex: 0 0 auto; border-radius: 8px; background: var(--el-color-primary-light-9); color: var(--cc-primary); }
|
||||
.pallet-cell > span { width: 34px; height: 34px; }
|
||||
.remark-text { display: -webkit-box; overflow: hidden; -webkit-box-orient: vertical; -webkit-line-clamp: 2; line-height: 1.55; }
|
||||
.detail-body { min-height: 180px; }
|
||||
.detail-hero { display: flex; align-items: center; gap: 12px; margin-bottom: 20px; }
|
||||
.detail-hero > span { width: 46px; height: 46px; }
|
||||
.detail-hero h2, .detail-hero p { margin: 0; }
|
||||
.detail-hero h2 { font-size: 18px; }
|
||||
.detail-hero p { margin-top: 4px; color: var(--cc-muted); font-size: 13px; }
|
||||
.detail-section-title { margin: 20px 0 8px; color: var(--cc-text); font-size: 14px; }
|
||||
.detail-section-title:first-of-type { margin-top: 0; }
|
||||
.editor-context { display: flex; align-items: center; gap: 10px; margin-bottom: 20px; padding: 12px; border: 1px solid var(--el-color-primary-light-7); border-radius: 8px; background: var(--el-color-primary-light-9); color: var(--cc-primary); }
|
||||
.editor-context div { display: grid; gap: 2px; }
|
||||
.editor-context span { color: var(--cc-muted); font-size: 12px; }
|
||||
.editor-form :deep(.el-input-number), .editor-form :deep(.el-select) { width: 100%; }
|
||||
.field-help { margin: 6px 0 0; color: var(--cc-muted); font-size: 12px; line-height: 1.5; }
|
||||
.drawer-footer { display: flex; justify-content: flex-end; gap: 8px; }
|
||||
.drawer-footer :deep(.el-button + .el-button) { margin-left: 0; }
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.panel-heading { align-items: stretch; flex-direction: column; }
|
||||
.panel-heading > .el-button { width: 100%; min-height: 44px; }
|
||||
.pallet-row-actions :deep(.el-button) { min-height: 36px; padding-inline: 5px; }
|
||||
.drawer-footer { display: grid; grid-template-columns: 1fr 1fr; }
|
||||
.drawer-footer :deep(.el-button) { min-height: 44px; margin-left: 0; }
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.pallet-filter-toolbar :deep(.query-toolbar__filters) { width: 100%; flex-direction: column; }
|
||||
.pallet-filter-toolbar :deep(.query-toolbar__filters .el-input),
|
||||
.pallet-filter-toolbar :deep(.query-toolbar__filters .el-select),
|
||||
.pallet-filter-toolbar :deep(.query-toolbar__filters .el-date-editor) { width: 100% !important; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
|
||||
export const warehouseResourceRoutes = [
|
||||
{
|
||||
path: 'master-data/locations',
|
||||
name: 'locations',
|
||||
component: () => import('./locations/pages/LocationsView.vue'),
|
||||
meta: {
|
||||
title: '库位管理',
|
||||
description: '维护平台仓库库位和可用状态',
|
||||
permission: 'platform:location:list',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'master-data/pallets',
|
||||
name: 'pallets',
|
||||
component: () => import('./pallets/pages/PalletsView.vue'),
|
||||
meta: {
|
||||
title: '托盘管理',
|
||||
description: '维护平台仓库托盘和可用容积',
|
||||
permission: 'platform:pallet:list',
|
||||
},
|
||||
},
|
||||
] satisfies RouteRecordRaw[]
|
||||
@@ -0,0 +1 @@
|
||||
export * from './api/platformWarehouses'
|
||||
Reference in New Issue
Block a user