Merge branch 'dev' of http://8.163.30.128:10082/html/admin-web into dev
This commit is contained in:
@@ -445,7 +445,7 @@ onBeforeUnmount(clearVoucherPreview)
|
||||
</AppDialog>
|
||||
<AppDialog v-model="creditLimitOpen" title="设置授信额度" width="min(560px, calc(100vw - 28px))" confirm-text="保存授信额度" :confirm-loading="creditLimitSaving" @confirm="submitCreditLimit">
|
||||
<el-alert title="授信额度不能低于已用授信与冻结授信之和;修改后立即影响客户总可用余额。" type="info" :closable="false" />
|
||||
<el-form label-position="top" class="wallet-form"><el-form-item label="客户名称"><el-input :model-value="adjustmentAccount ? customerName(adjustmentAccount.customerId, adjustmentAccount.customerName) : ''" readonly /></el-form-item><el-form-item label="当前已用授信"><el-input :model-value="money(adjustmentAccount?.creditUsed)" readonly /></el-form-item><el-form-item label="授信额度" required><el-input v-model="creditLimitForm.amount" inputmode="decimal" placeholder="请输入大于或等于 0 的金额" /></el-form-item><el-form-item label="调整说明"><el-input v-model="creditLimitForm.remark" type="textarea" :rows="3" maxlength="500" show-word-limit /></el-form-item></el-form>
|
||||
<el-form label-position="top" class="wallet-form"><el-form-item label="客户名称"><el-input :model-value="adjustmentAccount ? customerName(adjustmentAccount.customerId, adjustmentAccount.customerName) : ''" readonly /></el-form-item><el-form-item label="当前已用授信"><el-input :model-value="money(adjustmentAccount?.creditUsed)" readonly /></el-form-item><el-form-item label="授信额度" required><el-input v-model="creditLimitForm.amount" inputmode="decimal" placeholder="请输入大于或等于 0 的金额" /></el-form-item><el-form-item label="调整说明"><el-input v-model="creditLimitForm.remark" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="请输入授信调整说明(选填)" /></el-form-item></el-form>
|
||||
</AppDialog>
|
||||
<AppDialog v-model="reviewOpen" title="审核充值申请" width="min(620px, calc(100vw - 28px))" confirm-text="确认审核" :confirm-loading="reviewSaving" @confirm="submitReview">
|
||||
<el-descriptions v-if="reviewRequest" :column="2" border><el-descriptions-item label="申请单号">{{ reviewRequest.requestNo }}</el-descriptions-item><el-descriptions-item label="充值金额">{{ money(reviewRequest.amount) }}</el-descriptions-item><el-descriptions-item label="付款方式">{{ paymentMethodLabel(reviewRequest.paymentMethod) }}</el-descriptions-item><el-descriptions-item label="付款凭证">{{ reviewRequest.voucherFileName || '—' }}</el-descriptions-item></el-descriptions>
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
OverseasBusinessHistory, OverseasBusinessPage, OverseasPurchaseCustomerOption, OverseasPurchaseDetail,
|
||||
OverseasPurchaseQuery, OverseasPurchaseSummary, OverseasPurchaseWarehouseOption, UpdateLocalLooseboardOrderRequest,
|
||||
UpdateLocalTransportOrderRequest, UpdateOverseasPurchaseRequest, OverseasPurchaseOutboundDetail,
|
||||
OverseasPurchaseFile, OverseasPurchaseFee,
|
||||
} from '../types'
|
||||
|
||||
const procurementBasePath = '/platform/v1/last-leg/overseas-procurements'
|
||||
@@ -19,7 +20,7 @@ interface ProcurementDetailPayload {
|
||||
contactName?: string | null; contactPhone?: string | null; domesticCarrier?: string | null; domesticTrackingNo?: string | null
|
||||
overseasCarrier?: string | null; overseasTrackingNo?: string | null; estimatedArrivalAt?: string | null; remark?: string | null
|
||||
createdBy?: string | null; reviewedBy?: string | null; reviewRemark?: string | null; reviewedAt?: string | null
|
||||
histories?: OverseasBusinessHistory[]; outboundDetails?: OverseasPurchaseOutboundDetail[]
|
||||
histories?: OverseasBusinessHistory[]; outboundDetails?: OverseasPurchaseOutboundDetail[]; files?: any[]; fees?: any[]
|
||||
}
|
||||
interface TruckSummaryPayload extends Omit<LocalTransportOrder, 'id' | 'budgetAmount' | 'totalWeightKg' | 'totalVolumeCbm'> { id: Numeric; budgetAmount?: Numeric | null; totalWeightKg: Numeric; totalVolumeCbm: Numeric }
|
||||
interface TruckPalletPayload extends Omit<LocalTransportPallet, 'id' | 'warehousePalletId'> { id: Numeric; warehousePalletId?: Numeric | null }
|
||||
@@ -40,7 +41,7 @@ function procurementSummary(value: ProcurementSummaryPayload): OverseasPurchaseS
|
||||
return { ...value, id: String(value.id), purchaseWarehouseId: value.purchaseWarehouseId == null ? null : String(value.purchaseWarehouseId), budgetUnitPrice: value.budgetUnitPrice == null ? null : String(value.budgetUnitPrice) }
|
||||
}
|
||||
function procurementDetail(value: ProcurementDetailPayload): OverseasPurchaseDetail {
|
||||
return { ...procurementSummary(value.summary), productUrl: value.productUrl || null, receivingAddress: value.receivingAddress || null, contactName: value.contactName || null, contactPhone: value.contactPhone || null, domesticCarrier: value.domesticCarrier || null, domesticTrackingNo: value.domesticTrackingNo || null, overseasCarrier: value.overseasCarrier || null, overseasTrackingNo: value.overseasTrackingNo || null, estimatedArrivalAt: value.estimatedArrivalAt || null, remark: value.remark || null, createdBy: value.createdBy || null, reviewedBy: value.reviewedBy || null, reviewRemark: value.reviewRemark || null, reviewedAt: value.reviewedAt || null, histories: value.histories || [], outboundDetails: (value.outboundDetails || []).map((item) => ({ ...item, id: String(item.id), quantity: Number(item.quantity) })) }
|
||||
return { ...procurementSummary(value.summary), productUrl: value.productUrl || null, receivingAddress: value.receivingAddress || null, contactName: value.contactName || null, contactPhone: value.contactPhone || null, domesticCarrier: value.domesticCarrier || null, domesticTrackingNo: value.domesticTrackingNo || null, overseasCarrier: value.overseasCarrier || null, overseasTrackingNo: value.overseasTrackingNo || null, estimatedArrivalAt: value.estimatedArrivalAt || null, remark: value.remark || null, createdBy: value.createdBy || null, reviewedBy: value.reviewedBy || null, reviewRemark: value.reviewRemark || null, reviewedAt: value.reviewedAt || null, histories: value.histories || [], outboundDetails: (value.outboundDetails || []).map((item) => ({ ...item, id: String(item.id), quantity: Number(item.quantity) })), files: (value.files || []).map((item) => ({ ...item, id: String(item.id), fileId: String(item.fileId), sizeBytes: Number(item.sizeBytes) })), fees: (value.fees || []).map((item) => ({ ...item, id: String(item.id), voucherFileId: String(item.voucherFileId), amount: String(item.amount) })) }
|
||||
}
|
||||
function truckSummary(value: TruckSummaryPayload): LocalTransportOrder {
|
||||
return { ...value, id: String(value.id), totalWeightKg: String(value.totalWeightKg), totalVolumeCbm: String(value.totalVolumeCbm), budgetAmount: value.budgetAmount == null ? null : String(value.budgetAmount) }
|
||||
@@ -75,6 +76,15 @@ export async function updateOverseasPurchase(orderNo: string, customerId: string
|
||||
const response = await overseasHttp.patch<ApiEnvelope<ProcurementDetailPayload>, ApiEnvelope<ProcurementDetailPayload>>(`${procurementBasePath}/${encodeURIComponent(orderNo)}`, payload, { params: { customerId } })
|
||||
return procurementDetail(unwrapApiResponse(response))
|
||||
}
|
||||
export async function uploadOverseasPurchaseFile(orderNo: string, customerId: string, category: 'RECEIPT' | 'VOUCHER', file: File) {
|
||||
const body = new FormData(); body.append('file', file)
|
||||
const response = await overseasHttp.post<ApiEnvelope<OverseasPurchaseFile>, ApiEnvelope<OverseasPurchaseFile>>(`${procurementBasePath}/${encodeURIComponent(orderNo)}/files/${category}`, body, { params: { customerId } })
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
export async function createOverseasPurchaseFee(orderNo: string, customerId: string, payload: { feeItemCode: string; feeItemName: string; amount: number; voucherFileId: string; remark?: string }) {
|
||||
const response = await overseasHttp.post<ApiEnvelope<OverseasPurchaseFee>, ApiEnvelope<OverseasPurchaseFee>>(`${procurementBasePath}/${encodeURIComponent(orderNo)}/fees`, payload, { params: { customerId } })
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
export async function listUsTruckOrders(query: LocalTransportQuery = {}) {
|
||||
const response = await overseasHttp.get<ApiEnvelope<PagePayload<TruckSummaryPayload>>, ApiEnvelope<PagePayload<TruckSummaryPayload>>>(truckBasePath, { params: { ...query, orderNo: query.orderNo?.trim() || undefined, routeKeyword: query.routeKeyword?.trim() || undefined, status: query.status || undefined, transportType: query.transportType || undefined, customerId: query.customerId || undefined } })
|
||||
return normalizePage(unwrapApiResponse(response), truckSummary)
|
||||
|
||||
@@ -33,7 +33,7 @@ describe('overseas business real contract pages', () => {
|
||||
expect(api).toContain('updateUsTruckOrder')
|
||||
expect(api).toContain('createLocalLooseboardOrder')
|
||||
expect(api).toContain('updateLocalLooseboardOrder')
|
||||
expect(api).not.toContain('createOverseasPurchase')
|
||||
expect(api).not.toMatch(/export async function createOverseasPurchase\s*\(/)
|
||||
expect(api).not.toMatch(/\/merchant\/|receive-pallets|\/status/)
|
||||
expect(list).toContain('listLocalLooseboardOrders')
|
||||
expect(form).toContain('listPlatformTenants')
|
||||
|
||||
@@ -22,7 +22,7 @@ const routing = vi.hoisted(() => ({ push: vi.fn(), route: { params: {} as Record
|
||||
vi.mock('vue-router', () => ({ useRoute: () => routing.route, useRouter: () => ({ push: routing.push }) }))
|
||||
vi.mock('@/modules/iam', () => ({ listPlatformTenants: vi.fn() }))
|
||||
vi.mock('../api/platformOverseasBusiness', () => ({
|
||||
listOverseasPurchases: vi.fn(), getOverseasPurchase: vi.fn(), updateOverseasPurchase: vi.fn(), listOverseasPurchaseCustomerOptions: vi.fn(), listOverseasPurchaseWarehouseOptions: vi.fn(),
|
||||
listOverseasPurchases: vi.fn(), getOverseasPurchase: vi.fn(), updateOverseasPurchase: vi.fn(), uploadOverseasPurchaseFile: vi.fn(), createOverseasPurchaseFee: vi.fn(), listOverseasPurchaseCustomerOptions: vi.fn(), listOverseasPurchaseWarehouseOptions: vi.fn(),
|
||||
listUsTruckOrders: vi.fn(), getUsTruckOrder: vi.fn(), createUsTruckOrder: vi.fn(), updateUsTruckOrder: vi.fn(),
|
||||
listLocalLooseboardOrders: vi.fn(), getLocalLooseboardOrder: vi.fn(), createLocalLooseboardOrder: vi.fn(), updateLocalLooseboardOrder: vi.fn(),
|
||||
}))
|
||||
@@ -36,7 +36,7 @@ const purchase: OverseasPurchaseDetail = {
|
||||
purchaseQuantity: 20, budgetUnitPrice: '12.50', currency: 'USD', purchaseWarehouseId: '20001', purchaseWarehouseName: '洛杉矶采购仓', inboundQuantity: 10, outboundQuantity: 0, inventoryQuantity: 10, status: 'PENDING_OUTBOUND', version: 3,
|
||||
createdAt: '2026-08-01T10:00:00', updatedAt: '2026-08-02T10:00:00', receivingAddress: 'LA', contactName: 'Alice', contactPhone: '10086', domesticCarrier: null, domesticTrackingNo: null,
|
||||
overseasCarrier: 'UPS', overseasTrackingNo: 'UPS-1', estimatedArrivalAt: '2026-08-25T12:00:00', remark: '采购备注', createdBy: '7', reviewedBy: '8', reviewRemark: null, reviewedAt: '2026-08-02T09:00:00',
|
||||
histories: [{ operationType: 'UPDATE', fromStatus: 'APPROVED', toStatus: 'APPROVED', operatorId: '7', changeDetail: '{"remark":"采购备注"}', remark: null, createdAt: '2026-08-02T10:00:00' }],
|
||||
histories: [{ operationType: 'UPDATE', fromStatus: 'APPROVED', toStatus: 'APPROVED', operatorId: 'INTERNAL-OPERATOR-7', changeDetail: '{"remark":"采购备注"}', remark: null, createdAt: '2026-08-02T10:00:00' }], files: [], fees: [],
|
||||
}
|
||||
const truck: LocalTransportOrder = { id: '9007199254740995', customerId: '10002', orderNo: 'TK-200', transportType: 'PORT_PICKUP', origin: 'Long Beach', destination: 'Ontario', containerType: '40HQ', palletCount: 2, packageCount: 20, receivedPalletCount: 0, allocatedPalletCount: 0, shippedPalletCount: 0, totalWeightKg: '1200.000', totalVolumeCbm: '16.500', budgetAmount: '850.00', currency: 'USD', contactName: 'Bob', contactInfo: '10010', requestedDate: '2026-08-28', status: 'PENDING_REVIEW', version: 1, createdAt: '2026-08-01T10:00:00', updatedAt: '2026-08-02T10:00:00' }
|
||||
const truckDetail: LocalTransportDetail = { ...truck, originAddress: 'Pier A', destinationAddress: 'Warehouse B', remark: null, createdBy: '7', pallets: [], histories: [] }
|
||||
@@ -61,7 +61,7 @@ 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: '' }); expect(wrapper.text()).toContain('OP-100'); expect(wrapper.text()).toContain('待出库'); expect(wrapper.text()).toContain('2026-08-01 10:00') })
|
||||
it('paginates procurement results', async () => { vi.mocked(listOverseasPurchases).mockResolvedValue({ records: [purchase], page: 1, size: 20, total: 40, pages: 2 }); const wrapper = mountPage(PurchaseOrdersView); await flushPromises(); await wrapper.find('button[aria-label="第 2 页"]').trigger('click'); await flushPromises(); expect(listOverseasPurchases).toHaveBeenLastCalledWith(expect.objectContaining({ page: 2 })) })
|
||||
it('renders procurement empty and traceable 403 states', async () => { vi.mocked(listOverseasPurchases).mockResolvedValueOnce({ records: [], page: 1, size: 20, total: 0, pages: 0 }); let wrapper = mountPage(PurchaseOrdersView); await flushPromises(); expect(wrapper.text()).toContain('暂无符合筛选条件的海外采购订单'); wrapper.unmount(); vi.mocked(listOverseasPurchases).mockRejectedValue(new ApiResponseError('forbidden', 'A0403', 403, null, 'REQ-403')); wrapper = mountPage(PurchaseOrdersView); await flushPromises(); expect(wrapper.text()).toContain('当前账号无权访问该数据'); expect(wrapper.text()).toContain('REQ-403') })
|
||||
it('mounts procurement detail operation history', async () => { routing.route.params = { purchaseId: 'OP-100' }; routing.route.query = { customerId: '10001' }; const wrapper = mountPage(PurchaseOrderDetailView); await flushPromises(); expect(getOverseasPurchase).toHaveBeenCalledWith('OP-100', '10001'); expect(wrapper.text()).toContain('操作流水'); expect(wrapper.text()).toContain('编辑') })
|
||||
it('mounts procurement detail operation history without exposing internal operator ids', async () => { routing.route.params = { purchaseId: 'OP-100' }; routing.route.query = { customerId: '10001' }; const wrapper = mountPage(PurchaseOrderDetailView); await flushPromises(); expect(getOverseasPurchase).toHaveBeenCalledWith('OP-100', '10001'); expect(wrapper.text()).toContain('操作流水'); expect(wrapper.text()).toContain('编辑'); expect(wrapper.text()).not.toContain('INTERNAL-OPERATOR-7') })
|
||||
it('submits procurement explicit null clears and keeps a 409 visible without local overwrite', async () => { routing.route.params = { purchaseId: 'OP-100' }; routing.route.query = { customerId: '10001' }; vi.mocked(updateOverseasPurchase).mockRejectedValue(new ApiResponseError('订单版本冲突,请刷新后重试', 'B0409', 409)); const wrapper = mountPage(PurchaseOrderEditView); await flushPromises(); const productUrl = wrapper.findAll('.el-form-item').find((item) => item.text().startsWith('商品链接'))!.find('input'); const domesticCarrier = wrapper.findAll('.el-form-item').find((item) => item.text().startsWith('国内承运商'))!.find('input'); await productUrl.setValue(''); await domesticCarrier.setValue(''); wrapper.findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', null); wrapper.findComponent({ name: 'ElDatePicker' }).vm.$emit('update:modelValue', null); await wrapper.vm.$nextTick(); await wrapper.findAll('button').find((button) => button.text().trim() === '保存')?.trigger('click'); await flushPromises(); expect(updateOverseasPurchase).toHaveBeenCalledWith('OP-100', '10001', expect.objectContaining({ version: 3, productUrl: null, domesticCarrier: null, estimatedArrivalAt: null, purchaseWarehouseId: null })); expect(document.body.textContent).toContain('订单版本冲突,请刷新后重试'); expect((productUrl.element as HTMLInputElement).value).toBe(''); expect((domesticCarrier.element as HTMLInputElement).value).toBe(''); expect(routing.push).not.toHaveBeenCalled() })
|
||||
it('keeps procurement list usable when customer candidates fail', async () => { vi.mocked(listOverseasPurchaseCustomerOptions).mockRejectedValue(new Error('candidate unavailable')); const wrapper = mountPage(PurchaseOrdersView); await flushPromises(); expect(listOverseasPurchases).toHaveBeenCalled(); expect(wrapper.text()).toContain('OP-100'); expect(wrapper.text()).not.toContain('candidate unavailable') })
|
||||
it('loads procurement edit data but blocks unsafe warehouse selection when candidates fail', async () => { routing.route.params = { purchaseId: 'OP-100' }; routing.route.query = { customerId: '10001' }; vi.mocked(listOverseasPurchaseWarehouseOptions).mockRejectedValue(new Error('candidate unavailable')); const wrapper = mountPage(PurchaseOrderEditView); await flushPromises(); expect(getOverseasPurchase).toHaveBeenCalledWith('OP-100', '10001'); expect(wrapper.text()).toContain('采购仓候选加载失败'); expect(wrapper.text()).toContain('OP-100') })
|
||||
|
||||
@@ -1,17 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ArrowLeft } from '@lucide/vue'
|
||||
import { ArrowLeft, Upload } from '@lucide/vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { getOverseasPurchase } from '../api/platformOverseasBusiness'
|
||||
import type { OverseasBusinessHistory, OverseasPurchaseDetail, OverseasPurchaseStatus } from '../types'
|
||||
import { createOverseasPurchaseFee, getOverseasPurchase, uploadOverseasPurchaseFile } from '../api/platformOverseasBusiness'
|
||||
import { overseasBusinessPermissions } from '../permissions'
|
||||
import type { OverseasBusinessHistory, OverseasPurchaseDetail, OverseasPurchaseFile, OverseasPurchaseFee, OverseasPurchaseStatus } from '../types'
|
||||
|
||||
const route = useRoute(); const router = useRouter()
|
||||
const access = useAccessPreviewStore()
|
||||
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<OverseasPurchaseDetail | null>(null); const loading = ref(false); const loadError = ref<ApiErrorDetails | null>(null)
|
||||
@@ -21,12 +26,17 @@ const stockFields = computed(() => [['入库量', detail.value?.inboundQuantity]
|
||||
const orderFields = computed(() => [['订单编号', detail.value?.orderNo], ['采购数量', detail.value?.purchaseQuantity], ['预算单价', detail.value ? formatAmount(detail.value.budgetUnitPrice, detail.value.currency) : null], ['订单状态', detail.value ? statusLabels[detail.value.status] : null], ['联系人', detail.value?.contactName], ['联系电话', detail.value?.contactPhone], ['收货地址', detail.value?.receivingAddress], ['订单备注', detail.value?.remark]] as const)
|
||||
const courierFields = computed(() => [['国内承运商', detail.value?.domesticCarrier], ['国内运单号', detail.value?.domesticTrackingNo], ['海外承运商', detail.value?.overseasCarrier], ['海外运单号', detail.value?.overseasTrackingNo], ['预计到达时间', formatDateTime(detail.value?.estimatedArrivalAt)]] as const)
|
||||
const outboundDetails = computed(() => detail.value?.outboundDetails || [])
|
||||
const receiptInput = ref<HTMLInputElement | null>(null); const voucherInput = ref<HTMLInputElement | null>(null)
|
||||
const uploading = ref(false); const feeDialogOpen = ref(false); const savingFee = ref(false); const voucher = ref<OverseasPurchaseFile | null>(null)
|
||||
const feeForm = reactive({ feeItemCode: 'COURIER', feeItemName: '快递费', amount: 0, remark: '' })
|
||||
const fileColumns: DataTableColumn[] = [{ key: 'category', label: '类型', width: 100, formatter: (row: OverseasPurchaseFile) => row.category === 'RECEIPT' ? '签收文件' : '费用凭证' }, { key: 'fileName', prop: 'fileName', label: '文件名', minWidth: 220 }, { key: 'createdAt', label: '上传时间', minWidth: 155, formatter: (row: OverseasPurchaseFile) => formatDateTime(row.createdAt) }]
|
||||
const feeColumns: DataTableColumn[] = [{ key: 'feeNo', prop: 'feeNo', label: '费用编号', minWidth: 170 }, { key: 'feeItemName', prop: 'feeItemName', label: '费用项', minWidth: 120 }, { key: 'amount', label: '金额', width: 120, formatter: (row: OverseasPurchaseFee) => formatAmount(row.amount, row.currency) }, { key: 'voucherFileName', prop: 'voucherFileName', label: '凭证', minWidth: 180 }, { key: 'walletStatus', label: '资金状态', minWidth: 130, formatter: (row: OverseasPurchaseFee) => walletStatus(row.walletStatus) }, { key: 'walletErrorMessage', label: '异常说明', minWidth: 180, formatter: (row: OverseasPurchaseFee) => row.walletErrorMessage || '—' }]
|
||||
const historyColumns: DataTableColumn[] = [
|
||||
{ key: 'createdAt', label: '时间', minWidth: 155, formatter: (row: OverseasBusinessHistory) => formatDateTime(row.createdAt) },
|
||||
{ key: 'operationType', label: '操作类型', width: 110, formatter: (row: OverseasBusinessHistory) => operationLabels[row.operationType || ''] || row.operationType || '—' },
|
||||
{ key: 'status', label: '状态变化', minWidth: 150, formatter: (row: OverseasBusinessHistory) => row.fromStatus ? `${statusLabel(row.fromStatus)} → ${statusLabel(row.toStatus)}` : statusLabel(row.toStatus) },
|
||||
{ key: 'changeDetail', prop: 'changeDetail', label: '变更明细', minWidth: 260 },
|
||||
{ key: 'operatorId', prop: 'operatorId', label: '操作人', minWidth: 120 },
|
||||
{ key: 'operator', label: '操作人', minWidth: 120, formatter: () => '—' },
|
||||
{ key: 'remark', prop: 'remark', label: '备注', minWidth: 180 },
|
||||
]
|
||||
const operationLabels: Record<string, string> = { CREATE: '创建', REVIEW: '审核', UPDATE: '编辑' }
|
||||
@@ -34,6 +44,20 @@ function statusLabel(status?: string | null) { return status ? (statusLabels[sta
|
||||
function display(value: unknown) { return value === null || value === undefined || value === '' ? '—' : String(value) }
|
||||
function formatDateTime(value?: string | null) { return value ? value.replace('T', ' ').slice(0, 16) : '—' }
|
||||
function formatAmount(value: string | null, currency: string) { return value == null ? '—' : `${Number(value).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} ${currency}` }
|
||||
function walletStatus(value: string) { return ({ FREEZE_PENDING: '冻结中', FROZEN: '已冻结', SETTLE_PENDING: '结算中', SETTLED: '已结算', RELEASE_PENDING: '释放中', RELEASED: '已释放', FREEZE_FAILED: '冻结失败', SETTLE_FAILED: '结算失败', RELEASE_FAILED: '释放失败' } as Record<string,string>)[value] || value }
|
||||
async function upload(category: 'RECEIPT' | 'VOUCHER', event: Event) {
|
||||
const input = event.target as HTMLInputElement; const file = input.files?.[0]; input.value = ''
|
||||
if (!file || !detail.value) return
|
||||
uploading.value = true
|
||||
try { const saved = await uploadOverseasPurchaseFile(detail.value.orderNo, customerId.value, category, file); if (category === 'VOUCHER') { voucher.value = saved; feeDialogOpen.value = true } else { ElMessage.success('签收文件已上传'); await load() } }
|
||||
catch (error) { ElMessage.error(getApiErrorDetails(error, '文件上传失败').message) } finally { uploading.value = false }
|
||||
}
|
||||
async function saveFee() {
|
||||
if (!detail.value || !voucher.value || feeForm.amount <= 0 || !feeForm.feeItemName.trim()) { ElMessage.warning('请填写正确的费用名称和金额'); return }
|
||||
savingFee.value = true
|
||||
try { await createOverseasPurchaseFee(detail.value.orderNo, customerId.value, { feeItemCode: feeForm.feeItemCode.trim(), feeItemName: feeForm.feeItemName.trim(), amount: feeForm.amount, voucherFileId: voucher.value.fileId, remark: feeForm.remark.trim() || undefined }); feeDialogOpen.value = false; voucher.value = null; ElMessage.success('费用已登记并提交资金冻结'); await load() }
|
||||
catch (error) { ElMessage.error(getApiErrorDetails(error, '费用登记失败').message) } finally { savingFee.value = false }
|
||||
}
|
||||
async function load() {
|
||||
detail.value = null; loadError.value = null
|
||||
if (!purchaseId.value || !customerId.value) { loadError.value = { message: '缺少采购订单号或客户标识,请从采购订单列表重新进入' }; return }
|
||||
@@ -54,12 +78,14 @@ watch([purchaseId, customerId], () => { void load() }, { immediate: true })
|
||||
<section><h3>订单信息</h3><dl><div v-for="field in orderFields" :key="field[0]"><dt>{{ field[0] }}</dt><dd>{{ display(field[1]) }}</dd></div></dl></section>
|
||||
<section><h3>快递信息</h3><dl><div v-for="field in courierFields" :key="field[0]"><dt>{{ field[0] }}</dt><dd>{{ display(field[1]) }}</dd></div></dl></section>
|
||||
<section class="full"><div class="section-heading"><h3>出库明细</h3><span>共 {{ outboundDetails.length }} 条,出库量 {{ outboundDetails.reduce((sum, item) => sum + item.quantity, 0) }}</span></div><DataTable :data="outboundDetails" :columns="[{ key: 'quantity', label: '出库数量', width: 110 }, { key: 'carrier', label: '承运商', minWidth: 140 }, { key: 'trackingNo', label: '运单号', minWidth: 160 }, { key: 'shippedAt', label: '出库时间', minWidth: 155, formatter: (row: any) => formatDateTime(row.shippedAt) }, { key: 'remark', label: '备注', minWidth: 180 }]" row-key="id" empty-text="暂无出库明细" /></section>
|
||||
<section class="full"><div class="section-heading"><h3>签收文件与费用凭证</h3><div v-if="access.can(overseasBusinessPermissions.purchaseEdit)" class="section-actions"><input ref="receiptInput" hidden type="file" @change="upload('RECEIPT', $event)"><input ref="voucherInput" hidden type="file" @change="upload('VOUCHER', $event)"><el-button :loading="uploading" @click="receiptInput?.click()"><Upload :size="15" />上传签收文件</el-button><el-button type="primary" :loading="uploading" :disabled="!detail || ['PENDING_REVIEW', 'REJECTED'].includes(detail.status)" @click="voucherInput?.click()"><Upload :size="15" />登记费用</el-button></div></div><DataTable :data="detail?.files || []" :columns="fileColumns" row-key="id" empty-text="暂无签收文件或费用凭证" /></section>
|
||||
<section class="full"><div class="section-heading"><h3>费用明细</h3><span>每条费用独立冻结,订单全部入库后结算。</span></div><DataTable :data="detail?.fees || []" :columns="feeColumns" row-key="id" empty-text="暂无费用明细" /></section>
|
||||
<section class="full"><div class="section-heading"><h3>操作流水</h3><span>展示创建、审核、入库与出库明细变更。</span></div><DataTable :data="detail?.histories || []" :columns="historyColumns" row-key="createdAt" empty-text="暂无操作流水" /></section>
|
||||
</div>
|
||||
</div>
|
||||
</DetailPageLayout></AdminPage>
|
||||
</DetailPageLayout><AppDialog v-model="feeDialogOpen" title="登记采购费用" width="min(560px, calc(100vw - 28px))" confirm-text="确认登记" :confirm-loading="savingFee" @confirm="saveFee"><el-form label-position="top"><el-form-item label="费用项" required><el-input v-model="feeForm.feeItemName" maxlength="64" placeholder="请输入费用名称" /></el-form-item><el-form-item label="费用代码" required><el-input v-model="feeForm.feeItemCode" maxlength="64" placeholder="请输入费用代码" /></el-form-item><el-form-item label="费用金额(CNY)" required><el-input-number v-model="feeForm.amount" :min="0.01" :precision="2" controls-position="right" placeholder="请输入费用金额" /></el-form-item><el-form-item label="费用凭证"><el-input :model-value="voucher?.fileName" disabled /></el-form-item><el-form-item label="备注"><el-input v-model="feeForm.remark" type="textarea" :rows="3" maxlength="500" placeholder="请输入费用说明(选填)" /></el-form-item></el-form></AppDialog></AdminPage>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.detail-header { display:flex; align-items:center; justify-content:space-between; gap:16px; padding:14px 18px; border:1px solid var(--cc-border); border-radius:8px; background:#fff; }.detail-header h2 { margin:0; font-size:16px; }.detail-header span { display:block; margin-top:4px; color:var(--cc-muted); font-size:12px; }.detail-surface { min-height:240px; padding:20px; }.detail-grid { display:grid; grid-template-columns:1fr 1fr; gap:0 24px; }.detail-grid section { padding:0 0 18px; border-bottom:1px solid var(--cc-border); }.detail-grid section:nth-child(3), .detail-grid section:nth-child(4) { padding-top:18px; border-bottom:0; }.detail-grid h3 { margin:0 0 12px; font-size:14px; }.detail-grid .full { grid-column:1 / -1; padding-top:18px; border-top:1px solid var(--cc-border); border-bottom:0; }.detail-grid dl { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:12px 18px; margin:0; }.detail-grid dl div { min-width:0; }.detail-grid dt { color:var(--cc-muted); font-size:12px; }.detail-grid dd { margin:4px 0 0; overflow-wrap:anywhere; color:var(--cc-text); font-size:13px; }.section-heading { display:flex; align-items:baseline; justify-content:space-between; gap:16px; }.section-heading span { color:var(--cc-muted); font-size:12px; }
|
||||
.detail-header { display:flex; align-items:center; justify-content:space-between; gap:16px; padding:14px 18px; border:1px solid var(--cc-border); border-radius:8px; background:#fff; }.detail-header h2 { margin:0; font-size:16px; }.detail-header span { display:block; margin-top:4px; color:var(--cc-muted); font-size:12px; }.detail-surface { min-height:240px; padding:20px; }.detail-grid { display:grid; grid-template-columns:1fr 1fr; gap:0 24px; }.detail-grid section { padding:0 0 18px; border-bottom:1px solid var(--cc-border); }.detail-grid section:nth-child(3), .detail-grid section:nth-child(4) { padding-top:18px; border-bottom:0; }.detail-grid h3 { margin:0 0 12px; font-size:14px; }.detail-grid .full { grid-column:1 / -1; padding-top:18px; border-top:1px solid var(--cc-border); border-bottom:0; }.detail-grid dl { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:12px 18px; margin:0; }.detail-grid dl div { min-width:0; }.detail-grid dt { color:var(--cc-muted); font-size:12px; }.detail-grid dd { margin:4px 0 0; overflow-wrap:anywhere; color:var(--cc-text); font-size:13px; }.section-heading,.section-actions { display:flex; align-items:center; justify-content:space-between; gap:10px; }.section-heading span { color:var(--cc-muted); font-size:12px; }:deep(.el-input-number){width:100%}
|
||||
</style>
|
||||
|
||||
@@ -25,7 +25,10 @@ export interface OverseasPurchaseDetail extends OverseasPurchaseSummary {
|
||||
estimatedArrivalAt: string | null; remark: string | null; createdBy: string | null; reviewedBy: string | null
|
||||
reviewRemark: string | null; reviewedAt: string | null; histories: OverseasBusinessHistory[]
|
||||
outboundDetails?: OverseasPurchaseOutboundDetail[]
|
||||
files: OverseasPurchaseFile[]; fees: OverseasPurchaseFee[]
|
||||
}
|
||||
export interface OverseasPurchaseFile { id: string; category: 'RECEIPT' | 'VOUCHER'; fileId: string; fileName: string; contentType: string | null; sizeBytes: number; createdAt: string }
|
||||
export interface OverseasPurchaseFee { id: string; feeNo: string; feeItemCode: string; feeItemName: string; amount: string; currency: string; voucherFileId: string; voucherFileName: string; remark: string | null; walletStatus: string; walletErrorMessage: string | null; createdAt: string }
|
||||
export interface OverseasPurchaseOutboundDetail { id: string; quantity: number; carrier: string | null; trackingNo: string | null; shippedAt: string | null; remark: string | null }
|
||||
export interface OverseasPurchaseCustomerOption { customerId: string; customerName: string }
|
||||
export interface OverseasPurchaseWarehouseOption { warehouseId: string; warehouseCode: string; warehouseName: string; countryCode: string }
|
||||
|
||||
@@ -43,6 +43,7 @@ export interface SavePalletPayload {
|
||||
export interface UpdatePalletPayload extends SavePalletPayload {
|
||||
version: number
|
||||
}
|
||||
export interface PalletSummaryDto { totalCount: number; idleCount: number; occupiedCount: number; scrappedCount: number }
|
||||
|
||||
export async function listReceivingOverseasWarehouses() {
|
||||
const records: ReceivingOverseasWarehouseDto[] = (await listSharedReceivingOverseasWarehouseOptions()).map((warehouse) => ({
|
||||
@@ -63,6 +64,11 @@ export async function listPallets(warehouseId: string | undefined, query: { page
|
||||
)
|
||||
return normalizeOverseasPage(unwrapApiResponse(response))
|
||||
}
|
||||
export async function getPalletSummary(warehouseId: string, query: { keyword?: string; palletType?: string }) {
|
||||
const path = warehouseId ? `/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/pallets/summary` : '/platform/v1/warehouse-admin/pallets/summary'
|
||||
const response = await overseasHttp.get<ApiEnvelope<PalletSummaryDto>, ApiEnvelope<PalletSummaryDto>>(path, { params: { page: 1, size: 1, ...query } })
|
||||
const value = unwrapApiResponse(response); return { totalCount: Number(value.totalCount), idleCount: Number(value.idleCount), occupiedCount: Number(value.occupiedCount), scrappedCount: Number(value.scrappedCount) }
|
||||
}
|
||||
|
||||
export async function getPallet(warehouseId: string, palletId: string) {
|
||||
const response = await overseasHttp.get<ApiEnvelope<PalletDto>, ApiEnvelope<PalletDto>>(
|
||||
|
||||
@@ -29,7 +29,7 @@ describe('relabel overseas API', () => {
|
||||
it('uses the real create and status endpoints', async () => {
|
||||
vi.mocked(overseasHttp.post).mockResolvedValue({ code: '200', data: {} })
|
||||
vi.mocked(overseasHttp.put).mockResolvedValue({ code: '200', data: {} })
|
||||
const payload = { customerOrderNo: 'CO-1', overseasWarehouseId: '9', labelDeadline: '2026-08-20T10:00:00', contactName: 'A', contactPhone: '1', idempotencyKey: 'k', items: [{ skuCode: 'SKU', productNameZh: 'P', totalPackageCount: 1, notifiedPackageCount: 1, shipmentNo: 'S', newSkuCode: 'SKU-2' }] }
|
||||
const payload = { customerOrderNo: 'CO-1', overseasWarehouseId: '9', labelDeadline: '2026-08-20T10:00:00', contactName: 'A', contactPhone: '1', chargeAmount: 20, idempotencyKey: 'k', items: [{ skuCode: 'SKU', productNameZh: 'P', totalPackageCount: 1, notifiedPackageCount: 1, shipmentNo: 'S', newSkuCode: 'SKU-2' }] }
|
||||
await createRelabelOrder('10001', payload)
|
||||
await changeRelabelOrderStatus('RL/1', 'PROCESSING', 'ok')
|
||||
expect(overseasHttp.post).toHaveBeenCalledWith('/platform/v1/last-leg/relabel-applications', payload, { params: { customerId: '10001' } })
|
||||
|
||||
@@ -34,6 +34,7 @@ export interface CreateRelabelPayload {
|
||||
labelDeadline: string
|
||||
contactName: string
|
||||
contactPhone: string
|
||||
chargeAmount: number
|
||||
remark?: string
|
||||
idempotencyKey: string
|
||||
items: CreateRelabelItemPayload[]
|
||||
@@ -63,6 +64,10 @@ export interface RelabelSummaryDto {
|
||||
totalPackageCount?: number | null
|
||||
notifiedPackageCount?: number | null
|
||||
skuCodes?: string | null
|
||||
chargeAmount?: number | string | null
|
||||
chargeCurrency?: string | null
|
||||
walletStatus?: string | null
|
||||
walletErrorMessage?: string | null
|
||||
createdAt?: string | null
|
||||
}
|
||||
|
||||
|
||||
@@ -4,13 +4,13 @@ import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vites
|
||||
import { listAllPlatformWarehouses } from '@/modules/warehouses'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import QueryToolbar from '@/shared/components/QueryToolbar.vue'
|
||||
import { createPallet, getPallet, listPallets, listReceivingOverseasWarehouses, updatePallet } from '../api/pallets'
|
||||
import { createPallet, getPallet, getPalletSummary, listPallets, listReceivingOverseasWarehouses, updatePallet } from '../api/pallets'
|
||||
import PalletsView from './PalletsView.vue'
|
||||
|
||||
vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
|
||||
vi.mock('@/modules/warehouses', () => ({ listAllPlatformWarehouses: vi.fn() }))
|
||||
vi.mock('../api/pallets', () => ({ createPallet: vi.fn(), getPallet: vi.fn(), listPallets: vi.fn(), listReceivingOverseasWarehouses: vi.fn(), updatePallet: vi.fn() }))
|
||||
vi.mock('../api/pallets', () => ({ createPallet: vi.fn(), getPallet: vi.fn(), getPalletSummary: vi.fn(), listPallets: vi.fn(), listReceivingOverseasWarehouses: vi.fn(), updatePallet: vi.fn() }))
|
||||
|
||||
class ResizeObserverStub implements ResizeObserver { observe() {} unobserve() {} disconnect() {} }
|
||||
function memoryStorage(): Storage { const values = new Map<string, string>(); return { get length() { return values.size }, clear: () => values.clear(), getItem: (key) => values.get(key) ?? null, key: (index) => [...values.keys()][index] ?? null, removeItem: (key) => values.delete(key), setItem: (key, value) => values.set(key, value) } }
|
||||
@@ -25,11 +25,25 @@ describe('PalletsView', () => {
|
||||
vi.mocked(listAllPlatformWarehouses).mockReset().mockResolvedValue([{ id: '300', warehouseCode: 'ABE3', warehouseName: 'ABE3', warehouseType: 'FBA', status: 'ACTIVE' } as never])
|
||||
const pallet = { id: '9007199254740993', customerId: '0', warehouseId: '200', locationId: null, palletCode: 'PLT-1', palletType: 'WOOD', fbaWarehouseCode: 'ABE3', capacityCbm: 2, unitPrice: 10, status: 'IDLE', remark: null, version: 7 }
|
||||
vi.mocked(listPallets).mockReset().mockResolvedValue({ records: [pallet], page: 1, size: 10, total: 1, pages: 1 })
|
||||
vi.mocked(getPalletSummary).mockReset().mockResolvedValue({ totalCount: 1, idleCount: 1, occupiedCount: 0, scrappedCount: 0 })
|
||||
vi.mocked(getPallet).mockReset().mockResolvedValue(pallet)
|
||||
vi.mocked(createPallet).mockReset()
|
||||
vi.mocked(updatePallet).mockReset().mockResolvedValue(null)
|
||||
})
|
||||
|
||||
it('loads the all-warehouse pallet summary when the page opens', async () => {
|
||||
vi.mocked(getPalletSummary).mockResolvedValueOnce({ totalCount: 250, idleCount: 100, occupiedCount: 98, scrappedCount: 52 })
|
||||
|
||||
const wrapper = mount(PalletsView, { attachTo: document.body, global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
|
||||
expect(getPalletSummary).toHaveBeenCalledWith('', {})
|
||||
expect(wrapper.get('[aria-label="海外托盘数量概览"]').text()).toContain('托盘总数250')
|
||||
expect(wrapper.get('[aria-label="海外托盘数量概览"]').text()).toContain('闲置托盘100')
|
||||
expect(wrapper.get('[aria-label="海外托盘数量概览"]').text()).toContain('占用托盘98')
|
||||
expect(wrapper.get('[aria-label="海外托盘数量概览"]').text()).toContain('已报废52')
|
||||
})
|
||||
|
||||
it('loads the current detail version and keeps the edit open on a conflict', async () => {
|
||||
vi.mocked(updatePallet).mockRejectedValueOnce(Object.assign(new Error('version conflict'), { code: 'A0409' }))
|
||||
const wrapper = mount(PalletsView, { attachTo: document.body, global: { plugins: [ElementPlus] } })
|
||||
|
||||
@@ -14,7 +14,7 @@ import ListPageLayout from '@/shared/components/ListPageLayout.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
|
||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||
import { createPallet, getPallet, listPallets, listReceivingOverseasWarehouses, updatePallet, type PalletDto, type ReceivingOverseasWarehouseDto } from '../api/pallets'
|
||||
import { createPallet, getPallet, getPalletSummary, listPallets, listReceivingOverseasWarehouses, updatePallet, type PalletDto, type PalletSummaryDto, type ReceivingOverseasWarehouseDto } from '../api/pallets'
|
||||
import { overseasWarehousePermissions as permission } from '../permissions'
|
||||
import type { PalletListItem } from '../types'
|
||||
|
||||
@@ -32,6 +32,7 @@ const loadErrorKind = ref<'context' | 'list'>('context')
|
||||
const page = ref(1)
|
||||
const pageSize = ref(10)
|
||||
const total = ref(0)
|
||||
const summary = ref<PalletSummaryDto>({ totalCount: 0, idleCount: 0, occupiedCount: 0, scrappedCount: 0 })
|
||||
const filters = ref<QueryModel>({ warehouseId: '', code: '', type: '', status: '' })
|
||||
const fields = computed<QueryField[]>(() => [
|
||||
{ key: 'warehouseId', type: 'select', label: '仓库', width: 240, placeholder: contextLoading.value ? '仓库加载中' : '请选择仓库', options: warehouses.value.map((item) => ({ label: `${item.warehouseName}(${item.warehouseCode})`, value: String(item.id) })), props: { filterable: true, clearable: true, disabled: contextLoading.value } },
|
||||
@@ -62,12 +63,14 @@ async function loadWarehouses() {
|
||||
warehouses.value = []; filters.value.warehouseId = ''; rows.value = []; total.value = 0
|
||||
contextLoading.value = true; loadError.value = null; loadErrorKind.value = 'context'
|
||||
try {
|
||||
const [warehousePage, platformFbaWarehouses] = await Promise.all([
|
||||
const [warehousePage, platformFbaWarehouses, overallSummary] = await Promise.all([
|
||||
listReceivingOverseasWarehouses(),
|
||||
listAllPlatformWarehouses({ warehouseType: 'FBA', status: 'ACTIVE' }),
|
||||
getPalletSummary('', {}),
|
||||
])
|
||||
warehouses.value = warehousePage.records
|
||||
fbaWarehouses.value = platformFbaWarehouses
|
||||
summary.value = overallSummary
|
||||
}
|
||||
catch (error) { loadError.value = getApiErrorDetails(error, '海外仓列表加载失败,请重试') }
|
||||
finally { contextLoading.value = false }
|
||||
@@ -78,8 +81,10 @@ async function load(model: QueryModel = filters.value) {
|
||||
const warehouseId = String(filters.value.warehouseId || '')
|
||||
loading.value = true; loadError.value = null; loadErrorKind.value = 'list'
|
||||
try {
|
||||
const result = await listPallets(warehouseId, { page: page.value, size: pageSize.value, keyword: String(filters.value.code || '').trim() || undefined, palletType: String(filters.value.type || '') || undefined, status: String(filters.value.status || '') || undefined })
|
||||
const query = { page: page.value, size: pageSize.value, keyword: String(filters.value.code || '').trim() || undefined, palletType: String(filters.value.type || '') || undefined, status: String(filters.value.status || '') || undefined }
|
||||
const [result, summaryResult] = await Promise.all([listPallets(warehouseId, query), getPalletSummary(warehouseId, query)])
|
||||
rows.value = result.records.map(toRow); total.value = result.total
|
||||
summary.value = summaryResult
|
||||
} catch (error) { rows.value = []; total.value = 0; loadError.value = getApiErrorDetails(error, '托盘列表加载失败,请重试') }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
@@ -121,7 +126,9 @@ onMounted(loadWarehouses)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminPage><OperationErrorToast :details="mutationError" /><ListPageLayout aria-label="托盘管理列表">
|
||||
<AdminPage><OperationErrorToast :details="mutationError" />
|
||||
<section class="summary-grid" aria-label="海外托盘数量概览"><article><span>托盘总数</span><strong>{{ summary.totalCount }}</strong></article><article><span>闲置托盘</span><strong class="idle">{{ summary.idleCount }}</strong></article><article><span>占用托盘</span><strong class="occupied">{{ summary.occupiedCount }}</strong></article><article><span>已报废</span><strong>{{ summary.scrappedCount }}</strong></article></section>
|
||||
<ListPageLayout aria-label="托盘管理列表">
|
||||
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" :fields="fields" :loading="loading || contextLoading" ariaLabel="托盘筛选" preference-key="overseas-pallets-query" @search="search" @reset="page = 1; rows = []; total = 0" /></template>
|
||||
<template v-if="loadError" #state><LoadErrorState title="托盘数据加载失败" :message="loadError.message" :loading="loading || contextLoading" @retry="retryLoad"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template>
|
||||
<section v-if="!loadError" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-pallets" row-key="id" :empty-text="filters.warehouseId ? '暂无托盘' : '请先选择仓库后查询'" @update:page="load()" @update:page-size="page = 1; load()">
|
||||
@@ -146,6 +153,7 @@ onMounted(loadWarehouses)
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.summary-grid { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:16px; margin-bottom:16px; }.summary-grid article { display:grid; gap:8px; padding:18px 20px; border:1px solid var(--cc-border); border-radius:8px; background:#fff; }.summary-grid span { color:var(--cc-muted); font-size:12px; }.summary-grid strong { font-size:26px; }.summary-grid .idle { color:var(--el-color-success); }.summary-grid .occupied { color:var(--el-color-warning); }
|
||||
.el-form :deep(.el-row) { margin-left: 0 !important; margin-right: 0 !important; }
|
||||
.el-form :deep(.el-input), .el-form :deep(.el-select), .el-form :deep(.el-input-number) { width: 100%; }
|
||||
</style>
|
||||
|
||||
@@ -15,16 +15,17 @@ const detail = ref<RelabelDetailDto | null>(null)
|
||||
const loading = ref(false)
|
||||
const loadError = ref<ApiErrorDetails | null>(null)
|
||||
const groups = [
|
||||
{ title: '单据信息', fields: ['换标单号', '关联订单', '状态', '创建时间', '更新时间'] },
|
||||
{ title: '单据信息', fields: ['换标单号', '关联订单', '状态', '资金状态', '创建时间', '更新时间'] },
|
||||
{ title: '商品信息', fields: ['商品SKU', '中文品名', '箱数', '预算总价'] },
|
||||
{ title: '作业要求', fields: ['贴标要求', '上传文件', '备注'] },
|
||||
{ title: '费用明细', fields: ['开箱费', '挑货费', '换标费', '换箱唛标费', '换标费总计'] },
|
||||
{ title: '费用明细', fields: ['换标费总计', '资金异常说明'] },
|
||||
]
|
||||
function text(value: unknown) { return String(value ?? '').trim() || '—' }
|
||||
function status(value: unknown) {
|
||||
const labels: Record<string, string> = { PENDING_PROCESSING: '待处理', PROCESSING: '处理中', COMPLETED: '已完成', REJECTED: '已驳回', CANCELLED: '已取消' }
|
||||
return labels[String(value || '')] || text(value)
|
||||
}
|
||||
function walletStatus(value: unknown) { return ({ FREEZE_PENDING: '冻结中', FROZEN: '已冻结', SETTLE_PENDING: '结算中', SETTLED: '已结算', RELEASE_PENDING: '释放中', RELEASED: '已释放', FREEZE_FAILED: '冻结失败', SETTLE_FAILED: '结算失败', RELEASE_FAILED: '释放失败' } as Record<string,string>)[String(value || '')] || text(value) }
|
||||
const referenceValue = computed(() => text(detail.value?.application.relabelNo || '换标单号待补充'))
|
||||
const fieldValues = computed<Record<string, string | number>>(() => {
|
||||
const value = detail.value
|
||||
@@ -33,6 +34,7 @@ const fieldValues = computed<Record<string, string | number>>(() => {
|
||||
换标单号: text(value?.application.relabelNo),
|
||||
关联订单: text(value?.application.lastlegOrderNo || value?.application.customerOrderNo),
|
||||
状态: status(value?.application.status),
|
||||
资金状态: walletStatus(value?.application.walletStatus),
|
||||
创建时间: formatOverseasDateTime(value?.application.createdAt),
|
||||
更新时间: value?.history.length ? formatOverseasDateTime(value.history[value.history.length - 1]?.createdAt) : '—',
|
||||
商品SKU: items.map((item) => text(item.skuCode)).join('、') || '—',
|
||||
@@ -42,7 +44,8 @@ const fieldValues = computed<Record<string, string | number>>(() => {
|
||||
贴标要求: items.map((item) => text(item.labelRequirement)).join(';') || '—',
|
||||
上传文件: items.map((item) => text(item.relabelFileName)).filter((item) => item !== '—').join('、') || '—',
|
||||
备注: text(value?.remark),
|
||||
开箱费: '—', 挑货费: '—', 换标费: '—', 换箱唛标费: '—', 换标费总计: '—',
|
||||
换标费总计: value?.application.chargeAmount == null ? '—' : `${Number(value.application.chargeAmount).toFixed(2)} ${value.application.chargeCurrency || 'USD'}`,
|
||||
资金异常说明: text(value?.application.walletErrorMessage),
|
||||
}
|
||||
})
|
||||
async function load() {
|
||||
|
||||
@@ -82,6 +82,7 @@ describe('RelabelOrdersView', () => {
|
||||
formItem(wrapper, '商品SKU').findComponent({ name: 'ElInput' }).vm.$emit('update:modelValue', 'SKU-1')
|
||||
formItem(wrapper, '中文品名').findComponent({ name: 'ElInput' }).vm.$emit('update:modelValue', '商品一')
|
||||
formItem(wrapper, '箱数').findComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 2)
|
||||
formItem(wrapper, '换标费用(CNY)').findComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 20)
|
||||
formItem(wrapper, '截止时间').findComponent({ name: 'ElDatePicker' }).vm.$emit('update:modelValue', '2026-08-30T10:00:00')
|
||||
formItem(wrapper, '联系人').findComponent({ name: 'ElInput' }).vm.$emit('update:modelValue', '张三')
|
||||
formItem(wrapper, '联系电话').findComponent({ name: 'ElInput' }).vm.$emit('update:modelValue', '13800000000')
|
||||
|
||||
@@ -43,6 +43,8 @@ const columns: DataTableColumn[] = [
|
||||
{ key: 'productName', label: '中文品名', minWidth: 180 },
|
||||
{ key: 'cartons', label: '箱数', width: 90, align: 'right' },
|
||||
{ key: 'budgetTotal', label: '预算总价', width: 120, align: 'right' },
|
||||
{ key: 'relabelCost', label: '换标费用', width: 120, align: 'right' },
|
||||
{ key: 'walletStatus', label: '资金状态', width: 120 },
|
||||
{ key: 'labelRequirement', label: '贴标要求', minWidth: 150 },
|
||||
{ key: 'fileName', label: '上传文件', minWidth: 160 },
|
||||
{ key: 'status', label: '状态', width: 110 },
|
||||
@@ -115,12 +117,12 @@ async function submitForm() {
|
||||
try {
|
||||
if (dialogMode.value === 'create') {
|
||||
if (!form.customerId || !form.warehouseId) { ElMessage.warning('请选择客户和公共海外收货仓'); return }
|
||||
if (!form.relatedOrderNo.trim() || !form.sku.trim() || !form.productName.trim() || !form.cartons || !form.labelDeadline || !form.contactName.trim() || !form.contactPhone.trim() || !form.shipmentNo.trim() || !form.newSkuCode.trim() || !form.labelRequirement) {
|
||||
if (!form.relatedOrderNo.trim() || !form.sku.trim() || !form.productName.trim() || !form.cartons || !form.relabelCost || form.relabelCost <= 0 || !form.labelDeadline || !form.contactName.trim() || !form.contactPhone.trim() || !form.shipmentNo.trim() || !form.newSkuCode.trim() || !form.labelRequirement) {
|
||||
ElMessage.warning('请完整填写换标单必填信息')
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
await createRelabelOrder(form.customerId, { customerOrderNo: form.relatedOrderNo.trim(), overseasWarehouseId: form.warehouseId, labelDeadline: form.labelDeadline, contactName: form.contactName.trim(), contactPhone: form.contactPhone.trim(), remark: form.remark.trim() || undefined, idempotencyKey: createIdempotencyKey.value, items: [{ skuCode: form.sku.trim(), productNameZh: form.productName.trim(), totalPackageCount: Number(form.cartons), notifiedPackageCount: Number(form.cartons), shipmentNo: form.shipmentNo.trim(), newSkuCode: form.newSkuCode.trim(), labelRequirement: form.labelRequirement }] })
|
||||
await createRelabelOrder(form.customerId, { customerOrderNo: form.relatedOrderNo.trim(), overseasWarehouseId: form.warehouseId, labelDeadline: form.labelDeadline, contactName: form.contactName.trim(), contactPhone: form.contactPhone.trim(), chargeAmount: Number(form.relabelCost), remark: form.remark.trim() || undefined, idempotencyKey: createIdempotencyKey.value, items: [{ skuCode: form.sku.trim(), productNameZh: form.productName.trim(), totalPackageCount: Number(form.cartons), notifiedPackageCount: Number(form.cartons), shipmentNo: form.shipmentNo.trim(), newSkuCode: form.newSkuCode.trim(), labelRequirement: form.labelRequirement }] })
|
||||
ElMessage.success('换标单已创建')
|
||||
} else {
|
||||
if (!allowedStatusOptions.value.includes(form.status)) { ElMessage.warning('当前状态没有可提交的审核流转'); return }
|
||||
@@ -148,6 +150,7 @@ function relabelStatusCode(value: unknown) {
|
||||
const codes: Record<string, string> = { 待处理: 'PENDING_PROCESSING', 处理中: 'PROCESSING', 已完成: 'COMPLETED', 已驳回: 'REJECTED', 已取消: 'CANCELLED' }
|
||||
return codes[String(value || '')] || String(value || 'PENDING_PROCESSING')
|
||||
}
|
||||
function walletStatus(value: unknown) { return ({ FREEZE_PENDING: '冻结中', FROZEN: '已冻结', SETTLE_PENDING: '结算中', SETTLED: '已结算', RELEASE_PENDING: '释放中', RELEASED: '已释放', FREEZE_FAILED: '冻结失败', SETTLE_FAILED: '结算失败', RELEASE_FAILED: '释放失败' } as Record<string,string>)[String(value || '')] || String(value || '—') }
|
||||
function canAudit(source: object) {
|
||||
const row = source as RelabelOrderListItem
|
||||
return ['PENDING_PROCESSING', 'PROCESSING'].includes(relabelStatusCode(row.status))
|
||||
@@ -159,7 +162,7 @@ async function load(model: QueryModel = filters.value) {
|
||||
const result = await listRelabelOrders({ page: page.value, size: pageSize.value, relabelNo: String(filters.value.relabelNo || '').trim() || undefined, skuCode: String(filters.value.sku || '').trim() || undefined, status: String(filters.value.status || '').trim() || undefined })
|
||||
rows.value = result.records.map((item) => ({
|
||||
customerId: '', orderNo: String(item.relabelNo), relatedOrderNo: String(item.lastlegOrderNo || item.customerOrderNo || '—'), sku: String(item.skuCodes || '—'),
|
||||
productName: '—', cartons: Number(item.totalPackageCount || 0), budgetTotal: null, labelRequirement: '—', fileName: '—', status: relabelStatus(item.status), remark: '',
|
||||
productName: '—', cartons: Number(item.totalPackageCount || 0), budgetTotal: null, relabelCost: Number(item.chargeAmount || 0), walletStatus: walletStatus(item.walletStatus), walletErrorMessage: item.walletErrorMessage || '', labelRequirement: '—', fileName: '—', status: relabelStatus(item.status), remark: '',
|
||||
createdAt: formatOverseasDateTime(item.createdAt),
|
||||
}))
|
||||
total.value = result.total
|
||||
@@ -199,7 +202,7 @@ watch(dialogOpen, (open) => { if (!open) createIdempotencyKey.value = '' })
|
||||
<el-col :span="12"><el-form-item label="贴标要求" required><el-select v-model="form.labelRequirement" :disabled="dialogMode === 'edit'" placeholder="请选择贴标要求"><el-option v-for="value in ['换箱唛', '换SKU标', '换箱唛和SKU标']" :key="value" :label="value" :value="value" /></el-select></el-form-item></el-col>
|
||||
<el-col :span="12"><el-form-item label="货件号" required><el-input v-model="form.shipmentNo" :disabled="dialogMode === 'edit'" placeholder="请输入货件号" /></el-form-item></el-col>
|
||||
<el-col :span="12"><el-form-item label="新SKU" required><el-input v-model="form.newSkuCode" :disabled="dialogMode === 'edit'" placeholder="请输入新SKU" /></el-form-item></el-col>
|
||||
<el-col v-if="dialogMode === 'edit'" :span="12"><el-form-item label="预算报价(只读)"><el-input-number v-model="form.relabelCost" disabled :min="0" :precision="2" controls-position="right" /></el-form-item></el-col>
|
||||
<el-col :span="12"><el-form-item label="换标费用(CNY)" required><el-input-number v-model="form.relabelCost" :disabled="dialogMode === 'edit'" :min="0.01" :precision="2" controls-position="right" placeholder="请输入换标费用" /></el-form-item></el-col>
|
||||
<el-col :span="24"><el-form-item label="备注"><el-input v-model="form.remark" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="请输入备注(选填)" /></el-form-item></el-col>
|
||||
</el-row></el-form>
|
||||
</AppDialog>
|
||||
|
||||
@@ -357,7 +357,7 @@ describe('overseas warehouse first-batch prototype contract', () => {
|
||||
'inTransit', 'updatedAt',
|
||||
])
|
||||
expect(columnKeys('RelabelOrdersView.vue')).toEqual([
|
||||
'actions', 'orderNo', 'sku', 'productName', 'cartons', 'budgetTotal', 'labelRequirement', 'fileName', 'status', 'createdAt',
|
||||
'actions', 'orderNo', 'sku', 'productName', 'cartons', 'budgetTotal', 'relabelCost', 'walletStatus', 'labelRequirement', 'fileName', 'status', 'createdAt',
|
||||
])
|
||||
expect(columnKeys('PalletsView.vue')).toEqual([
|
||||
'actions', 'code', 'type', 'warehouse', 'volume', 'purchasePrice', 'fbaWarehouse', 'status', 'remark',
|
||||
|
||||
@@ -126,6 +126,8 @@ export interface RelabelOrderListItem {
|
||||
budgetTotal: number | null
|
||||
labelRequirement: string
|
||||
relabelCost?: number
|
||||
walletStatus?: string
|
||||
walletErrorMessage?: string
|
||||
fileName: string
|
||||
status: string
|
||||
remark: string
|
||||
|
||||
@@ -21,6 +21,7 @@ export interface PlatformPalletPage {
|
||||
total: number
|
||||
pages: number
|
||||
}
|
||||
export interface PlatformPalletSummary { totalCount: number; idleCount: number; occupiedCount: number }
|
||||
|
||||
export interface PlatformPalletQuery {
|
||||
page: number
|
||||
@@ -61,6 +62,10 @@ export async function listPlatformPallets(query: PlatformPalletQuery) {
|
||||
pages: Number(result.pages),
|
||||
}
|
||||
}
|
||||
export async function getPlatformPalletSummary(query: PlatformPalletQuery) {
|
||||
const response = await http.get<ApiEnvelope<PlatformPalletSummary>, ApiEnvelope<PlatformPalletSummary>>(`${basePath}/summary`, { params: { tenantId: platformResourceScope, warehouseId: query.warehouseId || undefined, palletCode: query.palletCode?.trim() || undefined, palletType: query.palletType?.trim() || undefined, page: 1, size: 1 } })
|
||||
const value = unwrapApiResponse(response); return { totalCount: Number(value.totalCount), idleCount: Number(value.idleCount), occupiedCount: Number(value.occupiedCount) }
|
||||
}
|
||||
|
||||
export async function getPlatformPallet(
|
||||
warehouseId: number | string,
|
||||
|
||||
@@ -12,6 +12,7 @@ import QueryToolbar from '@/shared/components/QueryToolbar.vue'
|
||||
import {
|
||||
createPlatformPallet,
|
||||
getPlatformPallet,
|
||||
getPlatformPalletSummary,
|
||||
listPlatformPallets,
|
||||
type PlatformPalletResource,
|
||||
} from '../api/platformPallets'
|
||||
@@ -22,6 +23,7 @@ const palletsSource = readFileSync(resolve(process.cwd(), 'src/modules/warehouse
|
||||
vi.mock('@/modules/warehouses', () => ({ listAllPlatformWarehouses: vi.fn() }))
|
||||
vi.mock('../api/platformPallets', () => ({
|
||||
listPlatformPallets: vi.fn(),
|
||||
getPlatformPalletSummary: vi.fn(),
|
||||
getPlatformPallet: vi.fn(),
|
||||
createPlatformPallet: vi.fn(),
|
||||
updatePlatformPallet: vi.fn(),
|
||||
@@ -134,6 +136,7 @@ describe('PalletsView', () => {
|
||||
document.body.innerHTML = ''
|
||||
vi.mocked(listAllPlatformWarehouses).mockReset().mockResolvedValue([warehouse, fbaWarehouse, overseasWarehouse])
|
||||
vi.mocked(listPlatformPallets).mockReset().mockResolvedValue({ records: [pallet], page: 1, size: 20, total: 1, pages: 1 })
|
||||
vi.mocked(getPlatformPalletSummary).mockReset().mockResolvedValue({ totalCount: 1, idleCount: 1, occupiedCount: 0 })
|
||||
vi.mocked(getPlatformPallet).mockReset().mockResolvedValue(pallet)
|
||||
vi.mocked(createPlatformPallet).mockReset().mockResolvedValue('20002')
|
||||
})
|
||||
|
||||
@@ -16,9 +16,11 @@ import {
|
||||
createPlatformPallet,
|
||||
deletePlatformPallet,
|
||||
getPlatformPallet,
|
||||
getPlatformPalletSummary,
|
||||
listPlatformPallets,
|
||||
updatePlatformPallet,
|
||||
type PlatformPalletResource,
|
||||
type PlatformPalletSummary,
|
||||
type SavePlatformPalletRequest,
|
||||
} from '../api/platformPallets'
|
||||
|
||||
@@ -32,6 +34,7 @@ const loadError = ref<ApiErrorDetails | null>(null)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const total = ref(0)
|
||||
const summary = ref<PlatformPalletSummary>({ totalCount: 0, idleCount: 0, occupiedCount: 0 })
|
||||
const editorOpen = ref(false)
|
||||
const saving = ref(false)
|
||||
const deletingIds = ref(new Set<string>())
|
||||
@@ -187,16 +190,18 @@ async function loadPallets() {
|
||||
loadError.value = null
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await listPlatformPallets({
|
||||
const query = {
|
||||
page: page.value,
|
||||
size: pageSize.value,
|
||||
...(appliedFilters.value.warehouseId ? { warehouseId: apiId(appliedFilters.value.warehouseId) } : {}),
|
||||
...(appliedFilters.value.palletCode ? { palletCode: appliedFilters.value.palletCode } : {}),
|
||||
...(appliedFilters.value.palletType ? { palletType: appliedFilters.value.palletType } : {}),
|
||||
})
|
||||
}
|
||||
const [result, summaryResult] = await Promise.all([listPlatformPallets(query), getPlatformPalletSummary(query)])
|
||||
if (sequence !== palletSequence) return
|
||||
rows.value = result.records
|
||||
total.value = result.total
|
||||
summary.value = summaryResult
|
||||
} catch (error) {
|
||||
if (sequence !== palletSequence) return
|
||||
loadError.value = getApiErrorDetails(error, '托盘列表加载失败,请稍后重试')
|
||||
@@ -339,6 +344,11 @@ onMounted(() => {
|
||||
|
||||
<template>
|
||||
<AdminPage as="div" class="pallet-page">
|
||||
<section class="summary-grid" aria-label="托盘数量概览">
|
||||
<article><span>托盘总数</span><strong>{{ summary.totalCount }}</strong></article>
|
||||
<article><span>闲置托盘</span><strong class="idle">{{ summary.idleCount }}</strong></article>
|
||||
<article><span>占用托盘</span><strong class="occupied">{{ summary.occupiedCount }}</strong></article>
|
||||
</section>
|
||||
<ListPageLayout aria-label="托盘列表页面">
|
||||
<template #toolbar>
|
||||
<QueryToolbar
|
||||
@@ -468,6 +478,9 @@ onMounted(() => {
|
||||
|
||||
<style scoped>
|
||||
.pallet-page { display: grid; min-width: 0; gap: 16px; }
|
||||
.summary-grid { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:16px; }
|
||||
.summary-grid article { display:grid; gap:8px; padding:18px 20px; border:1px solid var(--cc-border); border-radius:8px; background:#fff; }
|
||||
.summary-grid span { color:var(--cc-muted); font-size:12px; }.summary-grid strong { font-size:26px; }.summary-grid .idle { color:var(--el-color-success); }.summary-grid .occupied { color:var(--el-color-warning); }
|
||||
.surface-panel { overflow: hidden; border: 1px solid var(--cc-border); border-radius: 8px; background: #fff; }
|
||||
.table-panel { min-width: 0; }
|
||||
.context-empty { padding: 56px 20px; color: var(--cc-muted); text-align: center; }
|
||||
@@ -489,6 +502,7 @@ onMounted(() => {
|
||||
.editor-form :deep(.el-form-item) { margin-bottom: 18px; }
|
||||
.editor-form :deep(.el-form-item__label) { padding-bottom: 7px; color: #344054; font-size: 12px; font-weight: 650; line-height: 1.4; }
|
||||
@media (max-width: 720px) {
|
||||
.summary-grid { grid-template-columns:1fr; }
|
||||
.editor-field-grid { grid-template-columns: minmax(0, 1fr); }
|
||||
}
|
||||
.editor-remark-field { margin-bottom: 0 !important; }
|
||||
|
||||
Reference in New Issue
Block a user