diff --git a/src/components/ClientSidebarMenu.vue b/src/components/ClientSidebarMenu.vue index 67e3bd8..ba8a240 100644 --- a/src/components/ClientSidebarMenu.vue +++ b/src/components/ClientSidebarMenu.vue @@ -5,6 +5,7 @@ import { clientNavigation, filterNavigationByPermissions, getNavigationParentKeys, + getNavigationLeaves, } from '@/navigation/clientNavigation' import { useAuthStore } from '@/modules/auth/public' import { useCustomsPendingStore } from '@/modules/first-leg/public' @@ -40,8 +41,11 @@ onBeforeUnmount(() => { }) const visibleNavigation = computed(() => filterNavigationByPermissions(clientNavigation, auth.permissionCodes)) +const activeNavigationPath = computed(() => getNavigationLeaves(visibleNavigation.value) + .filter(node => node.path === props.activePath || (node.path !== '/' && props.activePath.startsWith(`${node.path}/`))) + .sort((left, right) => right.path.length - left.path.length)[0]?.path ?? props.activePath) -const activeParentKeys = computed(() => new Set(getNavigationParentKeys(visibleNavigation.value, props.activePath))) +const activeParentKeys = computed(() => new Set(getNavigationParentKeys(visibleNavigation.value, activeNavigationPath.value))) const openedKeys = ref>(new Set(activeParentKeys.value)) // Expand the order group by default so the three customer order shortcuts are visible. openedKeys.value = new Set([...openedKeys.value, 'order-center']) @@ -73,7 +77,7 @@ function handleSelect(path: string) { :key="node.key" :node="node" :badges="badges" - :active-path="activePath" + :active-path="activeNavigationPath" :opened-keys="openedKeys" :active-parent-keys="activeParentKeys" :level="0" diff --git a/src/components/ClientSidebarMenuNode.vue b/src/components/ClientSidebarMenuNode.vue index 25df696..7c7b853 100644 --- a/src/components/ClientSidebarMenuNode.vue +++ b/src/components/ClientSidebarMenuNode.vue @@ -27,6 +27,7 @@ const groupId = computed(() => `client-nav-group-${props.node.key}`)
  • +
  • setTimeout(resolve, 1000)) result = await apiRequest({ method: 'GET', url }) } - if (!result.usable) throw new Error(result.message || '余额及授信校验未完成,请稍后重试') + // 已绑定订单沿用原键恢复创建结果;审核释放预占后也不能重置请求键重复下单。 + if (!result.usable && !result.orderNo) throw new Error(result.message || '余额及授信校验未完成,请稍后重试') } diff --git a/src/modules/cross-border/procurement.test.mjs b/src/modules/cross-border/procurement.test.mjs index 5586584..98d9565 100644 --- a/src/modules/cross-border/procurement.test.mjs +++ b/src/modules/cross-border/procurement.test.mjs @@ -261,6 +261,54 @@ test('unchanged create retries reuse the idempotency key', async () => { } finally { harness.dispose() } }) +test('a lost create response recovers the original order after review releases its budget', async () => { + for (const status of ['RELEASE_PENDING', 'RELEASED', 'RELEASE_FAILED']) { + const budgetCalls = [], createCalls = [] + let boundOrder = null + const budgets = moduleFrom(readFileSync(new URL('./api/orderBudgets.ts', import.meta.url), 'utf8'), name => { + assert.equal(name, '@/api/http') + return { apiRequest: async request => { + budgetCalls.push(normalized(request)) + return boundOrder + ? { status, usable: false, orderNo: boundOrder, message: '本次资金预占已释放,请重新下单' } + : { status: 'FROZEN', usable: true, orderNo: null } + } } + }) + const harness = viewHarness({ createPage: true, api: { + requireOrderBudget: budgets.requireOrderBudget, + createProcurement: async payload => { + createCalls.push(normalized(payload)) + if (!boundOrder) { boundOrder = 'CG-EXISTING'; throw new ApiError('timeout', 'TIMEOUT') } + assert.equal(payload.idempotencyKey, createCalls[0].idempotencyKey) + return { order: { orderNo: boundOrder } } + }, + } }) + try { + Object.assign(harness.state.form, draft()) + await harness.state.submit() + assert.equal(harness.state.submitError.value, 'timeout') + await harness.state.submit() + assert.equal(createCalls.length, 2, status) + assert.equal(budgetCalls[0].url, budgetCalls[1].url) + assert.equal(harness.state.submitError.value, '') + assert.equal(harness.state.submittedOrder.value, 'CG-EXISTING') + assert.equal(harness.pushes[0].name, 'cross-border-procurement') + await harness.state.submit() + assert.equal(createCalls.length, 2) + } finally { harness.dispose() } + } +}) + +test('an unbound released or failed budget still blocks procurement and trucking submission', async () => { + for (const kind of ['procurement', 'trucking']) for (const status of ['FROZEN', 'RELEASE_PENDING', 'RELEASED', 'RELEASE_FAILED', 'FAILED']) { + const budgets = moduleFrom(readFileSync(new URL('./api/orderBudgets.ts', import.meta.url), 'utf8'), name => { + assert.equal(name, '@/api/http') + return { apiRequest: async () => ({ status, usable: false, orderNo: null, message: '资金校验失败' }) } + }) + await assert.rejects(budgets.requireOrderBudget(kind, 'same-key', 100, 'USD'), /资金校验失败/) + } +}) + test('bill-settled manual fees show review results without counting a balance debit', () => { const bill = determination => ({ ...fee('OTHER', 'NOT_REQUIRED', '100'), determination }) assert.equal(presentation.feeStatus(bill('PENDING')), '账单结算 · 待审核') diff --git a/src/modules/first-leg/components/InboundBillingPanel.test.mjs b/src/modules/first-leg/components/InboundBillingPanel.test.mjs index c717f17..a188670 100644 --- a/src/modules/first-leg/components/InboundBillingPanel.test.mjs +++ b/src/modules/first-leg/components/InboundBillingPanel.test.mjs @@ -56,7 +56,7 @@ test('supports a standalone dialog and keeps the unpushed billing-weight state v }) test('shows the published merchant inbound, measurement details and customer confirmation entry', () => { - for (const label of ['入仓码', '平台收货仓', '实际毛重', '实测体积', '托盘与库位', '换算与精度', '最终计费量', '单价', '基础费用', '附加费用', '费用合计', '平台备注']) { + for (const label of ['入仓码', '平台收货仓', '实际毛重', '实测体积', '换算与精度', '最终计费量', '单价', '基础费用', '附加费用', '费用合计', '平台备注']) { assert.ok(source.includes(label), `missing label: ${label}`) } assert.match(source, /formatInboundMoney\(props\.data\.latestFee\.totalAmount/) @@ -67,7 +67,7 @@ test('shows the published merchant inbound, measurement details and customer con assert.match(source, /confirmMerchantInboundFee/) assert.match(source, /measurementItems/) assert.doesNotMatch(source, /调整费用|发送费用|下载照片/) - assert.doesNotMatch(source, /internalNotes|statusHistory|files/) + assert.doesNotMatch(source, /internalNotes|statusHistory|files|palletLocations|托盘与库位/) assert.doesNotMatch(source, /latestFee\.(feeVersion|ruleCode|rateCode|chargeRuleVersion|carrierRateVersion|sentAt)/) assert.match(source, /箱规规则/) assert.match(source, /getCalculationRuleLabel\(row\.calculationRuleVersion\)/) @@ -87,7 +87,7 @@ test('keeps the billing summary, confirmation table, notice and footer', () => { test('confirms the selected billing weight and volume even when outer carton totals differ', async () => { const html = await renderConfirmation({ latestFee: { - actualWeightKg: 570.5, actualVolumeCbm: 3.6894, + actualWeightKg: 393, actualVolumeCbm: 3.6894, convertedQuantity: 616.1298, chargeableQuantity: 616.1298, chargeMode: 'KG', chargeUnit: 'KG', unitPrice: 13.6, totalAmount: 8379.37, currency: 'CNY', }, @@ -96,10 +96,12 @@ test('confirms the selected billing weight and volume even when outer carton tot unitVolumeCbm: 0.177, totalVolumeCbm: 3.894, volumeWeightKg: 650.298 }], }) - assert.match(html, /570\.5 kg/) + assert.match(html, /393 kg/) assert.match(html, /3\.6894 m³/) assert.match(html, /616\.13 kg/) assert.match(html, /8,379\.37/) + assert.match(html, /按体积重计费/) + assert.doesNotMatch(html, /按重量计费|按毛重计费/) assert.doesNotMatch(html, /3\.894|650\.298|0\.177/) assert.doesNotMatch(confirmationBody, /measurementItems|billingBasis|箱规长|每箱体积/) }) @@ -107,7 +109,7 @@ test('confirms the selected billing weight and volume even when outer carton tot test('uses the published volume charge quantity and retains six decimal places', async () => { const html = await renderConfirmation({ latestFee: { - actualWeightKg: 600, actualVolumeCbm: 1.234567, + actualWeightKg: 600, actualVolumeCbm: 1.234567, convertedQuantity: 1.652893, chargeableQuantity: 1.652893, chargeMode: 'CBM', chargeUnit: 'CBM', unitPrice: 100, totalAmount: 165.29, currency: 'CNY', }, @@ -115,7 +117,7 @@ test('uses the published volume charge quantity and retains six decimal places', }) assert.match(html, /计费量/) - assert.match(html, /按体积计费/) + assert.match(html, /按重量折算体积计费/) assert.match(html, /600 kg/) assert.match(html, /1\.234567 m³/) assert.match(html, /1\.652893 m³/) diff --git a/src/modules/first-leg/components/InboundBillingPanel.vue b/src/modules/first-leg/components/InboundBillingPanel.vue index a40ae51..fdc3b12 100644 --- a/src/modules/first-leg/components/InboundBillingPanel.vue +++ b/src/modules/first-leg/components/InboundBillingPanel.vue @@ -1,7 +1,7 @@ + + + + diff --git a/src/modules/tenant-management/views/CompanyProfileView.vue b/src/modules/tenant-management/views/CompanyProfileView.vue index 42e1c1a..45675e3 100644 --- a/src/modules/tenant-management/views/CompanyProfileView.vue +++ b/src/modules/tenant-management/views/CompanyProfileView.vue @@ -1,11 +1,11 @@ ')) + return { ...context, requests, routes, errors, save: compile(source, context, 'saveMember') } +} + +test('member create and edit retain distinct payloads and return to the list only after success', async () => { + const create = memberSaver() + await create.save() + assert.equal(create.requests[0].kind, 'create') + assert.equal(create.requests[0].payload.password, 'Test1234') + assert.equal(create.form.password, '') + assert.deepEqual(create.routes, [{ name: 'members' }]) + const edit = memberSaver(true) + await edit.save() + assert.equal(edit.requests[0].kind, 'edit') + assert.equal(edit.requests[0].id, 'member-a') + assert.equal(edit.requests[0].payload.password, undefined) + assert.deepEqual(edit.requests[0].payload.roleIds, ['role-a']) +}) + +test('member permission, loading and invalid forms block writes; save failure keeps input for retry', async () => { + for (const field of ['saving', 'loading', 'loadError', 'canSave']) { + const state = memberSaver() + state[field].value = field === 'canSave' ? false : true + await state.save() + assert.equal(state.requests.length, 0) + } + const invalid = memberSaver() + invalid.formRef.value.validate = async () => { throw new Error('invalid') } + await invalid.save() + assert.equal(invalid.requests.length, 0) + const failed = memberSaver() + failed.store.createMember = async () => { throw new Error('创建失败') } + await failed.save() + assert.deepEqual(failed.errors, ['创建失败']) + assert.deepEqual(failed.routes, []) + assert.equal(failed.form.password, 'Test1234') + assert.equal(failed.saving.value, false) +}) + +test('member validation still requires create password, valid login phone, email and at least one role', async () => { + const { default: Schema } = createRequire(import.meta.resolve('element-plus'))('async-validator') + const source = memberSource.slice(memberSource.indexOf('const memberRules:'), memberSource.indexOf('let loadVersion')) + const rules = compile(source, {}, 'memberRules') + const validator = new Schema(rules) + const valid = { name: '成员', account: 'member.a', password: 'Test1234', phone: '', email: '', roleIds: ['role-a'] } + await validator.validate(valid) + for (const patch of [{ password: '' }, { phone: '123' }, { email: 'bad-email' }, { roleIds: [] }, { name: ' ' }]) { + await assert.rejects(validator.validate({ ...valid, ...patch })) + } +}) + +test('archived company blocks direct editing, and failed company saves preserve contact data', async () => { + const expression = profileSource.match(/^const canEdit = .*$/m)[0] + const store = { company: { auditStatus: 'ARCHIVED' }, async updateCompany() { throw new Error('保存失败') } } + const canEdit = compile(expression, { computed, auth: { hasPermission: () => true }, store }, 'canEdit') + assert.equal(canEdit.value, false) + const errors = [], routes = [] + const context = { + profileSubmitting: { value: false }, loading: { value: false }, loadError: { value: '' }, canEdit: { value: false }, + profileFormRef: { value: { async validate() { return true } } }, draft: { companyName: '企业', contactPhone: '010-12345678' }, store, + ElMessage: { success() {}, error(message) { errors.push(message) } }, router: { async push(route) { routes.push(route) } }, + } + const source = profileSource.slice(profileSource.indexOf('async function saveProfile()'), profileSource.indexOf('')) + const save = compile(source, context, 'saveProfile') + await save() + assert.deepEqual(errors, []) + context.canEdit.value = true + await save() + assert.deepEqual(errors, ['保存失败']) + assert.equal(context.draft.contactPhone, '010-12345678') + assert.deepEqual(routes, []) + store.updateCompany = async payload => { assert.equal(payload.contactPhone, '010-12345678'); assert.equal(payload.phone, undefined) } + await save() + assert.deepEqual(routes, [{ name: 'company-profile' }]) +}) diff --git a/src/modules/tenant-management/views/MemberEditorView.vue b/src/modules/tenant-management/views/MemberEditorView.vue new file mode 100644 index 0000000..885a0f2 --- /dev/null +++ b/src/modules/tenant-management/views/MemberEditorView.vue @@ -0,0 +1,113 @@ + + + + + diff --git a/src/modules/tenant-management/views/MembersView.vue b/src/modules/tenant-management/views/MembersView.vue index ff9d29a..35cf23b 100644 --- a/src/modules/tenant-management/views/MembersView.vue +++ b/src/modules/tenant-management/views/MembersView.vue @@ -1,6 +1,7 @@