diff --git a/src/modules/iam/pages/RolesView.test.ts b/src/modules/iam/pages/RolesView.test.ts index f7142337..10a1a56a 100644 --- a/src/modules/iam/pages/RolesView.test.ts +++ b/src/modules/iam/pages/RolesView.test.ts @@ -43,7 +43,7 @@ function mountView(pinia = createPinia()) { AdminPage: { template: '
' }, ListPageLayout: { template: '
' }, FormSection: { template: '
' }, - AppDrawer: { template: '
' }, + AppDialog: { template: '
' }, LoadErrorState: { props: ['message'], template: '
{{ message }}
' }, DataTable: { props: ['data'], template: '
{{ data.length }}
' }, 'el-alert': { props: ['title'], template: '
{{ title }}
' }, @@ -95,6 +95,15 @@ describe('RolesView', () => { expect(rolesSource).toContain('aria-label="平台角色列表"') }) + it('uses the prototype-sized centered role editor with responsive split panes', () => { + expect(rolesSource).toContain(' { const pinia = createPinia() setActivePinia(pinia) diff --git a/src/modules/iam/pages/RolesView.vue b/src/modules/iam/pages/RolesView.vue index 6f7cb4b1..5cd395f0 100644 --- a/src/modules/iam/pages/RolesView.vue +++ b/src/modules/iam/pages/RolesView.vue @@ -19,7 +19,7 @@ import StatusBadge from '@/shared/components/StatusBadge.vue' import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue' import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue' import AdminPage from '@/shared/components/AdminPage.vue' -import AppDrawer from '@/shared/components/AppDrawer.vue' +import AppDialog from '@/shared/components/AppDialog.vue' import FormSection from '@/shared/components/FormSection.vue' import ListPageLayout from '@/shared/components/ListPageLayout.vue' import LoadErrorState from '@/shared/components/LoadErrorState.vue' @@ -259,11 +259,30 @@ onMounted(loadRoles) - - -
- + + +
+ + + + + + +
-
+
+ + diff --git a/src/modules/loading-lists/api/containerFees.test.ts b/src/modules/loading-lists/api/containerFees.test.ts index 20646267..2f0e0d3b 100644 --- a/src/modules/loading-lists/api/containerFees.test.ts +++ b/src/modules/loading-lists/api/containerFees.test.ts @@ -1,13 +1,13 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { http } from '@/shared/api/http' -import { adjustContainerFee, approveContainerFee, createContainerFee, listContainerCostDetails, listContainerFees, rejectContainerFee, voidContainerFee } from './containerFees' +import { adjustContainerFee, approveContainerFee, createContainerFee, listContainerCostDetails, listContainerFeePartnerOptions, listContainerFees, rejectContainerFee, voidContainerFee } from './containerFees' describe('container fee API', () => { beforeEach(() => vi.restoreAllMocks()) it('passes accountId and exact controller DTOs', async () => { const get = vi.spyOn(http, 'get').mockResolvedValue({ code: '200', data: [] }) const post = vi.spyOn(http, 'post').mockResolvedValue({ code: '200', data: { id: 3, sourceVersion: 1 } }) - const values = { feeItemId: '7', direction: 'PAYABLE' as const, businessDate: '2026-08-12', amount: 100, currency: 'CNY' as const, settlementIncluded: true, remark: null } + const values = { feeItemId: '7', direction: 'PAYABLE' as const, partnerId: '41', partnerName: '承运商A', businessDate: '2026-08-12', amount: 100, currency: 'CNY' as const, settlementIncluded: true, remark: null } await listContainerFees('9', '11'); await createContainerFee('9', '11', values) await approveContainerFee('9', '11', '3'); await rejectContainerFee('9', '11', '3', '资料不全') await adjustContainerFee('9', '11', '3', 2, values); await voidContainerFee('9', '11', '3', 3) @@ -17,6 +17,12 @@ describe('container fee API', () => { expect(post).toHaveBeenCalledWith('/platform/v1/loading-plans/9/fee-details/3/void', { expectedSourceVersion: 3 }, { params: { accountId: '11' } }) }) + it('loads only ACTIVE counterparties for payable selection', async () => { + const get = vi.spyOn(http, 'get').mockResolvedValue({ code: '200', data: { records: [{ id: 41, partnerName: '承运商A' }] } }) + await expect(listContainerFeePartnerOptions()).resolves.toEqual([{ id: '41', partnerName: '承运商A' }]) + expect(get).toHaveBeenCalledWith('/platform/v1/partners', { params: { status: 'ACTIVE', page: 1, size: 200 } }) + }) + it('reads allocation rows from Finance and never calculates them', async () => { const get = vi.spyOn(http, 'get') .mockResolvedValueOnce({ code: '200', data: [{ id: 81, containerId: 99 }] }) diff --git a/src/modules/loading-lists/api/containerFees.ts b/src/modules/loading-lists/api/containerFees.ts index cb09d9ab..3761e58a 100644 --- a/src/modules/loading-lists/api/containerFees.ts +++ b/src/modules/loading-lists/api/containerFees.ts @@ -11,6 +11,8 @@ export interface ContainerFeeResource { feeItemId: string feeItemName: string direction: ContainerFeeDirection + partnerId: string | null + partnerName: string | null businessDate: string amount: number currency: 'CNY' @@ -27,6 +29,8 @@ export interface ContainerFeeResource { export interface ContainerFeeSaveRequest { feeItemId: string direction: ContainerFeeDirection + partnerId: string | null + partnerName: string | null businessDate: string amount: number currency: 'CNY' @@ -43,6 +47,11 @@ export interface ApplicableFeeItem { pickupPayable: boolean } +export interface ContainerFeePartnerOption { + id: string + partnerName: string +} + export interface ContainerCostAllocation { id: string orderId: string @@ -73,6 +82,7 @@ function normalizeFee(raw: Record): ContainerFeeResource { id: id(raw.id), loadingPlanId: id(raw.loadingPlanId), containerBookingId: raw.containerBookingId == null ? null : id(raw.containerBookingId), containerNo: raw.containerNo == null ? null : String(raw.containerNo), feeItemId: id(raw.feeItemId), feeItemName: String(raw.feeItemNameSnapshot || ''), direction: raw.direction as ContainerFeeDirection, + partnerId: raw.partnerId == null ? null : id(raw.partnerId), partnerName: raw.partnerName == null ? null : String(raw.partnerName), businessDate: String(raw.businessDate || ''), amount: numberValue(raw.amount), currency: 'CNY', settlementIncluded: Boolean(raw.settlementIncluded), reviewStatus: raw.reviewStatus as ContainerFeeReviewStatus, sourceStatus: raw.sourceStatus as ContainerFeeResource['sourceStatus'], sourceVersion: numberValue(raw.sourceVersion), @@ -113,6 +123,11 @@ export async function listApplicableFeeItems() { return (unwrapApiResponse(response).records || []).map((raw) => ({ id: id(raw.id), name: String(raw.feeTypeName || ''), warehouseReceivable: Boolean(raw.warehouseReceivable), warehousePayable: Boolean(raw.warehousePayable), pickupReceivable: Boolean(raw.pickupReceivable), pickupPayable: Boolean(raw.pickupPayable) })) } +export async function listContainerFeePartnerOptions(): Promise { + const response = await http.get[] }>, ApiEnvelope<{ records: Record[] }>>('/platform/v1/partners', { params: { status: 'ACTIVE', page: 1, size: 200 } }) + return (unwrapApiResponse(response).records || []).map((raw) => ({ id: id(raw.id), partnerName: String(raw.partnerName || '') })) +} + export async function listContainerCostDetails(customerId: string, containerId: string): Promise { const response = await http.get[]>, ApiEnvelope[]>>('/platform/finance/v1/cost-entries', { params: { customerId } }) const costs = unwrapApiResponse(response).filter((raw) => id(raw.containerId) === containerId) diff --git a/src/modules/loading-lists/components/ContainerFeesPanel.test.ts b/src/modules/loading-lists/components/ContainerFeesPanel.test.ts index cc729172..5cb0b454 100644 --- a/src/modules/loading-lists/components/ContainerFeesPanel.test.ts +++ b/src/modules/loading-lists/components/ContainerFeesPanel.test.ts @@ -6,12 +6,12 @@ import { readFileSync } from 'node:fs' import { resolve } from 'node:path' import { useAccessPreviewStore } from '@/core/access/accessPreview' import { useAuthStore } from '@/core/auth/auth' -import { createContainerFee, listApplicableFeeItems, listContainerCostDetails, listContainerFees } from '../api/containerFees' +import { createContainerFee, listApplicableFeeItems, listContainerCostDetails, listContainerFeePartnerOptions, listContainerFees } from '../api/containerFees' import ContainerFeesPanel from './ContainerFeesPanel.vue' vi.mock('../api/containerFees', async (importOriginal) => { const actual = await importOriginal() - return { ...actual, listContainerFees: vi.fn(), listApplicableFeeItems: vi.fn(), listContainerCostDetails: vi.fn(), createContainerFee: vi.fn(), adjustContainerFee: vi.fn(), approveContainerFee: vi.fn(), rejectContainerFee: vi.fn(), voidContainerFee: vi.fn() } + return { ...actual, listContainerFees: vi.fn(), listApplicableFeeItems: vi.fn(), listContainerFeePartnerOptions: vi.fn(), listContainerCostDetails: vi.fn(), createContainerFee: vi.fn(), adjustContainerFee: vi.fn(), approveContainerFee: vi.fn(), rejectContainerFee: vi.fn(), voidContainerFee: vi.fn() } }) const panel = readFileSync(resolve(process.cwd(), 'src/modules/loading-lists/components/ContainerFeesPanel.vue'), 'utf8') @@ -30,6 +30,7 @@ async function mountPanel(permissions: string[], customers = [{ accountId: '11', beforeEach(() => { vi.stubGlobal('ResizeObserver', ResizeObserverStub); vi.stubGlobal('localStorage', memoryStorage()); vi.stubGlobal('sessionStorage', memoryStorage()) vi.mocked(listContainerFees).mockReset().mockResolvedValue([]); vi.mocked(listApplicableFeeItems).mockReset().mockResolvedValue([]) + vi.mocked(listContainerFeePartnerOptions).mockReset().mockResolvedValue([]) vi.mocked(listContainerCostDetails).mockReset().mockResolvedValue([]); vi.mocked(createContainerFee).mockReset() document.body.innerHTML = '' }) @@ -51,6 +52,7 @@ describe('ContainerFeesPanel contract', () => { expect(panel).toContain('adjustContainerFee') expect(panel).toContain('voidContainerFee') expect(panel).toContain('调整后版本递增并重新进入待审核状态') + expect(panel).toContain("row.partnerName || '服务端未返回'") }) it('shows only server allocations and supplier snapshots', () => { @@ -71,7 +73,7 @@ describe('ContainerFeesPanel contract', () => { state.accountId = '11'; await flushPromises(); state.editorOpen = true state.accountId = '22'; await flushPromises() expect(state.fees).toEqual([]); expect(state.costs).toEqual([]); expect(state.editorOpen).toBe(false) - resolveFirst([{ id: 'old', loadingPlanId: '9', containerBookingId: '99', containerNo: null, feeItemId: '1', feeItemName: '旧客户费用', direction: 'RECEIVABLE', businessDate: '2026-08-12', amount: 10, currency: 'CNY', settlementIncluded: true, reviewStatus: 'PENDING', sourceStatus: 'ACTIVE', sourceVersion: 1, remark: null, reviewerName: null, reviewedAt: null, reviewReason: null }]) + resolveFirst([{ id: 'old', loadingPlanId: '9', containerBookingId: '99', containerNo: null, feeItemId: '1', feeItemName: '旧客户费用', direction: 'RECEIVABLE', partnerId: null, partnerName: null, businessDate: '2026-08-12', amount: 10, currency: 'CNY', settlementIncluded: true, reviewStatus: 'PENDING', sourceStatus: 'ACTIVE', sourceVersion: 1, remark: null, reviewerName: null, reviewedAt: null, reviewReason: null }]) await flushPromises() expect(state.fees).toEqual([]) expect(listContainerCostDetails).not.toHaveBeenCalledWith('11', expect.anything()) @@ -87,11 +89,55 @@ describe('ContainerFeesPanel contract', () => { it('hides all mutation buttons when permissions are missing', async () => { vi.mocked(listContainerFees).mockResolvedValue([ - { id: '1', loadingPlanId: '9', containerBookingId: '99', containerNo: null, feeItemId: '1', feeItemName: '待审费用', direction: 'RECEIVABLE', businessDate: '2026-08-12', amount: 10, currency: 'CNY', settlementIncluded: true, reviewStatus: 'PENDING', sourceStatus: 'ACTIVE', sourceVersion: 1, remark: null, reviewerName: null, reviewedAt: null, reviewReason: null }, - { id: '2', loadingPlanId: '9', containerBookingId: '99', containerNo: null, feeItemId: '2', feeItemName: '已审费用', direction: 'PAYABLE', businessDate: '2026-08-12', amount: 20, currency: 'CNY', settlementIncluded: true, reviewStatus: 'APPROVED', sourceStatus: 'ACTIVE', sourceVersion: 1, remark: null, reviewerName: null, reviewedAt: null, reviewReason: null }, + { id: '1', loadingPlanId: '9', containerBookingId: '99', containerNo: null, feeItemId: '1', feeItemName: '待审费用', direction: 'RECEIVABLE', partnerId: null, partnerName: null, businessDate: '2026-08-12', amount: 10, currency: 'CNY', settlementIncluded: true, reviewStatus: 'PENDING', sourceStatus: 'ACTIVE', sourceVersion: 1, remark: null, reviewerName: null, reviewedAt: null, reviewReason: null }, + { id: '2', loadingPlanId: '9', containerBookingId: '99', containerNo: null, feeItemId: '2', feeItemName: '已审费用', direction: 'PAYABLE', partnerId: '41', partnerName: '承运商A', businessDate: '2026-08-12', amount: 20, currency: 'CNY', settlementIncluded: true, reviewStatus: 'APPROVED', sourceStatus: 'ACTIVE', sourceVersion: 1, remark: null, reviewerName: null, reviewedAt: null, reviewReason: null }, ]) const wrapper = await mountPanel(['firstleg:inbound:fee-detail:view']) expect(wrapper.text()).not.toContain('新增费用') for (const label of ['通过', '驳回', '调整', '作废']) expect(wrapper.findAll('button').some((button) => button.text() === label)).toBe(false) }) + + it('keeps receivable usable when counterparty permission is missing', async () => { + vi.mocked(listApplicableFeeItems).mockResolvedValue([{ id: '7', name: '操作费', warehouseReceivable: true, warehousePayable: true, pickupReceivable: false, pickupPayable: false }]) + const wrapper = await mountPanel(['firstleg:inbound:fee-detail:view', 'firstleg:inbound:fee-detail:edit', 'firstleg:fee-item:view']) + const state = (wrapper.vm.$ as unknown as { setupState: { openCreate(): void; form: { direction: string; partnerId: string | null; partnerName: string | null } } }).setupState + state.openCreate(); await flushPromises() + expect(wrapper.text()).toContain('当前账号缺少往来单位查看权限,仅可维护应收费用') + expect(listApplicableFeeItems).toHaveBeenCalledTimes(1) + expect(listContainerFeePartnerOptions).not.toHaveBeenCalled() + expect(state.form.direction).toBe('RECEIVABLE') + }) + + it('isolates a counterparty load failure and clears partner fields for receivable', async () => { + vi.mocked(listApplicableFeeItems).mockResolvedValue([{ id: '7', name: '操作费', warehouseReceivable: true, warehousePayable: true, pickupReceivable: false, pickupPayable: false }]) + vi.mocked(listContainerFeePartnerOptions).mockRejectedValue(new Error('partners unavailable')) + const wrapper = await mountPanel(['firstleg:inbound:fee-detail:view', 'firstleg:inbound:fee-detail:edit', 'firstleg:fee-item:view', 'platform:counterparty:list']) + const state = (wrapper.vm.$ as unknown as { setupState: { openCreate(): void; form: { direction: string; partnerId: string | null; partnerName: string | null }; feeItems: unknown[] } }).setupState + state.openCreate(); await flushPromises() + expect(state.feeItems).toHaveLength(1) + expect(wrapper.text()).toContain('往来单位加载失败') + state.form.direction = 'PAYABLE'; await flushPromises() + state.form.partnerId = '41'; state.form.partnerName = '旧快照'; state.form.direction = 'RECEIVABLE'; await flushPromises() + expect(state.form.partnerId).toBeNull(); expect(state.form.partnerName).toBeNull() + }) + + it('submits the selected ACTIVE counterparty snapshot for payable fees', async () => { + vi.mocked(listApplicableFeeItems).mockResolvedValue([{ id: '7', name: '操作费', warehouseReceivable: true, warehousePayable: true, pickupReceivable: false, pickupPayable: false }]) + vi.mocked(listContainerFeePartnerOptions).mockResolvedValue([{ id: '41', partnerName: '承运商A' }]) + const wrapper = await mountPanel(['firstleg:inbound:fee-detail:view', 'firstleg:inbound:fee-detail:edit', 'firstleg:fee-item:view', 'platform:counterparty:list']) + const state = (wrapper.vm.$ as unknown as { setupState: { openCreate(): void; save(): Promise; form: { direction: string; feeItemId: string; partnerId: string | null; amount: number } } }).setupState + state.openCreate(); await flushPromises() + state.form.direction = 'PAYABLE'; state.form.feeItemId = '7'; state.form.partnerId = '41'; state.form.amount = 100; await flushPromises() + await state.save() + expect(createContainerFee).toHaveBeenCalledWith('9', '11', expect.objectContaining({ direction: 'PAYABLE', partnerId: '41', partnerName: '承运商A' })) + }) + + it('requires fee-item permission before opening a write form', async () => { + const wrapper = await mountPanel(['firstleg:inbound:fee-detail:view', 'firstleg:inbound:fee-detail:edit']) + const state = (wrapper.vm.$ as unknown as { setupState: { openCreate(): void; editorOpen: boolean } }).setupState + state.openCreate(); await flushPromises() + expect(state.editorOpen).toBe(false) + expect(wrapper.text()).toContain('当前账号缺少费用项目查看权限') + expect(listApplicableFeeItems).not.toHaveBeenCalled() + }) }) diff --git a/src/modules/loading-lists/components/ContainerFeesPanel.vue b/src/modules/loading-lists/components/ContainerFeesPanel.vue index 13f32c7a..1c099309 100644 --- a/src/modules/loading-lists/components/ContainerFeesPanel.vue +++ b/src/modules/loading-lists/components/ContainerFeesPanel.vue @@ -9,28 +9,35 @@ import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.v import LoadErrorState from '@/shared/components/LoadErrorState.vue' import StatusBadge from '@/shared/components/StatusBadge.vue' import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http' -import { adjustContainerFee, approveContainerFee, createContainerFee, listApplicableFeeItems, listContainerCostDetails, listContainerFees, rejectContainerFee, voidContainerFee, type ApplicableFeeItem, type ContainerCostDetail, type ContainerFeeDirection, type ContainerFeeResource, type ContainerFeeSaveRequest } from '../api/containerFees' +import { adjustContainerFee, approveContainerFee, createContainerFee, listApplicableFeeItems, listContainerCostDetails, listContainerFeePartnerOptions, listContainerFees, rejectContainerFee, voidContainerFee, type ApplicableFeeItem, type ContainerCostDetail, type ContainerFeePartnerOption, type ContainerFeeResource, type ContainerFeeSaveRequest } from '../api/containerFees' const props = defineProps<{ planId: string; customers: Array<{ accountId: string; customerName: string | null }> }>() const access = useAccessPreviewStore() const accountId = ref('') const fees = ref([]) const feeItems = ref([]) +const partners = ref([]) const costs = ref([]) const loading = ref(false) const error = ref(null) +const feeItemsError = ref(null) +const partnersError = ref(null) +const feeItemsLoading = ref(false) +const partnersLoading = ref(false) const editorOpen = ref(false) const rejectOpen = ref(false) const saving = ref(false) const target = ref(null) const formRef = ref() -const form = reactive({ feeItemId: '', direction: 'RECEIVABLE', businessDate: new Date().toISOString().slice(0, 10), amount: 0, currency: 'CNY', settlementIncluded: true, remark: null }) +const form = reactive({ feeItemId: '', direction: 'RECEIVABLE', partnerId: null, partnerName: null, businessDate: new Date().toISOString().slice(0, 10), amount: 0, currency: 'CNY', settlementIncluded: true, remark: null }) const rejection = ref('') let loadSequence = 0 const canView = computed(() => access.can('firstleg:inbound:fee-detail:view')) const canEdit = computed(() => access.can('firstleg:inbound:fee-detail:edit')) const canReview = computed(() => access.can('firstleg:inbound:fee-detail:review')) +const canViewFeeItems = computed(() => access.can('firstleg:fee-item:view')) +const canViewPartners = computed(() => access.can('platform:counterparty:list')) const customerOptions = computed(() => { const unique = new Map() for (const item of props.customers) if (item.accountId) unique.set(item.accountId, item.customerName || '客户信息缺失') @@ -44,6 +51,7 @@ const columns: DataTableColumn[] = [ { key: 'actions', label: '操作', fixed: 'left', width: 300, slotName: 'actions', hideable: false, movable: false }, { key: 'feeItemName', prop: 'feeItemName', label: '费用项目', minWidth: 150 }, { key: 'direction', label: '方向', width: 90, slotName: 'direction' }, + { key: 'partner', label: '往来单位/客户', minWidth: 170, slotName: 'partner' }, { key: 'amount', label: '金额(CNY)', width: 120, align: 'right', slotName: 'amount' }, { key: 'businessDate', prop: 'businessDate', label: '业务日期', width: 120 }, { key: 'reviewStatus', label: '审核状态', width: 110, slotName: 'reviewStatus' }, @@ -62,8 +70,26 @@ const rules: FormRules = { feeItemId: [{ required: true, message: '请选择费 function resetForm(item?: ContainerFeeResource) { target.value = item || null - Object.assign(form, item ? { feeItemId: item.feeItemId, direction: item.direction, businessDate: item.businessDate, amount: item.amount, currency: 'CNY', settlementIncluded: item.settlementIncluded, remark: item.remark } - : { feeItemId: '', direction: 'RECEIVABLE', businessDate: new Date().toISOString().slice(0, 10), amount: 0, currency: 'CNY', settlementIncluded: true, remark: null }) + Object.assign(form, item ? { feeItemId: item.feeItemId, direction: item.direction, partnerId: item.partnerId, partnerName: item.partnerName, businessDate: item.businessDate, amount: item.amount, currency: 'CNY', settlementIncluded: item.settlementIncluded, remark: item.remark } + : { feeItemId: '', direction: 'RECEIVABLE', partnerId: null, partnerName: null, businessDate: new Date().toISOString().slice(0, 10), amount: 0, currency: 'CNY', settlementIncluded: true, remark: null }) +} + +async function loadFeeItems() { + feeItems.value = []; feeItemsError.value = null + if (!canViewFeeItems.value) return + feeItemsLoading.value = true + try { feeItems.value = await listApplicableFeeItems() } + catch (cause) { feeItemsError.value = getApiErrorDetails(cause, '费用项目加载失败') } + finally { feeItemsLoading.value = false } +} + +async function loadPartners() { + partners.value = []; partnersError.value = null + if (!canViewPartners.value) return + partnersLoading.value = true + try { partners.value = await listContainerFeePartnerOptions() } + catch (cause) { partnersError.value = getApiErrorDetails(cause, '往来单位加载失败') } + finally { partnersLoading.value = false } } async function load() { @@ -72,9 +98,9 @@ async function load() { const requestedAccountId = accountId.value loading.value = true; error.value = null try { - const [feeRows, items] = await Promise.all([listContainerFees(props.planId, requestedAccountId), listApplicableFeeItems()]) + const feeRows = await listContainerFees(props.planId, requestedAccountId) if (sequence !== loadSequence || requestedAccountId !== accountId.value) return - fees.value = feeRows; feeItems.value = items + fees.value = feeRows costs.value = [] const currentContainerId = feeRows.find((item) => item.containerBookingId)?.containerBookingId if (currentContainerId) { @@ -86,14 +112,19 @@ async function load() { finally { if (sequence === loadSequence) loading.value = false } } -function openCreate() { if (!canEdit.value || !accountId.value) return; resetForm(); editorOpen.value = true } -function openAdjust(item: ContainerFeeResource) { if (!canEdit.value || !accountId.value) return; resetForm(item); editorOpen.value = true } +function openCreate() { if (!canEdit.value || !canViewFeeItems.value || !accountId.value) return; resetForm(); editorOpen.value = true; void Promise.all([loadFeeItems(), loadPartners()]) } +function openAdjust(item: ContainerFeeResource) { if (!canEdit.value || !canViewFeeItems.value || !accountId.value) return; resetForm(item); editorOpen.value = true; void Promise.all([loadFeeItems(), loadPartners()]) } async function save() { - if (!canEdit.value || !accountId.value || !await formRef.value?.validate().catch(() => false)) return + if (!canEdit.value || !canViewFeeItems.value || !accountId.value) return + if (form.direction === 'PAYABLE' && (!canViewPartners.value || !form.partnerId)) { ElMessage.warning(canViewPartners.value ? '应付费用必须选择往来单位' : '当前账号缺少往来单位查看权限,无法保存应付费用'); return } + if (!await formRef.value?.validate().catch(() => false)) return + const partner = partners.value.find((item) => item.id === String(form.partnerId)) + const payload: ContainerFeeSaveRequest = { ...form, partnerId: form.direction === 'PAYABLE' ? form.partnerId : null, partnerName: form.direction === 'PAYABLE' ? partner?.partnerName || null : null } + if (form.direction === 'PAYABLE' && !payload.partnerName) { ElMessage.warning('请选择有效的往来单位'); return } saving.value = true try { - if (target.value) await adjustContainerFee(props.planId, accountId.value, target.value.id, target.value.sourceVersion, { ...form }) - else await createContainerFee(props.planId, accountId.value, { ...form }) + if (target.value) await adjustContainerFee(props.planId, accountId.value, target.value.id, target.value.sourceVersion, payload) + else await createContainerFee(props.planId, accountId.value, payload) editorOpen.value = false; ElMessage.success(target.value ? '费用调整已提交审核' : '费用已新增'); await load() } catch (cause) { ElMessage.error(getApiErrorDetails(cause, '费用保存失败').message) } finally { saving.value = false } @@ -116,35 +147,40 @@ function money(value: number) { return value.toLocaleString('zh-CN', { minimumFr function initializeCustomer() { accountId.value = customerOptions.value.length === 1 ? customerOptions.value[0].value : '' } function clearCustomerContext() { loadSequence += 1 - fees.value = []; costs.value = []; feeItems.value = []; error.value = null; loading.value = false + fees.value = []; costs.value = []; feeItems.value = []; partners.value = []; error.value = null; feeItemsError.value = null; partnersError.value = null; loading.value = false; feeItemsLoading.value = false; partnersLoading.value = false editorOpen.value = false; rejectOpen.value = false; target.value = null; rejection.value = '' resetForm() } watch(customerOptions, initializeCustomer, { immediate: true }) watch(accountId, () => { clearCustomerContext(); void load() }) -watch(() => form.direction, () => { if (!applicableItems.value.some((item) => item.id === form.feeItemId)) form.feeItemId = '' }) +watch(() => form.direction, (direction) => { + if (!applicableItems.value.some((item) => item.id === form.feeItemId)) form.feeItemId = '' + if (direction === 'RECEIVABLE') { form.partnerId = null; form.partnerName = null; partnersError.value = null } +}) onMounted(() => void load()) diff --git a/src/modules/unified-orders/components/InboundFeeDetailManager.test.ts b/src/modules/unified-orders/components/InboundFeeDetailManager.test.ts index bef6757b..40b834ce 100644 --- a/src/modules/unified-orders/components/InboundFeeDetailManager.test.ts +++ b/src/modules/unified-orders/components/InboundFeeDetailManager.test.ts @@ -1,4 +1,6 @@ import ElementPlus from 'element-plus' +import { readFileSync } from 'node:fs' +import { join } from 'node:path' import { createPinia, setActivePinia } from 'pinia' import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -27,6 +29,7 @@ const pending: InboundFeeDetail = { reviewReason: null, reviewerName: null, reviewedAt: null, publicationStatus: 'DRAFT', settlementIncluded: true, } const approved: InboundFeeDetail = { ...pending, id: '12', sourceFeeVersion: 3, reviewStatus: 'APPROVED', publicationStatus: 'PUBLISHED' } +const source = readFileSync(join(process.cwd(), 'src/modules/unified-orders/components/InboundFeeDetailManager.vue'), 'utf8') enableAutoUnmount(afterEach) @@ -136,9 +139,19 @@ describe('InboundFeeDetailManager', () => { await flushPromises() expect(listInboundFeeItemOptions).toHaveBeenCalledTimes(1) expect(listInboundFeePartnerOptions).toHaveBeenCalledTimes(1) - expect(document.body.textContent).toContain('往来单位不可用') + expect(document.body.textContent).toContain('当前账号无权访问该数据') + expect(document.body.textContent).toContain('A0403') expect(document.body.textContent).toContain('request-partner') expect(document.body.textContent).toContain('trace-partner') expect(document.querySelector('.fee-form .el-select')?.classList.contains('is-disabled')).toBe(false) }) + + it('refreshes fee rows and the order detail after every successful write', () => { + expect(source).toContain("async function finishAction(message: string) {\n await load(); emit('changed')") + expect(source).toMatch(/createInboundFeeDetail[\s\S]*updateInboundFeeDetail[\s\S]*adjustInboundFeeDetail[\s\S]*await finishAction\(/) + expect(source).toMatch(/deleteInboundFeeDetail[\s\S]*await finishAction\('费用明细已删除'\)/) + expect(source).toMatch(/reviewInboundFeeDetail[\s\S]*await finishAction\('费用审核通过'\)/) + expect(source).toMatch(/reviewInboundFeeDetail[\s\S]*rejectReason[\s\S]*await finishAction\('费用已驳回'\)/) + expect(source).toMatch(/voidInboundFeeDetail[\s\S]*await finishAction\('费用明细已作废'\)/) + }) }) diff --git a/src/modules/warehouse-resources/locations/pages/LocationsView.test.ts b/src/modules/warehouse-resources/locations/pages/LocationsView.test.ts index dddddade..6ffe9239 100644 --- a/src/modules/warehouse-resources/locations/pages/LocationsView.test.ts +++ b/src/modules/warehouse-resources/locations/pages/LocationsView.test.ts @@ -1,4 +1,6 @@ import ElementPlus from 'element-plus' +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' import { createPinia, setActivePinia } from 'pinia' import { enableAutoUnmount, flushPromises, mount, type VueWrapper } from '@vue/test-utils' import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -15,6 +17,8 @@ import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/compone import DataTable from '@/shared/components/DataTable.vue' import LocationsView from './LocationsView.vue' +const locationsSource = readFileSync(resolve(process.cwd(), 'src/modules/warehouse-resources/locations/pages/LocationsView.vue'), 'utf8') + vi.mock('../../../warehouses/api/platformWarehouses', () => ({ listAllPlatformWarehouses: vi.fn() })) vi.mock('../api/platformLocations', () => ({ listPlatformLocations: vi.fn(), @@ -138,6 +142,12 @@ enableAutoUnmount(afterEach) afterAll(() => vi.unstubAllGlobals()) describe('LocationsView', () => { + it('uses the prototype-sized centered editor while retaining the detail drawer', () => { + expect(locationsSource).toContain("drawerMode.value === 'view' ? AppDrawer : AppDialog") + expect(locationsSource).toContain("width: 'min(560px, calc(100vw - 28px))'") + expect(locationsSource).toContain(':global(.location-editor-dialog .el-dialog__body) { min-height: 0; overflow-y: auto; }') + expect(locationsSource).toContain('.drawer-form :deep(.form-grid.two-cols) { grid-template-columns: minmax(0, 1fr); }') + }) beforeEach(() => { vi.stubGlobal('ResizeObserver', ResizeObserverStub) document.body.innerHTML = '' diff --git a/src/modules/warehouse-resources/locations/pages/LocationsView.vue b/src/modules/warehouse-resources/locations/pages/LocationsView.vue index 9e706717..27298f00 100644 --- a/src/modules/warehouse-resources/locations/pages/LocationsView.vue +++ b/src/modules/warehouse-resources/locations/pages/LocationsView.vue @@ -7,6 +7,7 @@ import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRe import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http' import AdminPage from '@/shared/components/AdminPage.vue' import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue' +import AppDialog from '@/shared/components/AppDialog.vue' import AppDrawer from '@/shared/components/AppDrawer.vue' import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue' import FormSection from '@/shared/components/FormSection.vue' @@ -149,6 +150,10 @@ const domesticWarehouses = computed(() => warehouses.value.filter((warehouse) => const editorOpen = computed(() => drawerOpen.value && drawerMode.value !== 'view') const editorTitle = computed(() => drawerMode.value === 'create' ? '新增库位' : '编辑库位') const drawerTitle = computed(() => drawerMode.value === 'view' ? '库位详情' : editorTitle.value) +const drawerContainer = computed(() => drawerMode.value === 'view' ? AppDrawer : AppDialog) +const drawerContainerProps = computed(() => drawerMode.value === 'view' + ? { size: 'min(620px, 94vw)', showConfirm: false } + : { width: 'min(560px, calc(100vw - 28px))', showConfirm: false, destroyOnClose: true }) const editorPermission = computed(() => drawerMode.value === 'create' ? 'platform:location:create' : 'platform:location:edit') const selectionDisabled = computed(() => drawerOpen.value || saving.value || actionLoadingIds.value.size > 0) const filtered = computed(() => Object.values(appliedFilters.value).some((value) => String(value || '').trim())) @@ -646,14 +651,14 @@ onMounted(() => { - { 保存库位 - + @@ -801,6 +806,9 @@ onMounted(() => { .location-cell small, .stacked-value small { color: var(--cc-muted); font-size: 11px; } .remark-cell { display: -webkit-box; overflow: hidden; line-height: 1.5; -webkit-box-orient: vertical; -webkit-line-clamp: 2; } .drawer-content { min-height: 260px; } +:global(.location-editor-dialog) { max-height: min(760px, calc(100vh - 32px)); display: flex; flex-direction: column; } +:global(.location-editor-dialog .el-dialog__body) { min-height: 0; overflow-y: auto; } +:global(.location-editor-dialog .el-dialog__footer) { flex: 0 0 auto; } .drawer-content > .el-alert + .el-alert, .detail-descriptions + .el-alert { margin-top: 14px; } .detail-identity { display: grid; grid-template-columns: 48px minmax(0, 1fr) auto; align-items: center; gap: 12px; margin-bottom: 18px; } .detail-identity__icon { display: grid; width: 48px; height: 48px; place-items: center; border-radius: 10px; background: #eef3fa; color: var(--cc-primary); } @@ -820,6 +828,7 @@ onMounted(() => { :global(.location-action-menu .location-action-menu__danger:not(.is-disabled)) { color: var(--el-color-danger); } @media (max-width: 720px) { + .drawer-form :deep(.form-grid.two-cols) { grid-template-columns: minmax(0, 1fr); } .detail-identity { grid-template-columns: 44px minmax(0, 1fr); } .detail-identity > .el-tag { grid-column: 2; justify-self: start; } .detail-descriptions :deep(.el-descriptions__body), .detail-descriptions :deep(table), .detail-descriptions :deep(tbody) { display: block; width: 100%; } diff --git a/src/modules/warehouse-resources/pallets/pages/PalletsView.test.ts b/src/modules/warehouse-resources/pallets/pages/PalletsView.test.ts index c8de8533..4b79eb93 100644 --- a/src/modules/warehouse-resources/pallets/pages/PalletsView.test.ts +++ b/src/modules/warehouse-resources/pallets/pages/PalletsView.test.ts @@ -1,4 +1,6 @@ import ElementPlus from 'element-plus' +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' import { createPinia, setActivePinia } from 'pinia' import { enableAutoUnmount, flushPromises, mount, type VueWrapper } from '@vue/test-utils' import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -15,6 +17,8 @@ import { } from '../api/platformPallets' import PalletsView from './PalletsView.vue' +const palletsSource = readFileSync(resolve(process.cwd(), 'src/modules/warehouse-resources/pallets/pages/PalletsView.vue'), 'utf8') + vi.mock('@/modules/warehouses', () => ({ listAllPlatformWarehouses: vi.fn() })) vi.mock('../api/platformPallets', () => ({ listPlatformPallets: vi.fn(), @@ -108,6 +112,13 @@ enableAutoUnmount(afterEach) afterAll(() => vi.unstubAllGlobals()) describe('PalletsView', () => { + it('uses the prototype-sized centered editor while retaining the detail drawer', () => { + expect(palletsSource).toContain(' { vi.stubGlobal('ResizeObserver', ResizeObserverStub) document.body.innerHTML = '' @@ -272,12 +283,9 @@ describe('PalletsView', () => { await createButton?.trigger('click') await flushPromises() - expect(document.body.querySelector('.pallet-editor-header')?.textContent).toContain('新增托盘') - expect(document.body.querySelector('.pallet-editor-header')?.textContent).toContain('录入归属仓库与基础规格') - const editorDialog = document.body.querySelector('.pallet-editor-drawer[role="dialog"]') - const labelledBy = editorDialog?.getAttribute('aria-labelledby') - expect(labelledBy).toBeTruthy() - expect(document.getElementById(labelledBy || '')?.textContent).toBe('新增托盘') + const editorDialog = document.body.querySelector('.pallet-editor-dialog') + expect(editorDialog?.textContent).toContain('新增托盘') + expect(editorDialog?.querySelector('.el-dialog__title')?.textContent).toBe('新增托盘') expect(document.body.querySelectorAll('.editor-section')).toHaveLength(2) expect(document.body.querySelector('#pallet-ownership-heading')?.textContent).toBe('归属仓库') expect(document.body.querySelector('#pallet-spec-heading')?.textContent).toBe('托盘规格') diff --git a/src/modules/warehouse-resources/pallets/pages/PalletsView.vue b/src/modules/warehouse-resources/pallets/pages/PalletsView.vue index e19e61d0..391a9b95 100644 --- a/src/modules/warehouse-resources/pallets/pages/PalletsView.vue +++ b/src/modules/warehouse-resources/pallets/pages/PalletsView.vue @@ -7,6 +7,7 @@ import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRe import { listAllPlatformWarehouses, type PlatformWarehouseResource } from '@/modules/warehouses' import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http' import AdminPage from '@/shared/components/AdminPage.vue' +import AppDialog from '@/shared/components/AppDialog.vue' import AppDrawer from '@/shared/components/AppDrawer.vue' import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue' import FormSection from '@/shared/components/FormSection.vue' @@ -474,16 +475,7 @@ onMounted(() => { - - + 归属仓库 @@ -543,7 +535,7 @@ onMounted(() => {
取消保存托盘
-
+ @@ -569,10 +561,9 @@ onMounted(() => { .detail-hero p { margin-top: 4px; color: var(--cc-muted); font-size: 13px; } .detail-section-title { margin: 20px 0 8px; color: var(--cc-text); font-size: 14px; } .detail-section-title:first-of-type { margin-top: 0; } -.pallet-editor-header { display: flex; min-width: 0; align-items: center; gap: 12px; } -.pallet-editor-header > span { display: grid; width: 38px; height: 38px; flex: 0 0 auto; place-items: center; border-radius: 8px; background: var(--el-color-primary-light-9); color: var(--cc-primary); } -.pallet-editor-header h2 { margin: 0; color: #172033; font-size: 17px; line-height: 1.4; } -.pallet-editor-header p { margin: 3px 0 0; color: var(--cc-muted); font-size: 12px; line-height: 1.5; } +:global(.pallet-editor-dialog) { max-height: min(760px, calc(100vh - 32px)); display: flex; flex-direction: column; } +:global(.pallet-editor-dialog .el-dialog__body) { min-height: 0; overflow-y: auto; } +:global(.pallet-editor-dialog .el-dialog__footer) { flex: 0 0 auto; } .editor-form { padding: 4px 24px 28px; } .editor-section { padding: 20px 0 4px; border-bottom: 1px solid var(--cc-border); } .editor-section:last-child { padding-bottom: 0; border-bottom: 0; } @@ -588,6 +579,9 @@ onMounted(() => { .editor-form :deep(.el-input-number), .editor-form :deep(.el-select) { width: 100%; } .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) { + .editor-field-grid { grid-template-columns: minmax(0, 1fr); } +} .editor-remark-field { margin-bottom: 0 !important; } .field-help { margin: 6px 0 0; color: var(--cc-muted); font-size: 12px; line-height: 1.5; } .drawer-footer { display: flex; justify-content: flex-end; gap: 8px; } diff --git a/src/modules/warehouses/pages/WarehousesView.test.ts b/src/modules/warehouses/pages/WarehousesView.test.ts index b5e43daa..2a88b43d 100644 --- a/src/modules/warehouses/pages/WarehousesView.test.ts +++ b/src/modules/warehouses/pages/WarehousesView.test.ts @@ -1,4 +1,6 @@ import ElementPlus from 'element-plus' +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' import { createPinia, setActivePinia } from 'pinia' import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils' import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -19,6 +21,8 @@ import { useAccessPreviewStore } from '@/core/access/accessPreview' import { useAuthStore } from '@/core/auth/auth' import WarehousesView from './WarehousesView.vue' +const warehousesSource = readFileSync(resolve(process.cwd(), 'src/modules/warehouses/pages/WarehousesView.vue'), 'utf8') + vi.mock('@/modules/warehouses/api/platformWarehouses', () => ({ listPlatformWarehouses: vi.fn(), getPlatformWarehouse: vi.fn(), @@ -171,6 +175,12 @@ enableAutoUnmount(afterEach) afterAll(() => vi.unstubAllGlobals()) describe('WarehousesView', () => { + it('uses the prototype-sized centered warehouse editor with a fixed footer', () => { + expect(warehousesSource).toContain(' { vi.stubGlobal('ResizeObserver', ResizeObserverStub) document.body.innerHTML = '' @@ -263,12 +273,12 @@ describe('WarehousesView', () => { wrapper.findComponent({ name: 'ElDropdown' }).vm.$emit('command', 'FBA') await flushPromises() - const drawerText = document.querySelector('.el-drawer')?.textContent || '' - expect(drawerText).toContain('新增FBA 地址') - expect(drawerText).toContain('国家或地区') - expect(drawerText).not.toContain('时区覆盖') - expect(drawerText).not.toContain('联系人') - expect(drawerText).not.toContain('收货时间') + const dialogText = document.querySelector('.warehouse-editor-dialog')?.textContent || '' + expect(dialogText).toContain('新增FBA 地址') + expect(dialogText).toContain('国家或地区') + expect(dialogText).not.toContain('时区覆盖') + expect(dialogText).not.toContain('联系人') + expect(dialogText).not.toContain('收货时间') const countrySelect = wrapper.findAllComponents({ name: 'ElSelect' }) .find((select) => select.props('placeholder') === '美国 US') expect(countrySelect).toBeDefined() @@ -309,10 +319,10 @@ describe('WarehousesView', () => { wrapper.findComponent({ name: 'ElDropdown' }).vm.$emit('command', 'DOMESTIC') await flushPromises() - const drawerText = document.querySelector('.el-drawer')?.textContent || '' - expect(drawerText).not.toContain('国家或地区') - expect(drawerText).not.toContain('时区') - expect(drawerText).not.toContain('仓库类型') + const dialogText = document.querySelector('.warehouse-editor-dialog')?.textContent || '' + expect(dialogText).not.toContain('国家或地区') + expect(dialogText).not.toContain('时区') + expect(dialogText).not.toContain('仓库类型') setField('input[placeholder="请输入仓库名称"]', '义乌新仓') setField('input[placeholder="例如 浙江省"]', '浙江省') @@ -384,10 +394,10 @@ describe('WarehousesView', () => { await editButton.trigger('click') await flushPromises() - const drawerText = document.querySelector('.el-drawer')?.textContent || '' - expect(drawerText).toContain('编辑FBA 地址') - expect(drawerText).not.toContain('例如 US') - expect(drawerText).not.toContain('例如 UTC-8') + const dialogText = document.querySelector('.warehouse-editor-dialog')?.textContent || '' + expect(dialogText).toContain('编辑FBA 地址') + expect(dialogText).not.toContain('例如 US') + expect(dialogText).not.toContain('例如 UTC-8') const countrySelect = wrapper.findAllComponents({ name: 'ElSelect' }) .find((select) => select.props('placeholder') === '请选择国家或地区') diff --git a/src/modules/warehouses/pages/WarehousesView.vue b/src/modules/warehouses/pages/WarehousesView.vue index d26c2ef3..035b7c15 100644 --- a/src/modules/warehouses/pages/WarehousesView.vue +++ b/src/modules/warehouses/pages/WarehousesView.vue @@ -3,7 +3,7 @@ import { computed, onMounted, reactive, ref, watch } from 'vue' import { ElMessage, ElMessageBox, type UploadFile } from 'element-plus' import { ChevronDown, Download, FileSpreadsheet, FileUp, MapPin, Pencil, Plus, Power, Save, Warehouse as WarehouseIcon } from '@lucide/vue' import AdminPage from '@/shared/components/AdminPage.vue' -import AppDrawer from '@/shared/components/AppDrawer.vue' +import AppDialog from '@/shared/components/AppDialog.vue' import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue' import FormSection from '@/shared/components/FormSection.vue' import ListPageLayout from '@/shared/components/ListPageLayout.vue' @@ -733,7 +733,7 @@ onMounted(() => void loadWarehouses()) - +
@@ -790,7 +790,7 @@ onMounted(() => void loadWarehouses()) - +
@@ -839,7 +839,13 @@ onMounted(() => void loadWarehouses()) .warehouse-address span { min-width: 0; } .field-error { width: 100%; margin: 6px 0 0; color: var(--el-color-danger); font-size: 12px; line-height: 1.35; overflow-wrap: anywhere; } .compact-grid { row-gap: 10px; } +:global(.warehouse-editor-dialog) { max-height: min(800px, calc(100vh - 32px)); display: flex; flex-direction: column; } +:global(.warehouse-editor-dialog .el-dialog__body) { min-height: 0; overflow-y: auto; } +:global(.warehouse-editor-dialog .el-dialog__footer) { flex: 0 0 auto; } .warehouse-status-toggle :deep(.el-radio-button__inner) { min-width: 74px; } +@media (max-width: 720px) { + .drawer-form :deep(.form-grid.two-cols) { grid-template-columns: minmax(0, 1fr); } +} .import-content { display: grid; gap: 20px; } .import-toolbar { justify-content: space-between; } .import-result { display: grid; gap: 14px; padding-top: 16px; border-top: 1px solid var(--cc-border); }