402 lines
17 KiB
TypeScript
402 lines
17 KiB
TypeScript
import assert from 'node:assert/strict'
|
|
import test from 'node:test'
|
|
import { execFileSync } from 'node:child_process'
|
|
import { fileURLToPath } from 'node:url'
|
|
import { prepareLineAliasAndBrandForSubmit } from '../src/utils/crm-product-brand'
|
|
import {
|
|
mirrorMobilePlanProducts,
|
|
buildMobileProductLinks,
|
|
hydrateMobileStopMeta,
|
|
enrichMobileStopProducts
|
|
} from '../src/views/m/pl/plan/mobilePlanPersistence'
|
|
|
|
const line = (overrides: any = {}) => ({
|
|
productId: 'p',
|
|
productNo: 'P',
|
|
productName: '标准名',
|
|
unit: '吨',
|
|
packageSpec: '桶',
|
|
model: 'M',
|
|
planQty: 1,
|
|
...overrides
|
|
})
|
|
|
|
test('actual mobile mirror consumes each old plan identity once and repairs duplicate historic links', () => {
|
|
const rows = [
|
|
line({
|
|
planProductId: 'pp',
|
|
contractProductId: 'c1',
|
|
aliasName: '别名一',
|
|
brandMode: 'required',
|
|
brandId: 'b1'
|
|
}),
|
|
line({ planProductId: 'pp', contractProductId: 'c2', packageSpec: '袋', planQty: 2 })
|
|
]
|
|
const result = mirrorMobilePlanProducts(rows, [
|
|
{ id: 'pp', productId: 'p', packageSpec: '桶', purchaseCertUrl: 'cert' }
|
|
] as any)
|
|
assert.deepEqual(
|
|
result.map((x) => x.id),
|
|
['pp', undefined]
|
|
)
|
|
assert.equal(result[0].purchaseCertUrl, 'cert')
|
|
assert.equal(result[0].brandId, 'b1')
|
|
assert.equal(
|
|
result.reduce((n, x) => n + Number(x.quantity), 0),
|
|
3
|
|
)
|
|
})
|
|
|
|
test('request keys link same product different contract packaging without client-created database ids', () => {
|
|
const loads = [
|
|
line({ contractProductId: 'c1' }),
|
|
line({ contractProductId: 'c2', packageSpec: '袋', planQty: 2 })
|
|
]
|
|
const deliveries = [
|
|
{
|
|
products: [
|
|
line({
|
|
contractProductId: 'sell2',
|
|
packageSpec: '袋',
|
|
brandMode: 'required',
|
|
brandId: 'delivery-brand',
|
|
aliasName: '送货别名'
|
|
})
|
|
]
|
|
}
|
|
]
|
|
const plan = mirrorMobilePlanProducts(loads, [])
|
|
const linked = buildMobileProductLinks(loads, deliveries, plan)
|
|
assert.equal(linked.products.length, 2)
|
|
assert.equal(linked.products[0].id, undefined)
|
|
assert.notEqual(linked.products[0].clientKey, linked.products[1].clientKey)
|
|
assert.equal(linked.deliveries[0][0].planProductClientKey, linked.products[1].clientKey)
|
|
assert.equal(linked.deliveries[0][0].mobileProductMeta.brandId, 'delivery-brand')
|
|
assert.equal(linked.deliveries[0][0].mobileProductMeta.aliasName, '送货别名')
|
|
})
|
|
|
|
test('existing valid associations remain and new associations follow Web row order', () => {
|
|
const loads = [line({ planProductId: 'a' }), line({ planProductId: 'b' })]
|
|
const plan = mirrorMobilePlanProducts(loads, [
|
|
{ id: 'a', productId: 'p', packageSpec: '桶' },
|
|
{ id: 'b', productId: 'p', packageSpec: '桶' }
|
|
] as any)
|
|
assert.equal(
|
|
buildMobileProductLinks(loads, [{ products: [line({ planProductId: 'b' })] }], plan)
|
|
.deliveries[0][0].planProductId,
|
|
'b'
|
|
)
|
|
assert.equal(
|
|
buildMobileProductLinks(loads, [{ products: [line()] }], plan).deliveries[0][0].planProductId,
|
|
'a'
|
|
)
|
|
})
|
|
|
|
test('delivery packaging override links automatically and remains independent in the submit payload', async () => {
|
|
const { mobileLoadCandidates, resolveMobileDeliveryLoad } = await import(
|
|
'../src/views/m/pl/plan/mobilePlanPersistence'
|
|
)
|
|
const { buildStopProductPayload } = await import('../src/utils/pl-plan-stop-form')
|
|
const load = line({ packageSpec: '25Kg/袋', productName: '片碱', planQty: 1 })
|
|
const delivery = line({ packageSpec: '20kg/袋', productName: '片碱', planQty: 100 })
|
|
const plans = mirrorMobilePlanProducts([load], [])
|
|
assert.deepEqual(mobileLoadCandidates(delivery, [load]), [load])
|
|
assert.equal(resolveMobileDeliveryLoad(delivery, [load], plans), 0)
|
|
const links = buildMobileProductLinks([load], [{ products: [delivery] }], plans)
|
|
const payload = {
|
|
load: { ...buildStopProductPayload(load), ...links.load[0] },
|
|
delivery: { ...buildStopProductPayload(delivery), ...links.deliveries[0][0] }
|
|
}
|
|
assert.equal(payload.load.planProductClientKey, payload.delivery.planProductClientKey)
|
|
assert.equal(payload.load.packageSpec, '25Kg/袋')
|
|
assert.equal(payload.delivery.packageSpec, '20kg/袋')
|
|
assert.equal(payload.load.planQty, 1)
|
|
assert.equal(payload.delivery.planQty, 100)
|
|
})
|
|
|
|
test('multiple load rows prefer an exact package match but permit explicit cross-package association', async () => {
|
|
const { mobileLoadCandidates, mobileLoadProductKey, resolveMobileDeliveryLoad } = await import(
|
|
'../src/views/m/pl/plan/mobilePlanPersistence'
|
|
)
|
|
const loads = [
|
|
line({ packageSpec: '25Kg/袋', planProductId: 'pp1' }),
|
|
line({ packageSpec: '20kg/袋', planProductId: 'pp2' })
|
|
]
|
|
const plans = mirrorMobilePlanProducts(
|
|
loads,
|
|
loads.map((row) => ({ ...row, id: row.planProductId }))
|
|
)
|
|
assert.equal(resolveMobileDeliveryLoad(line({ packageSpec: '20kg/袋' }), loads, plans), 1)
|
|
const row = line({ packageSpec: '50kg/袋' })
|
|
assert.equal(mobileLoadCandidates(row, loads).length, 2)
|
|
assert.equal(resolveMobileDeliveryLoad(row, loads, plans), 0)
|
|
row.mobileLoadProductKey = mobileLoadProductKey(loads[1])
|
|
assert.equal(resolveMobileDeliveryLoad(row, loads, plans), 1)
|
|
const saved = line({ planProductId: 'pp2', packageSpec: '50kg/袋' })
|
|
assert.equal(resolveMobileDeliveryLoad(saved, loads, plans), 1)
|
|
loads[1].packageSpec = '散装'
|
|
assert.equal(resolveMobileDeliveryLoad(saved, loads, plans), 1)
|
|
for (const field of ['productId', 'model', 'unit']) {
|
|
assert.equal(mobileLoadCandidates(line({ [field]: 'different' }), loads).length, 0)
|
|
}
|
|
})
|
|
|
|
test('mobile hydration uses independent stop snapshots, not first plan product brand', () => {
|
|
const stop = line({ id: 's', planProductId: 'pp', productName: '送货别名' })
|
|
const rows = hydrateMobileStopMeta(
|
|
[stop],
|
|
[
|
|
{
|
|
...stop,
|
|
mobileProductMeta: {
|
|
aliasName: '送货别名',
|
|
brandMode: 'required',
|
|
brandId: 'delivery',
|
|
brandName: '送货品牌'
|
|
}
|
|
}
|
|
]
|
|
)
|
|
const result = enrichMobileStopProducts(rows, [
|
|
{
|
|
id: 'pp',
|
|
productId: 'p',
|
|
productName: '标准名',
|
|
standardProductName: '标准名',
|
|
aliasName: '装货别名',
|
|
aliasOptions: ['装货别名', '送货别名'],
|
|
brandMode: 'required',
|
|
brandId: 'load'
|
|
}
|
|
] as any)
|
|
assert.equal(result[0].aliasName, '送货别名')
|
|
assert.equal(result[0].brandId, 'delivery')
|
|
})
|
|
|
|
test('copy without source ids creates only request keys and keeps metadata', () => {
|
|
const loads = [line({ aliasName: '复制别名' })]
|
|
const linked = buildMobileProductLinks(
|
|
loads,
|
|
[{ products: [line()] }],
|
|
mirrorMobilePlanProducts(loads, [])
|
|
)
|
|
assert.equal(linked.load[0].planProductId, undefined)
|
|
assert.equal(linked.load[0].id, undefined)
|
|
assert.equal(linked.load[0].mobileProductMeta.aliasName, '复制别名')
|
|
})
|
|
|
|
test('saved same-packaging link prefers its source and recovers automatically when it is removed', async () => {
|
|
const { mobileLoadProductKey } = await import('../src/views/m/pl/plan/mobilePlanPersistence')
|
|
const loads = [
|
|
line({ contractProductId: '002', contractNo: 'HT002', planQty: 1 }),
|
|
line({ contractProductId: '003', contractNo: 'HT003', planQty: 2 })
|
|
]
|
|
const delivery = line({
|
|
contractProductId: '005',
|
|
planQty: 2,
|
|
mobileLoadProductKey: mobileLoadProductKey(loads[1])
|
|
})
|
|
const plans = mirrorMobilePlanProducts(loads, [])
|
|
assert.equal(
|
|
buildMobileProductLinks(loads, [{ products: [delivery] }], plans).deliveries[0][0]
|
|
.planProductClientKey,
|
|
'product-2'
|
|
)
|
|
assert.equal(
|
|
buildMobileProductLinks([loads[0]], [{ products: [delivery] }], [plans[0]]).deliveries[0][0]
|
|
.planProductClientKey,
|
|
'product-1'
|
|
)
|
|
})
|
|
|
|
test('mobile product cards do not expose an internal load association field or picker', async () => {
|
|
const { readFile } = await import('node:fs/promises')
|
|
const source = await readFile(
|
|
new URL('../src/views/m/pl/plan/MobilePlanProductCards.vue', import.meta.url),
|
|
'utf8'
|
|
)
|
|
assert.doesNotMatch(source, /关联装货产品|mobileLoadProductKey|loadAssociationIndex/)
|
|
})
|
|
|
|
test('copy carries two same-packaging associations and one-to-many delivery references through id removal', async () => {
|
|
const persistence = await import('../src/views/m/pl/plan/mobilePlanPersistence')
|
|
assert.equal(typeof persistence.preserveMobileCopyAssociations, 'function')
|
|
const loads = [line({ planProductId: 'pp1', id: 'l1' }), line({ planProductId: 'pp2', id: 'l2' })]
|
|
const deliveries = [
|
|
{ products: [line({ planProductId: 'pp2', id: 'd1' })] },
|
|
{ products: [line({ planProductId: 'pp2', id: 'd2' })] }
|
|
]
|
|
persistence.preserveMobileCopyAssociations(loads, deliveries)
|
|
const copiedLoads = loads.map((row) => ({ ...row, id: undefined, planProductId: undefined }))
|
|
const copiedDelivery = deliveries.map((stop) => ({
|
|
products: stop.products.map((row) => ({ ...row, id: undefined, planProductId: undefined }))
|
|
}))
|
|
const links = buildMobileProductLinks(
|
|
copiedLoads,
|
|
copiedDelivery,
|
|
mirrorMobilePlanProducts(copiedLoads, [])
|
|
)
|
|
assert.deepEqual(
|
|
links.deliveries.map((rows) => rows[0].planProductClientKey),
|
|
['product-2', 'product-2']
|
|
)
|
|
assert.ok(links.products.every((row) => !row.id))
|
|
})
|
|
|
|
test('historically collapsed same-packaging load IDs resolve using existing Web order', () => {
|
|
const loads = [line({ planProductId: 'collapsed' }), line({ planProductId: 'collapsed' })]
|
|
const plans = mirrorMobilePlanProducts(loads, [
|
|
{ id: 'collapsed', productId: 'p', packageSpec: '桶' }
|
|
] as any)
|
|
assert.equal(
|
|
buildMobileProductLinks(loads, [{ products: [line({ planProductId: 'collapsed' })] }], plans)
|
|
.deliveries[0][0].planProductClientKey,
|
|
'product-1'
|
|
)
|
|
})
|
|
|
|
test('mobile capability requires explicit version and rejects successful old-server responses', async () => {
|
|
const { assertMobilePersistenceCapability } = await import(
|
|
'../src/views/m/pl/plan/mobilePlanPersistence'
|
|
)
|
|
assert.equal(typeof assertMobilePersistenceCapability, 'function')
|
|
assert.throws(() => assertMobilePersistenceCapability({}), /服务.*更新/)
|
|
assert.throws(
|
|
() => assertMobilePersistenceCapability({ mobileProductMetaVersion: 0 }),
|
|
/服务.*更新/
|
|
)
|
|
assertMobilePersistenceCapability({ mobileProductMetaVersion: 1 })
|
|
})
|
|
|
|
test('incomplete draft keeps delivery product and independent metadata without creating a plan product', () => {
|
|
const row = line({ aliasName: '待关联别名', brandMode: 'required', brandId: 'b' })
|
|
const linked = buildMobileProductLinks([], [{ products: [row] }], [], { draft: true })
|
|
assert.equal(linked.products.length, 0)
|
|
assert.equal(linked.deliveries[0][0].unlinkedDraft, true)
|
|
assert.equal(linked.deliveries[0][0].planProductClientKey, undefined)
|
|
assert.equal(linked.deliveries[0][0].mobileProductMeta.brandId, 'b')
|
|
assert.throws(
|
|
() => buildMobileProductLinks([], [{ products: [row] }], []),
|
|
/没有可匹配的装货产品/
|
|
)
|
|
const loads = [line()]
|
|
const formal = buildMobileProductLinks(
|
|
loads,
|
|
[{ products: [row] }],
|
|
mirrorMobilePlanProducts(loads, [])
|
|
)
|
|
assert.equal(formal.deliveries[0][0].unlinkedDraft, undefined)
|
|
assert.equal(formal.deliveries[0][0].planProductClientKey, 'product-1')
|
|
})
|
|
|
|
for (const change of ['delete', 'packageSpec', 'model', 'unit']) {
|
|
test(`saved association recovers automatically after load ${change} and keeps draft metadata`, () => {
|
|
const loads = [
|
|
line({ planProductId: 'pp1', id: 'l1' }),
|
|
line({ planProductId: 'pp2', id: 'l2' })
|
|
]
|
|
const previous = loads.map((row) => ({ ...row, id: row.planProductId }))
|
|
const delivery = line({ id: 'd', planProductId: 'pp2', aliasName: '片碱' })
|
|
const initial = mirrorMobilePlanProducts(loads, previous)
|
|
assert.equal(
|
|
buildMobileProductLinks(loads, [{ products: [delivery] }], initial).deliveries[0][0]
|
|
.planProductId,
|
|
'pp2'
|
|
)
|
|
if (change === 'delete') loads.splice(1, 1)
|
|
else loads[1][change] = 'changed'
|
|
const plans = mirrorMobilePlanProducts(loads, initial)
|
|
const linked = buildMobileProductLinks(loads, [{ products: [delivery] }], plans)
|
|
.deliveries[0][0]
|
|
assert.equal(linked.planProductId, change === 'packageSpec' ? 'pp2' : 'pp1')
|
|
assert.equal(linked.mobileProductMeta.aliasName, '片碱')
|
|
const draft = buildMobileProductLinks([], [{ products: [delivery] }], [], { draft: true })
|
|
.deliveries[0][0]
|
|
assert.equal(draft.unlinkedDraft, true)
|
|
const reopened = hydrateMobileStopMeta([line({ id: 'd' })], [{ ...draft, id: 'd' }])[0]
|
|
assert.throws(
|
|
() => buildMobileProductLinks([], [{ products: [reopened] }], []),
|
|
/没有可匹配的装货产品/
|
|
)
|
|
const recovered = buildMobileProductLinks(loads, [{ products: [reopened] }], plans)
|
|
.deliveries[0][0]
|
|
assert.equal(recovered.planProductId, 'pp1')
|
|
assert.equal(recovered.unlinkedDraft, undefined)
|
|
assert.equal(recovered.mobileProductMeta.aliasName, '片碱')
|
|
})
|
|
}
|
|
|
|
// Execute the real pure Python resolver and read serializer, with the actual Pydantic class
|
|
// extracted to avoid importing application/DB startup. No API, ORM or network is involved.
|
|
function resolvedBackendMetadata(clear: boolean, productName = '新别名') {
|
|
const backend = fileURLToPath(new URL('../../backend/', import.meta.url))
|
|
const code = `
|
|
import ast, importlib.util, json, sys, types
|
|
from pathlib import Path
|
|
from types import SimpleNamespace as Row
|
|
from typing import Optional
|
|
from pydantic import BaseModel, Field
|
|
path = Path('app/services/pl_mobile_stop_meta.py')
|
|
spec = importlib.util.spec_from_file_location('isolated_meta', path)
|
|
m = importlib.util.module_from_spec(spec); spec.loader.exec_module(m)
|
|
node = next(n for n in ast.parse(Path('app/schemas/pl/plan.py').read_text()).body if isinstance(n, ast.ClassDef) and n.name == 'PlanStopProductMeta')
|
|
scope = dict(BaseModel=BaseModel, Field=Field, Optional=Optional)
|
|
exec(compile(ast.Module(body=[node], type_ignores=[]), '<actual-meta-schema>', 'exec'), scope)
|
|
module = types.ModuleType('app.schemas.pl.plan'); module.PlanStopProductMeta = scope['PlanStopProductMeta']
|
|
sys.modules['app.schemas.pl.plan'] = module
|
|
old = Row(id='meta', tenant_no='t', plan_id='plan', stop_type='load', stop_id='load', stop_product_id='s', contract_id='c', contract_product_id='line', product_id='p', package_spec='桶', model='M', unit='吨', product_name='旧别名', alias_name='旧别名', brand_mode='any', brand_id='', brand_name='', source_product_name='旧别名', source_brand_mode='any', source_brand_id='', source_brand_name='')
|
|
product = Row(**{k: getattr(old, k) for k in m.IDENTITY_FIELDS}, id='s', product_name=sys.argv[2], mobile_product_meta=None)
|
|
plan = Row(product_name=sys.argv[2], brand_mode='any', brand_id='', brand_name='')
|
|
if sys.argv[1] == 'clear':
|
|
product.mobile_product_meta = Row(alias_name='', brand_mode='any', brand_id='', brand_name='')
|
|
values = m.resolve_stop_meta([old], set(), 't', 'plan', 'load', 'load', product, plan)
|
|
model = types.ModuleType('app.models.pl_mobile_stop_meta'); model.PlPlanStopProductMeta = Row
|
|
utils = types.ModuleType('app.core.utils'); utils.generate_uuid = lambda: 'new-meta'
|
|
sys.modules['app.models.pl_mobile_stop_meta'] = model
|
|
sys.modules['app.core.utils'] = utils
|
|
written = []
|
|
m.write_stop_meta(Row(add=written.append), [old], set(), 't', 'plan', 'load', 'load', 's', product, plan, 'u', 'd', None)
|
|
snapshot = written[0]
|
|
assert snapshot.alias_name == values['alias_name']
|
|
# A subsequent unrelated Web save must preserve the distinction too.
|
|
product.mobile_product_meta = None
|
|
values = m.resolve_stop_meta([snapshot], set(), 't', 'plan', 'load', 'load', product, plan)
|
|
snapshot.__dict__.update(values)
|
|
result = Row(load=Row(products=[Row(id='s')]), deliveries=[])
|
|
m.attach_stop_meta(result, [snapshot])
|
|
print(json.dumps(result.load.products[0].mobile_product_meta.model_dump(by_alias=True)))
|
|
`
|
|
return JSON.parse(
|
|
execFileSync(`${backend}/venv/bin/python`, ['-c', code, clear ? 'clear' : 'web', productName], {
|
|
cwd: backend,
|
|
encoding: 'utf8'
|
|
})
|
|
)
|
|
}
|
|
|
|
for (const [clear, productName] of [
|
|
[false, '新别名'],
|
|
[false, '标准名'],
|
|
[true, '新别名']
|
|
] as const) {
|
|
test(`actual backend snapshot → hydrate → enrich → mirror → submit preserves ${clear ? 'mobile clear' : `Web edit to ${productName}`}`, () => {
|
|
const meta = resolvedBackendMetadata(clear, productName)
|
|
const expectedAlias = clear || productName === '标准名' ? '' : '新别名'
|
|
const rows = hydrateMobileStopMeta(
|
|
[line({ id: 's', planProductId: 'pp', productName })],
|
|
[{ id: 's', mobileProductMeta: meta }]
|
|
)
|
|
const catalog = [
|
|
{ ...line(), id: 'pp', standardProductName: '标准名', aliasOptions: ['旧别名', '新别名'] }
|
|
]
|
|
const enriched = enrichMobileStopProducts(rows, catalog as any, true)
|
|
assert.equal(enriched[0].aliasName, expectedAlias)
|
|
const mirrored = mirrorMobilePlanProducts(enriched, catalog as any)
|
|
prepareLineAliasAndBrandForSubmit(mirrored[0])
|
|
assert.equal(mirrored[0].productName, expectedAlias || '标准名')
|
|
assert.equal(meta.aliasName, clear ? '' : null)
|
|
const payload = buildMobileProductLinks(enriched, [], mirrored)
|
|
assert.equal(payload.load[0].mobileProductMeta.aliasName, expectedAlias)
|
|
})
|
|
}
|