| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967 |
- 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<string, any>) {
- 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, /<el-form\b[^>]*: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<string, any> = {
- 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<any>({ 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<any>({
- 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<any>({ 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<any>({})
- 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 : '自动生成')
- }
- }
- }
- })
|