Files
shanxia2.0/web-admin/scripts/mobile-contract-form.test.ts
T
2026-10-22 16:24:46 +08:00

912 lines
31 KiB
TypeScript

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!.content.trim()
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))
}
})
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]
)
})