466 lines
16 KiB
TypeScript
466 lines
16 KiB
TypeScript
import assert from 'node:assert/strict'
|
|
import { readFileSync } from 'node:fs'
|
|
import test from 'node:test'
|
|
import ts from 'typescript'
|
|
import { parse } from '@vue/compiler-sfc'
|
|
import { ref } from 'vue'
|
|
import { formatBrandConstraintLabel } from '../src/utils/crm-product-brand'
|
|
import { useServerColumnFilters } from '../src/composables/useServerColumnFilters'
|
|
import { plPlanColumnFilterDefs } from '../src/views/pl/plan/columnFilterDefs'
|
|
import { plPlanListTabToFilters } from '../src/utils/pl-display'
|
|
import {
|
|
applyMergedCustomerQueryParams,
|
|
applyContractSideQueryParams
|
|
} from '../src/utils/pl-plan-pair-list-query'
|
|
import { facetPlanDateParams } from '../src/views/m/pl/plan-cert/model'
|
|
|
|
function productLine() {
|
|
const source = ts.createSourceFile(
|
|
'card.ts',
|
|
readFileSync(new URL('../src/utils/mp-pl-plan-card.ts', import.meta.url), 'utf8'),
|
|
ts.ScriptTarget.Latest,
|
|
true
|
|
)
|
|
const names = [
|
|
'formatStopProductLine',
|
|
'resolveLinkedPlanProduct',
|
|
'formatQty',
|
|
'resolveUnitLabel'
|
|
]
|
|
const code = ts.transpile(
|
|
source.statements
|
|
.filter((node) => ts.isFunctionDeclaration(node) && names.includes(node.name!.text))
|
|
.map((node) => node.getText(source))
|
|
.join('\n'),
|
|
{ target: ts.ScriptTarget.ESNext }
|
|
)
|
|
const env = {
|
|
formatBrandConstraintLabel,
|
|
resolveProductHazardFlags: () => ({ isToxic: false, isExplosive: false }),
|
|
getPlanProductActualQuantity: () => '0',
|
|
resolveMpPackageSpecLabel: (v: string) => v || '—',
|
|
resolveMpProductUnitLabel: (v: string) => v || '—'
|
|
}
|
|
return new Function(...Object.keys(env), `${code};return formatStopProductLine`)(
|
|
...Object.values(env)
|
|
)
|
|
}
|
|
|
|
test('mobile list product brands and aliases follow the exact plan line before product ID fallback', () => {
|
|
const format = productLine()
|
|
const plan = {
|
|
products: [
|
|
{
|
|
id: 'first',
|
|
productId: 'p',
|
|
productName: '标准名称',
|
|
brandMode: 'required',
|
|
brandName: '品牌甲',
|
|
aliasName: '别名甲'
|
|
},
|
|
{
|
|
id: 'second',
|
|
productId: 'p',
|
|
productName: '标准名称',
|
|
brandMode: 'required',
|
|
brandName: '品牌乙',
|
|
aliasName: ' 别名乙 '
|
|
}
|
|
]
|
|
}
|
|
const second = format(
|
|
plan,
|
|
{ kind: 'load' },
|
|
{ productId: 'p', planProductId: 'second', productName: '段上别名', planQty: 2 }
|
|
)
|
|
assert.equal(second.name, '标准名称')
|
|
assert.equal(second.brand, '品牌乙')
|
|
assert.equal(second.alias, '别名乙')
|
|
assert.equal(second.planQty, '2')
|
|
const fallback = format(plan, { kind: 'delivery' }, { productId: 'p' })
|
|
assert.equal(fallback.brand, '品牌甲')
|
|
assert.equal(fallback.alias, '别名甲')
|
|
})
|
|
|
|
test('mobile product metadata uses the Web brand constraint label and empty placeholders', () => {
|
|
const format = productLine()
|
|
for (const brandMode of ['any', 'required', undefined]) {
|
|
const product = { id: 'line', productId: 'p', brandMode, brandName: '', aliasName: '' }
|
|
const line = format({ products: [product] }, { kind: 'load' }, { planProductId: 'line' })
|
|
assert.equal(line.brand, formatBrandConstraintLabel(brandMode, ''))
|
|
assert.equal(line.alias, '—')
|
|
}
|
|
const missing = format({ products: [] }, { kind: 'load' }, { productId: 'missing' })
|
|
assert.equal(missing.brand, '—')
|
|
assert.equal(missing.alias, '—')
|
|
})
|
|
|
|
for (const metadata of [
|
|
{ aliasName: '送货别名', brandMode: 'any', brandName: '' },
|
|
{ aliasName: '', brandMode: '', brandName: '' },
|
|
{},
|
|
{ aliasName: '另一段别名', brandMode: 'required', brandName: '另一段品牌' }
|
|
]) {
|
|
test(`segment metadata overrides the same root product, including explicit empty values: ${JSON.stringify(metadata)}`, () => {
|
|
const format = productLine()
|
|
const root = {
|
|
id: 'root',
|
|
productId: 'p',
|
|
productName: '标准名称',
|
|
aliasName: '装货根别名',
|
|
brandMode: 'required',
|
|
brandName: '装货根品牌'
|
|
}
|
|
const plan = { products: [root] }
|
|
const load = format(plan, { kind: 'load' }, { productId: 'p', planProductId: 'root' })
|
|
const delivery = format(
|
|
plan,
|
|
{ kind: 'delivery' },
|
|
{
|
|
productId: 'p',
|
|
planProductId: 'root',
|
|
mobileProductMeta: metadata
|
|
}
|
|
)
|
|
assert.equal(load.alias, '装货根别名')
|
|
assert.equal(load.brand, '装货根品牌')
|
|
assert.equal(delivery.alias, metadata.aliasName || '—')
|
|
assert.equal(delivery.brand, formatBrandConstraintLabel(metadata.brandMode, metadata.brandName))
|
|
assert.equal(root.aliasName, '装货根别名')
|
|
assert.equal(root.brandName, '装货根品牌')
|
|
})
|
|
}
|
|
|
|
function queryFunction(path: string, name: string, bindings: Record<string, any>) {
|
|
const ast = ts.createSourceFile(
|
|
'query.ts',
|
|
parse(readFileSync(new URL(path, import.meta.url), 'utf8')).descriptor.scriptSetup!.content,
|
|
ts.ScriptTarget.Latest,
|
|
true
|
|
)
|
|
const fn = ast.statements.find(
|
|
(node) => ts.isFunctionDeclaration(node) && node.name?.text === name
|
|
)!
|
|
return new Function(
|
|
...Object.keys(bindings),
|
|
`${ts.transpile(fn.getText(ast), { target: ts.ScriptTarget.ESNext })};return ${name}`
|
|
)(...Object.values(bindings))
|
|
}
|
|
|
|
for (const tab of ['pending_arrange', 'all', 'inbound', 'outbound']) {
|
|
test(`mobile ${tab} list query matches the Web keyword, filter, tab, sort and contract-side parameters`, () => {
|
|
const state = useServerColumnFilters(plPlanColumnFilterDefs)
|
|
Object.assign(state.filters, {
|
|
followStatus: { selected: ['accepted'], text: '' },
|
|
planType: { selected: ['direct'], text: '' },
|
|
planNo: { selected: ['PO1', 'PO2'], text: '' },
|
|
planDate: { from: '2026-10-01', to: '2026-10-10' },
|
|
deliveryTimes: { from: '2026-10-02', to: '2026-10-11' },
|
|
loadCustomer: { selected: ['供方'], text: '' },
|
|
deliveryCustomer: { selected: ['需方'], text: '' },
|
|
productContract: { selected: ['load', 'CT1'], text: '' },
|
|
personnelSummary: { text: '司机', selected: [] },
|
|
planQty: { op: 'between', min: 1, max: 9 }
|
|
})
|
|
const keyword = ref(' 搜索关键词 '),
|
|
activeTab = ref(tab),
|
|
dateRange = ref(['2026-09-01', '2026-09-30'])
|
|
const sort = { sortField: 'deliveryTimes', sortOrder: 'asc' }
|
|
const bindings = {
|
|
...state,
|
|
keyword,
|
|
activeTab,
|
|
dateRange,
|
|
sort,
|
|
pageSize: 20,
|
|
plPlanListTabToFilters,
|
|
applyMergedCustomerQueryParams,
|
|
applyContractSideQueryParams,
|
|
isStockList: ref(false),
|
|
quickSearch: keyword,
|
|
activePlanTypeTab: activeTab,
|
|
toSortQueryParams: () => sort
|
|
}
|
|
const mobile = queryFunction('../src/views/m/pl/plan/index.vue', 'buildParams', bindings)()
|
|
const web = queryFunction('../src/views/pl/plan/index.vue', 'buildListQueryParams', bindings)()
|
|
assert.deepEqual(mobile, { ...web, pageSize: 20 })
|
|
})
|
|
}
|
|
|
|
test('plan facets preserve independent load and delivery customer restrictions from the draft', () => {
|
|
const state = useServerColumnFilters(plPlanColumnFilterDefs)
|
|
Object.assign(state.filters, {
|
|
loadCustomer: { text: '', selected: ['装货甲'] },
|
|
deliveryCustomer: { text: '', selected: ['送货乙'] }
|
|
})
|
|
const source = ts.createSourceFile(
|
|
'filter.ts',
|
|
parse(readFileSync(new URL('../src/views/m/pl/plan/PlanFilters.vue', import.meta.url), 'utf8'))
|
|
.descriptor.scriptSetup!.content,
|
|
ts.ScriptTarget.Latest,
|
|
true
|
|
)
|
|
const node = source.statements.find(
|
|
(node) =>
|
|
ts.isVariableStatement(node) &&
|
|
node.declarationList.declarations.some((d) => d.name.getText(source) === 'picker')
|
|
)!
|
|
const env = {
|
|
defs: plPlanColumnFilterDefs,
|
|
draft: state.filters,
|
|
toQueryParams: state.toQueryParams,
|
|
props: {
|
|
context: {
|
|
followStatus: 'accepted',
|
|
page: 2,
|
|
loadCustomersIn: '旧装货',
|
|
deliveryCustomersIn: '旧送货'
|
|
},
|
|
filters: {}
|
|
},
|
|
userStore: {},
|
|
useMobileCertFacet: (config: any) => config,
|
|
fetchPlanColumnFacetValues: () => {},
|
|
applyMergedCustomerQueryParams,
|
|
applyContractSideQueryParams,
|
|
facetPlanDateParams
|
|
}
|
|
const picker = new Function(
|
|
...Object.keys(env),
|
|
`${ts.transpile(node.getText(source), { target: ts.ScriptTarget.ESNext })};return picker`
|
|
)(...Object.values(env))
|
|
const q = picker.context()
|
|
assert.equal(q.loadCustomersIn, '装货甲')
|
|
assert.equal(q.deliveryCustomersIn, '送货乙')
|
|
assert.equal(q.stopCustomers, undefined)
|
|
assert.equal(q.followStatus, 'accepted')
|
|
})
|
|
|
|
import * as Vue from 'vue'
|
|
import { useMobileCertFacet } from '../src/views/m/pl/plan-cert/useMobileCertFacet'
|
|
import {
|
|
withPlanContractIoFacetOptions,
|
|
PL_PLAN_CONTRACT_SIDE_FILTER_OPTIONS
|
|
} from '../src/utils/pl-plan-contract-display'
|
|
|
|
test('plan filter pickers confirm drafts, keep server query semantics and reset all filters', async () => {
|
|
const source = ts.createSourceFile(
|
|
'filters.ts',
|
|
parse(readFileSync(new URL('../src/views/m/pl/plan/PlanFilters.vue', import.meta.url), 'utf8'))
|
|
.descriptor.scriptSetup!.content,
|
|
ts.ScriptTarget.Latest,
|
|
true
|
|
)
|
|
const code = source.statements
|
|
.filter((node) => !ts.isImportDeclaration(node))
|
|
.map((node) => node.getText(source))
|
|
.join('\n')
|
|
const props = Vue.reactive({
|
|
modelValue: false,
|
|
dateOnly: false,
|
|
filters: {} as Record<string, any>,
|
|
sort: { sortField: '', sortOrder: 'desc' },
|
|
context: { followStatus: 'accepted' }
|
|
})
|
|
const events: any[] = [],
|
|
requests: any[] = [],
|
|
warnings: string[] = []
|
|
const bindings = {
|
|
...Object.fromEntries(
|
|
Object.entries(Vue).filter(([key]) => /^[a-zA-Z_$][\w$]*$/.test(key) && key !== 'default')
|
|
),
|
|
defs: plPlanColumnFilterDefs,
|
|
useServerColumnFilters,
|
|
useMobileCertFacet,
|
|
applyMergedCustomerQueryParams,
|
|
applyContractSideQueryParams,
|
|
facetPlanDateParams,
|
|
withPlanContractIoFacetOptions,
|
|
PL_PLAN_CONTRACT_SIDE_FILTER_OPTIONS,
|
|
defineProps: () => props,
|
|
defineEmits:
|
|
() =>
|
|
(...args: any[]) =>
|
|
events.push(args),
|
|
useUserStore: () => ({ accessToken: 'test', getUserInfo: { id: 'U' } }),
|
|
fetchPlanColumnFacetValues: async (query: any) => {
|
|
requests.push(query)
|
|
return { list: [{ value: 'P1', label: '计划一' }] }
|
|
},
|
|
ElMessage: { warning: (text: string) => warnings.push(text) }
|
|
}
|
|
const scope = Vue.effectScope()
|
|
const h = scope.run(() =>
|
|
new Function(
|
|
...Object.keys(bindings),
|
|
ts.transpile(code, { target: ts.ScriptTarget.ESNext }) +
|
|
';return {picker,draft,confirmPicker,enumSummary,openSingle,confirmSingle,setNumberValue,openDate,confirmDate,toQueryParams,apply,resetAndApply,singleOpen,dateOpen,visibleDateDefs}'
|
|
)(...Object.values(bindings))
|
|
)
|
|
const flush = async () => {
|
|
for (let i = 0; i < 10; i++) await Promise.resolve()
|
|
}
|
|
try {
|
|
props.modelValue = true
|
|
await Vue.nextTick()
|
|
h.picker.open('planType', [])
|
|
await flush()
|
|
assert.equal(requests.length, 0, 'fixed options do not call the remote facet API')
|
|
h.picker.toggle('inbound')
|
|
h.picker.close()
|
|
assert.equal(h.draft.planType, undefined)
|
|
h.picker.open('planType', [])
|
|
await flush()
|
|
h.picker.toggle('inbound')
|
|
h.confirmPicker()
|
|
assert.equal(h.enumSummary('planType'), '入库单')
|
|
h.picker.open('planNo', [])
|
|
await flush()
|
|
assert.equal(requests[0].followStatus, 'accepted')
|
|
h.picker.toggle('P1')
|
|
h.confirmPicker()
|
|
assert.equal(h.enumSummary('planNo'), '计划一')
|
|
h.openSingle('numberOp', 'planQty')
|
|
h.confirmSingle({ selectedValues: ['gte'] })
|
|
h.setNumberValue('planQty', 'value', '10')
|
|
h.openDate('planDate', 'from')
|
|
h.confirmDate({ selectedValues: ['2026', '10', '24'] })
|
|
h.openSingle('sortField')
|
|
h.confirmSingle({ selectedValues: ['planDate'] })
|
|
assert.deepEqual(h.toQueryParams(), {
|
|
planNoIn: 'P1',
|
|
planTypeIn: 'inbound',
|
|
planDateFrom: '2026-10-24',
|
|
productPlanQtyOp: 'gte',
|
|
productPlanQty: '10'
|
|
})
|
|
h.apply()
|
|
assert.deepEqual(events.find((event) => event[0] === 'apply').slice(2), [
|
|
{ sortField: 'planDate', sortOrder: 'desc' },
|
|
false
|
|
])
|
|
h.openDate('planDate', 'to')
|
|
h.confirmDate({ selectedValues: ['2026', '10', '23'] })
|
|
h.apply()
|
|
assert.equal(warnings.length, 1)
|
|
h.openSingle('sortOrder')
|
|
h.openDate('planDate', 'to')
|
|
props.modelValue = false
|
|
await Vue.nextTick()
|
|
assert.equal(h.singleOpen.value, false)
|
|
assert.equal(h.dateOpen.value, false)
|
|
h.resetAndApply()
|
|
assert.deepEqual(events.filter((event) => event[0] === 'apply').at(-1), [
|
|
'apply',
|
|
{},
|
|
{ sortField: '', sortOrder: 'desc' },
|
|
true
|
|
])
|
|
props.modelValue = false
|
|
await Vue.nextTick()
|
|
props.dateOnly = true
|
|
props.filters = {
|
|
planNo: { selected: ['P1'] },
|
|
planDate: { from: '2026-10-24', to: '2026-10-25' }
|
|
}
|
|
props.sort = { sortField: 'createTime', sortOrder: 'asc' }
|
|
props.modelValue = true
|
|
await Vue.nextTick()
|
|
assert.deepEqual(
|
|
h.visibleDateDefs.value.map((d: any) => d.prop),
|
|
['planDate']
|
|
)
|
|
h.openDate('planDate', 'from')
|
|
h.confirmDate({ selectedValues: ['2026', '10', '23'] })
|
|
assert.equal(
|
|
props.filters.planDate.from,
|
|
'2026-10-24',
|
|
'date edits stay in draft until confirmed'
|
|
)
|
|
h.resetAndApply()
|
|
assert.deepEqual(events.filter((event) => event[0] === 'apply').at(-1), [
|
|
'apply',
|
|
{ planNo: { selected: ['P1'] } },
|
|
{ sortField: 'createTime', sortOrder: 'asc' },
|
|
true
|
|
])
|
|
} finally {
|
|
scope.stop()
|
|
}
|
|
})
|
|
|
|
test('date entry opens only date fields and preserves applied quick dates until confirmed', () => {
|
|
const source = ts.createSourceFile(
|
|
'list.ts',
|
|
parse(readFileSync(new URL('../src/views/m/pl/plan/index.vue', import.meta.url), 'utf8'))
|
|
.descriptor.scriptSetup!.content,
|
|
ts.ScriptTarget.Latest,
|
|
true
|
|
)
|
|
const names = [
|
|
'rangeFilters',
|
|
'onRangeOpen',
|
|
'onMoreOpen',
|
|
'onDateDropdownChange',
|
|
'pickDate',
|
|
'applyFilters'
|
|
]
|
|
const code = source.statements
|
|
.filter((node) =>
|
|
ts.isFunctionDeclaration(node)
|
|
? names.includes(node.name!.text)
|
|
: ts.isVariableStatement(node) &&
|
|
node.declarationList.declarations.some((d) => names.includes(d.name.getText(source)))
|
|
)
|
|
.map((node) => node.getText(source))
|
|
.join('\n')
|
|
let reloads = 0
|
|
const bindings = {
|
|
computed: Vue.computed,
|
|
filters: Vue.reactive<Record<string, any>>({ planNo: { selected: ['P1'] } }),
|
|
dateShortcut: ref('today'),
|
|
dateRange: ref(['2026-10-24', '2026-10-24']),
|
|
customFrom: ref(''),
|
|
customTo: ref(''),
|
|
customDateLabel: ref(''),
|
|
dateOnly: ref(false),
|
|
filterOpen: ref(false),
|
|
rangeDropdown: ref(null),
|
|
moreDropdown: ref(null),
|
|
sort: { sortField: 'createTime', sortOrder: 'asc' },
|
|
reload: () => reloads++,
|
|
closeFilter: () => {},
|
|
rangeFor: () => ['2026-10-25', '2026-10-25']
|
|
}
|
|
const h = new Function(
|
|
...Object.keys(bindings),
|
|
ts.transpile(code, { target: ts.ScriptTarget.ESNext }) +
|
|
';return {rangeFilters,onRangeOpen,onMoreOpen,onDateDropdownChange,pickDate,applyFilters}'
|
|
)(...Object.values(bindings))
|
|
h.onRangeOpen()
|
|
assert.equal(bindings.dateOnly.value, true)
|
|
assert.equal(bindings.filterOpen.value, true)
|
|
assert.equal(bindings.dateShortcut.value, 'today')
|
|
assert.equal(bindings.filters.planDate, undefined)
|
|
assert.deepEqual(h.rangeFilters.value.planDate, { from: '2026-10-24', to: '2026-10-24' })
|
|
bindings.filterOpen.value = false
|
|
h.onDateDropdownChange('custom')
|
|
assert.equal(bindings.dateShortcut.value, 'today')
|
|
assert.equal(reloads, 0)
|
|
h.applyFilters(
|
|
{ ...bindings.filters, planDate: { from: '2026-10-20', to: '2026-10-22' } },
|
|
{ ...bindings.sort }
|
|
)
|
|
assert.equal(bindings.dateShortcut.value, 'custom')
|
|
assert.deepEqual(bindings.dateRange.value, [])
|
|
assert.equal(bindings.customDateLabel.value, '2026-10-20 ~ 2026-10-22')
|
|
h.onMoreOpen()
|
|
assert.equal(bindings.dateOnly.value, false)
|
|
h.pickDate('custom')
|
|
assert.equal(bindings.filters.planDate.from, '2026-10-20')
|
|
h.pickDate('tomorrow')
|
|
assert.equal(bindings.filters.planDate, undefined)
|
|
assert.deepEqual(bindings.dateRange.value, ['2026-10-25', '2026-10-25'])
|
|
assert.deepEqual(bindings.filters.planNo, { selected: ['P1'] })
|
|
})
|