209 lines
7.5 KiB
TypeScript
209 lines
7.5 KiB
TypeScript
import assert from 'node:assert/strict'
|
|
import { readFileSync } from 'node:fs'
|
|
import test from 'node:test'
|
|
import { parse } from '@vue/compiler-sfc'
|
|
import ts from 'typescript'
|
|
import {
|
|
buildPackageSpecsPayload,
|
|
normalizeContractProductPackageFields
|
|
} from '../src/utils/crm-package-spec'
|
|
import { partitionQuoteLinesForCopy } from '../src/utils/crm-quote-copy-products'
|
|
|
|
// Execute the actual form handlers, with UI/network dependencies replaced at the boundary.
|
|
function handler(file: string, name: string, bindings: Record<string, any>) {
|
|
const source = parse(readFileSync(new URL(`../src/${file}`, import.meta.url), 'utf8')).descriptor
|
|
.scriptSetup!.content
|
|
const ast = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true)
|
|
const statement = ast.statements.find(
|
|
(node) =>
|
|
(ts.isVariableStatement(node) &&
|
|
node.declarationList.declarations.some((d) => d.name.getText(ast) === name)) ||
|
|
(ts.isFunctionDeclaration(node) && node.name?.getText(ast) === name)
|
|
)
|
|
assert.ok(statement, name)
|
|
return new Function(
|
|
...Object.keys(bindings),
|
|
ts.transpile(statement.getText(ast), { target: ts.ScriptTarget.ESNext }) + `;return ${name}`
|
|
)(...Object.values(bindings))
|
|
}
|
|
|
|
const contractFile = 'views/crm/contract/components/ContractFormDialog.vue'
|
|
const quoteFile = 'views/crm/quote/components/QuoteFormPanel.vue'
|
|
const product = {
|
|
id: 'detail-id',
|
|
productId: 'catalog-id',
|
|
productName: 'Product',
|
|
unit: '吨',
|
|
packageSpec: '桶',
|
|
quantity: 2,
|
|
price: 3,
|
|
priceTiers: [
|
|
{
|
|
id: 'tier-id',
|
|
contractProductId: 'detail-id',
|
|
startDate: '2026-01-01',
|
|
endDate: '2026-02-01',
|
|
price: 4,
|
|
letterOssIds: ['file-id']
|
|
}
|
|
]
|
|
}
|
|
|
|
test('desktop and mobile contract mapper retain edit IDs and clear copied line/tier IDs', () => {
|
|
const map = handler(contractFile, 'mapContractProductsFromSource', {
|
|
normalizeContractProductPackageFields
|
|
})
|
|
const edit = map([product])[0]
|
|
assert.equal(edit.id, 'detail-id')
|
|
assert.equal(edit.productId, 'catalog-id')
|
|
assert.equal(edit.priceTiers[0].id, 'tier-id')
|
|
const copy = map([product], true)[0]
|
|
assert.equal(copy.id, undefined)
|
|
assert.equal(copy.productId, 'catalog-id')
|
|
assert.equal(copy.priceTiers[0].id, undefined)
|
|
assert.equal(copy.priceTiers[0].contractProductId, undefined)
|
|
assert.equal(product.id, 'detail-id')
|
|
})
|
|
|
|
test('shared desktop and H5 product editor preserves brand and alias IDs on save', async () => {
|
|
const file = 'components/business/product-form-dialog/index.vue'
|
|
const formData: Record<string, any> = {}
|
|
const source = {
|
|
id: 'product',
|
|
productName: 'Product',
|
|
brands: [{ id: 'brand', brandName: 'Brand', status: 'off' }],
|
|
aliases: [{ id: 'alias', aliasName: 'Alias' }]
|
|
}
|
|
const open = handler(file, 'handleOpen', {
|
|
formData,
|
|
dialogLoading: {},
|
|
resetFormData: () => {},
|
|
loadCategoryOptions: async () => {},
|
|
props: { productId: 'product' },
|
|
fetchProductDetail: async () => ({ data: source }),
|
|
isPageVariant: { value: false },
|
|
ElMessage: { error: assert.fail },
|
|
emit: () => {}
|
|
})
|
|
await open()
|
|
formData.brands.push({ brandName: 'New' })
|
|
formData.aliases.push({ aliasName: 'New' })
|
|
let sent: any
|
|
const submit = handler(file, 'submitForm', {
|
|
formData,
|
|
productImagesUploadRef: {},
|
|
formRef: { value: { validate: async () => true } },
|
|
submitLoading: {},
|
|
isEdit: { value: true },
|
|
fetchUpdateProduct: async (data: any) => {
|
|
sent = data
|
|
},
|
|
ElMessage: { success: () => {} },
|
|
visible: {},
|
|
emit: () => {}
|
|
})
|
|
await submit()
|
|
assert.deepEqual(
|
|
sent.brands.map((row: any) => row.id),
|
|
['brand', undefined]
|
|
)
|
|
assert.deepEqual(
|
|
sent.aliases.map((row: any) => row.id),
|
|
['alias', undefined]
|
|
)
|
|
assert.equal(sent.brands[0].status, 'off')
|
|
assert.ok(
|
|
readFileSync(new URL('../src/views/m/crm/product/form.vue', import.meta.url), 'utf8').includes(
|
|
'@/components/business/product-form-dialog/index.vue'
|
|
)
|
|
)
|
|
assert.ok(
|
|
readFileSync(new URL('../../miniprogram/config/api.js', import.meta.url), 'utf8').includes(
|
|
"encodeURIComponent('/m/crm/product')"
|
|
)
|
|
)
|
|
})
|
|
|
|
for (const file of [contractFile, 'views/crm/contract/form.vue']) {
|
|
test(`${file} sends detail and tier identities independently of catalog ID`, () => {
|
|
const form = { value: { id: 'contract', contractType: 'framework_sale', products: [product] } }
|
|
const build = handler(file, 'buildProductsPayload', {
|
|
form,
|
|
buildPackageSpecsPayload,
|
|
prepareLineAliasAndBrandForSubmit: () => {},
|
|
isFrameworkContractType: () => true,
|
|
prepareFrameworkProductForSubmit: (p: any) => ({ quantity: p.quantity, price: p.price })
|
|
})
|
|
const payload = build()[0]
|
|
assert.equal(payload.id, 'detail-id')
|
|
assert.equal(payload.productId, 'catalog-id')
|
|
assert.equal(payload.priceTiers[0].id, 'tier-id')
|
|
})
|
|
}
|
|
|
|
for (const mobile of [false, true]) {
|
|
test(`quote ${mobile ? 'mobile' : 'desktop'} load/copy retain only the correct identities`, async () => {
|
|
const form = { value: {} as any }
|
|
const bindings = {
|
|
form,
|
|
getQuote: async () => ({ data: { id: 'quote', productList: [product] } }),
|
|
isPageVariant: { value: mobile },
|
|
customerShortName: { value: '' },
|
|
confirmedCustomerId: { value: '' },
|
|
opportunityDisplayName: { value: '' },
|
|
attachmentList: { value: [] },
|
|
ensureCustomerOption: () => {},
|
|
ensureContactOption: () => {},
|
|
loadContactOptions: async () => {},
|
|
enrichQuoteProductBrandMeta: async () => {},
|
|
updateQuoteTitleAndNo: () => {},
|
|
fetchProductDetail: async () => ({ data: { id: 'catalog-id', status: 'on' } }),
|
|
partitionQuoteLinesForCopy,
|
|
finishClose: () => {},
|
|
$modal: { msgError: assert.fail, msgWarning: assert.fail }
|
|
}
|
|
await handler(quoteFile, 'getQuoteInfo', bindings)('quote')
|
|
assert.equal(form.value.products[0].id, 'detail-id')
|
|
assert.equal(form.value.products[0].productId, 'catalog-id')
|
|
await handler(quoteFile, 'getQuoteInfoForCopy', bindings)('quote')
|
|
assert.equal(form.value.products[0].id, undefined)
|
|
assert.equal(form.value.products[0].productId, 'catalog-id')
|
|
})
|
|
}
|
|
|
|
test('catalog picker does not put catalog IDs into child IDs', () => {
|
|
for (const [file, name, quote] of [
|
|
[quoteFile, 'handleProductConfirm', true],
|
|
[contractFile, 'handleProductSelected', false]
|
|
] as const) {
|
|
const form = { value: { products: [] as any[], contractType: 'sale' } }
|
|
const pick = handler(file, name, {
|
|
form,
|
|
isPageVariant: { value: false },
|
|
initLineBrandMetaFromProduct: () => ({}),
|
|
normalizeTaxRateDisplay: () => 13,
|
|
isFrameworkContractType: () => false,
|
|
calculateProductAmount: () => {},
|
|
calculateTotalAmount: () => {}
|
|
})
|
|
pick([{ id: 'catalog-id', productName: 'Product' }])
|
|
assert.equal(form.value.products[0].productId, 'catalog-id')
|
|
assert.equal(form.value.products[0].id, undefined, `${quote ? 'quote' : 'contract'} new row`)
|
|
}
|
|
})
|
|
|
|
test('native mini program uses the same verified CRM mobile forms', () => {
|
|
const config = readFileSync(new URL('../../miniprogram/config/api.js', import.meta.url), 'utf8')
|
|
for (const domain of ['quote', 'contract']) {
|
|
assert.match(
|
|
config,
|
|
new RegExp(`'crm-${domain}-webview':[\\s\\S]*?encodeURIComponent\\('/m/crm/${domain}'\\)`)
|
|
)
|
|
const mobile = readFileSync(
|
|
new URL(`../src/views/m/crm/${domain}/form.vue`, import.meta.url),
|
|
'utf8'
|
|
)
|
|
assert.ok(mobile.includes(`@/views/crm/${domain}/components/`))
|
|
}
|
|
})
|