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=[]), '', '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) }) }