This commit is contained in:
2026-11-04 10:39:15 +08:00
parent 01d0a81603
commit 84dd9dcbb4
9 changed files with 34 additions and 12 deletions
@@ -498,6 +498,7 @@ async function saveDocument() {
} catch (cause) {
const details = getApiErrorDetails(cause, `${documentTitle.value}上传失败`)
if (details.code === 'A0409') try { overview.value = await getContainerOverview(containerId.value) } catch { /* 保留原并发错误证据 */ }
console.error('[Container document upload] failed', details)
documentError.value = details
}
finally { documentSaving.value = false }
@@ -578,6 +579,7 @@ async function uploadClearance() {
if (details.code === 'A0409') {
try { await refreshOverviewDocument('DESTINATION_CLEARANCE') } catch { /* 保留原并发错误证据 */ }
}
console.error('[Destination clearance upload] failed', details)
clearanceError.value = details
}
finally { clearanceSaving.value = false }
@@ -686,9 +688,9 @@ onMounted(async () => {
<OceanBookingLinkDialog v-model="oceanBookingLinkOpen" :container="detail" @linked="load" />
<AppDialog class="container-detail-dialog admin-density-dialog admin-workbench is-dialog" v-model="emptyReturnOpen" :title="containerDialogTitle('确认还柜', detail)" width="min(520px, calc(100vw - 28px))" :confirm-text="emptyReturnPending ? '重试本次还空登记' : '确认还柜'" :before-close="closeEmptyReturn" :confirm-loading="emptyReturnSaving" :confirm-disabled="!emptyReturnAt || !canRecordEmptyReturn" @confirm="confirmEmptyReturn"><el-alert title="提交后货柜将进入已还柜状态,请核对实际还柜时间。" type="warning" :closable="false" show-icon /><el-form class="density-form density-form--flush-end" label-position="top" :disabled="emptyReturnSaving || !!emptyReturnPending"><el-form-item label="实际还柜当地时间" required><el-date-picker v-model="emptyReturnAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择实际还柜时间" /></el-form-item><el-form-item label="备注"><el-input v-model="emptyReturnReason" type="textarea" :rows="3" maxlength="500" show-word-limit /></el-form-item></el-form><LoadErrorState v-if="emptyReturnError" compact title="确认还柜失败" :message="emptyReturnError.message" :show-retry="false"><template #details><ApiErrorMeta :details="emptyReturnError" /></template></LoadErrorState></AppDialog>
<AppDialog class="container-detail-dialog admin-density-dialog admin-workbench is-dialog" v-model="documentOpen" :title="containerDialogTitle(documentTitle, detail)" width="min(520px, calc(100vw - 28px))" :show-confirm="!!documentError && !!documentFile" confirm-text="重试上传" :confirm-loading="documentSaving" :confirm-disabled="!documentFile" @confirm="saveDocument"><section class="density-dialog-section"><el-form class="density-form density-form--flush-end" label-position="top"><el-form-item><el-upload :disabled="documentSaving" :key="documentUploadKey" :auto-upload="false" :limit="1" :on-change="selectDocumentFile" :on-remove="removeDocumentFile"><el-button :loading="documentSaving" :aria-label="`选择${documentTitle}文件`"><FileUp :size="16" />选择文件</el-button><template v-if="selectedDocument" #tip><div class="el-upload__tip">上传新文件将替换当前资料并保留历史。</div></template></el-upload></el-form-item></el-form></section><LoadErrorState v-if="documentError" compact title="资料上传失败" :message="documentError.message" :show-retry="false"><template #details><ApiErrorMeta :details="documentError" /></template></LoadErrorState></AppDialog>
<AppDialog class="container-detail-dialog admin-density-dialog admin-workbench is-dialog" v-model="documentOpen" :title="containerDialogTitle(documentTitle, detail)" width="min(520px, calc(100vw - 28px))" :show-confirm="!!documentError && !!documentFile" confirm-text="重试上传" :confirm-loading="documentSaving" :confirm-disabled="!documentFile" @confirm="saveDocument"><section class="density-dialog-section"><el-form class="density-form density-form--flush-end" label-position="top"><el-form-item><el-upload :disabled="documentSaving" :key="documentUploadKey" :auto-upload="false" :limit="1" :on-change="selectDocumentFile" :on-remove="removeDocumentFile"><el-button :loading="documentSaving" :aria-label="`选择${documentTitle}文件`"><FileUp :size="16" />选择文件</el-button><template v-if="selectedDocument" #tip><div class="el-upload__tip">上传新文件将替换当前资料并保留历史。</div></template></el-upload></el-form-item></el-form></section><LoadErrorState v-if="documentError" compact title="资料上传失败" :message="documentError.message" :show-retry="false"></LoadErrorState></AppDialog>
<AppDialog class="container-detail-dialog admin-density-dialog admin-workbench is-dialog" v-model="exceptionOpen" :title="containerDialogTitle(exceptionTitle, detail)" width="min(520px, calc(100vw - 28px))" :confirm-text="overview?.exception.active ? '确认取消' : '确认标记'" :confirm-loading="exceptionSaving" :confirm-disabled="!exceptionReason.trim()" @confirm="saveException"><el-alert :title="overview?.exception.active ? '取消备注为必填项,提交后将保留历史记录。' : '异常原因将同步记录在货柜详情和操作日志中。'" :type="overview?.exception.active ? 'warning' : 'error'" :closable="false" /><el-form class="density-form density-form--flush-end" label-position="top"><el-form-item :label="overview?.exception.active ? '取消备注' : '异常原因'" required><el-input v-model="exceptionReason" type="textarea" :rows="4" maxlength="500" show-word-limit /></el-form-item></el-form><LoadErrorState v-if="exceptionError" compact title="异常处理失败" :message="exceptionError.message" :show-retry="false" /></AppDialog>
<AppDialog class="container-detail-dialog admin-density-dialog admin-workbench is-dialog" v-model="clearanceOpen" :title="containerDialogTitle('目的港清关资料', detail)" width="min(520px, calc(100vw - 28px))" :loading="clearanceLoading" :show-confirm="false" :confirm-loading="clearanceSaving" :confirm-disabled="!clearanceFile" @confirm="uploadClearance"><el-alert v-if="clearanceDocument?.completedAt" type="success" :closable="false" :title="`目的港清关已完成:${clearanceDocument.fileName}`" /><template v-else><section class="density-dialog-section"><el-form class="density-form density-form--flush-end" label-position="top"><el-form-item><el-upload :key="clearanceUploadKey" :disabled="clearanceSaving || clearanceLoading" :auto-upload="false" :limit="1" :on-change="selectClearanceFile" :on-remove="removeClearanceFile"><el-button :loading="clearanceSaving" aria-label="选择目的港清关资料文件"><FileUp :size="16" />选择文件</el-button></el-upload></el-form-item></el-form></section></template><LoadErrorState v-if="clearanceError" compact title="目的港清关资料操作失败" :message="clearanceError.message" :show-retry="false"><template #details><ApiErrorMeta :details="clearanceError" /></template></LoadErrorState><template #footer="{ close }"><div class="density-actions density-actions--footer"><el-button :disabled="clearanceSaving" @click="close">取消</el-button><el-button v-if="clearanceDocument && !clearanceDocument.completedAt" :disabled="clearanceLoading" type="success" plain :loading="clearanceSaving" @click="completeClearance">确认目的港清关完成</el-button><el-button v-if="clearanceError && clearanceFile && !clearanceDocument?.completedAt" type="primary" :loading="clearanceSaving" :disabled="clearanceLoading" @click="uploadClearance">重试上传</el-button></div></template></AppDialog>
<AppDialog class="container-detail-dialog admin-density-dialog admin-workbench is-dialog" v-model="clearanceOpen" :title="containerDialogTitle('目的港清关资料', detail)" width="min(520px, calc(100vw - 28px))" :loading="clearanceLoading" :show-confirm="false" :confirm-loading="clearanceSaving" :confirm-disabled="!clearanceFile" @confirm="uploadClearance"><el-alert v-if="clearanceDocument?.completedAt" type="success" :closable="false" :title="`目的港清关已完成:${clearanceDocument.fileName}`" /><template v-else><section class="density-dialog-section"><el-form class="density-form density-form--flush-end" label-position="top"><el-form-item><el-upload :key="clearanceUploadKey" :disabled="clearanceSaving || clearanceLoading" :auto-upload="false" :limit="1" :on-change="selectClearanceFile" :on-remove="removeClearanceFile"><el-button :loading="clearanceSaving" aria-label="选择目的港清关资料文件"><FileUp :size="16" />选择文件</el-button></el-upload></el-form-item></el-form></section></template><LoadErrorState v-if="clearanceError" compact title="目的港清关资料操作失败" :message="clearanceError.message" :show-retry="false"></LoadErrorState><template #footer="{ close }"><div class="density-actions density-actions--footer"><el-button :disabled="clearanceSaving" @click="close">取消</el-button><el-button v-if="clearanceDocument && !clearanceDocument.completedAt" :disabled="clearanceLoading" type="success" plain :loading="clearanceSaving" @click="completeClearance">确认目的港清关完成</el-button><el-button v-if="clearanceError && clearanceFile && !clearanceDocument?.completedAt" type="primary" :loading="clearanceSaving" :disabled="clearanceLoading" @click="uploadClearance">重试上传</el-button></div></template></AppDialog>
<AppDialog class="container-detail-dialog admin-density-dialog admin-workbench is-dialog" v-model="directOpen" :title="containerDialogTitle('直送卸货登记', detail)" width="1080px" fullscreenable :show-confirm="canWriteDirect && !!directSelected && !directSelected.actualAt" :confirm-text="directPending ? '重试本次登记' : '确认本票卸货'" :confirm-loading="directSaving" :confirm-disabled="directLoading || !!directError" :before-close="closeDirect" @confirm="saveDirect">
<LoadErrorState v-if="directError" title="直送卸货清单读取失败" :message="directError.message" :loading="directLoading" @retry="loadDirect"><template #details><ApiErrorMeta :details="directError" /></template></LoadErrorState>
@@ -131,7 +131,7 @@ const paginatedRows = computed(() => {
const start = (page.value - 1) * pageSize.value
return filteredRows.value.slice(start, start + pageSize.value)
})
const totals = computed(() => filteredRows.value.reduce((result, row) => ({
const totals = computed(() => filteredRows.value.filter(row => row.status !== 'VOIDED').reduce((result, row) => ({
amount: result.amount + totalAmount(row),
paid: result.paid + paidAmount(row),
outstanding: result.outstanding + row.outstandingAmount,
@@ -72,6 +72,15 @@ enableAutoUnmount(afterEach)
afterAll(() => vi.unstubAllGlobals())
describe('Finance G3/G4 views', () => {
it('excludes voided settlements from totals while retaining them in the list', async () => {
vi.mocked(listFinancePayableSettlements).mockResolvedValue([
payable,
{ ...payable, id: '702', settlementNo: 'AP-VOIDED', status: 'VOIDED', payableAmount: 50, outstandingAmount: 50 },
])
const wrapper = await mountWithAccess(FinanceFormalSettlementPage, ['platform:finance:settlement-ap:list'], { kind: 'payable' })
expect(wrapper.findAll('.workbench-metric-value strong').map(item => item.text())).toEqual(['¥200.00', '¥0.00', '¥200.00'])
expect(wrapper.text()).toContain('AP-VOIDED')
})
beforeEach(() => {
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
vi.stubGlobal('localStorage', memoryStorage())
@@ -25,5 +25,5 @@ export interface FleetArrangement {
export const poolOrderStatusLabels: Record<string, string> = {
WAITING_INBOUND: '待入仓', RECEIVING: '收货中', WAITING_CONSOLIDATION: '待合托',
PENDING_DISPATCH: '待派车', IN_TRANSIT: '运输中', COMPLETED: '已完成', CANCELLED: '已取消',
PENDING_DISPATCH: '待派车', IN_TRANSIT: '派送中', COMPLETED: '已完成', CANCELLED: '已取消',
}
@@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useAuthStore } from '@/core/auth/auth'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import RowActions from '@/shared/components/RowActions.vue'
import QueryToolbar from '@/shared/components/QueryToolbar.vue'
import type { DataTableColumn } from '@/shared/components/DataTable.vue'
import { listLocalLooseboardOrders, listUsTruckOrders } from '../api/platformOverseasBusiness'
import { overseasBusinessPermissions as permissions } from '../permissions'
@@ -31,7 +32,7 @@ function mountList(domain: 'truck' | 'looseboard', edit = false) {
auth.permissions = [permissions.truckList, permissions.looseboardList, ...(edit ? [permissions.looseboardEdit] : [])]
useAccessPreviewStore().initializeAfterLogin()
return mount(TransportOrdersView, { props: { domain }, global: { plugins: [pinia, ElementPlus], stubs: {
DataTable: { name: 'DataTable', props: ['data', 'columns'], template: '<div><div v-for="row in data" :key="row.id"><span v-for="column in columns" :key="column.key" :data-column="column.key">{{ column.prop ? row[column.prop] : \'\' }}</span><slot name="actions" :row="row" /></div></div>' },
DataTable: { name: 'DataTable', props: ['data', 'columns'], template: '<div><slot name="filters" /><div v-for="row in data" :key="row.id"><span v-for="column in columns" :key="column.key" :data-column="column.key">{{ column.prop ? row[column.prop] : \'\' }}</span><slot name="actions" :row="row" /></div></div>' },
AppDialog: true,
} } })
}
@@ -44,6 +45,15 @@ beforeEach(() => {
})
describe('transport document entry', () => {
it.each(['truck', 'looseboard'] as const)('limits retired status filters only in truck mode (%s)', async domain => {
const wrapper = mountList(domain); await flushPromises()
const status = wrapper.findComponent(QueryToolbar).props('fields').find(field => field.key === 'status')!
const values = status.options!.map(option => option.value)
expect(values.includes('WAITING_CONSOLIDATION')).toBe(domain === 'looseboard')
if (domain === 'truck') for (const value of ['WAITING_INBOUND', 'RECEIVING', 'PALLET_OVERAGE_REVIEW', 'PARTIALLY_ALLOCATED', 'ALLOCATED', 'PENDING_DISPATCH']) expect(values).not.toContain(value)
else expect(values).toContain('WAITING_INBOUND')
expect(status.options!.find(option => option.value === 'IN_TRANSIT')?.label).toBe('派送中')
})
it('lets list-only users open looseboard details without edit permission', async () => {
const wrapper = mountList('looseboard'); await flushPromises()
const actions = wrapper.findComponent(RowActions)
@@ -42,7 +42,7 @@ const page = ref(1); const pageSize = ref(20); const total = ref(0)
const arrangementOpen = ref(false), arrangement = ref<FleetArrangement | null>(null)
function showArrangement(row: LocalTransportOrder | LocalLooseboardOrder) { arrangement.value = row.fleetArrangement; arrangementOpen.value = true }
const filters = ref<QueryModel>({ orderNo: String(route.query.orderNo || ''), status: '', routeKeyword: '' }); const applied = ref({ ...filters.value })
const looseboardStatusLabels: Record<LocalLooseboardStatus, string> = { PENDING_ACCEPTANCE: '待接单', WAITING_INBOUND: '待入仓', RECEIVING: '收货中', WAITING_CONSOLIDATION: '待合托', PENDING_DISPATCH: '待派车', DISPATCHED: '已派车', IN_TRANSIT: '运输中', COMPLETED: '已完成', CANCELLED: '已取消', ...poolOrderStatusLabels }
const looseboardStatusLabels: Record<LocalLooseboardStatus, string> = { PENDING_ACCEPTANCE: '待接单', WAITING_INBOUND: '待入仓', RECEIVING: '收货中', WAITING_CONSOLIDATION: '待合托', PENDING_DISPATCH: '待派车', DISPATCHED: '已派车', IN_TRANSIT: '派送中', COMPLETED: '已完成', CANCELLED: '已取消', ...poolOrderStatusLabels }
const transportTypeLabels: Record<LocalTransportType, string> = { PORT_PICKUP: '港口提柜', WAREHOUSE_TO_WAREHOUSE: '仓到仓', WAREHOUSE_TO_DOOR: '仓到门' }
const statusLabels = computed<Record<string, string>>(() => isLooseboard.value ? looseboardStatusLabels : truckStatusLabels)
const statusTones: Record<LocalTransportStatus | LocalLooseboardStatus, 'warning' | 'primary' | 'success' | 'danger' | 'info'> = {
@@ -53,7 +53,7 @@ const statusTones: Record<LocalTransportStatus | LocalLooseboardStatus, 'warning
}
const fields = computed<QueryField[]>(() => [
{ key: 'orderNo', type: 'input', label: '商业单号', placeholder: '请输入商业单号', width: 230 },
{ key: 'status', type: 'select', label: '状态', placeholder: '全部状态', width: 170, options: Object.entries(isLooseboard.value ? looseboardStatusLabels : truckStatusLabels).map(([value, label]) => ({ value, label })) },
{ key: 'status', type: 'select', label: '状态', placeholder: '全部状态', width: 170, options: Object.entries(isLooseboard.value ? looseboardStatusLabels : truckStatusLabels).filter(([value]) => isLooseboard.value || !['WAITING_INBOUND', 'RECEIVING', 'WAITING_CONSOLIDATION', 'PALLET_OVERAGE_REVIEW', 'PARTIALLY_ALLOCATED', 'ALLOCATED', 'PENDING_DISPATCH'].includes(value)).map(([value, label]) => ({ value, label })) },
{ key: 'routeKeyword', type: 'input', label: '运输路线', placeholder: '起运地/目的地', width: 240 },
])
const truckColumns: DataTableColumn[] = [
@@ -35,7 +35,7 @@ export const truckCommandHints: Record<TruckCommand, string> = {
'allocate-pallets': '选择本单已实收且未分配的托盘,可分批分配;全部已接收托盘分配完成后才能进入待派车。',
'prepare-dispatch': '确认全部已接收托盘已分配完成,将本单交由调度安排车辆。',
dispatch: '请在车辆安排已实际完成后确认,本单将进入已派车。',
'start-transit': '请在车辆已实际发运后确认,本单将进入运输中。',
'start-transit': '请在车辆已实际发运后确认,本单将进入派送中。',
complete: '请在运输及交付已实际完成后确认。确认完成后不能撤回。',
cancel: '取消后本单不能继续履约,请填写取消原因。',
}
@@ -156,8 +156,8 @@ describe('LastMileOrderDetailView domestic detail alignment', () => {
expect(createLastMileOrderFee).toHaveBeenCalledWith('internal-order-id', expect.objectContaining({ feeItemCode: 'FEE_TYPE_077', amount: 80 }))
})
it('creates a return order fee without charging quantity or unit inputs', async () => {
vi.mocked(getLastMileOrder).mockResolvedValue({ order: { id: 'internal-order-id', customerId: 'customer-1', orderNo: 'RT-1', orderType: 'RETURN_RELABEL', currentStatus: 'INBOUND_COMPLETED' }, cargo: [], fees: [], statusHistory: [], operationHistory: [] })
it.each(['RETURN_RELABEL', 'WAREHOUSE_TRANSFER'])('creates a %s fee without charging quantity or unit inputs', async orderType => {
vi.mocked(getLastMileOrder).mockResolvedValue({ order: { id: 'internal-order-id', customerId: 'customer-1', orderNo: 'RT-1', orderType, currentStatus: 'INBOUND_COMPLETED' }, cargo: [], fees: [], statusHistory: [], operationHistory: [] })
const { dialog } = await openFeeEditor()
expect(dialog.text()).not.toContain('计费数量')
expect(dialog.text()).not.toContain('计费单位')
@@ -93,6 +93,7 @@ function isSingleLineBusinessField(field: DetailField) { return field.label ===
const referenceValue = computed(() => lastMileOrderReference(detail.value?.order.orderNo, '订单编码待补充'))
const isReturnOrder = computed(() => detail.value?.order.orderType === 'RETURN_RELABEL')
const showFeeQuantity = computed(() => !isReturnOrder.value && detail.value?.order.orderType !== 'WAREHOUSE_TRANSFER')
const returnReceiptUnits = computed(() => {
const receiptTypes = new Map((detail.value?.returnReceipt?.receipts || []).map(receipt => [receipt.receiptNo, receipt.unitType]))
const counts = { parcels: 0, boxes: 0, unknown: 0 }
@@ -324,7 +325,7 @@ async function saveFee() {
if (feeForm.direction === 'PAYABLE' && !supplier) { ElMessage.warning('应付费用必须选择供应商'); return }
saving.value = true; mutationError.value = null
try {
await createLastMileOrderFee(orderId.value, { feeItemCode: selectedItem.feeCode, feeItemName: selectedItem.feeTypeName, feeRemark: feeForm.feeRemark.trim() || undefined, direction: feeForm.direction, currency: feeForm.currency, amount: feeForm.amount, businessDate: feeForm.businessDate, supplierId: supplier ? String(supplier.id) : undefined, supplierName: supplier?.name, supplierCategory: supplier?.category, settlementMode: feeForm.direction === 'PAYABLE' ? 'ON_ACCOUNT' : (feeForm.settlementMode || undefined), ...(!isReturnOrder.value ? { chargeableQuantity: feeForm.quantity, quantityUnit: feeForm.quantity == null ? undefined : feeForm.quantityUnit.trim(), unitPrice: feeForm.quantity ? feeForm.amount / feeForm.quantity : undefined } : {}) })
await createLastMileOrderFee(orderId.value, { feeItemCode: selectedItem.feeCode, feeItemName: selectedItem.feeTypeName, feeRemark: feeForm.feeRemark.trim() || undefined, direction: feeForm.direction, currency: feeForm.currency, amount: feeForm.amount, businessDate: feeForm.businessDate, supplierId: supplier ? String(supplier.id) : undefined, supplierName: supplier?.name, supplierCategory: supplier?.category, settlementMode: feeForm.direction === 'PAYABLE' ? 'ON_ACCOUNT' : (feeForm.settlementMode || undefined), ...(showFeeQuantity.value ? { chargeableQuantity: feeForm.quantity, quantityUnit: feeForm.quantity == null ? undefined : feeForm.quantityUnit.trim(), unitPrice: feeForm.quantity ? feeForm.amount / feeForm.quantity : undefined } : {}) })
feeDialogOpen.value = false; ElMessage.success('费用已提交审核'); await load()
await Promise.all([feePanel.value?.refresh(), payableFeePanel.value?.refresh()]); activeTab.value = 'fees'
} catch (error) { mutationError.value = getApiErrorDetails(error, '费用新增失败,请重试') }
@@ -443,7 +444,7 @@ onMounted(load)
<tr v-if="feeForm.direction === 'PAYABLE'"><td colspan="2"><el-form-item label="供应商" required><el-select v-model="feeForm.supplierId" filterable :loading="suppliersLoading" :disabled="suppliersLoading || !!suppliersError" placeholder="请选择供应商"><el-option v-for="item in supplierOptions" :key="String(item.id)" :label="item.name" :value="String(item.id)" /></el-select></el-form-item></td></tr>
<tr><td><el-form-item label="币种" required><el-select v-model="feeForm.currency" placeholder="请选择币种"><el-option label="USD" value="USD" /><el-option label="CNY" value="CNY" /></el-select></el-form-item></td><td><el-form-item label="金额" required><el-input-number v-model="feeForm.amount" :min="0" :precision="2" :step="10" controls-position="right" /></el-form-item></td></tr>
<tr v-if="feeForm.direction === 'RECEIVABLE'"><td colspan="2"><el-form-item label="结算方式"><el-select v-model="feeForm.settlementMode" clearable placeholder="按业务默认规则"><el-option label="推送结算(账期)" value="BILL" /><el-option label="余额扣费" value="WALLET" /></el-select></el-form-item></td></tr>
<tr v-if="!isReturnOrder"><td><el-form-item label="计费数量"><el-input-number v-model="feeForm.quantity" :min="0.0001" :precision="4" controls-position="right" /></el-form-item></td><td><el-form-item label="计费单位"><el-input v-model="feeForm.quantityUnit" :disabled="saving || feeForm.quantity == null" placeholder="如 箱、托、天、m³" /></el-form-item></td></tr>
<tr v-if="showFeeQuantity"><td><el-form-item label="计费数量"><el-input-number v-model="feeForm.quantity" :min="0.0001" :precision="4" controls-position="right" /></el-form-item></td><td><el-form-item label="计费单位"><el-input v-model="feeForm.quantityUnit" :disabled="saving || feeForm.quantity == null" placeholder="如 箱、托、天、m³" /></el-form-item></td></tr>
<tr><td colspan="2"><el-form-item label="费用说明" :required="duplicateFees.length > 0"><el-input v-model="feeForm.feeRemark" type="textarea" :rows="2" maxlength="500" show-word-limit placeholder="补收或重复作业时,请说明原因" /></el-form-item></td></tr>
<tr v-if="duplicateFees.length"><td colspan="2" role="status">此单已有相同费用,确认本次为新增作业后可继续添加。<div v-for="row in duplicateFees" :key="row.sourceFeeId">{{ sameProjectFeeSummary(row) }}</div></td></tr>
<tr v-if="existingFeesError"><td colspan="2">{{ existingFeesError }} <el-button link type="primary" @click="loadExistingFees">重新加载</el-button></td></tr>