优化
This commit is contained in:
@@ -212,7 +212,7 @@ describe('ContainerCustomsPanel', () => {
|
||||
|
||||
wrapper = await render('DECLARING', true, true)
|
||||
expect(wrapper.text()).toContain('发送报关行执行')
|
||||
expect(wrapper.text()).toContain('覆盖预录单')
|
||||
expect(wrapper.text()).toContain('选择预录单')
|
||||
expect(wrapper.text()).toContain('报关行回执.pdf')
|
||||
expect(wrapper.text()).toContain('真实报关完税文件.pdf')
|
||||
expect(wrapper.text()).not.toContain('确认 · 2026/8/29 11:15:00 · 客户一')
|
||||
@@ -262,12 +262,35 @@ describe('ContainerCustomsPanel', () => {
|
||||
const onChange = wrapper.findAllComponents({ name: 'ElUpload' })[0]!.props('onChange') as (upload: { raw: File }) => void
|
||||
onChange({ raw: selected })
|
||||
await flushPromises()
|
||||
await wrapper.findAll('button').find(button => button.text() === '提交')!.trigger('click')
|
||||
await wrapper.findAll('button').find(button => button.text() === '提交预录单')!.trigger('click')
|
||||
await flushPromises()
|
||||
expect(api.upload).toHaveBeenCalledWith(document.documentId, selected, 7)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('一次选择的预录单会关联柜内全部客户资料', async () => {
|
||||
const value = customs('DECLARING', false)
|
||||
value.documents.push({ ...value.documents[0]!, documentId: '2092438843630391414', source: { ...value.documents[0]!.source, fileId: '2092438843630391415', fileName: '另一份客户资料.pdf', version: 1 }, receipt: null, confirmationHistory: [] })
|
||||
value.brokerSubmission = { id: '2092438843630391412', customsContainerId: value.aggregateId, loadingPlanId: value.loadingPlanId, batchNo: 'BS-20260829-001', status: 'SENT', sourceVersion: 3, sentByName: '平台员', sentAt: '2026-08-29T10:45:00', version: 1 }
|
||||
api.get.mockResolvedValue(value)
|
||||
const wrapper = mount(ContainerCustomsPanel, {
|
||||
props: { loadingPlanId: value.loadingPlanId, canList: true, canManage: true, departureState: 'WAITING_OTHER_GATES' },
|
||||
global: { plugins: [ElementPlus] },
|
||||
})
|
||||
await flushPromises()
|
||||
const selected = new File(['receipt'], '整柜预录单.pdf', { type: 'application/pdf' })
|
||||
const onChange = wrapper.findAllComponents({ name: 'ElUpload' })[0]!.props('onChange') as (upload: { raw: File }) => void
|
||||
onChange({ raw: selected })
|
||||
await flushPromises()
|
||||
await wrapper.findAll('button').find(button => button.text() === '提交预录单')!.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(api.upload).toHaveBeenCalledTimes(2)
|
||||
expect(api.upload).toHaveBeenNthCalledWith(1, value.documents[0]!.documentId, selected, 0)
|
||||
expect(api.upload).toHaveBeenNthCalledWith(2, value.documents[1]!.documentId, selected, 0)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('keeps COMPLETED strictly read-only with both files visible and downloadable', async () => {
|
||||
const wrapper = await render('COMPLETED', true, true)
|
||||
expect(wrapper.text()).toContain('客户退税资料.pdf')
|
||||
@@ -341,7 +364,7 @@ describe('ContainerCustomsPanel', () => {
|
||||
const onChange = wrapper.findAllComponents({ name: 'ElUpload' })[0]!.props('onChange') as (upload: { raw: File }) => void
|
||||
onChange({ raw: selected })
|
||||
await flushPromises()
|
||||
const submit = wrapper.findAll('button').find(button => button.text() === '提交')!
|
||||
const submit = wrapper.findAll('button').find(button => button.text() === '提交预录单')!
|
||||
await submit.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@ const actionLoading = ref(false)
|
||||
const loadError = ref<ApiErrorDetails | null>(null)
|
||||
const actionError = ref<ApiErrorDetails | null>(null)
|
||||
const downloadingKey = ref('')
|
||||
const receiptFiles = reactive<Record<string, File | null>>({})
|
||||
const receiptFile = ref<File | null>(null)
|
||||
const finalFiles = reactive<Record<string, File | null>>({})
|
||||
const clearanceDialogVisible = ref(false)
|
||||
const clearanceBatch = ref<DeclarationBatch | null>(null)
|
||||
@@ -84,9 +84,9 @@ function formatDateTime(value: string | null | undefined) {
|
||||
const date = new Date(value)
|
||||
return Number.isNaN(date.getTime()) ? value : date.toLocaleString('zh-CN', { hour12: false })
|
||||
}
|
||||
function selectReceipt(documentId: string, upload: UploadFile) {
|
||||
function selectReceipt(upload: UploadFile) {
|
||||
if (!props.canManage || customs.value?.status !== 'DECLARING' || !brokerSubmission.value || brokerSubmitted.value) return
|
||||
receiptFiles[documentId] = upload.raw || null
|
||||
receiptFile.value = upload.raw || null
|
||||
actionError.value = null
|
||||
}
|
||||
function selectFinal(documentId: string, upload: UploadFile) {
|
||||
@@ -185,18 +185,26 @@ async function start() {
|
||||
finally { actionLoading.value = false }
|
||||
}
|
||||
|
||||
async function uploadReceipt(document: ContainerCustomsDocument) {
|
||||
async function uploadReceipt() {
|
||||
if (!props.canManage || customs.value?.status !== 'DECLARING' || !brokerSubmission.value || brokerSubmitted.value) return
|
||||
const selected = receiptFiles[document.documentId]
|
||||
const selected = receiptFile.value
|
||||
if (!selected) { ElMessage.warning('请选择对应的预录单'); return }
|
||||
const documents = customs.value.documents
|
||||
let uploadedCount = 0
|
||||
actionLoading.value = true; actionError.value = null
|
||||
try {
|
||||
await uploadContainerCustomsReceipt(document.documentId, selected, document.receipt?.version || 0)
|
||||
receiptFiles[document.documentId] = null
|
||||
for (const document of documents) {
|
||||
await uploadContainerCustomsReceipt(document.documentId, selected, document.receipt?.version || 0)
|
||||
uploadedCount += 1
|
||||
}
|
||||
receiptFile.value = null
|
||||
await load()
|
||||
emit('refreshed')
|
||||
ElMessage.success('预录单已上传,等待客户确认')
|
||||
} catch (error) { actionError.value = getApiErrorDetails(error, '预录单上传失败') }
|
||||
ElMessage.success(`预录单已上传并关联 ${documents.length} 份客户资料`)
|
||||
} catch (error) {
|
||||
if (uploadedCount) await load()
|
||||
actionError.value = getApiErrorDetails(error, uploadedCount ? `预录单已关联 ${uploadedCount}/${documents.length} 份资料,后续上传失败` : '预录单上传失败')
|
||||
}
|
||||
finally { actionLoading.value = false }
|
||||
}
|
||||
|
||||
@@ -317,10 +325,10 @@ onMounted(load)
|
||||
<div class="workbench-evidence"><table class="compact-table density-table"><thead><tr><th>客户</th><th>订单号</th><th>入仓码</th></tr></thead><tbody><tr v-for="item in missingInbounds" :key="item.inboundId"><td>{{ item.customerName }}</td><td>{{ item.orderNo }}</td><td>{{ item.inboundCode }}</td></tr></tbody></table></div>
|
||||
</div>
|
||||
<div v-if="customs.documents.length" class="compact-table-wrap">
|
||||
<h3>已冻结退税资料、预录单与完税文件</h3>
|
||||
<div class="documents-heading"><h3>已冻结退税资料、预录单与完税文件</h3><div v-if="canManage && customs.status === 'DECLARING'" class="document-actions density-actions"><el-upload :disabled="!brokerSubmission || brokerSubmitted" :auto-upload="false" :limit="1" :show-file-list="false" :on-change="selectReceipt"><el-button class="density-action" size="small" :disabled="!brokerSubmission || brokerSubmitted">{{ !brokerSubmission ? '首次发送后上传预录单' : '选择预录单' }}</el-button></el-upload><span v-if="receiptFile" class="selected-receipt" :title="receiptFile.name">{{ receiptFile.name }}</span><el-button data-tone="primary" class="density-action" size="small" type="primary" :disabled="!brokerSubmission || brokerSubmitted || !receiptFile" :loading="actionLoading" @click="uploadReceipt">提交预录单</el-button></div></div>
|
||||
<div class="workbench-evidence"><table class="compact-table document-table density-table"><thead><tr><th>操作</th><th>关联入仓码</th><th>报关行状态</th><th>客户退税资料</th><th>预录单</th><th>真实报关完税文件</th><th>客户</th><th>预录单情况</th></tr></thead><tbody>
|
||||
<tr v-for="document in customs.documents" :key="document.documentId">
|
||||
<td><div v-if="canManage && customs.status === 'DECLARING'" class="document-actions density-actions"><el-upload :disabled="!brokerSubmission || brokerSubmitted" :auto-upload="false" :limit="1" :show-file-list="false" :on-change="(upload: UploadFile) => selectReceipt(document.documentId, upload)"><el-button class="density-action" size="small" :disabled="!brokerSubmission || brokerSubmitted">{{ !brokerSubmission ? '首次发送后上传预录单' : document.receipt ? '覆盖预录单' : '上传预录单' }}</el-button></el-upload><el-button data-tone="primary" class="density-action" size="small" type="primary" :disabled="!brokerSubmission || brokerSubmitted || !receiptFiles[document.documentId]" :loading="actionLoading" @click="uploadReceipt(document)">提交</el-button><el-upload :disabled="!brokerSubmitted" :auto-upload="false" :limit="1" :show-file-list="false" :on-change="(upload: UploadFile) => selectFinal(document.documentId, upload)"><el-button class="density-action" size="small" :disabled="!brokerSubmitted">{{ document.finalDocument ? '覆盖完税文件' : '上传完税文件' }}</el-button></el-upload><el-button data-tone="primary" class="density-action" size="small" type="primary" :disabled="!brokerSubmitted || !finalFiles[document.documentId]" :loading="actionLoading" @click="uploadFinal(document)">提交</el-button></div><span v-else class="readonly-label">只读</span></td>
|
||||
<td><div v-if="canManage && customs.status === 'DECLARING'" class="document-actions density-actions"><el-upload :disabled="!brokerSubmitted" :auto-upload="false" :limit="1" :show-file-list="false" :on-change="(upload: UploadFile) => selectFinal(document.documentId, upload)"><el-button class="density-action" size="small" :disabled="!brokerSubmitted">{{ document.finalDocument ? '覆盖完税文件' : '上传完税文件' }}</el-button></el-upload><el-button data-tone="primary" class="density-action" size="small" type="primary" :disabled="!brokerSubmitted || !finalFiles[document.documentId]" :loading="actionLoading" @click="uploadFinal(document)">提交</el-button></div><span v-else class="readonly-label">只读</span></td>
|
||||
<td>{{ document.inboundCodes.join('、') || '—' }}</td>
|
||||
<td>{{ brokerSubmitted ? '已发送报关行执行' : brokerSubmission ? '已首次发送,等待预录单确认' : '待首次发送报关行' }}</td>
|
||||
<td><div class="file-cell"><span :title="document.source.fileName">{{ document.source.fileName }}</span><el-button class="density-action" type="primary" :loading="downloadingKey === `source:${document.documentId}`" :aria-label="`下载客户退税资料 ${document.source.fileName}`" @click="downloadFile(document, 'source')">下载</el-button></div></td>
|
||||
@@ -361,4 +369,5 @@ onMounted(load)
|
||||
|
||||
<style scoped>
|
||||
.container-customs{padding:0;border:0}.customs-header{display:flex;align-items:center;justify-content:space-between;gap:var(--admin-space-5)}.customs-header h2,.compact-table-wrap h3{margin:0;font-size:var(--admin-font-section)}.customs-header p{margin:3px 0 0;color:var(--workbench-muted);font-size:var(--admin-font-caption)}.customs-actions{display:flex;align-items:center;gap:var(--admin-space-3)}.action-error{margin-top:var(--admin-space-3);padding:var(--admin-space-3) var(--admin-space-4);border:1px solid var(--el-color-danger-light-5);border-radius:var(--admin-radius);background:var(--el-color-danger-light-9);color:var(--el-color-danger);font-size:var(--admin-font-caption)}.action-error :deep(.api-error-meta){margin-top:5px}.customs-summary{margin-top:var(--admin-space-4)!important}.customs-summary .submission-batch{grid-column:span 2}.compact-table-wrap{margin-top:var(--admin-space-5);overflow-x:auto}.compact-table-wrap h3{margin-bottom:var(--admin-space-2)}.compact-table{width:100%}.document-table th:first-child{width:270px}.document-actions,.file-cell{display:flex;align-items:center;gap:var(--admin-space-2)}.file-cell span{display:block;max-width:210px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.readonly-label{color:var(--workbench-muted)}.departure-state{display:flex;align-items:center;gap:var(--admin-space-3);margin-top:var(--admin-space-4);padding-top:var(--admin-space-4);border-top:1px solid var(--workbench-border);font-size:var(--admin-font-body)}.declaration-batches-heading{display:flex;align-items:flex-start;justify-content:space-between;gap:var(--admin-space-4)}.declaration-batches-heading p{margin:3px 0 0;color:var(--workbench-muted);font-size:var(--admin-font-caption)}.declaration-batch-list{display:grid;gap:var(--admin-space-3)}.declaration-batch-card{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:var(--admin-space-2) var(--admin-space-4);padding:var(--admin-space-3) var(--admin-space-4);border:1px solid var(--workbench-border);background:var(--workbench-surface-soft)}.declaration-batch-main strong,.declaration-batch-main span{display:block}.declaration-batch-main span{margin-top:4px;color:var(--workbench-muted);font-size:var(--admin-font-caption)}.declaration-batch-actions,.declaration-result-list{grid-column:1 / -1}.declaration-result-list{display:grid;gap:5px;padding-top:var(--admin-space-2);border-top:1px solid var(--workbench-border)}.declaration-result-row{display:flex;align-items:center;gap:var(--admin-space-2);font-size:var(--admin-font-caption)}.declaration-result-row small{flex:1;color:var(--workbench-muted)}
|
||||
.documents-heading{display:flex;align-items:center;justify-content:space-between;gap:var(--admin-space-4)}.selected-receipt{max-width:220px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--workbench-muted);font-size:var(--admin-font-caption)}
|
||||
</style>
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
| 海外采购订单 | 列表/筛选 | `page-overseas-purchase` 2–12 行;海外面板 9–11 行;`renderOverseasPurchaseList()` 13531–13534 行 | 独立列表页,订单编号/采购品类/商品名称筛选 | 仅实现海外面板;国内页与 `domestic-*` 全部排除;操作列第一列;真实联调补充后端客户候选筛选 |
|
||||
| 海外采购订单 | 详情 | `page-overseas-purchase-detail` 204–206 行;`showOverseasPurchaseDetail()` 13538–13540 行 | 独立详情页,商品/库存/订单/快递/进销存五分区 | 建立正式详情路由,详情复用 `purchase:list` |
|
||||
| 海外采购订单 | 编辑 | `page-overseas-purchase-edit` 143–150 行;`editOverseasPurchase()`、`saveOverseasPurchaseEdit()` 13536–13537 行 | 独立编辑页 | 建立正式编辑路由,使用独立 `purchase:edit` 权限 |
|
||||
| 海外采购订单 | 审核 | 原型未定义;当前对话明确要求按后端既有审核契约补齐前端 | 详情页顶部操作 + 居中确认弹框 | 仅待审核订单且具备 `purchase:review` 权限展示;提交版本、通过/拒绝决定、稳定幂等键及可选备注 |
|
||||
| 海外采购订单 | 审核 | 原型未定义;当前对话明确要求按后端既有审核契约补齐前端 | 列表待审核行操作 + 审核弹框 | 仅待审核订单且具备 `purchase:review` 权限展示;通过需选择采购仓,驳回原因按 `remark` 提交;保留幂等键 |
|
||||
| 海外采购订单 | 新增 | 14–15 行明确“历史新增表单仅保留为不可达模板” | 不可达 | 不建立新增入口或路由 |
|
||||
| 本土卡车 | 列表 | `page-overseas-truck` 58–80 行;`renderTruckOrderList()` 13550–13566 行 | 独立列表页 | 正式列表;操作列按根规则移到第一列 |
|
||||
| 本土卡车 | 新增 | `page-overseas-truck-add` 83–130 行;`saveTruckOrder()` 13567–13579 行 | 独立页面 | 建立正式新增路由;使用平台创建接口真实提交并显式选择客户 |
|
||||
|
||||
@@ -68,6 +68,19 @@ beforeEach(() => {
|
||||
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); document.body.innerHTML = '' })
|
||||
|
||||
describe('单据费用操作', () => {
|
||||
it('resets a previous bill selection to wallet when switching to procurement', async () => {
|
||||
const wrapper = mount(OverseasFeeEditor, { props: { modelValue: false, fee: null, items: [] }, attachTo: document.body, global: { plugins: [ElementPlus, createPinia()] } })
|
||||
await wrapper.setProps({ modelValue: true }); await flushPromises()
|
||||
const dialog = activeDialog(wrapper)
|
||||
const form = dialog.getComponent({ name: 'ElForm' }).props('model')
|
||||
form.settlementMode = 'BILL'
|
||||
const typeSelect = dialog.findAllComponents({ name: 'ElSelect' })[0]!
|
||||
typeSelect.vm.$emit('update:modelValue', 'OVERSEAS_PROCUREMENT')
|
||||
typeSelect.vm.$emit('change', 'OVERSEAS_PROCUREMENT')
|
||||
await flushPromises()
|
||||
expect(form.settlementMode).toBe('WALLET')
|
||||
expect(api.createOverseasFee).not.toHaveBeenCalled()
|
||||
})
|
||||
it('uses the same fee dialog for procurement with balance deduction selected by default', async () => {
|
||||
vi.mocked(api.listBusinessOverseasFees).mockResolvedValue({ records: [], total: 0, page: 1, size: 20, pages: 0 })
|
||||
const procurement = { ...business, businessType: 'OVERSEAS_PROCUREMENT', businessNo: 'OP-1' }
|
||||
|
||||
@@ -48,7 +48,12 @@ async function loadOptions(keyword = '') {
|
||||
} catch (failure) { if (sequence === optionSequence) error.value = getApiErrorDetails(failure, '业务订单加载失败') }
|
||||
finally { if (sequence === optionSequence) optionsLoading.value = false }
|
||||
}
|
||||
function changeBusinessType() { form.businessId = ''; options.value = []; void loadOptions() }
|
||||
function changeBusinessType() {
|
||||
form.businessId = ''; options.value = []
|
||||
// 切换业务时重置结算选择,采购不能沿用上一业务的账期选择。
|
||||
form.settlementMode = form.businessType === 'OVERSEAS_PROCUREMENT' ? 'WALLET' : ''
|
||||
void loadOptions()
|
||||
}
|
||||
const selected = computed(() => props.business || options.value.find(row => row.businessId === form.businessId))
|
||||
const selectedItem = computed(() => manualItems.value.find(item => item.feeCode === form.feeItemCode))
|
||||
let existingSequence = 0
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import ElementPlus from 'element-plus'
|
||||
import ElementPlus, { ElInputNumber } from 'element-plus'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import { useAuthStore } from '@/core/auth/auth'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
@@ -9,14 +9,12 @@ import ProcurementWorkflowPanel from './ProcurementWorkflowPanel.vue'
|
||||
import type { PlatformProcurement } from '../procurementTypes'
|
||||
import type { ProcurementWorkflow } from '../api/procurementWorkflow'
|
||||
import { submitProcurementWorkflow } from '../api/procurementWorkflow'
|
||||
import { submitProcurementCommand, uploadProcurementFile } from '../api/procurementCommands'
|
||||
import { listOverseasPurchaseWarehouseOptions } from '../api/platformOverseasBusiness'
|
||||
import { uploadProcurementFile } from '../api/procurementCommands'
|
||||
vi.mock('../api/procurementWorkflow', () => ({ submitProcurementWorkflow: vi.fn() }))
|
||||
vi.mock('../api/procurementCommands', () => ({ submitProcurementCommand: vi.fn(), uploadProcurementFile: vi.fn() }))
|
||||
vi.mock('../api/platformOverseasBusiness', () => ({ listOverseasPurchaseWarehouseOptions: vi.fn() }))
|
||||
vi.mock('../api/procurementCommands', () => ({ uploadProcurementFile: vi.fn() }))
|
||||
vi.mock('@/core/unsaved-work/useUnsavedWorkRegistration', () => ({ useUnsavedWorkRegistration: () => ({ captureBaseline: vi.fn() }) }))
|
||||
enableAutoUnmount(afterEach)
|
||||
const workflow = (status = 'RECEIVING'): ProcurementWorkflow => ({ orderNo: 'OP-1', version: 4, status, receivedQuantity: 5, stockQuantity: 5, reservedQuantity: 0, availableQuantity: 5, receipts: [], outbounds: [] })
|
||||
const workflow = (status = 'RECEIVING'): ProcurementWorkflow => ({ orderNo: 'OP-1', version: 4, status, receivedQuantity: 5, stockQuantity: 5, reservedQuantity: 0, availableQuantity: 5, rejectionReason: null, photosNeedUpdate: false, receipts: [], outbounds: [] })
|
||||
const detail = { customerView: { order: { purchaseQuantity: 20 } }, files: [] } as unknown as PlatformProcurement
|
||||
const beforeOutbound = vi.fn<() => Promise<boolean | null>>()
|
||||
function render(status = 'RECEIVING', permissions = ['receive-overseas', 'ship-customer']) {
|
||||
@@ -24,8 +22,70 @@ function render(status = 'RECEIVING', permissions = ['receive-overseas', 'ship-c
|
||||
const auth = useAuthStore(); auth.token = 'test'; auth.permissions = permissions.map(value => `platform:overseas-business:purchase:${value}`); useAccessPreviewStore().initializeAfterLogin()
|
||||
return mount(ProcurementWorkflowPanel, { props: { detail, workflow: workflow(status), customerId: '1001', beforeOutbound }, global: { plugins: [pinia, ElementPlus] } })
|
||||
}
|
||||
beforeEach(() => { vi.clearAllMocks(); beforeOutbound.mockResolvedValue(null); vi.mocked(submitProcurementWorkflow).mockResolvedValue(workflow()); vi.mocked(listOverseasPurchaseWarehouseOptions).mockResolvedValue([]) })
|
||||
beforeEach(() => { vi.clearAllMocks(); beforeOutbound.mockResolvedValue(null); vi.mocked(submitProcurementWorkflow).mockResolvedValue(workflow()) })
|
||||
describe('simplified procurement operations', () => {
|
||||
it('allows a rejected purchase to be resubmitted without uploading photos and clears the old reason after success', async () => {
|
||||
const wrapper = render()
|
||||
const rejected = { ...workflow(), rejectionReason: '请核对', photosNeedUpdate: true, receipts: [
|
||||
{ id: 'r-1', quantity: 2, fileIds: ['old-1'], receivedAt: '' },
|
||||
{ id: 'r-2', quantity: 3, fileIds: ['old-2'], receivedAt: '' },
|
||||
] }
|
||||
await wrapper.setProps({ workflow: rejected }); await flushPromises()
|
||||
const button = wrapper.findAll('button').find(button => button.text() === '采购完成')!
|
||||
expect(button.attributes('disabled')).toBeUndefined()
|
||||
expect(wrapper.text()).toContain('请核对')
|
||||
await button.trigger('click'); await flushPromises()
|
||||
wrapper.findComponent(AppDialog).vm.$emit('confirm'); await flushPromises()
|
||||
expect(uploadProcurementFile).not.toHaveBeenCalled()
|
||||
expect(submitProcurementWorkflow).toHaveBeenCalledWith('OP-1', '1001', 'request-confirmation', { version: 4, idempotencyKey: expect.any(String) })
|
||||
expect(wrapper.emitted('changed')).toHaveLength(1)
|
||||
await wrapper.setProps({ workflow: { ...rejected, status: 'WAIT_CUSTOMER_CONFIRM', version: 5, rejectionReason: null, photosNeedUpdate: false } })
|
||||
expect(wrapper.text()).not.toContain('客户驳回原因')
|
||||
})
|
||||
it('accepts the first and subsequent receipt batches using the remaining purchase quantity', async () => {
|
||||
const wrapper = render()
|
||||
const receive = () => wrapper.findAll('button').find(button => button.text() === '登记收货')!
|
||||
await wrapper.setProps({ workflow: { ...workflow(), receivedQuantity: 0, stockQuantity: 0, availableQuantity: 0 } })
|
||||
await receive().trigger('click'); await flushPromises()
|
||||
const quantity = wrapper.findComponent(ElInputNumber)
|
||||
const increment = quantity.find('.el-input-number__increase')
|
||||
expect(increment.attributes('aria-disabled')).not.toBe('true')
|
||||
await increment.trigger('mousedown', { button: 0 })
|
||||
document.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }))
|
||||
expect(quantity.props('modelValue')).toBe(2)
|
||||
await quantity.find('input').setValue('10')
|
||||
await quantity.find('input').trigger('blur')
|
||||
expect(quantity.props('modelValue')).toBe(10)
|
||||
const form = wrapper.findComponent({ name: 'ElForm' }).props('model')
|
||||
form.fileIds = ['photo-1']
|
||||
const dialog = wrapper.findComponent(AppDialog)
|
||||
dialog.vm.$emit('confirm'); await flushPromises()
|
||||
expect(submitProcurementWorkflow).toHaveBeenLastCalledWith('OP-1', '1001', 'receipts', expect.objectContaining({ version: 4, quantity: 10, fileIds: ['photo-1'] }))
|
||||
expect(wrapper.emitted('changed')).toHaveLength(1)
|
||||
|
||||
await wrapper.setProps({ workflow: { ...workflow(), version: 5, receivedQuantity: 10, stockQuantity: 10, availableQuantity: 10 } })
|
||||
await receive().trigger('click'); await flushPromises()
|
||||
await wrapper.findComponent(ElInputNumber).find('input').setValue('10')
|
||||
await wrapper.findComponent(ElInputNumber).find('input').trigger('blur')
|
||||
form.fileIds = ['photo-2']
|
||||
dialog.vm.$emit('confirm'); await flushPromises()
|
||||
expect(submitProcurementWorkflow).toHaveBeenLastCalledWith('OP-1', '1001', 'receipts', expect.objectContaining({ version: 5, quantity: 10, fileIds: ['photo-2'] }))
|
||||
expect(wrapper.emitted('changed')).toHaveLength(2)
|
||||
const calls = vi.mocked(submitProcurementWorkflow).mock.calls
|
||||
expect(calls[0]![3].idempotencyKey).not.toBe(calls[1]![3].idempotencyKey)
|
||||
})
|
||||
it.each([
|
||||
{ received: 1, input: '10', expected: 10 },
|
||||
{ received: 18, input: '3', expected: 2 },
|
||||
])('limits receipts by remaining purchase quantity after $received units were received', async ({ received, input, expected }) => {
|
||||
const wrapper = render()
|
||||
await wrapper.setProps({ workflow: { ...workflow(), receivedQuantity: received, stockQuantity: received, availableQuantity: received } })
|
||||
await wrapper.findAll('button').find(button => button.text() === '登记收货')!.trigger('click'); await flushPromises()
|
||||
const quantity = wrapper.findComponent(ElInputNumber)
|
||||
await quantity.find('input').setValue(input)
|
||||
await quantity.find('input').trigger('blur')
|
||||
expect(quantity.props('modelValue')).toBe(expected)
|
||||
})
|
||||
it('allows outbound when the fee check reports missing or unreviewed details', async () => {
|
||||
const wrapper = render('CONFIRMED')
|
||||
const button = wrapper.findAll('button').find(button => button.text() === '创建出库单')!
|
||||
@@ -68,14 +128,6 @@ describe('simplified procurement operations', () => {
|
||||
expect(form.trackingNo).toBe('TRACK-1'); expect(form.quantity).toBe(3)
|
||||
form.quantity = 6; dialog.vm.$emit('confirm'); await flushPromises(); expect(submitProcurementWorkflow).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
it('requires a rejection reason and preserves the current version and customer scope', async () => {
|
||||
const wrapper = render('PENDING_REVIEW', ['review'])
|
||||
await wrapper.findAll('button').find(button => button.text() === '审核')!.trigger('click'); await flushPromises()
|
||||
const form = wrapper.findComponent({ name: 'ElForm' }).props('model'), dialog = wrapper.findComponent(AppDialog)
|
||||
form.decision = 'REJECT'; dialog.vm.$emit('confirm'); await flushPromises(); expect(submitProcurementCommand).not.toHaveBeenCalled()
|
||||
form.reason = '商品无法采购'; dialog.vm.$emit('confirm'); await flushPromises()
|
||||
expect(submitProcurementCommand).toHaveBeenCalledWith('OP-1', '1001', 'review', expect.objectContaining({ version: 4, decision: 'REJECT', reason: '商品无法采购', purchaseWarehouseId: undefined }))
|
||||
})
|
||||
it('only completes the selected delivering batch after signature evidence is supplied', async () => {
|
||||
const wrapper = render('DELIVERING')
|
||||
await wrapper.setProps({ workflow: { ...workflow('DELIVERING'), outbounds: [{ id: '9007199254740993', outboundNo: 'OUT-1', status: 'DELIVERING', quantity: 3, trackingNo: 'TRACK-1', fileIds: [], createdAt: '', completedAt: null }] } })
|
||||
|
||||
@@ -7,22 +7,24 @@ import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
|
||||
import { listOverseasPurchaseWarehouseOptions } from '../api/platformOverseasBusiness'
|
||||
import { submitProcurementCommand, uploadProcurementFile } from '../api/procurementCommands'
|
||||
import { uploadProcurementFile } from '../api/procurementCommands'
|
||||
import { editProcurementOutbound, submitProcurementWorkflow, type ProcurementWorkflow, type ProcurementOutbound, type ProcurementWorkflowReceipt } from '../api/procurementWorkflow'
|
||||
import { dateTime, statusLabel } from '../procurementPresentation'
|
||||
import type { PlatformProcurement } from '../procurementTypes'
|
||||
import type { OverseasPurchaseWarehouseOption } from '../types'
|
||||
|
||||
const props = defineProps<{ detail: PlatformProcurement; workflow: ProcurementWorkflow; customerId: string; downloading?: string; beforeOutbound: () => Promise<boolean | null> }>()
|
||||
const emit = defineEmits<{ changed: []; download: [id: string] }>()
|
||||
const access = useAccessPreviewStore()
|
||||
const open = ref(false), saving = ref(false), uploading = ref(false), checkingFees = ref(false), error = ref<ApiErrorDetails | null>(null)
|
||||
const action = ref(''), batchId = ref(''), warehouses = ref<OverseasPurchaseWarehouseOption[]>([]), warehouseLoading = ref(false)
|
||||
const form = reactive({ quantity: 1, trackingNo: '', fileIds: [] as string[], warehouseId: '', decision: 'APPROVE' as 'APPROVE' | 'REJECT', reason: '' })
|
||||
const action = ref(''), batchId = ref('')
|
||||
const remainingReceiptQuantity = computed(() => props.detail.customerView.order.purchaseQuantity - props.workflow.receivedQuantity)
|
||||
const quantityLimit = computed(() => Math.max(1, action.value === 'receipts'
|
||||
? remainingReceiptQuantity.value
|
||||
: props.workflow.availableQuantity + (action.value === 'edit-outbound' ? (props.workflow.outbounds.find(row => row.id === batchId.value)?.quantity || 0) : 0)))
|
||||
const form = reactive({ quantity: 1, trackingNo: '', fileIds: [] as string[] })
|
||||
const uploadedNames = ref<string[]>([])
|
||||
const title = computed(() => ({ review: '审核采购单', receipts: '登记收货', 'receipt-photos': '重新上传收货照片', 'request-confirmation': '采购完成', outbounds: '创建出库单', 'edit-outbound': '编辑出库单', dispatch: '开始派送', cancel: '取消出库单', complete: '上传签收凭证' })[action.value] || '采购操作')
|
||||
const permission = computed(() => `platform:overseas-business:purchase:${action.value === 'review' ? 'review' : ['receipts', 'receipt-photos', 'request-confirmation'].includes(action.value) ? 'receive-overseas' : 'ship-customer'}`)
|
||||
const title = computed(() => ({ receipts: '登记收货', 'receipt-photos': '重新上传收货照片', 'request-confirmation': '采购完成', outbounds: '创建出库单', 'edit-outbound': '编辑出库单', dispatch: '开始派送', cancel: '取消出库单', complete: '上传签收凭证' })[action.value] || '采购操作')
|
||||
const permission = computed(() => `platform:overseas-business:purchase:${['receipts', 'receipt-photos', 'request-confirmation'].includes(action.value) ? 'receive-overseas' : 'ship-customer'}`)
|
||||
const can = (name: string) => access.can(`platform:overseas-business:purchase:${name}`)
|
||||
const permissionHint = computed(() => props.workflow.status === 'RECEIVING' && !can('receive-overseas')
|
||||
? '当前账号未开通采购收货权限,请联系管理员开通。'
|
||||
@@ -35,7 +37,7 @@ watch(scope, () => { dialogGeneration++; open.value = false; error.value = null
|
||||
watch(open, value => { if (!value) dialogGeneration++ })
|
||||
async function begin(name: string, batch?: ProcurementOutbound | ProcurementWorkflowReceipt) {
|
||||
if (saving.value || uploading.value || checkingFees.value) return
|
||||
const required = name === 'review' ? 'review' : ['receipts', 'receipt-photos', 'request-confirmation'].includes(name) ? 'receive-overseas' : 'ship-customer'
|
||||
const required = ['receipts', 'receipt-photos', 'request-confirmation'].includes(name) ? 'receive-overseas' : 'ship-customer'
|
||||
if (!can(required)) return
|
||||
const current = ++dialogGeneration
|
||||
if (name === 'outbounds') {
|
||||
@@ -47,16 +49,10 @@ async function begin(name: string, batch?: ProcurementOutbound | ProcurementWork
|
||||
if (current !== dialogGeneration) return
|
||||
if (noFeeDetails) ElMessage.info('没有新增费用明细,仍可继续创建出库单')
|
||||
}
|
||||
action.value = name; batchId.value = batch?.id || ''; error.value = null; warehouseLoading.value = false
|
||||
Object.assign(form, { quantity: 1, trackingNo: '', fileIds: [], warehouseId: '', decision: 'APPROVE', reason: '' }); uploadedNames.value = []
|
||||
action.value = name; batchId.value = batch?.id || ''; error.value = null
|
||||
Object.assign(form, { quantity: 1, trackingNo: '', fileIds: [] }); uploadedNames.value = []
|
||||
if (name === 'edit-outbound' && batch && 'trackingNo' in batch) Object.assign(form, { quantity: batch.quantity, trackingNo: batch.trackingNo })
|
||||
fingerprint = ''; requestKey = ''; openedScope = scope(); open.value = true; captureBaseline()
|
||||
if (name === 'review') {
|
||||
warehouseLoading.value = true
|
||||
try { const result = await listOverseasPurchaseWarehouseOptions(props.detail.customerView.order.procurementDirection); if (current === dialogGeneration) warehouses.value = result }
|
||||
catch (failure) { if (current === dialogGeneration) error.value = getApiErrorDetails(failure, '仓库加载失败') }
|
||||
finally { if (current === dialogGeneration) warehouseLoading.value = false }
|
||||
}
|
||||
}
|
||||
async function upload(event: Event) {
|
||||
const input = event.target as HTMLInputElement; const files = Array.from(input.files || []); input.value = ''
|
||||
@@ -74,20 +70,17 @@ async function upload(event: Event) {
|
||||
}
|
||||
async function save() {
|
||||
if (saving.value || uploading.value || openedScope !== scope() || !access.can(permission.value)) return
|
||||
if (action.value === 'review' && form.decision === 'APPROVE' && !form.warehouseId) { ElMessage.warning('请选择收货仓'); return }
|
||||
if (action.value === 'review' && form.decision === 'REJECT' && !form.reason.trim()) { ElMessage.warning('请填写拒绝原因'); return }
|
||||
if (['receipts', 'outbounds', 'edit-outbound'].includes(action.value) && (!Number.isSafeInteger(form.quantity) || form.quantity < 1)) { ElMessage.warning('请输入有效数量'); return }
|
||||
if (action.value === 'receipts' && form.quantity > props.detail.customerView.order.purchaseQuantity - props.workflow.receivedQuantity) { ElMessage.warning('收货数量不能超过剩余采购数量'); return }
|
||||
if (action.value === 'receipts' && form.quantity > remainingReceiptQuantity.value) { ElMessage.warning('收货数量不能超过剩余采购数量'); return }
|
||||
if (['outbounds', 'edit-outbound'].includes(action.value) && (!form.trackingNo.trim() || form.quantity > props.workflow.availableQuantity + (action.value === 'edit-outbound' ? (props.workflow.outbounds.find(row => row.id === batchId.value)?.quantity || 0) : 0))) { ElMessage.warning('请填写物流跟踪号,数量不能超过可出库库存'); return }
|
||||
if (['receipts', 'receipt-photos', 'complete'].includes(action.value) && !form.fileIds.length) { ElMessage.warning(action.value === 'receipts' ? '请上传收货照片' : '请上传签收文件或图片'); return }
|
||||
const data = { version: props.workflow.version, ...(action.value === 'receipts' ? { quantity: form.quantity, fileIds: [...form.fileIds] } : ['outbounds', 'edit-outbound'].includes(action.value) ? { quantity: form.quantity, trackingNo: form.trackingNo.trim() } : action.value === 'receipt-photos' ? { receiptId: batchId.value, fileIds: [...form.fileIds] } : action.value === 'complete' ? { fileIds: [...form.fileIds] } : {}) }
|
||||
const next = JSON.stringify([action.value, batchId.value, form.warehouseId, form.decision, form.reason, data])
|
||||
const next = JSON.stringify([action.value, batchId.value, data])
|
||||
if (fingerprint !== next || !requestKey) { fingerprint = next; requestKey = crypto.randomUUID() }
|
||||
const current = dialogGeneration
|
||||
saving.value = true; error.value = null
|
||||
try {
|
||||
if (action.value === 'review') await submitProcurementCommand(props.workflow.orderNo, props.customerId, 'review', { version: props.workflow.version, idempotencyKey: requestKey, decision: form.decision, purchaseWarehouseId: form.decision === 'APPROVE' ? form.warehouseId : undefined, reason: form.reason.trim() || undefined })
|
||||
else if (action.value === 'edit-outbound') await editProcurementOutbound(props.workflow.orderNo, props.customerId, batchId.value, { ...data, idempotencyKey: requestKey })
|
||||
if (action.value === 'edit-outbound') await editProcurementOutbound(props.workflow.orderNo, props.customerId, batchId.value, { ...data, idempotencyKey: requestKey })
|
||||
else await submitProcurementWorkflow(props.workflow.orderNo, props.customerId, batchId.value && action.value !== 'receipt-photos' ? `outbounds/${encodeURIComponent(batchId.value)}/${action.value}` : action.value, { ...data, idempotencyKey: requestKey })
|
||||
if (current !== dialogGeneration) return
|
||||
captureBaseline(); open.value = false; ElMessage.success('操作成功'); emit('changed')
|
||||
@@ -110,18 +103,16 @@ const fileName = (id: string) => props.detail.files.find(file => file.fileId ===
|
||||
<section class="procurement-workflow">
|
||||
<el-alert v-if="workflow.rejectionReason" title="客户驳回原因" :description="workflow.rejectionReason" type="warning" :closable="false" show-icon />
|
||||
<div class="density-actions">
|
||||
<el-button v-if="workflow.status === 'PENDING_REVIEW' && can('review')" type="primary" @click="begin('review')">审核</el-button>
|
||||
<template v-if="workflow.status === 'RECEIVING'"><el-button type="primary" :disabled="!can('receive-overseas') || workflow.receivedQuantity >= detail.customerView.order.purchaseQuantity" @click="begin('receipts')">登记收货</el-button><el-button :disabled="!can('receive-overseas') || !workflow.receivedQuantity || workflow.photosNeedUpdate" @click="begin('request-confirmation')">采购完成</el-button></template>
|
||||
<template v-if="workflow.status === 'RECEIVING'"><el-button type="primary" :disabled="!can('receive-overseas') || workflow.receivedQuantity >= detail.customerView.order.purchaseQuantity" @click="begin('receipts')">登记收货</el-button><el-button :disabled="!can('receive-overseas') || !workflow.receivedQuantity" @click="begin('request-confirmation')">采购完成</el-button></template>
|
||||
<el-button v-if="['CONFIRMED', 'IN_TRANSIT', 'DELIVERING'].includes(workflow.status)" type="primary" :loading="checkingFees" :disabled="!can('ship-customer') || workflow.availableQuantity < 1" @click="begin('outbounds')">创建出库单</el-button>
|
||||
</div>
|
||||
<el-text v-if="permissionHint" type="warning" role="status">{{ permissionHint }}</el-text>
|
||||
<dl class="density-fields inventory"><div><dt>累计收货</dt><dd>{{ workflow.receivedQuantity }}</dd></div><div><dt>采购库存</dt><dd>{{ workflow.stockQuantity }}</dd></div><div><dt>出库处理中</dt><dd>{{ workflow.reservedQuantity }}</dd></div><div><dt>可出库</dt><dd>{{ workflow.availableQuantity }}</dd></div></dl>
|
||||
<h3>收货记录</h3><DataTable :data="workflow.receipts" :columns="receiptColumns" row-key="id" empty-text="暂无收货记录"><template #files="{ row }"><el-button v-if="workflow.status === 'RECEIVING' && workflow.rejectionReason && can('receive-overseas')" link type="primary" @click="begin('receipt-photos', row as ProcurementWorkflowReceipt)">重新上传照片</el-button><el-button v-for="id in row.fileIds" :key="id" :disabled="!!downloading" link type="primary" @click="emit('download', id)">{{ fileName(id) }}</el-button></template></DataTable>
|
||||
<h3>收货记录</h3><DataTable :data="workflow.receipts" :columns="receiptColumns" row-key="id" empty-text="暂无收货记录"><template #files="{ row }"><el-button v-if="row.id && workflow.status === 'RECEIVING' && workflow.rejectionReason && can('receive-overseas')" link type="primary" @click="begin('receipt-photos', row as ProcurementWorkflowReceipt)">重新上传照片</el-button><el-button v-for="id in row.fileIds" :key="id" :disabled="!!downloading" link type="primary" @click="emit('download', id)">{{ fileName(id) }}</el-button></template></DataTable>
|
||||
<h3>出库单</h3><DataTable :data="workflow.outbounds" :columns="outboundColumns" row-key="id" empty-text="暂无出库单"><template #actions="{ row }"><template v-if="row.status === 'IN_TRANSIT'"><el-button :disabled="!can('ship-customer')" link type="primary" @click="begin('edit-outbound', row as ProcurementOutbound)">编辑</el-button><el-button :disabled="!can('ship-customer')" link type="danger" @click="begin('cancel', row as ProcurementOutbound)">取消</el-button><el-button :disabled="!can('ship-customer')" link type="primary" @click="begin('dispatch', row as ProcurementOutbound)">开始派送</el-button></template><el-button v-if="row.status === 'DELIVERING'" :disabled="!can('ship-customer')" link type="primary" @click="begin('complete', row as ProcurementOutbound)">上传签收凭证</el-button></template><template #files="{ row }"><el-button v-for="id in row.fileIds" :key="id" :disabled="!!downloading" link type="primary" @click="emit('download', id)">{{ fileName(id) }}</el-button></template></DataTable>
|
||||
<AppDialog v-model="open" class="admin-density-dialog admin-workbench is-dialog" :title="title" width="580px" confirm-text="确认" :confirm-loading="saving" :confirm-disabled="uploading || warehouseLoading" @confirm="save">
|
||||
<AppDialog v-model="open" class="admin-density-dialog admin-workbench is-dialog" :title="title" width="580px" confirm-text="确认" :confirm-loading="saving" :confirm-disabled="uploading" @confirm="save">
|
||||
<el-form :model="form" class="density-form density-form--outlined" label-position="top" :disabled="saving">
|
||||
<template v-if="action === 'review'"><el-form-item 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="form.decision === 'APPROVE'" label="收货仓" required><el-select v-model="form.warehouseId" filterable :loading="warehouseLoading"><el-option v-for="warehouse in warehouses" :key="warehouse.warehouseId" :value="warehouse.warehouseId" :label="warehouse.warehouseName" /></el-select></el-form-item><el-form-item v-else label="拒绝原因" required><el-input v-model="form.reason" type="textarea" :rows="2" maxlength="1000" /></el-form-item></template>
|
||||
<el-form-item v-if="['receipts', 'outbounds', 'edit-outbound'].includes(action)" :label="action === 'receipts' ? '本批收货数量' : '本批出库数量'" required><el-input-number v-model="form.quantity" :min="1" :max="action === 'outbounds' ? workflow.availableQuantity : workflow.availableQuantity + (batchId ? (workflow.outbounds.find(row => row.id === batchId)?.quantity || 0) : 0)" :precision="0" /></el-form-item>
|
||||
<el-form-item v-if="['receipts', 'outbounds', 'edit-outbound'].includes(action)" :label="action === 'receipts' ? '本批收货数量' : '本批出库数量'" required><el-input-number v-model="form.quantity" :min="1" :max="quantityLimit" :precision="0" /></el-form-item>
|
||||
<el-form-item v-if="['outbounds', 'edit-outbound'].includes(action)" label="物流跟踪号" required><el-input v-model="form.trackingNo" maxlength="100" /></el-form-item>
|
||||
<el-form-item v-if="['receipts', 'receipt-photos', 'complete'].includes(action)" :label="action !== 'complete' ? '收货照片' : '签收文件或图片'" required><input type="file" multiple :accept="action !== 'complete' ? 'image/*' : 'image/*,application/pdf'" :disabled="saving || uploading" @change="upload" /><div v-for="name in uploadedNames" :key="name">{{ name }}</div></el-form-item>
|
||||
<p v-if="action === 'request-confirmation'">确认采购完成并提交客户确认?</p><p v-if="action === 'dispatch'">确认本批次开始派送?</p><p v-if="action === 'cancel'">确认取消本批出库单?取消后库存占用将释放。</p>
|
||||
|
||||
@@ -14,12 +14,16 @@ describe('overseas business real contract pages', () => {
|
||||
expect(`${purchase}${transport}`).not.toMatch(/OPO\d|TK\d|LB\d/)
|
||||
})
|
||||
|
||||
it('keeps procurement general editing limited to internal remarks', () => {
|
||||
it('keeps procurement editing limited to remarks and reviews pending rows from the list', () => {
|
||||
const list = read('pages/PurchaseOrdersView.vue'); const detail = read('pages/PurchaseOrderDetailView.vue'); const edit = read('pages/PurchaseOrderEditView.vue')
|
||||
expect(list).toContain('listOverseasPurchaseCustomerOptions')
|
||||
expect(edit).toContain('getOverseasPurchaseEditor')
|
||||
expect(detail).toContain('detail.reviewHistory')
|
||||
expect(detail).toContain('<ProcurementWorkflowPanel')
|
||||
expect(detail).not.toContain('outbounds')
|
||||
expect(list).toContain("status === 'PENDING_REVIEW'")
|
||||
expect(list).toContain('remark: reviewForm.remark.trim()')
|
||||
expect(list).not.toContain('>编辑</el-button>')
|
||||
expect(detail).not.toContain('编辑备注')
|
||||
expect(edit).toContain('remark: form.remark.trim() || null')
|
||||
expect(edit).not.toMatch(/v-model="form\.(inboundQuantity|purchaseWarehouseId|outboundDetails|productName)/)
|
||||
expect(edit).not.toContain('入库量不能小于 0')
|
||||
|
||||
@@ -11,6 +11,7 @@ import { useAuthStore } from '@/core/auth/auth'
|
||||
import { listPlatformTenants } from '@/modules/iam'
|
||||
import type { PlatformProcurement } from '../procurementTypes'
|
||||
import { downloadProcurementFile } from '../api/procurementCommands'
|
||||
import { submitProcurementCommand } from '../api/procurementCommands'
|
||||
import { getProcurementWorkflow } from '../api/procurementWorkflow'
|
||||
import type { FleetArrangement } from '../fleetArrangement'
|
||||
import type { LocalLooseboardDetail, LocalLooseboardOrder, LocalTransportDetail, LocalTransportOrder } from '../types'
|
||||
@@ -34,7 +35,7 @@ vi.mock('@/modules/iam', () => ({ listPlatformTenants: vi.fn() }))
|
||||
vi.mock('../api/procurementWorkflow', () => ({ getProcurementWorkflow: vi.fn(), submitProcurementWorkflow: vi.fn() }))
|
||||
vi.mock('../api/procurementCommands', async importOriginal => ({
|
||||
...await importOriginal<typeof import('../api/procurementCommands')>(),
|
||||
downloadProcurementFile: vi.fn(),
|
||||
downloadProcurementFile: vi.fn(), submitProcurementCommand: vi.fn(),
|
||||
}))
|
||||
vi.mock('../api/platformOverseasBusiness', () => ({
|
||||
listOverseasPurchases: vi.fn(), getOverseasPurchase: vi.fn(), getOverseasPurchaseEditor: vi.fn(), updateOverseasPurchase: vi.fn(), listOverseasPurchaseCustomerOptions: vi.fn(), listOverseasPurchaseWarehouseOptions: vi.fn(),
|
||||
@@ -72,7 +73,7 @@ describe('overseas business mounted pages', () => {
|
||||
vi.stubGlobal('ResizeObserver', ResizeObserverStub); vi.stubGlobal('localStorage', memoryStorage()); vi.stubGlobal('sessionStorage', memoryStorage()); document.body.innerHTML = ''
|
||||
routing.push.mockReset(); routing.route.params = {}; routing.route.query = {}
|
||||
vi.mocked(downloadProcurementFile).mockReset()
|
||||
vi.mocked(getProcurementWorkflow).mockReset().mockResolvedValue({ orderNo: 'OP-100', version: 3, status: 'RECEIVING', receivedQuantity: 5, stockQuantity: 5, reservedQuantity: 0, availableQuantity: 5, receipts: [], outbounds: [] })
|
||||
vi.mocked(getProcurementWorkflow).mockReset().mockResolvedValue({ orderNo: 'OP-100', version: 3, status: 'RECEIVING', receivedQuantity: 5, stockQuantity: 5, reservedQuantity: 0, availableQuantity: 5, rejectionReason: null, photosNeedUpdate: false, receipts: [], outbounds: [] })
|
||||
vi.mocked(listOverseasPurchases).mockReset().mockResolvedValue({ records: [purchase], page: 1, size: 20, total: 1, pages: 1 }); vi.mocked(getOverseasPurchase).mockReset().mockResolvedValue(procurementDetail()); vi.mocked(updateOverseasPurchase).mockReset().mockResolvedValue({ ...purchase, version: 4 })
|
||||
vi.mocked(getOverseasPurchaseEditor).mockReset().mockResolvedValue({ customerView: { order: { orderNo: purchase.orderNo, customerId: purchase.customerId, productName: purchase.productName, purchaseQuantity: purchase.purchaseQuantity, version: purchase.version } }, internalRemark: purchase.remark })
|
||||
vi.mocked(listOverseasPurchaseCustomerOptions).mockReset().mockResolvedValue([{ customerId: '10001', customerName: '客户甲' }]); vi.mocked(listOverseasPurchaseWarehouseOptions).mockReset().mockResolvedValue([{ warehouseId: '20001', warehouseCode: 'LAX', warehouseName: '洛杉矶采购仓', countryCode: 'US' }])
|
||||
@@ -172,6 +173,8 @@ describe('overseas business mounted pages', () => {
|
||||
expect(wrapper.text()).toContain('收货记录')
|
||||
expect(wrapper.text()).toContain('出库单')
|
||||
expect(wrapper.text()).toContain('采购库存')
|
||||
expect(wrapper.text()).toContain('采购要求')
|
||||
expect(wrapper.text()).toContain('采购备注')
|
||||
expect(wrapper.text()).not.toContain('两段实际交寄')
|
||||
expect(wrapper.text()).not.toContain('internal-purchase-route')
|
||||
await wrapper.get('.density-back').trigger('click')
|
||||
@@ -183,7 +186,7 @@ describe('overseas business mounted pages', () => {
|
||||
const result = procurementDetail()
|
||||
result.files = [{ id: '9007199254741000', fileId: '9007199254741001', category: 'LABEL', fileName: 'label.pdf', contentType: 'application/pdf', sizeBytes: 123, createdAt: '2026-10-10T10:00:00' }]
|
||||
vi.mocked(getOverseasPurchase).mockResolvedValue(result)
|
||||
vi.mocked(getProcurementWorkflow).mockResolvedValue({ orderNo: 'OP-100', version: 3, status: 'RECEIVING', receivedQuantity: 5, stockQuantity: 5, reservedQuantity: 0, availableQuantity: 5, receipts: [{ id: '1', quantity: 5, fileIds: ['9007199254741001'], receivedAt: '2026-10-10T10:00:00' }], outbounds: [] })
|
||||
vi.mocked(getProcurementWorkflow).mockResolvedValue({ orderNo: 'OP-100', version: 3, status: 'RECEIVING', receivedQuantity: 5, stockQuantity: 5, reservedQuantity: 0, availableQuantity: 5, rejectionReason: null, photosNeedUpdate: false, receipts: [{ id: '1', quantity: 5, fileIds: ['9007199254741001'], receivedAt: '2026-10-10T10:00:00' }], outbounds: [] })
|
||||
let rejectDownload!: (error: Error) => void
|
||||
vi.mocked(downloadProcurementFile).mockImplementation(() => new Promise((_resolve, reject) => { rejectDownload = reject }))
|
||||
const wrapper = mountPage(PurchaseOrderDetailView)
|
||||
@@ -209,6 +212,16 @@ describe('overseas business mounted pages', () => {
|
||||
})
|
||||
|
||||
it('mounts procurement list with customer candidates and real data', async () => { const wrapper = mountPage(PurchaseOrdersView); await flushPromises(); expect(listOverseasPurchaseCustomerOptions).toHaveBeenCalled(); expect(listOverseasPurchases).toHaveBeenCalledWith({ page: 1, size: 20, customerId: '', orderNo: '', purchaseCategory: '', productName: '', status: '' }); expect(wrapper.text()).toContain('OP-100'); expect(wrapper.text()).toContain('收货中'); expect(wrapper.text()).toContain('2026-08-01 10:00') })
|
||||
it('reviews pending procurement from the list, submits rejection as remark, and omits edit', async () => {
|
||||
vi.mocked(listOverseasPurchases).mockResolvedValue({ records: [{ ...purchase, status: 'PENDING_REVIEW' }], page: 1, size: 20, total: 1, pages: 1 })
|
||||
const wrapper = mountPage(PurchaseOrdersView); await flushPromises()
|
||||
expect(wrapper.text()).not.toContain('编辑')
|
||||
await wrapper.findAll('button').find(button => button.text() === '审核')!.trigger('click'); await flushPromises()
|
||||
const dialog = wrapper.findComponent(AppDialog), form = wrapper.findComponent({ name: 'ElForm' }).props('model') as { decision: string; remark: string }
|
||||
form.decision = 'REJECT'; form.remark = '商品型号与要求不符'; dialog.vm.$emit('confirm'); await flushPromises()
|
||||
expect(submitProcurementCommand).toHaveBeenCalledWith('OP-100', '10001', 'review', expect.objectContaining({ version: 3, decision: 'REJECT', remark: '商品型号与要求不符' }))
|
||||
expect(submitProcurementCommand).not.toHaveBeenCalledWith(expect.anything(), expect.anything(), expect.anything(), expect.objectContaining({ reason: expect.anything() }))
|
||||
})
|
||||
it('keeps long procurement identifiers and descriptive fields out of single-line truncation', async () => {
|
||||
const record = { ...purchase, orderNo: 'QLYSXDKH202610100002', customerName: '全量验收下单客户-20261010-长客户名称', productName: 'PROC-PURCHASE-20261010-长商品名称', productSpecification: '多规格描述-完整展示', purchaseCategory: 'OFFICE_SUPPLIES', purchaseWarehouseName: '洛杉矶海外采购收货仓库' }
|
||||
vi.mocked(listOverseasPurchases).mockResolvedValue({ records: [record], page: 1, size: 20, total: 1, pages: 1 })
|
||||
|
||||
@@ -5,7 +5,6 @@ import { computed, onActivated, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ArrowLeft } from '@lucide/vue'
|
||||
import { useAuthStore } from '@/core/auth/auth'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import StatusBadge from '@/shared/components/StatusBadge.vue'
|
||||
@@ -18,12 +17,11 @@ import { downloadProcurementFile } from '../api/procurementCommands'
|
||||
import { getProcurementWorkflow, type ProcurementWorkflow } from '../api/procurementWorkflow'
|
||||
import ProcurementWorkflowPanel from '../components/ProcurementWorkflowPanel.vue'
|
||||
import BusinessDocumentFees from '../components/BusinessDocumentFees.vue'
|
||||
import { overseasBusinessPermissions } from '../permissions'
|
||||
import { statusLabel, money, dateTime, text } from '../procurementPresentation'
|
||||
import type { PlatformProcurement } from '../procurementTypes'
|
||||
import { directionLabel } from '../procurementDirection'
|
||||
import { hasProcurementFeeDetails } from '../procurementOutboundFees'
|
||||
const route = useRoute(); const router = useRouter(); const auth = useAuthStore(); const access = useAccessPreviewStore()
|
||||
const route = useRoute(); const router = useRouter(); const auth = useAuthStore()
|
||||
const purchaseId = computed(() => typeof route.params.purchaseId === 'string' ? route.params.purchaseId : '')
|
||||
const customerId = computed(() => typeof route.query.customerId === 'string' ? route.query.customerId : '')
|
||||
const detail = ref<PlatformProcurement | null>(null), workflow = ref<ProcurementWorkflow | null>(null), loading = ref(false)
|
||||
@@ -40,7 +38,8 @@ const fields = computed(() => [
|
||||
['采购品类', order.value?.purchaseCategory], ['商品名称', order.value?.productName], ['规格', order.value?.productSpecification],
|
||||
['采购数量', order.value?.purchaseQuantity], ['预算单价', money(order.value?.budgetUnitPrice, order.value?.currency)], ['预计到仓', dateTime(order.value?.estimatedArrivalAt)],
|
||||
['收件人', order.value?.contactName], ['联系电话', order.value?.contactPhone], ['收货地址', order.value?.receivingAddress],
|
||||
...(detail.value?.internalRemark ? [['内部备注', detail.value.internalRemark]] : []),
|
||||
...(order.value?.remark ? [['采购要求', order.value.remark]] : []),
|
||||
...(detail.value?.internalRemark || order.value?.remark ? [['采购备注', detail.value?.internalRemark || order.value?.remark]] : []),
|
||||
])
|
||||
let generation = 0, inFlight: Promise<void> | null = null, inFlightScope = ''
|
||||
const scope = computed(() => `${auth.user?.id || ''}:${customerId.value}:${purchaseId.value}`)
|
||||
@@ -78,7 +77,7 @@ onActivated(() => { if (activatedOnce) void load(); else activatedOnce = true })
|
||||
<template>
|
||||
<AdminPage class="admin-density admin-workbench"><OperationErrorToast :details="operationError" />
|
||||
<DetailPageLayout class="purchase-detail density-detail-layout workbench-panel is-flush is-page-surface" aria-label="采购订单详情">
|
||||
<template #header><div class="density-detail-header"><div class="density-detail-heading"><el-button link class="density-back" @click="router.push({ name: 'overseas-business-purchases' })"><ArrowLeft :size="16" />返回列表</el-button><h1 class="density-detail-title">{{ order?.orderNo || '—' }}</h1><StatusBadge v-if="workflow" :status="statusLabel(workflow.status)" /></div><div class="density-actions"><el-button :loading="loading" @click="load">刷新</el-button><el-button v-if="order && access.can(overseasBusinessPermissions.purchaseEdit)" @click="router.push({ name: 'overseas-business-purchase-edit', params: { purchaseId: order.orderNo }, query: { customerId } })">编辑备注</el-button></div></div></template>
|
||||
<template #header><div class="density-detail-header"><div class="density-detail-heading"><el-button link class="density-back" @click="router.push({ name: 'overseas-business-purchases' })"><ArrowLeft :size="16" />返回列表</el-button><h1 class="density-detail-title">{{ order?.orderNo || '—' }}</h1><StatusBadge v-if="workflow" :status="statusLabel(workflow.status)" /></div><div class="density-actions"><el-button :loading="loading" @click="load">刷新</el-button></div></div></template>
|
||||
<LoadErrorState v-if="loadError" title="采购详情暂时无法加载" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
|
||||
<div v-else v-loading="loading" class="detail-grid">
|
||||
<template v-if="detail && order && workflow">
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminWorkbench.css'
|
||||
import '@/styles/adminDensity.css'
|
||||
import { computed, onActivated, onMounted, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { computed, onActivated, onMounted, onBeforeUnmount, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/core/auth/auth'
|
||||
import { procurementStatuses, statusLabel, statusTone, money } from '../procurementPresentation'
|
||||
@@ -11,9 +12,11 @@ import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.v
|
||||
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { listOverseasPurchaseCustomerOptions, listOverseasPurchases } from '../api/platformOverseasBusiness'
|
||||
import { listOverseasPurchaseCustomerOptions, listOverseasPurchaseWarehouseOptions, listOverseasPurchases } from '../api/platformOverseasBusiness'
|
||||
import { submitProcurementCommand } from '../api/procurementCommands'
|
||||
import { overseasBusinessPermissions } from '../permissions'
|
||||
import type { OverseasPurchaseCustomerOption, OverseasPurchaseQuery, OverseasPurchaseStatus, OverseasPurchaseSummary } from '../types'
|
||||
|
||||
@@ -23,6 +26,10 @@ const rows = ref<OverseasPurchaseSummary[]>([]); const loading = ref(false); con
|
||||
const page = ref(1); const pageSize = ref(20); const total = ref(0)
|
||||
const filters = ref<QueryModel>({ orderNo: '', customerId: '', purchaseCategory: '', productName: '', status: '' }); const applied = ref({ ...filters.value })
|
||||
const customerOptions = ref<OverseasPurchaseCustomerOption[]>([]); const customersLoading = ref(false)
|
||||
const reviewOpen = ref(false), reviewSaving = ref(false), reviewLoading = ref(false)
|
||||
const reviewRow = ref<OverseasPurchaseSummary | null>(null), reviewWarehouses = ref<Awaited<ReturnType<typeof listOverseasPurchaseWarehouseOptions>>>([])
|
||||
const reviewRequestKey = ref('')
|
||||
const reviewForm = reactive({ decision: 'APPROVE' as 'APPROVE' | 'REJECT', purchaseWarehouseId: '', remark: '' })
|
||||
const fields = computed<QueryField[]>(() => [
|
||||
{ key: 'orderNo', type: 'input', label: '订单编号', placeholder: '请输入订单编号', width: 210 },
|
||||
{ key: 'customerId', type: 'select', label: '客户', placeholder: '全部客户', width: 210, options: customerOptions.value.map((item) => ({ label: item.customerName, value: item.customerId })), props: { filterable: true, loading: customersLoading.value } },
|
||||
@@ -31,7 +38,7 @@ const fields = computed<QueryField[]>(() => [
|
||||
{ key: 'status', type: 'select', label: '履约状态', placeholder: '全部状态', width: 190, options: Object.entries(procurementStatuses).map(([value, label]) => ({ value, label })) },
|
||||
])
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 120, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'actions', label: '操作', width: 170, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'orderNo', prop: 'orderNo', label: '订单编号', minWidth: 220, singleLine: false },
|
||||
{ key: 'status', label: '状态', minWidth: 145, slotName: 'status', afterColumn: 'orderNo' },
|
||||
{ key: 'customerName', prop: 'customerName', label: '客户名称', minWidth: 220, singleLine: false, formatter: (row: OverseasPurchaseSummary) => row.customerName || '—' },
|
||||
@@ -64,7 +71,26 @@ function load() {
|
||||
}
|
||||
watch(() => auth.user?.id, () => { rows.value = []; total.value = 0; void load() })
|
||||
function search(model: QueryModel) { applied.value = { ...model }; if (page.value === 1) void load(); else page.value = 1 }
|
||||
function open(row: OverseasPurchaseSummary, edit = false) { void router.push({ name: edit ? 'overseas-business-purchase-edit' : 'overseas-business-purchase-detail', params: { purchaseId: row.orderNo }, query: { customerId: row.customerId } }) }
|
||||
function open(row: OverseasPurchaseSummary) { void router.push({ name: 'overseas-business-purchase-detail', params: { purchaseId: row.orderNo }, query: { customerId: row.customerId } }) }
|
||||
async function beginReview(row: OverseasPurchaseSummary) {
|
||||
reviewRow.value = row; reviewWarehouses.value = []; reviewRequestKey.value = crypto.randomUUID(); Object.assign(reviewForm, { decision: 'APPROVE', purchaseWarehouseId: '', remark: '' }); reviewOpen.value = true
|
||||
reviewLoading.value = true
|
||||
try { reviewWarehouses.value = await listOverseasPurchaseWarehouseOptions(row.procurementDirection) }
|
||||
catch (error) { ElMessage.error(getApiErrorDetails(error, '仓库加载失败').message) }
|
||||
finally { reviewLoading.value = false }
|
||||
}
|
||||
async function submitReview() {
|
||||
const row = reviewRow.value
|
||||
if (!row || reviewSaving.value) return
|
||||
if (reviewForm.decision === 'APPROVE' && !reviewForm.purchaseWarehouseId) { ElMessage.warning('请选择采购仓库'); return }
|
||||
if (reviewForm.decision === 'REJECT' && !reviewForm.remark.trim()) { ElMessage.warning('请填写驳回原因'); return }
|
||||
reviewSaving.value = true
|
||||
try {
|
||||
await submitProcurementCommand(row.orderNo, row.customerId, 'review', { version: row.version, idempotencyKey: reviewRequestKey.value, decision: reviewForm.decision, ...(reviewForm.decision === 'APPROVE' ? { purchaseWarehouseId: reviewForm.purchaseWarehouseId } : { remark: reviewForm.remark.trim() }) })
|
||||
reviewOpen.value = false; ElMessage.success('审核完成'); await load()
|
||||
} catch (error) { ElMessage.error(getApiErrorDetails(error, '审核失败').message) }
|
||||
finally { reviewSaving.value = false }
|
||||
}
|
||||
async function loadCustomerOptions() { customersLoading.value = true; try { customerOptions.value = await listOverseasPurchaseCustomerOptions() } catch { customerOptions.value = [] } finally { customersLoading.value = false } }
|
||||
let activatedOnce = false
|
||||
watch([page, pageSize], () => { void load() }); onMounted(() => { void load(); void loadCustomerOptions() })
|
||||
@@ -81,10 +107,17 @@ onActivated(() => { if (activatedOnce) void load(); else activatedOnce = true })
|
||||
<template #notices><template v-if="loadError"><LoadErrorState title="海外采购订单暂时无法加载" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template></template>
|
||||
|
||||
|
||||
<template #actions="{ row }"><div class="density-actions row-actions admin-text-actions"><el-button class="density-action" data-tone="neutral" size="small" @click="open(row as OverseasPurchaseSummary)">详情</el-button><el-button class="density-action" data-tone="neutral" v-if="access.can(overseasBusinessPermissions.purchaseEdit)" size="small" @click="open(row as OverseasPurchaseSummary, true)">编辑</el-button></div></template>
|
||||
<template #actions="{ row }"><div class="density-actions row-actions admin-text-actions"><el-button class="density-action" data-tone="neutral" size="small" @click="open(row as OverseasPurchaseSummary)">详情</el-button><el-button v-if="(row as OverseasPurchaseSummary).status === 'PENDING_REVIEW' && access.can(overseasBusinessPermissions.purchaseReview)" class="density-action" type="primary" size="small" @click="beginReview(row as OverseasPurchaseSummary)">审核</el-button></div></template>
|
||||
<template #status="{ row }"><el-tag :type="statusTone(row.status)" size="small" effect="light">{{ statusLabel(row.status, row.procurementDirection) }}</el-tag></template>
|
||||
<template #pagination-left>共 {{ total }} 条海外采购订单</template>
|
||||
</DataTable></div></section>
|
||||
<AppDialog v-model="reviewOpen" class="admin-density-dialog admin-workbench is-dialog" title="采购审核" width="560px" confirm-text="提交审核" :confirm-loading="reviewSaving" :confirm-disabled="reviewLoading" @confirm="submitReview">
|
||||
<el-form :model="reviewForm" label-position="top" class="density-form density-form--outlined">
|
||||
<el-form-item label="审核结果"><el-radio-group v-model="reviewForm.decision"><el-radio value="APPROVE">通过</el-radio><el-radio value="REJECT">驳回</el-radio></el-radio-group></el-form-item>
|
||||
<el-form-item v-if="reviewForm.decision === 'APPROVE'" label="采购仓库" required><el-select v-model="reviewForm.purchaseWarehouseId" filterable :loading="reviewLoading" placeholder="请选择采购仓库"><el-option v-for="warehouse in reviewWarehouses" :key="warehouse.warehouseId" :label="`${warehouse.warehouseName}(${warehouse.warehouseCode})`" :value="warehouse.warehouseId" /></el-select></el-form-item>
|
||||
<el-form-item v-else label="驳回原因" required><el-input v-model="reviewForm.remark" type="textarea" :rows="4" maxlength="1000" show-word-limit /></el-form-item>
|
||||
</el-form>
|
||||
</AppDialog>
|
||||
</ListPageLayout></AdminPage>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ export interface ProcurementOrder extends ProcurementSummary {
|
||||
receivingAddress: string
|
||||
contactName: string
|
||||
contactPhone: string
|
||||
remark?: string | null
|
||||
estimatedArrivalAt: string | null
|
||||
plannedWarehouseName: string | null
|
||||
actualWarehouseCode: string | null
|
||||
|
||||
@@ -18,6 +18,7 @@ export interface OverseasPurchaseSummary {
|
||||
purchaseQuantity: number; budgetUnitPrice: string | null; currency: string
|
||||
purchaseWarehouseId: string | null; purchaseWarehouseName: string | null
|
||||
status: OverseasPurchaseStatus; version: number; createdAt: string; updatedAt: string
|
||||
remark?: string | null
|
||||
}
|
||||
export interface OverseasPurchaseCustomerOption { customerId: string; customerName: string }
|
||||
export interface OverseasPurchaseWarehouseOption { warehouseId: string; warehouseCode: string; warehouseName: string; countryCode: string }
|
||||
|
||||
Reference in New Issue
Block a user