This commit is contained in:
2026-09-10 11:52:30 +08:00
parent 911c6acad2
commit ae7d8fb22e
6 changed files with 238 additions and 7 deletions
+166 -2
View File
@@ -13,6 +13,7 @@ import {
createPlatformTenantImpersonation,
downloadTenantQualification,
getPlatformTenant,
getPlatformTenantDimensionInitializationStatus,
listEnterpriseReviews,
listPlatformTenantOrderCounts,
listPlatformTenants,
@@ -32,6 +33,14 @@ import {
type PlatformTenantResource,
type PlatformTenantStatus,
} from '@/modules/iam'
import {
getCustomerDimensionConfig,
updateCustomerDimensionConfig,
updateCustomerDimensionDefaultCalculationMode,
type CustomerDimensionBoxType,
type CustomerDimensionCalculationMode,
} from '@/modules/dimension-config'
import { createCustomerChargeRule, setCustomerChargeRuleDefault } from '@/modules/fba-pricing'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
import CopyButton from '@/shared/components/CopyButton.vue'
@@ -136,6 +145,25 @@ const queryFields: QueryField[] = [
]
type CustomerForm = CreatePlatformTenantRequest & { unifiedSocialCreditCode: string }
type CreationBillingRow = {
boxType: CustomerDimensionBoxType
boxTypeName: string
innerLengthCm: number
innerWidthCm: number
innerHeightCm: number
edgeAdditionCm: number
coefficient: number
weight: number
remark: string
}
const newCreationBillingRows = (): CreationBillingRow[] => [
{ boxType: 'SMALL', boxTypeName: '小箱', innerLengthCm: 30, innerWidthCm: 20, innerHeightCm: 20, edgeAdditionCm: 2, coefficient: 1.1, weight: 5, remark: '标准小箱' },
{ boxType: 'MEDIUM', boxTypeName: '中箱', innerLengthCm: 40, innerWidthCm: 30, innerHeightCm: 30, edgeAdditionCm: 2, coefficient: 1.1, weight: 8, remark: '标准中箱' },
{ boxType: 'LARGE', boxTypeName: '大箱', innerLengthCm: 50, innerWidthCm: 40, innerHeightCm: 40, edgeAdditionCm: 2, coefficient: 1.1, weight: 12, remark: '标准大箱' },
{ boxType: 'EXTRA_LARGE', boxTypeName: '特大箱', innerLengthCm: 60, innerWidthCm: 50, innerHeightCm: 50, edgeAdditionCm: 2, coefficient: 1.1, weight: 15, remark: '特大箱' },
]
const emptyForm = (): CustomerForm => ({
tenantName: '', legalName: '', legalRepresentative: '', unifiedSocialCreditCode: '', contactName: '', contactPhone: '',
contactEmail: '', contactWechat: '', servicePackage: '基础版', salesOwnerName: '', paymentTermDays: 30,
@@ -143,6 +171,11 @@ const emptyForm = (): CustomerForm => ({
registeredAddress: '', internalRemark: '',
})
const form = ref(emptyForm())
const creationBillingEnabled = ref(false)
const creationBillingDefaultMode = ref<Extract<CustomerDimensionCalculationMode, 'ADD' | 'MULTIPLY'>>('ADD')
const creationBillingRows = ref<CreationBillingRow[]>(newCreationBillingRows())
const creationBillingSession = ref(false)
const savedCreationBillingSnapshot = ref('')
const rules: FormRules = {
tenantName: [{ required: true, message: '请输入公司名称', trigger: 'blur' }],
legalName: [{ required: true, message: '请输入营业执照上的企业名称', trigger: 'blur' }],
@@ -216,7 +249,9 @@ let editorLoadSequence = 0
const editorTitle = computed(() => editingId.value ? '编辑客户' : '新增客户')
const editorDialogWidth = computed(() => editingId.value
? 'min(640px, calc(100vw - 28px))'
: 'min(680px, calc(100vw - 28px))')
: 'min(1120px, calc(100vw - 28px))')
const canConfigureCreationBilling = computed(() => access.can('firstleg:dimension-config:edit')
&& access.can('firstleg:fba-pricing:rule:edit'))
const canUploadQualifications = computed(() => !editorStatus.value || ['PROFILE_REQUIRED', 'REJECTED'].includes(editorStatus.value))
const canSubmitReview = computed(() => Boolean(editingId.value) && ['PROFILE_REQUIRED', 'REJECTED'].includes(editorStatus.value || ''))
const uploadingQualifications = computed(() => qualifications.value.some((item) => item.uploading))
@@ -386,12 +421,110 @@ function clearQualificationPreviews() {
function resetEditor() {
clearQualificationPreviews()
form.value = emptyForm(); qualifications.value = newQualificationDrafts(); creditCodeEstablished.value = false; qualificationLoadError.value = ''; savedFormSnapshot.value = ''
creationBillingEnabled.value = false
creationBillingDefaultMode.value = 'ADD'
creationBillingRows.value = newCreationBillingRows()
creationBillingSession.value = false
savedCreationBillingSnapshot.value = ''
}
function openCreate() {
editorLoadSequence += 1
editingId.value = null; editorStatus.value = null; resetEditor(); editorOpen.value = true
editingId.value = null; editorStatus.value = null; resetEditor(); creationBillingSession.value = true; editorOpen.value = true
nextTick(() => formRef.value?.clearValidate())
}
function creationBillingSnapshot() {
return JSON.stringify({
enabled: creationBillingEnabled.value,
defaultMode: creationBillingDefaultMode.value,
rows: creationBillingRows.value,
})
}
function validateCreationBillingRules() {
if (!creationBillingEnabled.value) return true
if (!canConfigureCreationBilling.value) {
ElMessage.error('当前账号缺少箱规尺寸或计费规则编辑权限,无法随客户一并保存')
return false
}
if (creationBillingRows.value.some((row) => ![
row.innerLengthCm, row.innerWidthCm, row.innerHeightCm,
].every((value) => Number.isFinite(value) && value >= 0.001))) {
ElMessage.error('四档箱规的内径长、宽、高必须大于 0')
return false
}
if (creationBillingRows.value.some((row) => !Number.isFinite(row.edgeAdditionCm)
|| row.edgeAdditionCm < 0.001 || row.edgeAdditionCm > 100)) {
ElMessage.error('四档箱规的单边加宽必须在 0.001 至 100cm 之间')
return false
}
if (creationBillingRows.value.some((row) => !Number.isFinite(row.coefficient) || row.coefficient < 0.001)) {
ElMessage.error('四档箱规的权重系数必须大于 0')
return false
}
if (creationBillingRows.value.some((row) => !Number.isFinite(row.weight) || row.weight < 0.001)) {
ElMessage.error('四档箱规的重量必须大于 0')
return false
}
return true
}
function wait(milliseconds: number) {
return new Promise((resolve) => window.setTimeout(resolve, milliseconds))
}
async function waitForDimensionInitialization(tenantId: string) {
for (let attempt = 0; attempt < 20; attempt += 1) {
const status = await getPlatformTenantDimensionInitializationStatus(tenantId)
if (status.status === 'READY') return
if (status.status === 'FAILED') {
throw new Error(status.lastErrorMessage || '客户默认箱规初始化失败')
}
await wait(500)
}
throw new Error('客户默认箱规仍在初始化,请稍后再次点击保存')
}
async function saveCreationBillingRules(tenantId: string, customerName: string) {
if (!creationBillingEnabled.value || creationBillingSnapshot() === savedCreationBillingSnapshot.value) return
await waitForDimensionInitialization(tenantId)
const current = await getCustomerDimensionConfig(tenantId)
const versions = new Map(current.rules.map((rule) => [rule.boxType, rule.version]))
await updateCustomerDimensionConfig(tenantId, {
rules: creationBillingRows.value.map((row) => ({
boxType: row.boxType,
innerLengthCm: row.innerLengthCm,
innerWidthCm: row.innerWidthCm,
innerHeightCm: row.innerHeightCm,
edgeAdditionCm: row.edgeAdditionCm,
remark: row.remark || null,
version: versions.get(row.boxType) ?? 0,
})),
})
const firstCoefficient = creationBillingRows.value[0].coefficient
const created = await createCustomerChargeRule(tenantId, {
ruleCode: `RULE-${Date.now()}`,
customerId: tenantId,
customerName,
businessType: 'FBA',
chargeMode: 'CBM',
taxMode: 'TAX_INCLUDED',
conversionFactor: 167,
lengthFactor: firstCoefficient,
widthFactor: firstCoefficient,
heightFactor: firstCoefficient,
defaultRule: false,
roundingScale: 2,
roundingMode: 'HALF_UP',
currency: 'CNY',
effectiveFrom: new Date().toISOString().slice(0, 19),
effectiveTo: null,
remark: creationBillingRows.value.map((row) => `${row.boxType}:${row.innerLengthCm},${row.innerWidthCm},${row.innerHeightCm};系数:${row.coefficient};重量:${row.weight};${row.boxTypeName}:${row.remark || '—'}`).join(';'),
})
await setCustomerChargeRuleDefault(tenantId, created.id)
await updateCustomerDimensionDefaultCalculationMode(tenantId, creationBillingDefaultMode.value)
savedCreationBillingSnapshot.value = creationBillingSnapshot()
}
function mapTenantToForm(row: PlatformTenantResource): CustomerForm {
return {
tenantName: row.tenantName, legalName: row.legalName || '', legalRepresentative: row.legalRepresentative || '', unifiedSocialCreditCode: row.unifiedSocialCreditCode || '',
@@ -562,6 +695,7 @@ async function uploadSelectedQualifications(tenantId: string) {
async function saveCustomer() {
if (!formRef.value || !(await formRef.value.validate().catch(() => false))) return
if (!validateCreationBillingRules()) return
if (!requiredQualificationPackageComplete.value) {
ElMessage.warning(`请完整保存必填业务项:${missingQualificationPackageItems.value.join('、')}`)
return
@@ -592,6 +726,15 @@ async function saveCustomer() {
editorStatus.value = saved.status
savedFormSnapshot.value = JSON.stringify(form.value)
const failures = hadSelectedQualifications ? await uploadSelectedQualifications(String(saved.id)) : []
if (creationBillingSession.value && creationBillingEnabled.value) {
try {
await saveCreationBillingRules(String(saved.id), saved.tenantName)
} catch (error) {
ElMessage.error(`客户资料已保存,但箱规尺寸和权重系数未完整保存:${getApiErrorMessage(error, '请稍后再次点击保存')}`)
await loadCustomers()
return
}
}
if (failures.length) {
ElMessage.warning(`客户资料已保存,但${failures.join('、')}上传失败;失败文件已保留,请修正后再次保存`)
} else if (hadBusinessLicense && hadLegalPersonId) {
@@ -1090,6 +1233,26 @@ onMounted(() => { void loadCustomers() })
<el-form-item label="账期(天)" prop="paymentTermDays" required><el-input-number v-model="form.paymentTermDays" :min="0" :max="365" controls-position="right" placeholder="请输入账期天数" /></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></FormSection>
<FormSection v-if="creationBillingSession" class="creation-billing-section" title="客户计费规则" description="创建客户时一次配置箱规尺寸和权重系数,保存后无需再进入客户详情补录。">
<div class="creation-billing-heading">
<el-checkbox v-model="creationBillingEnabled" :disabled="!canConfigureCreationBilling">按箱规尺寸计算</el-checkbox>
<el-radio-group v-if="creationBillingEnabled" v-model="creationBillingDefaultMode" aria-label="默认箱规计算方式">
<el-radio-button value="ADD">箱规尺寸</el-radio-button>
<el-radio-button value="MULTIPLY">权重系数</el-radio-button>
</el-radio-group>
</div>
<p v-if="!canConfigureCreationBilling" class="creation-billing-permission-hint">当前账号需同时具备箱规尺寸和客户计费规则编辑权限,才能在创建时配置。</p>
<template v-if="creationBillingEnabled">
<p class="creation-billing-hint">四档尺寸与权重系数会随客户资料一并保存;所选计算方式将设为客户默认规则。</p>
<div class="creation-billing-table-wrap">
<table class="creation-billing-table">
<thead><tr><th>箱规类型</th><th>内径长(cm)</th><th>内径宽(cm)</th><th>内径高(cm)</th><th>单边加宽(cm)</th><th>权重系数</th><th>重量(kg)</th><th>备注</th></tr></thead>
<tbody><tr v-for="row in creationBillingRows" :key="row.boxType"><th scope="row">{{ row.boxTypeName }}</th><td><el-input-number v-model="row.innerLengthCm" :min="0.001" :precision="3" :controls="false" /></td><td><el-input-number v-model="row.innerWidthCm" :min="0.001" :precision="3" :controls="false" /></td><td><el-input-number v-model="row.innerHeightCm" :min="0.001" :precision="3" :controls="false" /></td><td><el-input-number v-model="row.edgeAdditionCm" :min="0.001" :max="100" :precision="3" :controls="false" /></td><td><el-input-number v-model="row.coefficient" :min="0.001" :precision="3" :controls="false" /></td><td><el-input-number v-model="row.weight" :min="0.001" :precision="3" :controls="false" /></td><td><el-input v-model="row.remark" maxlength="500" placeholder="选填" /></td></tr></tbody>
</table>
</div>
</template>
</FormSection>
<FormSection class="qualification-section" title="企业认证资料" description="营业执照和18位统一社会信用代码为必填业务项;法人身份证、货代资质证书和银行开户许可证均为选填。"><div v-if="editorStatus" class="qualification-heading"><el-tag :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>
@@ -1235,6 +1398,7 @@ onMounted(() => { void loadCustomers() })
:deep(.customer-list-table .el-tag--danger) { background:linear-gradient(135deg,rgba(239,68,68,.15),rgba(220,38,38,.1)); color:#dc2626; }
:deep(.customer-list-table .el-tag--info) { background:linear-gradient(135deg,rgba(102,126,234,.15),rgba(118,75,162,.1)); color:#667eea; }
.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%; }
.creation-billing-heading { display:flex; min-height:36px; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:12px; } .creation-billing-heading :deep(.el-checkbox) { margin-right:0; font-weight:650; } .creation-billing-hint,.creation-billing-permission-hint { margin:8px 0 0; color:var(--cc-muted); font-size:12px; line-height:1.6; } .creation-billing-permission-hint { color:var(--el-color-warning); } .creation-billing-table-wrap { overflow-x:auto; margin-top:12px; border:1px solid var(--cc-border); border-radius:8px; } .creation-billing-table { width:100%; min-width:1040px; border-collapse:collapse; } .creation-billing-table th,.creation-billing-table td { padding:9px 10px; border-bottom:1px solid var(--cc-border); text-align:left; white-space:nowrap; } .creation-billing-table thead th { background:#f7f8fc; color:var(--cc-muted); font-size:12px; } .creation-billing-table tbody tr:last-child th,.creation-billing-table tbody tr:last-child td { border-bottom:0; } .creation-billing-table tbody th { color:var(--cc-text); font-size:13px; } .creation-billing-table :deep(.el-input-number) { width:112px; } .creation-billing-table :deep(.el-input) { width:130px; }
.qualification-heading { display:flex; justify-content:flex-end; margin-bottom:12px; } .qualification-hint { margin:12px 0 0; color:var(--cc-muted); font-size:12px; line-height:1.6; } .qualification-hint span,.qualification-label > span { color:var(--el-color-danger); }
.qualification-credit-code { max-width:420px; margin-top:16px; } .qualification-credit-code small,.field-hint { 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:170px minmax(0,1fr) 220px; 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; }
+14
View File
@@ -147,6 +147,16 @@ export interface PlatformTenantCreationResponse {
temporaryPassword: string
}
export interface CustomerDimensionInitializationStatus {
tenantId: number | string
status: 'PENDING' | 'READY' | 'FAILED'
attemptCount: number
nextRetryAt: string | null
lastErrorCode: string | null
lastErrorMessage: string | null
updatedAt: string | null
}
export interface ResetPlatformTenantOwnerPasswordRequest {
newPassword: string
forceChangePassword: boolean
@@ -363,6 +373,10 @@ export function getPlatformTenant(tenantId: number | string) {
return getData<PlatformTenantResource>(`/platform/v1/iam/tenants/${tenantId}`)
}
export function getPlatformTenantDimensionInitializationStatus(tenantId: number | string) {
return getData<CustomerDimensionInitializationStatus>(`/platform/v1/iam/tenants/${tenantId}/dimension-initialization`)
}
export async function checkPlatformTenantContactPhone(contactPhone: string, excludeTenantId?: number | string) {
const response = await http.get<ApiEnvelope<boolean>, ApiEnvelope<boolean>>(
'/platform/v1/iam/tenants/contact-phone-availability',
@@ -1,6 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { http } from '@/shared/api/http'
import { appendCarrierScheduleManualNode, getCarrierScheduleQuery, listCarrierScheduleQueries } from './platformCarrierScheduleQueries'
import { appendCarrierScheduleManualNode, ensureCarrierScheduleQueryForContainer, getCarrierScheduleQuery, listCarrierScheduleQueries } from './platformCarrierScheduleQueries'
vi.mock('@/shared/api/http', async () => {
const actual = await vi.importActual<typeof import('@/shared/api/http')>('@/shared/api/http')
@@ -61,4 +61,11 @@ describe('platformCarrierScheduleQueries', () => {
await appendCarrierScheduleManualNode('9007199254740993', 'manual-session-key', request)
expect(vi.mocked(http.post)).toHaveBeenCalledWith('/platform/v1/carrier-schedule-queries/9007199254740993/manual-nodes', request, { headers: { 'Idempotency-Key': 'manual-session-key' } })
})
it('ensures a missing departed-container task before manual entry', async () => {
const detail = { query: { query: { ...query, customerId: 0 }, expired: false }, nodes: [], exceptions: [], histories: [] }
vi.mocked(http.post).mockResolvedValue({ code: '200', data: detail })
await expect(ensureCarrierScheduleQueryForContainer('303', 'ensure-key')).resolves.toMatchObject({ query: { query: { customerId: '0', containerBookingId: '303' } } })
expect(vi.mocked(http.post)).toHaveBeenCalledWith('/platform/v1/carrier-schedule-queries/containers/303/ensure', null, { headers: { 'Idempotency-Key': 'ensure-key' } })
})
})
@@ -56,6 +56,16 @@ export async function getCarrierScheduleQuery(queryId: string): Promise<CarrierS
return { ...result, query: view(result.query), nodes: (result.nodes || []).map(node), exceptions: (result.exceptions || []).map(exception), histories: (result.histories || []).map(history) }
}
export async function ensureCarrierScheduleQueryForContainer(containerBookingId: string, idempotencyKey: string): Promise<CarrierScheduleQueryDetail> {
const response = await http.post<ApiEnvelope<CarrierScheduleQueryDetail>, ApiEnvelope<CarrierScheduleQueryDetail>>(
`${basePath}/containers/${encodeURIComponent(containerBookingId)}/ensure`,
null,
{ headers: { 'Idempotency-Key': idempotencyKey } },
)
const result = unwrapApiResponse(response)
return { ...result, query: view(result.query), nodes: (result.nodes || []).map(node), exceptions: (result.exceptions || []).map(exception), histories: (result.histories || []).map(history) }
}
export async function appendCarrierScheduleManualNode(queryId: string, idempotencyKey: string, request: CarrierScheduleManualNodeRequest): Promise<CarrierScheduleQueryDetail> {
const response = await http.post<ApiEnvelope<CarrierScheduleQueryDetail>, ApiEnvelope<CarrierScheduleQueryDetail>>(
`${basePath}/${encodeURIComponent(queryId)}/manual-nodes`,
@@ -3,7 +3,7 @@ import { flushPromises, mount } from '@vue/test-utils'
import ElementPlus from 'element-plus'
import { createPinia, setActivePinia } from 'pinia'
import CarrierScheduleQueryPanel from './CarrierScheduleQueryPanel.vue'
import { appendCarrierScheduleManualNode, getCarrierScheduleQuery, listCarrierScheduleQueries } from '../api/platformCarrierScheduleQueries'
import { appendCarrierScheduleManualNode, ensureCarrierScheduleQueryForContainer, getCarrierScheduleQuery, listCarrierScheduleQueries } from '../api/platformCarrierScheduleQueries'
import { getPortSchedule } from '../api/platformPortSchedules'
import { useAuthStore } from '@/core/auth/auth'
import { ApiResponseError } from '@/shared/api/http'
@@ -11,6 +11,7 @@ import { ApiResponseError } from '@/shared/api/http'
vi.mock('../api/platformCarrierScheduleQueries', () => ({
listCarrierScheduleQueries: vi.fn(),
getCarrierScheduleQuery: vi.fn(),
ensureCarrierScheduleQueryForContainer: vi.fn(),
appendCarrierScheduleManualNode: vi.fn(),
}))
vi.mock('../api/platformPortSchedules', () => ({ getPortSchedule: vi.fn() }))
@@ -63,6 +64,21 @@ describe('CarrierScheduleQueryPanel loading states', () => {
expect(getPortSchedule).not.toHaveBeenCalled()
})
it('shows manual entry without an existing task and ensures the task on click', async () => {
vi.mocked(listCarrierScheduleQueries).mockResolvedValue({ records: [], page: 1, size: 200, total: 0, pages: 0 })
vi.mocked(ensureCarrierScheduleQueryForContainer).mockResolvedValue({ query: { ...queryView, query: { ...queryView.query, customerId: '0', resultPortScheduleId: null } }, nodes: [], exceptions: [], histories: [] })
const wrapper = mount(CarrierScheduleQueryPanel, { props: { oceanBookingId: '2', containerBookingId: '3' }, global: { plugins: [ElementPlus, pinia] } })
await flushPromises()
const button = wrapper.findAll('button').find((item) => item.text().includes('人工补录节点'))
expect(button).toBeTruthy()
await button!.trigger('click')
await flushPromises()
expect(ensureCarrierScheduleQueryForContainer).toHaveBeenCalledWith('3', expect.any(String))
expect(wrapper.findComponent({ name: 'AppDialog' }).props('modelValue')).toBe(true)
})
it('filters large no-op status batches, keeps real actions, deduplicates only adjacent explicit event keys and sorts newest first', async () => {
vi.mocked(listCarrierScheduleQueries).mockResolvedValue({ records: [], page: 1, size: 200, total: 0, pages: 0 })
const noops = Array.from({ length: 40 }, (_, index) => ({
@@ -6,7 +6,7 @@ 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 { appendCarrierScheduleManualNode, getCarrierScheduleQuery, listCarrierScheduleQueries } from '../api/platformCarrierScheduleQueries'
import { appendCarrierScheduleManualNode, ensureCarrierScheduleQueryForContainer, getCarrierScheduleQuery, listCarrierScheduleQueries } from '../api/platformCarrierScheduleQueries'
import { getPortSchedule } from '../api/platformPortSchedules'
import type { CarrierScheduleExceptionFact, CarrierScheduleManualNodeRequest, CarrierScheduleNodeFact, CarrierScheduleQueryDetail, CarrierScheduleQueryView, CarrierScheduleStandardNode, PortScheduleView } from '../types'
@@ -31,6 +31,8 @@ const manualDialogOpen = ref(false)
const manualIdempotencyKey = ref('')
const manualObservedAt = ref('')
const manualSaving = ref(false)
const manualPreparing = ref(false)
const manualEntryError = ref<ApiErrorDetails | null>(null)
const manualError = ref<ApiErrorDetails | null>(null)
const manualValidationError = ref('')
const manualForm = reactive({ standardNode: '' as CarrierScheduleStandardNode | '', estimatedAt: '', actualAt: '', locationCode: '', locationName: '', reason: '' })
@@ -205,7 +207,24 @@ function localDateTimeNow() {
const now = new Date()
return new Date(now.getTime() - now.getTimezoneOffset() * 60_000).toISOString().slice(0, 19)
}
function openManualDialog() { resetManualForm(); manualIdempotencyKey.value = createIdempotencyKey(); manualObservedAt.value = localDateTimeNow(); manualDialogOpen.value = true }
async function openManualDialog() {
if (manualPreparing.value) return
manualEntryError.value = null
if (!query.value) {
if (!props.containerBookingId) { manualEntryError.value = getApiErrorDetails(new Error('当前页面缺少货柜信息,无法建立船节点任务')); return }
manualPreparing.value = true
try {
const ensured = await ensureCarrierScheduleQueryForContainer(props.containerBookingId, createIdempotencyKey())
detail.value = ensured
selected.value = ensured.query
queryDetailError.value = null
} catch (cause) {
manualEntryError.value = getApiErrorDetails(cause, '船节点任务建立失败')
return
} finally { manualPreparing.value = false }
}
resetManualForm(); manualIdempotencyKey.value = createIdempotencyKey(); manualObservedAt.value = localDateTimeNow(); manualDialogOpen.value = true
}
async function submitManualNode() {
manualValidationError.value = ''
if (!query.value || !manualForm.standardNode) { manualValidationError.value = '请选择标准节点'; return }
@@ -254,11 +273,12 @@ watch(() => manualForm.actualAt, (actualAt) => {
<template>
<section class="carrier-query" aria-label="货柜履约轨迹">
<header><div><h2>货柜履约轨迹</h2><p>统一展示仓内操作、确认离仓与船运节点;预计节点不会推进实际进度。</p></div><div class="header-actions"><span class="history-count">仓内 {{ filteredWarehouseTimeline.length }} 条 · 共 {{ fulfillmentEvents.length }} 条</span><el-button v-if="query && access.can('platform:firstleg:port-schedule-query:manual')" size="small" type="primary" plain @click="openManualDialog"><Plus :size="15" />人工补录节点</el-button><el-tag v-if="query" :type="statusType" effect="light">{{ statusText }}</el-tag></div></header>
<header><div><h2>货柜履约轨迹</h2><p>统一展示仓内操作、确认离仓与船运节点;预计节点不会推进实际进度。</p></div><div class="header-actions"><span class="history-count">仓内 {{ filteredWarehouseTimeline.length }} 条 · 共 {{ fulfillmentEvents.length }} 条</span><el-button v-if="(query || props.containerBookingId) && access.can('platform:firstleg:port-schedule-query:manual')" size="small" type="primary" plain :loading="manualPreparing" @click="openManualDialog"><Plus v-if="!manualPreparing" :size="15" />人工补录节点</el-button><el-tag v-if="query" :type="statusType" effect="light">{{ statusText }}</el-tag></div></header>
<LoadErrorState v-if="error" compact title="船节点查询状态加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<div v-else-if="loading" class="loading-state"><el-skeleton :rows="1" animated /></div>
<template v-else>
<el-alert v-if="query?.lastErrorMessage || query?.status === 'MANUAL_REQUIRED'" type="error" :closable="false" show-icon :title="providerErrorText(query?.lastErrorMessage || query?.manualReason)" />
<el-alert v-if="manualEntryError" type="error" :closable="false" show-icon title="无法进入人工补录"><template #default><ApiErrorMeta :details="manualEntryError" /></template></el-alert>
<el-alert v-if="query?.status === 'STOPPED'" type="info" :closable="false" show-icon :title="query.stopReason || '船节点查询已停止。'" />
<el-alert v-if="queryDetailError" type="warning" :closable="false" show-icon title="标准节点与变更历史暂不可用"><template #default><ApiErrorMeta :details="queryDetailError" /></template></el-alert>
<el-alert v-if="scheduleError" type="warning" :closable="false" show-icon title="关联船期结果暂不可用"><template #default><ApiErrorMeta :details="scheduleError" /></template></el-alert>