968 lines
33 KiB
TypeScript
968 lines
33 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!.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 : '自动生成')
|
|
}
|
|
}
|
|
}
|
|
})
|