import test from 'node:test' import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import ts from 'typescript' import * as vue from 'vue' import { compileScript, parse } from '@vue/compiler-sfc' function evaluate(source, imports = {}) { const compiled = ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS } }).outputText const module = { exports: {} } new Function('require', 'module', 'exports', compiled)( (name) => { assert.ok(imports[name], `Unexpected dependency: ${name}`) return imports[name] }, module, module.exports ) return module.exports } const acting = evaluate(readFileSync('src/utils/business/employeeCollectionActing.ts', 'utf8')) const { descriptor } = parse( readFileSync( 'src/views/finance/employee-collection/applications/components/ApplicationFormDialog.vue', 'utf8' ) ) const componentSource = compileScript(descriptor, { id: 'collection-acting-test' }).content const employee = { id: 10, name: '测试员工', user_type: 1 } const bill = (id, name = employee.name, accountId = 99) => ({ id, remaining_amount: 10000, status: 0, debtor_account_id: accountId, debtor_snapshot: { account_id: accountId, account_name: name } }) function formHarness(props = {}, candidates = [], details = {}) { const calls = [] const detailCalls = [] const cleared = [] const user = vue.reactive({ info: { ...employee }, isSuperAdmin: true }) const service = { getPaymentMethods: async () => ({ code: 0, data: [{ id: 1, name: '银行转账', enabled: true }] }), getBills: async () => ({ code: 0, data: { items: candidates, total: candidates.length } }), getApplicationById: async () => ({ code: 0, data: { allocations: Object.keys(details).map((id) => ({ bill_id: Number(id), amount: 5000 })) } }), getBillById: async (id) => { detailCalls.push(id) return { code: 0, data: { bill: details[id] } } }, createApplication: async (data) => { calls.push({ method: 'create', data }) return { code: 0 } }, updateApplication: async (id, data) => { calls.push({ method: 'update', id, data }) return { code: 0 } } } const display = { BILL_SOURCE_OPTIONS: [], BILL_STATUS_OPTIONS: [], canCreateApplication: () => true, formatCollectionCurrency: String, normalizeCollectionList: (data) => (Array.isArray(data) ? data : data.items || []), normalizeCollectionPage: (data) => ({ list: data.items || [], total: data.total || 0 }) } const component = evaluate(componentSource, { vue, 'element-plus': { ElMessage: { success() {}, error() {}, warning() {} } }, '@/api/modules': { EmployeeCollectionService: service, AgentRechargeService: {}, ShopService: {} }, '@/utils/business/format': { fenToYuan: (value) => Number(value || 0) / 100, yuanToFen: (value) => Math.round(Number(value || 0) * 100) }, '@/utils/business': { getErrorMessage: String, toVoucherKeyList: (value) => (Array.isArray(value) ? value : value ? [value] : []) }, '@/store/modules/user': { useUserStore: () => user }, '@/utils/business/employeeCollectionActing': acting, '@/components/business/VoucherUpload.vue': { default: {} }, '../../employeeCollectionDisplay': display }).default const reactiveProps = vue.reactive({ modelValue: true, application: null, presetBill: null, ...props }) const state = component.setup(reactiveProps, { expose() {}, emit() {} }) state.formRef.value = { clearValidate: (field) => cleared.push(field), validate: async () => { if (state.rules.value.acting_reason?.[0]?.required && !state.form.acting_reason.trim()) { throw new Error('请填写代办原因') } } } const fillPayment = () => Object.assign(state.form, { payment_method_id: 1, paid_amount: 100, paid_at: '2026-10-14T15:00:00+08:00', payer_name: '付款方', external_transaction_no: 'LOCAL_TEST', payment_voucher_keys: ['test-voucher'] }) return { state, user, calls, cleared, detailCalls, fillPayment } } test('matching nonempty me.name makes a bill self-handled even when account IDs differ', () => { assert.equal(acting.isEmployeeCollectionBillSelf(bill(1), employee), true) assert.equal(acting.isEmployeeCollectionBillSelf(bill(1, ' 测试员工 '), employee), true) assert.equal(acting.isEmployeeCollectionBillSelf(bill(1, '其他员工'), employee), false) assert.equal(acting.isEmployeeCollectionBillSelf(bill(1, '', 10), employee), true) assert.equal( acting.isEmployeeCollectionBillSelf(bill(1, '', null), { name: '', user_type: 1 }), false ) assert.equal(acting.requiresEmployeeCollectionActingReason([bill(1), bill(2)], employee), false) assert.equal( acting.requiresEmployeeCollectionActingReason([bill(1), bill(2, '其他员工')], employee), true ) assert.equal(acting.requiresEmployeeCollectionActingReason([undefined], employee), true) assert.equal(acting.requiresEmployeeCollectionActingReason([], employee), true) assert.equal( acting.requiresEmployeeCollectionActingReason([bill(2, '其他员工')], { ...employee, user_type: 2 }), false ) }) test('creating a self-handled application has no acting reason rule and omits the field entirely', async () => { const { state, calls, fillPayment } = formHarness({ presetBill: bill(1) }) await state.initialize() await vue.nextTick() fillPayment() assert.equal(state.isActing.value, false) assert.equal(state.rules.value.acting_reason, undefined) state.form.acting_reason = '不得发送的残留值' await state.handleSubmit() assert.equal(calls[0].method, 'create') assert.equal(Object.hasOwn(calls[0].data, 'acting_reason'), false) assert.deepEqual(calls[0].data.allocations, [{ bill_id: 1, amount: 10000 }]) }) test('acting for another employee remains required; switching back to self clears the old value and validation', async () => { const { state, calls, cleared, fillPayment } = formHarness() fillPayment() state.toggleBill(bill(2, '其他员工')) await vue.nextTick() assert.equal(state.rules.value.acting_reason[0].required, true) await assert.rejects(state.handleSubmit(), /代办原因/) assert.equal(calls.length, 0) state.form.acting_reason = '代办核销' state.toggleBill(bill(2, '其他员工')) state.toggleBill(bill(1)) await vue.nextTick() assert.equal(state.isActing.value, false) assert.equal(state.form.acting_reason, '') assert.ok(cleared.includes('acting_reason')) await state.handleSubmit() assert.equal(Object.hasOwn(calls[0].data, 'acting_reason'), false) }) test('resubmission loads responsibility for bills outside the current candidate page and omits a historical acting reason', async () => { const application = { id: 8, acting_reason: '历史代办原因', paid_amount: 5000, payment_method_id: 1 } const { state, calls, detailCalls, fillPayment } = formHarness({ application }, [], { 21: bill(21) }) await state.initialize() await vue.nextTick() fillPayment() assert.deepEqual(detailCalls, [21]) assert.equal(state.displayBills.value[0].debtor_snapshot.account_name, employee.name) assert.equal(state.isActing.value, false) assert.equal(state.rules.value.acting_reason, undefined) assert.equal(state.form.acting_reason, '') await state.handleSubmit() assert.equal(calls[0].method, 'update') assert.equal(calls[0].id, 8) assert.equal(Object.hasOwn(calls[0].data, 'acting_reason'), false) assert.deepEqual(calls[0].data.allocations, [{ bill_id: 21, amount: 5000 }]) }) test('resubmission reuses responsibility from the candidate page and cannot be shadowed by an incomplete allocation cache', async () => { const { state, detailCalls } = formHarness({ application: { id: 9 } }, [bill(1)], { 1: bill(1) }) await state.initialize() await vue.nextTick() assert.deepEqual(detailCalls, []) state.selectedBillOptions.value[1] = { id: 1, remaining_amount: 10000 } assert.equal(state.isActing.value, false) }) test('mixed-responsibility resubmission still sends the required acting reason', async () => { const { state, calls, fillPayment } = formHarness({ application: { id: 10 } }, [], { 1: bill(1), 2: bill(2, '其他员工') }) await state.initialize() await vue.nextTick() fillPayment() assert.equal(state.isActing.value, true) assert.equal(state.rules.value.acting_reason[0].required, true) state.form.acting_reason = ' 帮员工提交核销 ' await state.handleSubmit() assert.equal(calls[0].data.acting_reason, '帮员工提交核销') })