Files
shanxia2.0/web-admin/scripts/mobile-pl-plan-persistence.test.ts
T
2026-10-23 23:07:00 +08:00

360 lines
15 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, ambiguous new association rejects instead of first productId', () => {
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.throws(
() => buildMobileProductLinks(loads, [{ products: [line()] }], plan),
/无法唯一关联/
)
})
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('same-packaging PO001 sample can explicitly link second load line and never silently relinks after deletion', 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.throws(
() => buildMobileProductLinks([loads[0]], [{ products: [delivery] }], [plans[0]]),
/选择关联装货产品/
)
})
test('mobile product cards provide an editable, searchable load association 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.match(source, /title="关联装货产品"/)
assert.match(source, /mobileLoadProductKey/)
assert.match(source, /resolveMobileDeliveryLoad/)
})
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 require explicit repair instead of trusting duplicate foreign keys', () => {
const loads = [line({ planProductId: 'collapsed' }), line({ planProductId: 'collapsed' })]
const plans = mirrorMobilePlanProducts(loads, [
{ id: 'collapsed', productId: 'p', packageSpec: '桶' }
] as any)
assert.throws(
() =>
buildMobileProductLinks(loads, [{ products: [line({ planProductId: 'collapsed' })] }], plans),
/选择关联装货产品/
)
})
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(`hydrated historical delivery stays pending after linked load ${change} until manual selection`, async () => {
const { mobileLoadProductKey } = await import('../src/views/m/pl/plan/mobilePlanPersistence')
const loads = [
line({ planProductId: 'pp1', id: 'l1' }),
line({ planProductId: 'pp2', id: 'l2' })
]
const previous = loads.map((row) => ({ ...row, id: row.planProductId }))
const delivery = hydrateMobileStopMeta(
[line({ id: 'd', planProductId: 'pp2' })],
[{ id: 'd' }]
)[0]
const initial = mirrorMobilePlanProducts(loads, previous as any)
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)
assert.throws(
() => buildMobileProductLinks(loads, [{ products: [delivery] }], plans),
/选择关联装货产品/
)
const draft = buildMobileProductLinks(loads, [{ products: [delivery] }], plans, { draft: true })
.deliveries[0][0]
assert.equal(draft.unlinkedDraft, true)
assert.equal(draft.planProductId, undefined)
assert.equal(draft.planProductClientKey, undefined)
// The regular stop mapper does not retain the API-only pending flag; mobile hydration must.
const reopened = hydrateMobileStopMeta([line({ id: 'd' })], [{ ...draft, id: 'd' }])[0]
assert.throws(
() => buildMobileProductLinks(loads, [{ products: [reopened] }], plans),
/选择关联装货产品/
)
;(reopened as any).mobileLoadProductKey = mobileLoadProductKey(loads[0])
const confirmed = buildMobileProductLinks(loads, [{ products: [reopened] }], plans)
.deliveries[0][0]
assert.equal(confirmed.planProductId, 'pp1')
assert.equal(confirmed.unlinkedDraft, undefined)
const fresh = buildMobileProductLinks([loads[0]], [{ products: [line()] }], [plans[0]])
assert.equal(fresh.deliveries[0][0].planProductId, 'pp1')
})
}
// 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)
})
}