import assert from 'node:assert/strict' import { existsSync, readFileSync } from 'node:fs' import test from 'node:test' import { computed, ref } from 'vue' import { parse, compileScript, compileTemplate } from '@vue/compiler-sfc' import ts from 'typescript' import { mobileContractMoneyLabels, validateMobileContractMoney } from '../src/views/m/crm/contract/mobileFormState' const file = new URL('../src/views/crm/contract/components/ContractFormDialog.vue', import.meta.url) const source = readFileSync(file, 'utf8') const descriptor = parse(source).descriptor const ast = ts.createSourceFile( 'contract.ts', descriptor.scriptSetup!.content, ts.ScriptTarget.Latest, true ) function declaration(name: string) { const node = ast.statements.find( (node) => ts.isVariableStatement(node) && node.declarationList.declarations.some((d) => d.name.getText(ast) === name) ) assert.ok(node, `Production declaration ${name} exists`) return node.getText(ast) } function evaluate(names: string[], bindings: Record) { const code = ts.transpile(names.map(declaration).join('\n'), { target: ts.ScriptTarget.ESNext }) return new Function(...Object.keys(bindings), `${code}; return { ${names.join(',')} }`)( ...Object.values(bindings) ) } function saveHarness(page: boolean, ready: boolean, uploading = false) { const writes: any[] = [] const warnings: string[] = [] const bindings = { isPageVariant: ref(page), mobileReady: ref(ready), mobileProductMetaError: ref(''), mobileContractMoneyLabels, validateMobileContractMoney, syncMobileFieldValidation: async () => {}, mobileActionPending: ref(false), mobileBusy: ref(false), mobileUploading: ref(uploading), formLoading: ref(false), saveDraftLoading: ref(false), submitLoading: ref(false), form: ref({ customerId: 'c1', supplierId: 's1', products: [], partyA: '' }), attachmentList: ref([]), $modal: { msgWarning: (m: string) => warnings.push(m), msgSuccess: () => {} }, confirmZeroPricingIfNeeded: async () => true, confirmContractZeroPricing: async () => true, isFrameworkContractType: () => false, rebuildContractName: () => {}, serializeContractAttachments: () => '[]', omitEmptyExceptPartyFields: () => {}, updateContract: async (p: any) => writes.push(p), addContract: async (p: any) => { writes.push(p) return { data: 'new-id' } }, handleClose: () => {}, emit: () => {} } const api = evaluate(['handleSaveDraft'], bindings) return { ...bindings, ...api, writes, warnings } } test('mobile initialization failure cannot save a seemingly valid draft', async () => { const h = saveHarness(true, false) await h.handleSaveDraft() assert.equal(h.writes.length, 0) }) test('mobile attachment upload blocks draft save at the handler', async () => { const h = saveHarness(true, true, true) await h.handleSaveDraft() assert.equal(h.writes.length, 0) }) test('desktop draft keeps its original weak validation and save behavior', async () => { const h = saveHarness(false, false, true) await h.handleSaveDraft() assert.equal(h.writes.length, 1) assert.equal(h.writes[0].status, 'draft') }) test('page is a real component branch; desktop dialog remains its separate default branch', () => { const roots = descriptor.template!.ast.children.filter((n: any) => n.type === 1) as any[] assert.equal(roots[0].tag, 'MobileContractForm') assert.match(roots[0].loc.source, /v-if="isPageVariant"/) assert.equal(roots[1].tag, 'el-dialog') assert.match(roots[1].loc.source, /v-else/) }) test('mobile required fields remain registered with the original Element form validator', () => { const path = new URL('../src/views/m/crm/contract/MobileContractForm.vue', import.meta.url) assert.ok(existsSync(path), 'Mobile form exists') const template = parse(readFileSync(path, 'utf8')).descriptor.template!.content assert.match(template, /]*:rules="c.rules"/) for (const prop of [ 'customerId', 'contactId', 'signDate', 'ownerUserId', 'supplierId', 'settlementMethod', 'startDate' ]) { assert.match(template, new RegExp(`prop="${prop}"`), `register ${prop}`) } }) test('changed SFCs compile without syntax or template errors', () => { for (const path of [ file, new URL('../src/views/m/crm/contract/MobileContractForm.vue', import.meta.url), new URL('../src/views/m/crm/contract/ContractPickerSheet.vue', import.meta.url), new URL('../src/views/m/crm/contract/ContractSubmitSheet.vue', import.meta.url), new URL('../src/views/m/crm/contract/ContractAuxiliarySheets.vue', import.meta.url), new URL('../src/views/m/crm/contract/form.vue', import.meta.url) ]) { assert.ok(existsSync(path)) const { descriptor, errors } = parse(readFileSync(path, 'utf8')) assert.deepEqual(errors, []) compileScript(descriptor, { id: 'test' }) const result = compileTemplate({ source: descriptor.template!.content, filename: path.pathname, id: 'test' }) assert.deepEqual(result.errors, []) } }) test('copy bootstrap failure clears the skeleton and exposes a retryable error', async () => { const parent = parse( readFileSync(new URL('../src/views/m/crm/contract/form.vue', import.meta.url), 'utf8') ).descriptor.scriptSetup!.content const tree = ts.createSourceFile('page.ts', parent, ts.ScriptTarget.Latest, true) const fn = tree.statements.find( (n) => ts.isFunctionDeclaration(n) && n.name?.text === 'bootForm' )! const bindings: Record = { shellReady: ref(true), pageMode: ref('copy'), copyFromId: ref('missing'), formReady: ref(false), bootError: ref(''), copySource: ref(null), contentLoading: ref(true), bootLoading: ref(false), bootGeneration: 0, getContract: async () => { throw new Error('来源不存在') } } const boot = new Function( ...Object.keys(bindings), ts.transpile(fn.getText(tree), { target: ts.ScriptTarget.ESNext }) + '; return bootForm' )(...Object.values(bindings)) await boot() assert.equal(bindings.contentLoading.value, false) assert.match(bindings.bootError.value, /来源不存在/) assert.equal(bindings.formReady.value, false) }) test('double-tap draft save cannot create duplicate mobile contracts', async () => { const h = saveHarness(true, true) await Promise.all([h.handleSaveDraft(), h.handleSaveDraft()]) assert.equal(h.writes.length, 1) }) import { execFileSync } from 'node:child_process' import * as pricing from '../src/views/crm/contract/frameworkProductPricing' import { isFrameworkContractType } from '../src/views/crm/contract/contractType' import { buildPackageSpecsPayload, normalizeContractProductPackageFields, validateContractProductPackageSpecs } from '../src/utils/crm-package-spec' import { prepareLineAliasAndBrandForSubmit, initLineBrandMetaFromProduct } from '../src/utils/crm-product-brand' import * as mobileState from '../src/views/m/crm/contract/mobileFormState' import { omitEmptyExceptPartyFields } from '../src/views/crm/contract/partyFields' test('desktop dialog template is byte-for-byte unchanged apart from the branch directive', () => { const old = execFileSync( 'git', ['show', 'HEAD:web-admin/src/views/crm/contract/components/ContractFormDialog.vue'], { encoding: 'utf8' } ) const before = (parse(old).descriptor.template!.ast.children as any[]) .find((node: any) => node.type === 1 && node.tag === 'el-dialog') .loc.source.replace(' v-else\n', '') const current = ( descriptor.template!.ast.children.filter((n: any) => n.type === 1) as any[] )[1].loc.source.replace(' v-else\n', '') assert.equal(current, before) }) function productHarness(page = false) { const bindings = { ...pricing, ...mobileState, isPageVariant: ref(page), isPartiesSwapped: ref(false), initLineBrandMetaFromProduct, enrichContractProductBrandMeta: async () => {}, assertMobileCurrent: () => {}, showCrmConfirmDialog: async () => {}, form: ref({ products: [], contractType: 'framework_sale' }), normalizeContractProductPackageFields, buildPackageSpecsPayload, prepareLineAliasAndBrandForSubmit, isFrameworkContractType } return { ...bindings, ...evaluate( [ 'mapContractProductsFromSource', 'buildProductsPayload', 'calculateProductAmount', 'calculateTotalAmount', 'handlePriceChange', 'handleQuantityChange', 'handleRemoveProduct', 'handleProductSelected', 'normalizeTaxRateDisplay', 'handleEnableFrameworkQuantityFixed', 'handleResetFrameworkQuantity', 'handleEnableFrameworkPriceFixed', 'handleResetFrameworkPrice', 'applyContractProductsToForm', 'handleQuoteConfirm' ], bindings ) } } test('copy clears contract/product/pricing identity and prices while preserving package and price-letter dates', () => { const h = productHarness() const [row] = h.mapContractProductsFromSource( [ { id: 'line-id', contractId: 'old-id', productId: 'p1', productName: '甲醇', quantity: 4, price: 9, subtotal: 36, taxAmount: 1, remark: 'old remark', packageSpec: '桶', quantityMode: 'fixed', priceMode: 'fixed', priceTiers: [ { id: 'tier-id', contractId: 'old-id', contractProductId: 'line-id', startDate: '2026-01-01', endDate: '2026-02-01', price: 100, letterOssId: 'minio/letter.png' } ] } ], true ) assert.equal(row.id, undefined) assert.equal(row.contractId, undefined) assert.equal(row.productId, 'p1') assert.deepEqual( [row.quantity, row.price, row.subtotal, row.taxAmount, row.remark], [0, 0, 0, 0, ''] ) assert.deepEqual(row.packageSpecs, ['桶']) assert.equal(row.priceTiers[0].id, undefined) assert.equal(row.priceTiers[0].price, undefined) assert.equal(row.priceTiers[0].startDate, '2026-01-01') assert.deepEqual(row.priceTiers[0].letterOssIds, ['minio/letter.png']) }) test('framework fixed quantity and price retain a nonzero amount; tiers do not alter it', () => { const h = productHarness() const row: any = { productId: 'p1', productName: '甲醇', quantity: 2, quantityMode: 'fixed', price: 100, priceMode: 'fixed', taxRate: 13, packageSpecs: ['桶', '槽罐车'], priceTiers: [ { startDate: '2026-01-01', endDate: '2026-02-01', price: 999, letterOssIds: ['letter-1', 'letter-2'] } ] } h.form.value.products = [row] h.calculateProductAmount(row) h.calculateTotalAmount() const [saved] = h.buildProductsPayload() assert.equal(h.form.value.amount, 200) assert.equal(saved.subtotal, 200) assert.deepEqual(saved.packageSpecs, ['桶', '槽罐车']) assert.deepEqual(saved.priceTiers[0].letterOssIds, ['letter-1', 'letter-2']) pricing.resetFrameworkPriceToMarket(row) const [market] = h.buildProductsPayload() assert.equal(market.quantity, 2) assert.equal(market.price, 0) assert.equal(market.quantityMode, 'fixed') assert.equal(market.priceMode, 'market') }) test('legacy framework copy carries only type and product content into fresh defaults', async () => { const h = productHarness() const bindings = { ...h, isPageVariant: ref(true), assertMobileCurrent: () => {}, initNewContractForm: async () => { h.form.value = { customerId: undefined, contactId: undefined, supplierId: 'default', products: [] } }, applyContractProductsToForm: async (products: any, type: string) => { h.form.value.products = products h.form.value.contractType = type } } const { loadContractForCopy } = evaluate(['loadContractForCopy'], bindings) await loadContractForCopy({ id: 'old', customerId: 'old-customer', contactId: 'old-contact', supplierId: 'old-supplier', contractType: 'framework', products: [{ productId: 'p', productName: '产品', packageSpecs: ['桶'] }] }) assert.equal(h.form.value.customerId, undefined) assert.equal(h.form.value.contactId, undefined) assert.equal(h.form.value.supplierId, 'default') assert.equal(h.form.value.contractType, 'framework') }) function submitHarness() { const h = productHarness() const writes: any[] = [] const approvals: any[] = [] const warnings: any[] = [] const bindings: any = { ...h, isPageVariant: ref(true), mobileReady: ref(true), mobileProductMetaError: ref(''), mobileContractMoneyLabels, validateMobileContractMoney, syncMobileFieldValidation: async () => {}, mobileBusy: ref(false), mobileActionPending: ref(false), mobileUploading: ref(false), formRef: ref({ validate: async () => true }), attachmentList: ref([]), submitLoading: ref(false), $modal: { msgWarning: (value: any) => warnings.push(value), msgSuccess: () => {}, msgError: (value: any) => warnings.push(value) }, ElMessage: { warning: (value: any) => warnings.push(value) }, expandPartiesForInvalidFields: () => {}, validateContractProductPackageSpecs, confirmContractZeroPricing: async () => true, rebuildContractName: () => {}, serializeContractAttachments: () => '', omitEmptyExceptPartyFields, addContract: async (data: any) => { writes.push(data) return { data: 'saved-id' } }, updateContract: async (data: any) => { writes.push(data) }, mobileFormRef: ref({ openApproval: async (id: any) => approvals.push(id) }), approvalDialogRef: ref(), emit: () => {} } h.form.value = { customerId: 'c', supplierId: 's', contractType: 'sale', contactId: 'ct', partyA: '', partyB: '', products: [ { productId: 'p', productName: '甲醇', quantity: 2, price: 100, subtotal: 200, taxAmount: 23.01, taxRate: 13, deliveryMethod: '送货', packageSpecs: ['桶'] } ] } return { ...bindings, ...evaluate(['handleSubmit'], bindings), writes, approvals, warnings } } test('submit invokes registered validation and does not save rejected required fields', async () => { const h = submitHarness() h.formRef.value.validate = async () => { throw { contactId: 'missing' } } await h.handleSubmit() assert.equal(h.writes.length, 0) assert.equal(h.mobileActionPending.value, false) }) test('customer-template attachment requirement applies to submit only and uses the actual customer value', async () => { const h = submitHarness() h.form.value.contractTemplate = 'customer' await h.handleSubmit() assert.equal(h.writes.length, 0) assert.match(JSON.stringify(h.warnings), /合同附件/) }) test('submit after canceling approval updates the saved ID rather than creating again', async () => { const h = submitHarness() await h.handleSubmit() assert.equal(h.form.value.id, 'saved-id') await h.handleSubmit() assert.equal(h.writes.length, 2) assert.equal(h.writes[0].id, undefined) assert.equal(h.writes[1].id, 'saved-id') assert.deepEqual(h.approvals, ['saved-id', 'saved-id']) assert.equal(h.writes[0].partyA, '') }) test('upload gate covers submission even if invoked without the disabled button', async () => { const h = submitHarness() h.mobileUploading.value = true await h.handleSubmit() assert.equal(h.writes.length, 0) }) test('framework tier validation requires dates, positive price and letters; no tiers is valid', async () => { const h = submitHarness() h.form.value.contractType = 'framework_sale' const row = h.form.value.products[0] Object.assign(row, { quantityMode: 'fixed', priceMode: 'fixed', priceTiers: [{ startDate: '2026-01-01', endDate: '2026-02-01', price: 100, letterOssIds: [] }] }) await h.handleSubmit() assert.equal(h.writes.length, 0) assert.match(JSON.stringify(h.warnings), /调价函/) row.priceTiers = [] await h.handleSubmit() assert.equal(h.writes.length, 1) }) test('mobile validation retains required rules and rejects reversed dates', async () => { const form = ref({ startDate: '2026-10-02', endDate: '2026-10-01' }) const contractDateRange = ref(['2026-10-02', '2026-10-01']) const { rules, mobileRules } = evaluate(['rules', 'mobileMoneyRules', 'mobileRules'], { mobileContractMoneyLabels, validateMobileContractMoney, ref, computed, form, contractDateRange }) assert.equal(mobileRules.value.customerId, rules.value.customerId) let error: any mobileRules.value.startDate[1].validator(null, null, (value: any) => { error = value }) assert.match(error.message, /开始日期/) contractDateRange.value = [] rules.value.startDate[0].validator(null, null, (value: any) => { error = value }) assert.match(error.message, /不能为空/) }) function deferredValue() { let resolve: any let reject: any const promise = new Promise((a, b) => { resolve = a reject = b }) return { resolve, reject, promise } } function bootHarness() { const parent = parse( readFileSync(new URL('../src/views/m/crm/contract/form.vue', import.meta.url), 'utf8') ).descriptor.scriptSetup!.content const tree = ts.createSourceFile('page.ts', parent, ts.ScriptTarget.Latest, true) const fn = tree.statements.find( (n) => ts.isFunctionDeclaration(n) && n.name?.text === 'bootForm' )! const calls: any[] = [] const bindings: any = { shellReady: ref(true), pageMode: ref('copy'), copyFromId: ref('first'), formReady: ref(false), bootError: ref(''), copySource: ref(null), contentLoading: ref(true), bootLoading: ref(false), bootGeneration: 0, getContract: (id: string) => { const d = deferredValue() calls.push({ id, ...d }) return d.promise } } const bootForm = new Function( ...Object.keys(bindings), ts.transpile(fn.getText(tree), { target: ts.ScriptTarget.ESNext }) + ';return bootForm' )(...Object.values(bindings)) return { ...bindings, bootForm, calls } } test('copy requests wait for shell authentication and ignore old success/failure after navigation', async () => { const h = bootHarness() h.shellReady.value = false await h.bootForm() assert.equal(h.calls.length, 0) h.shellReady.value = true const first = h.bootForm() h.copyFromId.value = 'second' const second = h.bootForm() h.calls[1].resolve({ data: { id: 'second' } }) await second h.calls[0].reject(new Error('stale')) await first assert.equal(h.copySource.value.id, 'second') assert.equal(h.bootError.value, '') assert.equal(h.bootLoading.value, false) assert.equal(h.formReady.value, true) }) test('mobile initialization failure leaves the form unsavable and retry can become ready', async () => { let fail = true const bindings: any = { mobileGeneration: 0, mobileReady: ref(false), mobileError: ref(''), mobileContactError: ref(''), mobileProductMetaError: ref(''), mobileProductMetaRetry: ref(null), mobileProductMetaCanDiscard: ref(false), formLoading: ref(false), emit: () => {}, resetForm: () => {}, props: { editId: null, copySource: null }, loadSupplierList: async () => { if (fail) throw new Error('供应商加载失败') }, loadUserList: async () => {}, assertMobileCurrent: () => {}, initNewContractForm: async () => {} } const { initializeMobileForm } = evaluate(['initializeMobileForm'], bindings) await initializeMobileForm() assert.equal(bindings.mobileReady.value, false) assert.equal(bindings.formLoading.value, false) assert.match(bindings.mobileError.value, /供应商/) fail = false await initializeMobileForm() assert.equal(bindings.mobileReady.value, true) assert.equal(bindings.mobileError.value, '') }) test('old detail response cannot overwrite a newly initialized mobile form', async () => { const pending = deferredValue() const form = ref({ contractName: 'fresh' }) const bindings: any = { getContract: () => pending.promise, assertMobileCurrent: () => { throw new Error('stale') }, form, isPageVariant: ref(true) } const { loadContractDetail } = evaluate(['loadContractDetail'], bindings) const request = loadContractDetail('old', 1) pending.resolve({ data: { id: 'old', contractName: 'stale' } }) await assert.rejects(request, /stale/) assert.equal(form.value.contractName, 'fresh') }) test('customer change clears related quote/opportunity amounts, replaces party snapshot and selects primary contact', async () => { const form = ref({ customerId: 'old', contactId: 'old-contact', quoteId: 'old-quote', quoteNo: 'old-no', opportunityId: 'old-opp', freight: 9, barrelDeposit: 8, partyB: 'old-party', partyBBank: 'old-bank' }) const picked: any[] = [] let rebuilt = 0 const bindings: any = { form, ensureCustomerOption: () => {}, confirmedCustomerId: ref('old'), contactOptions: ref([]), contactEmptyPrompted: ref(false), customerShortName: ref(''), customerInvoiceName: ref(''), isPartiesSwapped: ref(false), isPageVariant: ref(true), mobileContactError: ref(''), getDefaultInvoiceInfoByCustomerId: async () => ({ data: { companyName: '新开票公司', bankName: '新银行', bankAccount: '123' } }), fetchCustomerDetail: async () => ({ data: { customerName: '新客户', shortName: '新' } }), loadContactOptions: async () => [ { id: 'first', contactName: '普通' }, { id: 'primary', contactName: '主要', isPrimary: 1 } ], handleContactConfirm: (row: any) => { picked.push(row) form.value.contactId = row.id }, rebuildContractName: () => { rebuilt++ }, ElMessage: { warning: () => {} }, nextTick: () => {}, openContactFormDialog: () => {} } const { handleCustomerConfirm } = evaluate(['handleCustomerConfirm'], bindings) await handleCustomerConfirm({ id: 'new', customerName: '新客户', shortName: '新', mobile: '13800000000' }) assert.deepEqual( [form.value.quoteId, form.value.opportunityId, form.value.freight, form.value.barrelDeposit], [undefined, undefined, 0, 0] ) assert.equal(form.value.partyB, '新开票公司') assert.equal(form.value.partyBBank, '新银行') assert.equal(form.value.contactId, 'primary') assert.equal(rebuilt, 1) }) test('failed contact fetch remains an error state instead of a successful empty list', async () => { const form = ref({ customerId: 'c' }) const bindings: any = { isPageVariant: ref(true), mobileContactError: ref(''), mobileGeneration: 0, contactOptions: ref([]), contactOptionsLoading: ref(false), form, fetchContactsByCustomerId: async () => { throw new Error('offline') }, console: { error: () => {} } } const { loadContactOptions } = evaluate(['loadContactOptions'], bindings) assert.deepEqual(await loadContactOptions('c'), []) assert.match(bindings.mobileContactError.value, /加载失败/) assert.equal(bindings.contactOptionsLoading.value, false) }) function approvalHarness() { const text = readFileSync( new URL('../src/views/m/crm/contract/ContractSubmitSheet.vue', import.meta.url), 'utf8' ) const script = parse(text).descriptor.scriptSetup!.content const tree = ts.createSourceFile('submit-sheet.ts', script, ts.ScriptTarget.Latest, true) const writes: any[] = [] let fail = false let defaults: any = { status: '0', approverIds: ['disabled', 'u2', 'u1'] } const bindings: any = { ref, computed, props: { users: [ { userId: 'u1', nickName: '甲' }, { userId: 'u2', nickName: '乙' } ] }, getContractApproverSetting: async () => ({ data: defaults }), submitContract: async (...args: any[]) => { writes.push(args) if (fail) throw new Error('提交失败') }, showSuccessToast: () => {}, emit: () => {} } const statements = tree.statements.filter( (node) => (ts.isVariableStatement(node) && !['props', 'emit'].includes(node.declarationList.declarations[0].name.getText(tree))) || ts.isFunctionDeclaration(node) ) const code = ts.transpile(statements.map((node) => node.getText(tree)).join('\n'), { target: ts.ScriptTarget.ESNext }) const api = new Function( ...Object.keys(bindings), code + ';return {open,confirm,visible,loading,submitting,error,approverIds,contractId,remark,approvalType,selectedNames}' )(...Object.values(bindings)) return { ...api, writes, setFailure: (value: boolean) => { fail = value }, setDefaults: (value: any) => { defaults = value } } } test('approval defaults only retain available users in their saved order', async () => { const h = approvalHarness() await h.open('saved') assert.deepEqual(h.approverIds.value, ['u2', 'u1']) assert.equal(h.selectedNames.value, '乙 → 甲') h.setDefaults({ status: '1', approverIds: ['u1'] }) await h.open('saved') assert.deepEqual(h.approverIds.value, []) }) test('approval failure keeps the saved ID and retry sends only the existing API arguments', async () => { const h = approvalHarness() await h.open('saved') h.remark.value = '提交说明' h.approvalType.value = 'parallel' h.setFailure(true) await h.confirm() assert.equal(h.visible.value, true) assert.equal(h.contractId.value, 'saved') assert.match(h.error.value, /提交失败/) h.setFailure(false) await h.confirm() assert.equal(h.visible.value, false) assert.deepEqual(h.writes, [ ['saved', ['u2', 'u1'], '提交说明'], ['saved', ['u2', 'u1'], '提交说明'] ]) }) test('metadata errors block draft and approval handlers before snapshot serialization', async () => { for (const h of [saveHarness(true, true), submitHarness()]) { h.mobileProductMetaError.value = '产品加载失败' if ('handleSaveDraft' in h) await h.handleSaveDraft() else await h.handleSubmit() assert.equal(h.writes.length, 0) } }) test('mobile draft and approval reject invalid money without strengthening draft required fields', async () => { for (const key of ['amount', 'freight', 'barrelDeposit']) { for (const value of [-1, 1.234]) { for (const h of [saveHarness(true, true), submitHarness()]) { h.form.value[key] = value if ('handleSaveDraft' in h) await h.handleSaveDraft() else await h.handleSubmit() assert.equal(h.writes.length, 0) assert.match(String(h.warnings[0]), /非负|2位小数/) } } } const draft = saveHarness(true, true) draft.form.value.freight = 0.29 await draft.handleSaveDraft() assert.equal(draft.writes.length, 1) }) test('actual mobile price-change totals normalize IEEE tails before display and draft save', async () => { for (const price of [29.9, 0.1]) { const h = productHarness(true) h.form.value.contractType = 'sale' const row = { productId: 'p', productName: '甲醇', quantity: 3, price, taxRate: 13 } h.form.value.products = [row] h.handlePriceChange(row) assert.equal(h.form.value.amount, price === 29.9 ? 89.7 : 0.3) assert.equal(validateMobileContractMoney(h.form.value), '') const draft = saveHarness(true, true) draft.form.value.amount = h.form.value.amount await draft.handleSaveDraft() assert.equal(draft.writes.length, 1) } }) test('actual third decimal money remains invalid despite float tolerance', () => { for (const amount of [1.234, 1.001, 0.001, 1e-20]) { assert.match(validateMobileContractMoney({ amount }), /2位小数/) } }) test('mobile automatic totals round decimal quantities and positive half cents while desktop retains its arithmetic', () => { for (const [quantity, price, expected] of [ [0.123, 1.23, 0.15], [1.5, 29.99, 44.99], [1.5, 0.67, 1.01] ]) { for (const page of [true, false]) { const h = productHarness(page) h.form.value.contractType = 'sale' const row = { productId: 'p', quantity, price, taxRate: 13 } h.form.value.products = [row] h.handlePriceChange(row) assert.equal(h.form.value.amount, page ? expected : quantity * price) if (page) { assert.equal(h.form.value.products[0].subtotal, expected) assert.equal(Number(h.form.value.products[0].subtotal.toFixed(2)), expected) assert.equal(validateMobileContractMoney(h.form.value), '') } } } }) test('mobile automatic total boundary covers add, remove, quantity, framework modes, loading and quote import', async () => { const h = productHarness(true) h.form.value.contractType = 'sale' const row = () => ({ productId: 'p', productName: '甲醇', quantity: 1.5, price: 29.99, taxRate: 13 }) h.form.value.products = [row()] h.handleProductSelected([{ id: 'new', productName: '新产品', price: 1 }]) assert.equal(h.form.value.amount, 45.99) h.handleRemoveProduct(1) assert.equal(h.form.value.amount, 44.99) h.form.value.products[0].quantity = 0.123 h.form.value.products[0].price = 1.23 h.handleQuantityChange(h.form.value.products[0]) assert.equal(h.form.value.amount, 0.15) for (const type of ['framework_sale', 'framework_purchase', 'framework']) { h.form.value.contractType = type h.form.value.products = [{ ...row(), quantityMode: 'fixed', priceMode: 'fixed' }] h.handleEnableFrameworkQuantityFixed(h.form.value.products[0]) assert.equal(h.form.value.amount, 44.99) h.handleEnableFrameworkPriceFixed(h.form.value.products[0]) assert.equal(h.form.value.amount, 44.99) h.handleResetFrameworkPrice(h.form.value.products[0]) assert.equal(h.form.value.amount, 0) h.handleResetFrameworkQuantity(h.form.value.products[0]) assert.equal(h.form.value.amount, 0) } h.form.value.contractType = 'sale' await h.applyContractProductsToForm([row()], 'sale') assert.equal(h.form.value.amount, 44.99) await h.handleQuoteConfirm({ id: 'quote', productList: [row()] }) assert.equal(h.form.value.amount, 44.99) h.form.value.amount = '1.001' assert.match(validateMobileContractMoney(h.form.value), /2位小数/) }) test('desktop calculation source remains intact outside the explicit mobile total boundary', () => { const baseline = parse( execFileSync( 'git', ['show', 'HEAD:web-admin/src/views/crm/contract/components/ContractFormDialog.vue'], { encoding: 'utf8' } ) ).descriptor const tree = ts.createSourceFile( 'baseline.ts', baseline.scriptSetup!.content, ts.ScriptTarget.Latest, true ) for (const name of ['calculateProductAmount', 'calculateTotalAmount']) { const node = tree.statements.find( (item) => ts.isVariableStatement(item) && item.declarationList.declarations.some((d) => d.name.getText(tree) === name) )! const current = declaration(name).replace( ' if (isPageVariant.value) normalizeMobileContractAmounts(form.value)\n', '' ) assert.equal( current, node .getText(tree) .replace(' if (isPageVariant.value) normalizeMobileContractAmounts(form.value)\n', '') ) } }) test('mobile total equals the sum of rounded line subtotals and payload uses those same cents', () => { const h = productHarness(true) h.form.value.contractType = 'sale' h.form.value.products = [ { productId: 'p1', productName: '甲醇', quantity: 1.5, price: 29.99 }, { productId: 'p2', productName: '乙醇', quantity: 1.5, price: 29.99 } ] h.handlePriceChange(h.form.value.products[0]) assert.deepEqual( h.form.value.products.map((row) => row.subtotal), [44.99, 44.99] ) assert.equal(h.form.value.amount, 89.98) assert.deepEqual( h.buildProductsPayload().map((row) => row.subtotal), [44.99, 44.99] ) }) test('mobile edit initialization preserves saved contract name while desktop keeps rebuilding', async () => { for (const page of [true, false]) { const form = ref({}) const bindings = { isPageVariant: ref(page), form, getContract: async () => ({ data: { id: 'A', contractName: '约定名称' } }), assertMobileCurrent() {}, originalTemplateContent: ref(''), handleAttachmentData: async () => {}, customerShortName: ref(''), customerInvoiceName: ref(''), confirmedCustomerId: ref(''), contactEmptyPrompted: ref(false), customerOptions: ref([]), contactOptions: ref([]), syncPartiesSwappedFlagFromPanels() {}, rebuildContractName: () => { form.value.contractName = '自动生成' } } await evaluate(['loadContractDetail'], bindings).loadContractDetail('A') assert.equal(form.value.contractName, page ? '约定名称' : '自动生成') } }) test('mobile saves preserve entered names; blank names and desktop saves still rebuild', async () => { for (const kind of ['draft', 'submit']) { for (const page of [true, false]) { for (const name of ['约定名称', '']) { const h: any = kind === 'draft' ? saveHarness(page, true) : submitHarness() h.isPageVariant.value = page h.form.value.contractName = name const method = kind === 'draft' ? 'handleSaveDraft' : 'handleSubmit' const bindings = { ...h } delete bindings[method] const api = evaluate([method], { ...bindings, confirmZeroPricingIfNeeded: async () => true, rebuildContractName: () => { h.form.value.contractName = '自动生成' } }) await api[method]() assert.equal(h.writes[0].contractName, page && name ? name : '自动生成') } } } })