发布采购、拼车、预约及匹配端侧修复至灰度 #19

Merged
admin merged 55 commits from release/test into main 2026-10-12 10:20:54 +08:00
8 changed files with 231 additions and 21 deletions
Showing only changes of commit 1abc831df1 - Show all commits
@@ -12,6 +12,7 @@ import RowActions from '@/shared/components/RowActions.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { listPlanningCargo, resolvePlanningSource, previewPlanningBatch, createSourceAppointment, type SourceAppointmentPayload, type PlanningBatchPreview, type ResolvedPlanningSource, type PlanningCargo, type PlanningSource } from './planningApi'
import AppointmentFollowupDialog from './AppointmentFollowupDialog.vue'
import AppointmentExternalCandidates from './AppointmentExternalCandidates.vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
const router = useRouter()
@@ -22,6 +23,8 @@ const arrangement = reactive(createAppointmentForm())
const creating = ref(false), createError = ref<ApiErrorDetails | null>(null)
const pendingCreate = ref<SourceAppointmentPayload | null>(null)
const following = ref<ResolvedPlanningSource | null>(null)
const candidateMode = ref<'firstleg' | 'external'>('firstleg')
const externalRevision = ref(0)
const followupError = ref<ApiErrorDetails | null>(null)
type Selection = Pick<PlanningSource, 'sourceKey' | 'customerId' | 'originWarehouseId' | 'destinationWarehouseCode' | 'inboundCode'> & { bookingNo?: string | null }
const selected = ref<Record<string, Selection>>({})
@@ -76,7 +79,7 @@ async function saveArrangement() {
try {
const result = await createSourceAppointment(pendingCreate.value)
pendingCreate.value = null; batchPreview.value = null; selected.value = {}
ElMessage.success(`预约安排 ${result.appointmentNo} 已保存`); emit('created'); await load()
ElMessage.success(`预约安排 ${result.appointmentNo} 已保存`); ++externalRevision.value; emit('created'); await load()
} catch (cause) {
createError.value = getApiErrorDetails(cause, '保存结果待核实,请重试原请求')
if (['400','403','404','409','A0400','A0403','A0404','A0409'].includes(createError.value.code || '')) pendingCreate.value = null
@@ -122,7 +125,7 @@ async function load(reset = false) {
function source(row: PlanningCargo) {
return router.push({ name: 'overseas-last-mile-order-detail', params: { orderId: row.orderId }, query: { customerId: row.customerId } })
}
async function openFollowup(row: PlanningCargo) {
async function openFollowup(row: Pick<PlanningCargo, 'sourceKey' | 'customerId' | 'destinationWarehouseCode'>) {
const request = ++followupRequest
followupError.value = null
try {
@@ -135,7 +138,24 @@ onBeforeUnmount(() => { ++request; ++followupRequest; ++batchRequest; controller
</script>
<template>
<DataTable v-model:page="page" v-model:page-size="size" appearance="integrated" :columns="columns" :data="rows" :total="total" :loading="loading" :results-visible="!error" row-key="sourceKey" @update:page="load()" @update:page-size="load(true)">
<div role="group" aria-label="预约货源类型">
<el-button :type="candidateMode === 'firstleg' ? 'primary' : 'default'" @click="candidateMode = 'firstleg'">头程货物</el-button>
<el-button :type="candidateMode === 'external' ? 'primary' : 'default'" @click="candidateMode = 'external'">外接货物</el-button>
</div>
<AppointmentExternalCandidates v-if="candidateMode === 'external'" :key="externalRevision" :can-arrange="access.can(overseasWarehousePermissions.inboundOrderAppointment)" :selected-keys="Object.keys(selected)" :busy="batchLoading || creating" @select="addSource" @followup="openFollowup($event.source)">
<template #toolbar-actions>
<el-button :disabled="!selectedRows.length || batchLoading" @click="selected = {}; batchError = null">清空选择</el-button>
<el-button type="primary" :disabled="!selectedRows.length" :loading="batchLoading" @click="previewBatch">合批核对({{ selectedRows.length }}/100)</el-button>
</template>
<template #notices>
<details v-if="selectedRows.length" class="external-selection"><summary>已选 {{ selectedRows.length }} 票(切换货源保留选择)</summary>
<div class="external-selection-list"><div v-for="row in selectedRows" :key="row.sourceKey">{{ row.bookingNo || row.inboundCode || '尚未关联业务编号' }}<el-button :disabled="batchLoading || creating" @click="delete selected[row.sourceKey]">移除选择</el-button></div></div>
</details>
<LoadErrorState v-if="batchError" title="所选货物暂不能合批" :message="batchError.message" :show-retry="false" />
<LoadErrorState v-if="followupError" title="来源核对失败" :message="followupError.message" :show-retry="false" />
</template>
</AppointmentExternalCandidates>
<DataTable v-show="candidateMode === 'firstleg'" v-model:page="page" v-model:page-size="size" appearance="integrated" :columns="columns" :data="rows" :total="total" :loading="loading" :results-visible="!error" row-key="sourceKey" @update:page="load()" @update:page-size="load(true)">
<template #toolbar-start>已选 {{ selectedRows.length }} 票,可跨客户选择同一起运仓、同一目的仓货物</template>
<template #toolbar-actions>
<el-button :disabled="loading || batchLoading" @click="selectPage">选择本页可合批货物</el-button>
@@ -0,0 +1,63 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from 'vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import QueryToolbar, { type QueryModel, type QueryField } from '@/shared/components/QueryToolbar.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { listExternalAppointmentCandidates, type ExternalAppointmentCandidate, type PlanningSource } from './planningApi'
defineProps<{ canArrange: boolean; selectedKeys: string[]; busy: boolean }>()
const emit = defineEmits<{ select: [source: PlanningSource]; followup: [row: ExternalAppointmentCandidate] }>()
const page = ref(1), size = ref(20), total = ref(0), loading = ref(false)
const rows = ref<ExternalAppointmentCandidate[]>([]), error = ref<ApiErrorDetails | null>(null)
const query = ref<QueryModel>({ keyword: '' })
const fields: QueryField[] = [{ key: 'keyword', type: 'input', label: '外接货', placeholder: '登记号、车次号、客户、来货号', width: 300 }]
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', slotName: 'actions', width: 160, fixed: 'left' },
{ key: 'booking', label: '原登记号', slotName: 'booking', minWidth: 190 },
{ prop: 'customerName', label: '客户', minWidth: 130 },
{ prop: 'tripNo', label: '车次号', minWidth: 190 },
{ key: 'origin', label: '接仓点', slotName: 'origin', minWidth: 140 },
{ key: 'destination', label: '目的FBA仓', slotName: 'destination', width: 110 },
{ key: 'packages', label: '箱数', slotName: 'packages', width: 90 },
{ key: 'coverage', label: '预约情况', slotName: 'coverage', minWidth: 180 },
]
let request = 0
let controller: AbortController | undefined
async function load(reset = false) {
if (reset) page.value = 1
const current = ++request
controller?.abort(); controller = new AbortController()
loading.value = true; error.value = null
try {
const result = await listExternalAppointmentCandidates({ page: page.value, size: size.value, keyword: String(query.value.keyword || '').trim() || undefined }, controller.signal)
if (current === request) { rows.value = result.records; total.value = result.total }
} catch (cause) {
if (current === request) { rows.value = []; total.value = 0; error.value = getApiErrorDetails(cause, '外接候选加载失败') }
} finally { if (current === request) loading.value = false }
}
onMounted(() => load())
onBeforeUnmount(() => { ++request; controller?.abort() })
</script>
<template>
<DataTable v-model:page="page" v-model:page-size="size" appearance="integrated" :data="rows" :columns="columns" :total="total" :loading="loading" :results-visible="!error" @update:page="load()" @update:page-size="load(true)">
<template #toolbar-actions><slot name="toolbar-actions" /></template>
<template #filters><QueryToolbar v-model="query" ariaLabel="外接预约候选查询" :fields="fields" :loading="loading" @search="load(true)" /></template>
<template #notices>
<slot name="notices" />
<LoadErrorState v-if="error" title="外接候选加载失败" :message="error.message" @retry="load()"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<p v-else>已确认承运的美国FBA货物,来货前后保留同一原登记;箱数未知可先保存待确认安排。</p>
</template>
<template #actions="{ row }"><template v-if="row.source && row.coverage">
<el-button v-if="canArrange" link type="primary" :disabled="busy || selectedKeys.includes(row.source.sourceKey) || !row.source.canFollowup || row.coverage.appointments.length > 0" @click="emit('select', row.source)">{{ selectedKeys.includes(row.source.sourceKey) ? '已加入' : '加入合批' }}</el-button>
<el-button link type="primary" @click="emit('followup', row as ExternalAppointmentCandidate)">跟进</el-button>
</template></template>
<template #booking="{ row }">{{ row.source?.bookingNo || '—' }}</template>
<template #origin="{ row }">{{ row.source?.originWarehouseName || '—' }}</template>
<template #destination="{ row }">{{ row.source?.destinationWarehouseCode || '—' }}</template>
<template #packages="{ row }">{{ row.source?.packages ?? '待核实' }}</template>
<template #coverage="{ row }"><span v-if="row.source && row.coverage">{{ row.coverage.appointments.length ? (row.coverage.changeReviewRequired ? '已有预约,待复核' : '已有预约') : (row.source.canFollowup ? '待安排' : row.source.blockReasons.join(';')) }}</span></template>
</DataTable>
</template>
@@ -4,9 +4,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import AppointmentCargoPanel from './AppointmentCargoPanel.vue'
import AppointmentContainerPanel from './AppointmentContainerPanel.vue'
import AppointmentFollowupDialog from './AppointmentFollowupDialog.vue'
import { getFollowups, listPlanningCargo, resolvePlanningSource, listPlanningContainers, resolvePlanningContainer, previewPlanningBatch, createSourceAppointment, type PlanningCargo, type PlanningSource } from './planningApi'
import { listExternalAppointmentCandidates, getFollowups, listPlanningCargo, resolvePlanningSource, listPlanningContainers, resolvePlanningContainer, previewPlanningBatch, createSourceAppointment, type PlanningCargo, type PlanningSource } from './planningApi'
vi.mock('./planningApi', () => ({ listPlanningCargo: vi.fn(), resolvePlanningSource: vi.fn(), getFollowups: vi.fn(), saveFollowup: vi.fn(), listPlanningContainers: vi.fn(), resolvePlanningContainer: vi.fn(), previewPlanningBatch: vi.fn(), createSourceAppointment: vi.fn() }))
vi.mock('./planningApi', () => ({ listExternalAppointmentCandidates: vi.fn(), listPlanningCargo: vi.fn(), resolvePlanningSource: vi.fn(), getFollowups: vi.fn(), saveFollowup: vi.fn(), listPlanningContainers: vi.fn(), resolvePlanningContainer: vi.fn(), previewPlanningBatch: vi.fn(), createSourceAppointment: vi.fn() }))
vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
const source: PlanningSource = { sourceKey: 'ORDER:1', customerId: 'A', orderId: '1', inboundCode: 'IN-A', bookingNo: null, containerNo: 'CONTAINER-A', originWarehouseId: '100', originWarehouseName: '美国仓', destinationKey: 'FBA:ONT8', destinationWarehouseCode: 'ONT8', packages: 20, sourceVersion: 'ORDER:1', canFollowup: true, blockReasons: [] }
@@ -15,6 +15,24 @@ const coverage = { status: 'UNASSIGNED', appointments: [], missingFields: [], ch
describe('appointment tables use actual shared table slots', () => {
beforeEach(() => vi.resetAllMocks())
it('discovers external candidates lazily and mixes original bookings with selected first-leg cargo', async () => {
vi.mocked(listPlanningCargo).mockResolvedValue({ records: [cargo], page: 1, size: 20, total: 1, pages: 1 })
const external: PlanningSource = { ...source, sourceKey: 'BOOKING:B-1', customerId: 'B', orderId: null, inboundCode: null, bookingNo: 'B-1', packages: null }
vi.mocked(listExternalAppointmentCandidates).mockResolvedValue({ records: [{ source: external, customerName: '客户乙', tripNo: 'TR-1', coverage: { status: 'UNSCHEDULED', appointments: [], missingFields: [], changeReviewRequired: false } }], total: 1 })
vi.mocked(previewPlanningBatch).mockResolvedValue({ group: { originWarehouseId: '100', destinationKey: 'FBA:ONT8', packages: null, sourceCount: 2, customerCount: 2, readyFrom: null, readyTo: null, readinessIncomplete: true, unknownQuantitySources: 1 }, originWarehouseName: '美国仓', destinationWarehouseCode: 'ONT8', sources: [source, external], missingFields: [] })
const wrapper = mount(AppointmentCargoPanel, { global: { plugins: [ElementPlus], stubs: { AppDialog: { template: '<section><slot /></section>' } } } })
await flushPromises()
expect(listExternalAppointmentCandidates).not.toHaveBeenCalled()
await wrapper.findAll('button').find(b => b.text() === '选择本页可合批货物')!.trigger('click')
await wrapper.findAll('button').find(b => b.text() === '外接货物')!.trigger('click'); await flushPromises()
expect(listExternalAppointmentCandidates).toHaveBeenCalledTimes(1)
expect(wrapper.text()).toContain('B-1')
await wrapper.findAll('button').find(b => b.text() === '加入合批')!.trigger('click')
await wrapper.findAll('button').find(b => b.text().startsWith('合批核对') && b.isVisible())!.trigger('click'); await flushPromises()
expect(previewPlanningBatch).toHaveBeenCalledExactlyOnceWith([{ sourceKey: 'ORDER:1', customerId: 'A', destinationKey: 'FBA:ONT8' }, { sourceKey: 'BOOKING:B-1', customerId: 'B', destinationKey: 'FBA:ONT8' }])
expect(resolvePlanningSource).not.toHaveBeenCalled()
wrapper.unmount()
})
it('combines an original external booking with first-leg cargo and retries the identical save command', async () => {
const external: PlanningSource={...source,sourceKey:'BOOKING:B-1',customerId:'B',bookingNo:'B-1',orderId:null,inboundCode:null,packages:null}
vi.mocked(listPlanningCargo).mockResolvedValue({records:[cargo],page:1,size:20,total:1,pages:1})
@@ -18,6 +18,7 @@
| 整柜提柜与接仓 | 美国预约统一方案的整柜共享事项 | 独立工作视图按柜汇总,操作列左置;接仓点在行内分组,点击后共用1080px跟进弹框。整柜只选拖车/码头事项,接仓只记录对应仓库,单票只记录FBA联系;数量未知或范围不完整明确显示,不按客户复制整柜联系。首次只请求一页整柜数据,详情按点击读取;待1920×1080页面验收 |
| 在途跨客户合批安排 | 美国预约统一方案的同起运仓、同目的仓合批 | 操作列后选择同起运仓及同目的仓,跨页累计最多100票;批量核对后在1080px弹框填写与入仓入口共用的安排表单。未知箱数保留待核实;暂扣、多目的地和已有预约不可选入。保存必须重新核对,未知结果保留同一命令供重试 |
| 外接货进入合批 | 同仓跨客户共用预约,不因未入仓而只允许跟进 | 后端核实BOOKING深链后,“加入预约合批”进入现有合批工作区,再显式加入选择;可与同起运仓/目的仓头程货混选,保留原BOOKING键与未知箱数。跨仓不清空已有选择,已有关联预约不再开放新增入口;切换日历/合批工作视图保留本页选择,外接选择默认折叠、展开216px内滚动 |
| 完整外接候选 | 统一方案第九节两类货源均可筹划、合批及核对覆盖 | 在同一合批工作区切换头程/外接来源列表,外接按服务端分页、关键词筛选,展示原登记、客户、车次、接仓点、目的仓、可空箱数及预约覆盖。加入选择复用原100票合批选择与保存弹框;查看跟进沿用原弹框,不新增预约数据副本。切换来源保留选择,未打开外接列表不请求;待1920×1080验收 |
| 未定时安排 | 总览不得伪造时段 | 日历上方独立折叠分页列表;日期未定不补零点或09点,继续编辑仍操作原系统单号;仅有计划日期时日历显示日期,不生成确认时段 |
| 提柜日历 | 美国业务预约统一总览 | 读取原码头提柜预约,一柜一条,点击查看来源进入清关提柜详情;按美国目的仓确认业务范围,不等于确认港口国家。保留原业务日期,明确港口时区待核实,不套用目的仓时区;未知目的仓及截断显示统计不完整。无提柜权限不请求该来源并提示范围缺失 |
| 用车计划日历 | 统一总览的真实车次只读来源 | 一车一条,计划与实际出发分开,司机预计结束不当送货完成。查询一次带跨时区余量的UTC窗口,按起运仓地点时区筛回42个当地日;缺国家/时区的记录独立分页待核实,不放入每日格。路线最多3站摘要,完整路线进入原车次;不请求逐车详情,不在预约页创建车次或重算余位 |
@@ -65,6 +65,13 @@ export interface AppointmentCoverage {
appointments: Array<{ appointmentNo: string; confirmationNo: string | null; status: string; confirmedAt: string | null; confirmedEndAt: string | null; timezone: string | null }>;
}
export interface ResolvedPlanningSource { source: PlanningSource; coverage: AppointmentCoverage }
export interface ExternalAppointmentCandidate extends ResolvedPlanningSource { customerName: string | null; tripNo: string }
export async function listExternalAppointmentCandidates(query: { page: number; size: number; keyword?: string }, signal?: AbortSignal) {
const result = unwrapApiResponse(await http.get<ApiEnvelope<{ records: ExternalAppointmentCandidate[]; total: number }>, ApiEnvelope<{ records: ExternalAppointmentCandidate[]; total: number }>>(
'/platform/v1/last-leg/appointment-planning/external-candidates', { params: query, signal },
))
return { ...result, total: Number(result.total) }
}
export interface PlanningBatchPreview {
group: { originWarehouseId: string; destinationKey: string; packages: number | null; sourceCount: number; customerCount: number; readinessIncomplete: boolean; readyFrom: string | null; readyTo: string | null; unknownQuantitySources: number }
originWarehouseName: string
@@ -41,6 +41,51 @@ beforeEach(() => {
vi.mocked(uploadProcurementFile).mockReset().mockResolvedValue({ id: '11', fileId: '101', category: 'PURCHASE', fileName: 'purchase.pdf', contentType: 'application/pdf', sizeBytes: 3, createdAt: '' })
})
describe('procurement purchase node operations', () => {
it('saves and restores a separate correction while the immutable original request is pending, scoped to operator/customer/order', async () => {
const value = fixture('FINANCE_ADJUST', false)
value.fees = [{ id: '11', feeNo: 'PF-11', purpose: 'SAMPLE_PAYMENT', confirmationOrigin: 'BUSINESS', feeItemCode: 'SAMPLE_PAYMENT', feeItemName: '样品货款', feeVersion: 1, active: true, determination: 'POSITIVE', amount: '70.00', currency: 'CNY', originalAmount: '10.00', originalCurrency: 'USD', exchangeRate: '7', exchangeRateMonth: '2020-01', businessDate: '2020-01-01', parentFeeId: null, sourceFeeId: null, voucherFileId: '501', voucherFileName: 'invoice.pdf', walletStatus: 'PENDING', walletLedgerId: null, walletErrorCode: null, walletErrorMessage: null, remark: null }]
const original = JSON.stringify({ command: 'fees/correct-target', payload: { version: 3, idempotencyKey: 'original-request', feeId: '11', targetOriginalAmount: '11.00', reason: '原请求', voucherFileId: '501' } })
sessionStorage.setItem(storageKey, original)
let wrapper = render(value)
const click = async (label: string) => { await wrapper.findAll('button').find(button => button.text() === label)!.trigger('click'); await flushPromises() }
await click('准备费用更正草稿')
const dialog = wrapper.findComponent(AppDialog)
dialog.findAllComponents({ name: 'ElFormItem' }).find(row => row.props('label') === '原费用')!.findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', '11')
await dialog.find('input[placeholder="填写原费用链正确总额,最多两位小数"]').setValue('12.00')
await explain(wrapper, '准备下一版本')
await click('保存本地草稿')
expect(submitProcurementCommand).not.toHaveBeenCalled()
expect(sessionStorage.getItem(storageKey)).toBe(original)
expect(JSON.parse(sessionStorage.getItem(`${storageKey}:correction-draft`)!)).toMatchObject({ targetOriginalAmount: '12.00', feeId: '11', reason: '准备下一版本' })
expect(wrapper.findAll('button').find(button => button.text() === '核对并继续确认')!.attributes('disabled')).toBeDefined()
wrapper.unmount(); wrapper = render(value)
await click('恢复费用更正草稿')
expect((wrapper.findComponent(AppDialog).find('input[placeholder="填写原费用链正确总额,最多两位小数"]').element as HTMLInputElement).value).toBe('12.00')
await click('确认上次提交结果')
expect(submitProcurementCommand).toHaveBeenCalledWith('OP-100', '10001', 'fees/correct-target', JSON.parse(original).payload)
expect(sessionStorage.getItem(`${storageKey}:correction-draft`)).not.toBeNull()
const latest = structuredClone(value)
latest.actions[0].allowed = true; latest.customerView.order.version = 9
latest.files = [{ id: '51', fileId: '501', category: 'VOUCHER', fileName: 'invoice.pdf', contentType: 'application/pdf', sizeBytes: 3, createdAt: '' }]
await wrapper.setProps({ detail: latest })
await click('恢复费用更正草稿'); await click('核对并继续确认')
wrapper.findComponent(AppDialog).findAllComponents({ name: 'ElFormItem' }).find(row => row.props('label') === '本单凭证')!.findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', '501')
await confirm(wrapper)
expect(submitProcurementCommand).toHaveBeenCalledTimes(1)
expect(wrapper.findComponent(AppDialog).text()).toContain('原实际费用及来源关联尚未完成')
latest.fees[0].walletStatus = 'SETTLED'; latest.fees[0].walletLedgerId = 'LEDGER-1'
latest.customerView.fees = [{ feeNo: 'PF-11', purpose: 'SAMPLE_PAYMENT', name: '样品货款', version: 1, active: true, determination: 'POSITIVE', amount: '70.00', currency: 'CNY', originalAmount: '10.00', originalCurrency: 'USD', exchangeRate: '7', exchangeRateMonth: '2020-01', businessDate: '2020-01-01', parentFeeNo: null, walletStatus: 'SETTLED', sourceLinked: true }]
await wrapper.setProps({ detail: structuredClone(latest) })
await confirm(wrapper)
expect(submitProcurementCommand).toHaveBeenLastCalledWith('OP-100', '10001', 'fees/correct-target', expect.objectContaining({ version: 9, targetOriginalAmount: '12.00', feeId: '11' }))
expect(sessionStorage.getItem(`${storageKey}:correction-draft`)).toBeNull()
// Restore a saved draft before changing the scope to ensure isolation is meaningful.
sessionStorage.setItem(`${storageKey}:correction-draft`, JSON.stringify({ feeId: '11', targetOriginalAmount: '13.00', reason: '下一目标', voucherFileId: '501', orderVersion: 9 }))
await wrapper.setProps({ customerId: 'OTHER' }); expect(wrapper.text()).not.toContain('恢复费用更正草稿')
await wrapper.setProps({ customerId: '10001', detail: { ...value, customerView: { ...value.customerView, order: { ...value.customerView.order, orderNo: 'OTHER' } } } }); expect(wrapper.text()).not.toContain('恢复费用更正草稿')
useAuthStore().user = { id: '8', name: '另一操作员', account: 'other', roles: [] }
await wrapper.setProps({ detail: value }); expect(wrapper.text()).not.toContain('恢复费用更正草稿')
})
it('requires explicit remaining-goods outcomes and keeps private closure reasons separate', async () => {
const value = fixture('EXCEPTION_CLOSE'); value.purchaseFacts.purchased_at = '2020-01-01T10:00:00'
value.controls.remaining = [{ kind: 'PURCHASE_REMAINDER', sourceId: null, businessNo: 'OP-100', quantity: 2 }]
@@ -11,6 +11,7 @@ import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRe
import { listOverseasPurchaseWarehouseOptions } from '../api/platformOverseasBusiness'
import { submitProcurementCommand, uploadProcurementFile, type ProcurementCommand, type ProcurementCommandBody } from '../api/procurementCommands'
import { commandKey, definitelyRejected, readPendingProcurement, type PendingProcurement } from '../procurementPending'
import { readCorrectionDraft, type ProcurementCorrectionDraft } from '../procurementCorrectionDraft'
import type { PlatformProcurement, ProcurementAction, ProcurementFile } from '../procurementTypes'
import type { OverseasPurchaseWarehouseOption } from '../types'
import ProcurementControlFields from './ProcurementControlFields.vue'
@@ -29,6 +30,7 @@ const retryFees = computed(() => props.detail.fees.filter(fee => fee.active && f
const actions = computed(() => [...props.detail.actions.filter(item => routes[item.key] && access.can(item.permission)), ...(retryFees.value.length ? [{ key: 'RETRY_FEE', label: '重试失败资金请求', permission: feeRetryPermission(retryFees.value[0]), allowed: true, reason: null }] : [])])
const opened = ref(false); const active = ref<ProcurementAction | null>(null); const saving = ref(false); const uploading = ref(false)
const error = ref<ApiErrorDetails | null>(null); const storageError = ref(''); const pending = ref<PendingProcurement | null>(null)
const editingDraft = ref(false); const correctionDraft = ref<ProcurementCorrectionDraft | null>(null); const draftError = ref('')
const warehouses = ref<OverseasPurchaseWarehouseOption[]>([]); const warehouseLoading = ref(false); const warehouseError = ref('')
const extraFiles = ref<ProcurementFile[]>([])
const form = reactive({ decision: 'APPROVE' as 'APPROVE' | 'REJECT', warehouseId: '', reason: '', amount: '', currency: 'CNY' as 'CNY' | 'USD', purchasedAt: '', estimatedArrivalAt: '', voucherFileId: '' })
@@ -39,8 +41,10 @@ const transport = ref(transportForm(props.detail))
const physical = ref(physicalForm())
const manageCommand = ref<ProcurementCommand>('dispositions')
const key = computed(() => auth.user?.id ? `procurement-command:${auth.user.id}:${props.customerId}:${props.detail.customerView.order.orderNo}` : '')
const draftKey = computed(() => key.value ? `${key.value}:correction-draft` : '')
const canDraft = computed(() => !!key.value && access.can('platform:overseas-business:purchase:finance-adjust') && props.detail.fees.some(fee => !['INTERNATIONAL_ESTIMATE', 'ADJUSTMENT', 'REFUND'].includes(fee.purpose)))
const commandPermissions: Record<ProcurementCommand, string> = { review: 'review', purchase: 'buy', 'purchase/confirm': 'buy', 'budget-authorizations': 'review', 'planned-warehouse': 'review', cancel: 'cancel', 'overseas-receipts': 'receive-overseas', 'domestic-receipts': 'receive-domestic', 'overseas-receipts/complete': 'receive-overseas', 'domestic-receipts/complete': 'receive-domestic', packing: 'pack', 'packing/confirm': 'pack', 'packing/withdraw': 'manage', dispositions: 'manage', 'receipts/correct': 'manage', 'dispositions/revoke': 'manage', labels: 'label', 'international-estimate': 'estimate', 'labels/apply': 'apply-label', 'overseas-shipment': 'ship-overseas', 'customer-shipment': 'ship-customer', 'domestic-repack': 'receive-domestic', 'charges/confirm': 'charge', 'fees/retry': 'charge', 'fees/terminate': 'finance-adjust', 'fees/release-estimate': 'finance-adjust', 'fees/correct-target': 'finance-adjust', 'fees/additional': 'finance-adjust', 'fees/replace-failed': 'finance-adjust', 'fees/refund': 'finance-adjust', 'control-notes': 'manage', 'control-notes/resolve': 'manage', 'actual-warehouse/correct': 'manage', 'exception-close': 'manage' }
const command = computed(() => pending.value?.command ?? (active.value?.key === 'MANAGE' ? manageCommand.value : active.value?.key === 'FINANCE_ADJUST' ? financialCommand.value : active.value ? routes[active.value.key] : undefined))
const command = computed(() => editingDraft.value ? 'fees/correct-target' : pending.value?.command ?? (active.value?.key === 'MANAGE' ? manageCommand.value : active.value?.key === 'FINANCE_ADJUST' ? financialCommand.value : active.value ? routes[active.value.key] : undefined))
const isControl = computed(() => !!command.value && controlCommands.has(command.value))
const isFinance = computed(() => !!command.value && financeCommands.has(command.value))
const isTransport = computed(() => !!command.value && transportCommands.has(command.value))
@@ -53,10 +57,12 @@ const allFiles = computed(() => [...props.detail.files, ...extraFiles.value].fil
const files = computed(() => allFiles.value.filter(file => file.category === fileCategory.value))
const { captureBaseline } = useUnsavedWorkRegistration({ key: 'procurement-purchase-action', label: computed(() => active.value?.label || '采购节点操作'), isOpen: opened, model: reactive({ purchase: form, physical, transport, finance, control }), requiredPermission: computed(() => active.value?.permission ?? null) })
watch(key, () => {
opened.value = false; pending.value = null; storageError.value = ''; extraFiles.value = []; active.value = null
opened.value = false; pending.value = null; storageError.value = ''; extraFiles.value = []; active.value = null; editingDraft.value = false; correctionDraft.value = null; draftError.value = ''
if (!key.value) return
try { pending.value = readPendingProcurement(sessionStorage.getItem(key.value)) }
catch { storageError.value = '上次操作记录无法读取,请先联系管理员核对该订单的处理结果。' }
try { correctionDraft.value = readCorrectionDraft(sessionStorage.getItem(draftKey.value)) }
catch { draftError.value = '本浏览器的费用更正草稿无法读取,请核对后重新填写。' }
}, { immediate: true })
async function loadWarehouses() {
if (warehouseLoading.value) return
@@ -67,7 +73,7 @@ async function loadWarehouses() {
}
function open(action: ProcurementAction) {
if (!action.allowed || pending.value || storageError.value || saving.value) return
active.value = action; error.value = null; manageCommand.value = 'dispositions'; physical.value = physicalForm(props.detail)
editingDraft.value = false; active.value = action; error.value = null; manageCommand.value = 'dispositions'; physical.value = physicalForm(props.detail)
control.value = controlForm(props.detail)
finance.value = financeForm(props.detail); financialCommand.value = 'fees/correct-target'
transport.value = transportForm(props.detail, ['SHIP_CUSTOMER', 'REPACK'].includes(action.key) ? 'DOMESTIC' : action.key === 'LABELS' ? undefined : 'OVERSEAS')
@@ -76,7 +82,37 @@ function open(action: ProcurementAction) {
opened.value = true; captureBaseline()
if (needsWarehouse.value) void loadWarehouses()
}
watch(financialCommand, () => { finance.value.feeId = ''; finance.value.targetOriginalAmount = ''; finance.value.refundAmount = ''; form.voucherFileId = ''; error.value = null })
function openCorrectionDraft() {
if (!canDraft.value || saving.value || uploading.value) return
editingDraft.value = true
active.value = { key: 'CORRECTION_DRAFT', label: '费用更正草稿', permission: 'platform:overseas-business:purchase:finance-adjust', allowed: true, reason: null }
finance.value = financeForm(props.detail)
const draft = correctionDraft.value
finance.value.feeId = draft?.feeId || ''; finance.value.targetOriginalAmount = draft?.targetOriginalAmount || ''
form.reason = draft?.reason || ''; form.voucherFileId = draft?.voucherFileId || ''
error.value = null; opened.value = true; captureBaseline()
}
function saveCorrectionDraft() {
if (!editingDraft.value || !canDraft.value || saving.value || uploading.value) return
try {
const draft = { feeId: finance.value.feeId, targetOriginalAmount: finance.value.targetOriginalAmount, reason: form.reason, voucherFileId: form.voucherFileId, orderVersion: props.detail.customerView.order.version }
sessionStorage.setItem(draftKey.value, JSON.stringify(draft)); correctionDraft.value = draft; draftError.value = ''; captureBaseline()
ElMessage.success('更正草稿已保存在本浏览器标签页,尚未确认费用或发起资金请求')
} catch { draftError.value = '草稿保存失败,请保留当前输入并重试' }
}
function useCorrectionDraft() {
if (!editingDraft.value || pending.value || storageError.value || !canDraft.value) return
const action = actions.value.find(item => item.key === 'FINANCE_ADJUST' && item.allowed)
if (!action) { error.value = { message: '当前业务状态不允许财务确认,请刷新订单后核对' }; return }
const fee = props.detail.fees.find(row => row.id === finance.value.feeId)
active.value = action; financialCommand.value = fee && !fee.active && ['TERMINATED', 'RELEASED'].includes(fee.walletStatus) ? 'fees/replace-failed' : 'fees/correct-target'; editingDraft.value = false
}
async function retryPending() {
if (editingDraft.value) { saveCorrectionDraft(); if (draftError.value) return }
editingDraft.value = false; opened.value = false
await submit()
}
watch(financialCommand, () => { if (editingDraft.value) return; finance.value.feeId = ''; finance.value.targetOriginalAmount = ''; finance.value.refundAmount = ''; form.voucherFileId = ''; error.value = null }, { flush: 'sync' })
watch(needsWarehouse, value => { if (value && opened.value) void loadWarehouses() })
function build(): ProcurementCommandBody {
const order = props.detail.customerView.order
@@ -103,6 +139,7 @@ function build(): ProcurementCommandBody {
return base
}
async function submit() {
if (editingDraft.value) { saveCorrectionDraft(); return }
if (saving.value || uploading.value || storageError.value || !key.value) return
const resuming = !!pending.value
const scope = key.value; const orderNo = props.detail.customerView.order.orderNo; const customer = props.customerId
@@ -118,10 +155,12 @@ async function submit() {
const requiredPermission = pending.value.command === 'fees/retry' ? (retryFee ? feeRetryPermission(retryFee) : '') : `platform:overseas-business:purchase:${commandPermissions[pending.value.command]}`
if (!requiredPermission || !access.can(requiredPermission)) { error.value = { message: '当前账号无此节点操作权限' }; return }
saving.value = true; error.value = null
const confirmsCorrection = !resuming && ['fees/correct-target', 'fees/replace-failed'].includes(pending.value.command)
try {
await submitProcurementCommand(orderNo, customer, pending.value.command, pending.value.payload)
sessionStorage.removeItem(scope)
if (key.value !== scope) return
if (confirmsCorrection) { sessionStorage.removeItem(draftKey.value); correctionDraft.value = null }
pending.value = null; opened.value = false; captureBaseline(); ElMessage.success('操作已保存,请核对最新业务及资金结果'); emit('changed')
} catch (issue) {
if (key.value !== scope) return
@@ -131,7 +170,7 @@ async function submit() {
}
async function upload(event: Event) {
const input = event.target as HTMLInputElement; const file = input.files?.[0]; input.value = ''
if (!file || uploading.value || pending.value) return
if (!file || uploading.value || (pending.value && !editingDraft.value)) return
if (file.size > 10 * 1024 * 1024 || !['application/pdf', 'image/jpeg', 'image/png'].includes(file.type)) { ElMessage.warning('请上传不超过 10MB 的 PDF、JPG 或 PNG'); return }
const scope = key.value; uploading.value = true
try {
@@ -145,29 +184,30 @@ async function upload(event: Event) {
<template>
<section class="admin-density" aria-label="采购履约节点操作">
<div class="density-actions purchase-actions"><el-tooltip v-for="action in actions" :key="action.key" :content="action.reason || action.label"><span><el-button class="density-action" :disabled="!action.allowed || !!pending || !!storageError || saving" @click="open(action)">{{ action.label }}</el-button></span></el-tooltip></div>
<el-alert v-if="storageError" :title="storageError" type="error" :closable="false" />
<el-alert v-if="pending" title="上次提交结果尚未确认,重试将使用原始内容与原请求编号。" type="warning" :closable="false"><p>原操作:{{ pending.command === 'review' ? (pending.payload.decision === 'APPROVE' ? '审核通过' : '审核拒绝') : actions.find(action => routes[action.key] === pending?.command)?.label || '采购节点操作' }} · {{ pending.payload.remark || pending.payload.reason }}</p><p v-if="pending.payload.amount !== undefined">原提交金额:{{ pending.payload.amount }} {{ pending.payload.currency || (['international-estimate', 'fees/refund'].includes(pending.command) ? 'CNY' : '') }}</p><el-button :loading="saving" @click="submit">确认上次提交结果</el-button></el-alert>
<el-button v-if="canDraft" :disabled="saving || uploading" @click="openCorrectionDraft">{{ correctionDraft ? '恢复费用更正草稿' : '准备费用更正草稿' }}</el-button><el-alert v-if="draftError" :title="draftError" type="error" :closable="false" /><el-alert v-if="storageError" :title="storageError" type="error" :closable="false" />
<el-alert v-if="pending" title="上次提交结果尚未确认,重试将使用原始内容与原请求编号。" type="warning" :closable="false"><p>原操作:{{ pending.command === 'review' ? (pending.payload.decision === 'APPROVE' ? '审核通过' : '审核拒绝') : actions.find(action => routes[action.key] === pending?.command)?.label || '采购节点操作' }} · {{ pending.payload.remark || pending.payload.reason }}</p><p v-if="pending.payload.amount !== undefined">原提交金额:{{ pending.payload.amount }} {{ pending.payload.currency || (['international-estimate', 'fees/refund'].includes(pending.command) ? 'CNY' : '') }}</p><el-button :loading="saving" @click="retryPending">确认上次提交结果</el-button></el-alert>
<ApiErrorMeta v-if="error && !opened" :details="error" />
<AppDialog class="admin-density-dialog" v-model="opened" :title="active?.label || '采购节点操作'" :width="['packing', 'labels', 'charges/confirm', 'exception-close'].includes(command || '') ? 'min(1120px, calc(100vw - 28px))' : 'min(760px, calc(100vw - 28px))'" :confirm-loading="saving || uploading" :confirm-disabled="uploading || warehouseLoading || !!storageError || !key" :confirm-text="pending ? '重试原请求' : '确认提交'" @confirm="submit">
<template #default><el-alert v-if="command === 'purchase'" title="记录已实际购买的商品和供应商实付总额。资金未完成时,真实购买事实仍保留。" type="info" :closable="false" />
<el-form class="density-form" label-position="top" :disabled="saving || uploading || !!pending">
<AppDialog class="admin-density-dialog" v-model="opened" :title="active?.label || '采购节点操作'" :width="['packing', 'labels', 'charges/confirm', 'exception-close'].includes(command || '') ? 'min(1120px, calc(100vw - 28px))' : 'min(760px, calc(100vw - 28px))'" :confirm-loading="saving || uploading" :confirm-disabled="uploading || warehouseLoading || (!editingDraft && !!storageError) || !key" :confirm-text="editingDraft ? '保存本地草稿' : pending ? '重试原请求' : '确认提交'" @confirm="submit">
<template #default><el-alert v-if="editingDraft" title="草稿仅保存在当前浏览器标签页,关闭标签页后可能丢失;不代表已确认费用。原请求未决时只能保存,结果明确后请刷新订单再核对确认。" type="info" :closable="false" /><el-alert v-if="command === 'purchase'" title="记录已实际购买的商品和供应商实付总额。资金未完成时,真实购买事实仍保留。" type="info" :closable="false" />
<el-form class="density-form" label-position="top" :disabled="saving || uploading || (!!pending && !editingDraft)">
<el-form-item v-if="active?.key === 'MANAGE'" label="实物更正操作"><el-select v-model="manageCommand" placeholder="选择处置或更正操作"><el-option value="dispositions" label="记录差异处置" /><el-option value="receipts/correct" label="更正原实收数量" /><el-option value="dispositions/revoke" label="撤销原差异处置" /><el-option value="packing/withdraw" label="撤回未交寄的确认清单" /><el-option value="control-notes" label="追加事实说明或阻断" /><el-option value="control-notes/resolve" label="解除已处理的阻断" /><el-option value="actual-warehouse/correct" label="实际仓受控更正" /></el-select></el-form-item>
<el-form-item v-if="active?.key === 'FINANCE_ADJUST'" label="财务处理方式"><el-select v-model="financialCommand" placeholder="选择财务处理方式"><el-option value="fees/additional" label="登记独立新发生费用" /><el-option value="fees/correct-target" label="更正原费用链目标总额" /><el-option value="fees/refund" label="关联原扣款退款" /><el-option value="fees/terminate" label="终止明确失败的费用版本" /><el-option value="fees/replace-failed" label="重新核定已终止的未扣款费用" /><el-option value="fees/release-estimate" label="释放有效预估冻结" /></el-select></el-form-item>
<ProcurementControlFields v-if="isControl && command" v-model="control" :command="command" :detail="detail" :files="allFiles" />
<ProcurementFinanceFields v-if="isFinance && command" v-model="finance" :command="command" :detail="detail" :files="allFiles" :can="access.can" />
<template v-if="editingDraft"><el-form-item label="原费用"><el-select v-model="finance.feeId" placeholder="选择本单原实际费用"><el-option v-for="fee in detail.fees.filter(row => !['INTERNATIONAL_ESTIMATE', 'ADJUSTMENT', 'REFUND'].includes(row.purpose))" :key="fee.id" :value="fee.id" :label="`${fee.feeNo} · ${fee.feeItemName}`" /></el-select></el-form-item><el-form-item label="正确原币总额"><el-input v-model="finance.targetOriginalAmount" placeholder="填写原费用链正确总额,最多两位小数" /></el-form-item></template>
<ProcurementFinanceFields v-else-if="isFinance && command" v-model="finance" :command="command" :detail="detail" :files="allFiles" :can="access.can" />
<ProcurementTransportFields v-if="isTransport && command" v-model="transport" :command="command" :detail="detail" :files="allFiles" />
<ProcurementPhysicalFields v-if="isPhysical && command" v-model="physical" :command="command" :detail="detail" />
<el-form-item v-if="command === 'review'" label="审核决定"><el-radio-group v-model="form.decision"><el-radio value="APPROVE">通过</el-radio><el-radio value="REJECT">拒绝</el-radio></el-radio-group></el-form-item>
<el-form-item v-if="needsWarehouse" :label="command === 'actual-warehouse/correct' ? '更正后的实际仓' : isPhysical ? '实际海外收货仓' : '采购仓'" required><el-select v-model="form.warehouseId" :disabled="isPhysical && !!detail.purchaseFacts.actual_warehouse_id" filterable :loading="warehouseLoading" placeholder="选择有效海外收货仓"><el-option v-for="item in warehouses" :key="item.warehouseId" :value="item.warehouseId" :label="`${item.warehouseName} · ${item.warehouseCode}`" /></el-select><span v-if="warehouseError">{{ warehouseError }}<el-button link @click="loadWarehouses">重试</el-button></span></el-form-item>
<div v-if="needsAmount" class="purchase-form-grid"><el-form-item :label="command === 'purchase' ? '供应商实付总额' : '客户授权预算总额'" required><el-input v-model="form.amount" placeholder="明确填写金额,包括零元" /></el-form-item><el-form-item label="原币" required><el-select v-model="form.currency"><el-option value="CNY" label="人民币 CNY" /><el-option value="USD" label="美元 USD" /></el-select></el-form-item></div>
<div v-if="command === 'purchase'" class="purchase-form-grid"><el-form-item label="真实购买时间" required><el-date-picker v-model="form.purchasedAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item><el-form-item label="预计到海外仓时间" required><el-date-picker v-model="form.estimatedArrivalAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item></div>
<el-form-item v-if="command === 'exception-close'" label="上传异常处置或客户确认凭证"><input aria-label="上传异常关闭凭证" type="file" accept="application/pdf,image/jpeg,image/png" :disabled="uploading || !!pending || saving" @change="upload" /></el-form-item>
<el-form-item v-if="['charges/confirm', 'fees/additional'].includes(command || '')" label="上传费用凭证"><input aria-label="上传费用凭证" type="file" accept="application/pdf,image/jpeg,image/png" :disabled="uploading || !!pending || saving" @change="upload" /></el-form-item>
<el-form-item v-if="command === 'labels'" label="上传本单面单"><input aria-label="上传快递面单" type="file" accept="application/pdf,image/jpeg,image/png" :disabled="uploading || !!pending || saving" @change="upload" /></el-form-item>
<el-form-item v-if="needsAmount || needsEvidence" label="本单凭证" required><el-select v-model="form.voucherFileId" placeholder="选择已上传凭证"><el-option v-for="file in files" :key="file.fileId" :value="file.fileId" :label="file.fileName" /></el-select><input aria-label="上传本单凭证" type="file" accept="application/pdf,image/jpeg,image/png" :disabled="uploading || !!pending || saving" @change="upload" /></el-form-item>
<el-form-item v-if="command === 'exception-close'" label="上传异常处置或客户确认凭证"><input aria-label="上传异常关闭凭证" type="file" accept="application/pdf,image/jpeg,image/png" :disabled="uploading || (!!pending && !editingDraft) || saving" @change="upload" /></el-form-item>
<el-form-item v-if="['charges/confirm', 'fees/additional'].includes(command || '')" label="上传费用凭证"><input aria-label="上传费用凭证" type="file" accept="application/pdf,image/jpeg,image/png" :disabled="uploading || (!!pending && !editingDraft) || saving" @change="upload" /></el-form-item>
<el-form-item v-if="command === 'labels'" label="上传本单面单"><input aria-label="上传快递面单" type="file" accept="application/pdf,image/jpeg,image/png" :disabled="uploading || (!!pending && !editingDraft) || saving" @change="upload" /></el-form-item>
<el-form-item v-if="needsAmount || needsEvidence" label="本单凭证" required><el-select v-model="form.voucherFileId" placeholder="选择已上传凭证"><el-option v-for="file in files" :key="file.fileId" :value="file.fileId" :label="file.fileName" /></el-select><input aria-label="上传本单凭证" type="file" accept="application/pdf,image/jpeg,image/png" :disabled="uploading || (!!pending && !editingDraft) || saving" @change="upload" /></el-form-item>
<el-form-item label="操作说明" required><el-input v-model="form.reason" placeholder="填写本次操作的事实、依据或原因" type="textarea" :rows="3" maxlength="1000" show-word-limit /></el-form-item>
</el-form><el-alert v-if="error" :title="error.message" type="error" :closable="false" /><ApiErrorMeta v-if="error" :details="error" /></template>
<template #footer><el-button :disabled="saving || uploading" @click="opened = false">关闭</el-button><el-button type="primary" :loading="saving" :disabled="uploading || warehouseLoading || !!storageError || !key" @click="submit">{{ pending ? '重试原请求' : '确认提交' }}</el-button></template>
<template #footer><el-button :disabled="saving || uploading" @click="opened = false">关闭</el-button><el-button v-if="editingDraft" :disabled="!!pending || !!storageError || saving || uploading" @click="useCorrectionDraft">核对并继续确认</el-button><el-button type="primary" :loading="saving" :disabled="uploading || warehouseLoading || (!editingDraft && !!storageError) || !key" @click="submit">{{ editingDraft ? '保存本地草稿' : pending ? '重试原请求' : '确认提交' }}</el-button></template>
</AppDialog>
</section>
</template>
@@ -0,0 +1,16 @@
export interface ProcurementCorrectionDraft {
feeId: string
targetOriginalAmount: string
reason: string
voucherFileId: string
orderVersion: number
}
export function readCorrectionDraft(raw: string | null): ProcurementCorrectionDraft | null {
if (!raw) return null
const value: unknown = JSON.parse(raw)
if (!value || typeof value !== 'object') throw new Error('费用更正草稿无法读取')
const draft = value as Record<string, unknown>
if (!['feeId', 'targetOriginalAmount', 'reason', 'voucherFileId'].every(key => typeof draft[key] === 'string') || !Number.isSafeInteger(draft.orderVersion)) throw new Error('费用更正草稿无法读取')
return { feeId: draft.feeId as string, targetOriginalAmount: draft.targetOriginalAmount as string, reason: draft.reason as string, voucherFileId: draft.voucherFileId as string, orderVersion: draft.orderVersion as number }
}