mobile-pl-plan-persistence.test.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401
  1. import assert from 'node:assert/strict'
  2. import test from 'node:test'
  3. import { execFileSync } from 'node:child_process'
  4. import { fileURLToPath } from 'node:url'
  5. import { prepareLineAliasAndBrandForSubmit } from '../src/utils/crm-product-brand'
  6. import {
  7. mirrorMobilePlanProducts,
  8. buildMobileProductLinks,
  9. hydrateMobileStopMeta,
  10. enrichMobileStopProducts
  11. } from '../src/views/m/pl/plan/mobilePlanPersistence'
  12. const line = (overrides: any = {}) => ({
  13. productId: 'p',
  14. productNo: 'P',
  15. productName: '标准名',
  16. unit: '吨',
  17. packageSpec: '桶',
  18. model: 'M',
  19. planQty: 1,
  20. ...overrides
  21. })
  22. test('actual mobile mirror consumes each old plan identity once and repairs duplicate historic links', () => {
  23. const rows = [
  24. line({
  25. planProductId: 'pp',
  26. contractProductId: 'c1',
  27. aliasName: '别名一',
  28. brandMode: 'required',
  29. brandId: 'b1'
  30. }),
  31. line({ planProductId: 'pp', contractProductId: 'c2', packageSpec: '袋', planQty: 2 })
  32. ]
  33. const result = mirrorMobilePlanProducts(rows, [
  34. { id: 'pp', productId: 'p', packageSpec: '桶', purchaseCertUrl: 'cert' }
  35. ] as any)
  36. assert.deepEqual(
  37. result.map((x) => x.id),
  38. ['pp', undefined]
  39. )
  40. assert.equal(result[0].purchaseCertUrl, 'cert')
  41. assert.equal(result[0].brandId, 'b1')
  42. assert.equal(
  43. result.reduce((n, x) => n + Number(x.quantity), 0),
  44. 3
  45. )
  46. })
  47. test('request keys link same product different contract packaging without client-created database ids', () => {
  48. const loads = [
  49. line({ contractProductId: 'c1' }),
  50. line({ contractProductId: 'c2', packageSpec: '袋', planQty: 2 })
  51. ]
  52. const deliveries = [
  53. {
  54. products: [
  55. line({
  56. contractProductId: 'sell2',
  57. packageSpec: '袋',
  58. brandMode: 'required',
  59. brandId: 'delivery-brand',
  60. aliasName: '送货别名'
  61. })
  62. ]
  63. }
  64. ]
  65. const plan = mirrorMobilePlanProducts(loads, [])
  66. const linked = buildMobileProductLinks(loads, deliveries, plan)
  67. assert.equal(linked.products.length, 2)
  68. assert.equal(linked.products[0].id, undefined)
  69. assert.notEqual(linked.products[0].clientKey, linked.products[1].clientKey)
  70. assert.equal(linked.deliveries[0][0].planProductClientKey, linked.products[1].clientKey)
  71. assert.equal(linked.deliveries[0][0].mobileProductMeta.brandId, 'delivery-brand')
  72. assert.equal(linked.deliveries[0][0].mobileProductMeta.aliasName, '送货别名')
  73. })
  74. test('existing valid associations remain and new associations follow Web row order', () => {
  75. const loads = [line({ planProductId: 'a' }), line({ planProductId: 'b' })]
  76. const plan = mirrorMobilePlanProducts(loads, [
  77. { id: 'a', productId: 'p', packageSpec: '桶' },
  78. { id: 'b', productId: 'p', packageSpec: '桶' }
  79. ] as any)
  80. assert.equal(
  81. buildMobileProductLinks(loads, [{ products: [line({ planProductId: 'b' })] }], plan)
  82. .deliveries[0][0].planProductId,
  83. 'b'
  84. )
  85. assert.equal(
  86. buildMobileProductLinks(loads, [{ products: [line()] }], plan).deliveries[0][0].planProductId,
  87. 'a'
  88. )
  89. })
  90. test('delivery packaging override links automatically and remains independent in the submit payload', async () => {
  91. const { mobileLoadCandidates, resolveMobileDeliveryLoad } = await import(
  92. '../src/views/m/pl/plan/mobilePlanPersistence'
  93. )
  94. const { buildStopProductPayload } = await import('../src/utils/pl-plan-stop-form')
  95. const load = line({ packageSpec: '25Kg/袋', productName: '片碱', planQty: 1 })
  96. const delivery = line({ packageSpec: '20kg/袋', productName: '片碱', planQty: 100 })
  97. const plans = mirrorMobilePlanProducts([load], [])
  98. assert.deepEqual(mobileLoadCandidates(delivery, [load]), [load])
  99. assert.equal(resolveMobileDeliveryLoad(delivery, [load], plans), 0)
  100. const links = buildMobileProductLinks([load], [{ products: [delivery] }], plans)
  101. const payload = {
  102. load: { ...buildStopProductPayload(load), ...links.load[0] },
  103. delivery: { ...buildStopProductPayload(delivery), ...links.deliveries[0][0] }
  104. }
  105. assert.equal(payload.load.planProductClientKey, payload.delivery.planProductClientKey)
  106. assert.equal(payload.load.packageSpec, '25Kg/袋')
  107. assert.equal(payload.delivery.packageSpec, '20kg/袋')
  108. assert.equal(payload.load.planQty, 1)
  109. assert.equal(payload.delivery.planQty, 100)
  110. })
  111. test('multiple load rows prefer an exact package match but permit explicit cross-package association', async () => {
  112. const { mobileLoadCandidates, mobileLoadProductKey, resolveMobileDeliveryLoad } = await import(
  113. '../src/views/m/pl/plan/mobilePlanPersistence'
  114. )
  115. const loads = [
  116. line({ packageSpec: '25Kg/袋', planProductId: 'pp1' }),
  117. line({ packageSpec: '20kg/袋', planProductId: 'pp2' })
  118. ]
  119. const plans = mirrorMobilePlanProducts(
  120. loads,
  121. loads.map((row) => ({ ...row, id: row.planProductId }))
  122. )
  123. assert.equal(resolveMobileDeliveryLoad(line({ packageSpec: '20kg/袋' }), loads, plans), 1)
  124. const row = line({ packageSpec: '50kg/袋' })
  125. assert.equal(mobileLoadCandidates(row, loads).length, 2)
  126. assert.equal(resolveMobileDeliveryLoad(row, loads, plans), 0)
  127. row.mobileLoadProductKey = mobileLoadProductKey(loads[1])
  128. assert.equal(resolveMobileDeliveryLoad(row, loads, plans), 1)
  129. const saved = line({ planProductId: 'pp2', packageSpec: '50kg/袋' })
  130. assert.equal(resolveMobileDeliveryLoad(saved, loads, plans), 1)
  131. loads[1].packageSpec = '散装'
  132. assert.equal(resolveMobileDeliveryLoad(saved, loads, plans), 1)
  133. for (const field of ['productId', 'model', 'unit']) {
  134. assert.equal(mobileLoadCandidates(line({ [field]: 'different' }), loads).length, 0)
  135. }
  136. })
  137. test('mobile hydration uses independent stop snapshots, not first plan product brand', () => {
  138. const stop = line({ id: 's', planProductId: 'pp', productName: '送货别名' })
  139. const rows = hydrateMobileStopMeta(
  140. [stop],
  141. [
  142. {
  143. ...stop,
  144. mobileProductMeta: {
  145. aliasName: '送货别名',
  146. brandMode: 'required',
  147. brandId: 'delivery',
  148. brandName: '送货品牌'
  149. }
  150. }
  151. ]
  152. )
  153. const result = enrichMobileStopProducts(rows, [
  154. {
  155. id: 'pp',
  156. productId: 'p',
  157. productName: '标准名',
  158. standardProductName: '标准名',
  159. aliasName: '装货别名',
  160. aliasOptions: ['装货别名', '送货别名'],
  161. brandMode: 'required',
  162. brandId: 'load'
  163. }
  164. ] as any)
  165. assert.equal(result[0].aliasName, '送货别名')
  166. assert.equal(result[0].brandId, 'delivery')
  167. })
  168. test('copy without source ids creates only request keys and keeps metadata', () => {
  169. const loads = [line({ aliasName: '复制别名' })]
  170. const linked = buildMobileProductLinks(
  171. loads,
  172. [{ products: [line()] }],
  173. mirrorMobilePlanProducts(loads, [])
  174. )
  175. assert.equal(linked.load[0].planProductId, undefined)
  176. assert.equal(linked.load[0].id, undefined)
  177. assert.equal(linked.load[0].mobileProductMeta.aliasName, '复制别名')
  178. })
  179. test('saved same-packaging link prefers its source and recovers automatically when it is removed', async () => {
  180. const { mobileLoadProductKey } = await import('../src/views/m/pl/plan/mobilePlanPersistence')
  181. const loads = [
  182. line({ contractProductId: '002', contractNo: 'HT002', planQty: 1 }),
  183. line({ contractProductId: '003', contractNo: 'HT003', planQty: 2 })
  184. ]
  185. const delivery = line({
  186. contractProductId: '005',
  187. planQty: 2,
  188. mobileLoadProductKey: mobileLoadProductKey(loads[1])
  189. })
  190. const plans = mirrorMobilePlanProducts(loads, [])
  191. assert.equal(
  192. buildMobileProductLinks(loads, [{ products: [delivery] }], plans).deliveries[0][0]
  193. .planProductClientKey,
  194. 'product-2'
  195. )
  196. assert.equal(
  197. buildMobileProductLinks([loads[0]], [{ products: [delivery] }], [plans[0]]).deliveries[0][0]
  198. .planProductClientKey,
  199. 'product-1'
  200. )
  201. })
  202. test('mobile product cards do not expose an internal load association field or picker', async () => {
  203. const { readFile } = await import('node:fs/promises')
  204. const source = await readFile(
  205. new URL('../src/views/m/pl/plan/MobilePlanProductCards.vue', import.meta.url),
  206. 'utf8'
  207. )
  208. assert.doesNotMatch(source, /关联装货产品|mobileLoadProductKey|loadAssociationIndex/)
  209. })
  210. test('copy carries two same-packaging associations and one-to-many delivery references through id removal', async () => {
  211. const persistence = await import('../src/views/m/pl/plan/mobilePlanPersistence')
  212. assert.equal(typeof persistence.preserveMobileCopyAssociations, 'function')
  213. const loads = [line({ planProductId: 'pp1', id: 'l1' }), line({ planProductId: 'pp2', id: 'l2' })]
  214. const deliveries = [
  215. { products: [line({ planProductId: 'pp2', id: 'd1' })] },
  216. { products: [line({ planProductId: 'pp2', id: 'd2' })] }
  217. ]
  218. persistence.preserveMobileCopyAssociations(loads, deliveries)
  219. const copiedLoads = loads.map((row) => ({ ...row, id: undefined, planProductId: undefined }))
  220. const copiedDelivery = deliveries.map((stop) => ({
  221. products: stop.products.map((row) => ({ ...row, id: undefined, planProductId: undefined }))
  222. }))
  223. const links = buildMobileProductLinks(
  224. copiedLoads,
  225. copiedDelivery,
  226. mirrorMobilePlanProducts(copiedLoads, [])
  227. )
  228. assert.deepEqual(
  229. links.deliveries.map((rows) => rows[0].planProductClientKey),
  230. ['product-2', 'product-2']
  231. )
  232. assert.ok(links.products.every((row) => !row.id))
  233. })
  234. test('historically collapsed same-packaging load IDs resolve using existing Web order', () => {
  235. const loads = [line({ planProductId: 'collapsed' }), line({ planProductId: 'collapsed' })]
  236. const plans = mirrorMobilePlanProducts(loads, [
  237. { id: 'collapsed', productId: 'p', packageSpec: '桶' }
  238. ] as any)
  239. assert.equal(
  240. buildMobileProductLinks(loads, [{ products: [line({ planProductId: 'collapsed' })] }], plans)
  241. .deliveries[0][0].planProductClientKey,
  242. 'product-1'
  243. )
  244. })
  245. test('mobile capability requires explicit version and rejects successful old-server responses', async () => {
  246. const { assertMobilePersistenceCapability } = await import(
  247. '../src/views/m/pl/plan/mobilePlanPersistence'
  248. )
  249. assert.equal(typeof assertMobilePersistenceCapability, 'function')
  250. assert.throws(() => assertMobilePersistenceCapability({}), /服务.*更新/)
  251. assert.throws(
  252. () => assertMobilePersistenceCapability({ mobileProductMetaVersion: 0 }),
  253. /服务.*更新/
  254. )
  255. assertMobilePersistenceCapability({ mobileProductMetaVersion: 1 })
  256. })
  257. test('incomplete draft keeps delivery product and independent metadata without creating a plan product', () => {
  258. const row = line({ aliasName: '待关联别名', brandMode: 'required', brandId: 'b' })
  259. const linked = buildMobileProductLinks([], [{ products: [row] }], [], { draft: true })
  260. assert.equal(linked.products.length, 0)
  261. assert.equal(linked.deliveries[0][0].unlinkedDraft, true)
  262. assert.equal(linked.deliveries[0][0].planProductClientKey, undefined)
  263. assert.equal(linked.deliveries[0][0].mobileProductMeta.brandId, 'b')
  264. assert.throws(
  265. () => buildMobileProductLinks([], [{ products: [row] }], []),
  266. /没有可匹配的装货产品/
  267. )
  268. const loads = [line()]
  269. const formal = buildMobileProductLinks(
  270. loads,
  271. [{ products: [row] }],
  272. mirrorMobilePlanProducts(loads, [])
  273. )
  274. assert.equal(formal.deliveries[0][0].unlinkedDraft, undefined)
  275. assert.equal(formal.deliveries[0][0].planProductClientKey, 'product-1')
  276. })
  277. for (const change of ['delete', 'packageSpec', 'model', 'unit']) {
  278. test(`saved association recovers automatically after load ${change} and keeps draft metadata`, () => {
  279. const loads = [
  280. line({ planProductId: 'pp1', id: 'l1' }),
  281. line({ planProductId: 'pp2', id: 'l2' })
  282. ]
  283. const previous = loads.map((row) => ({ ...row, id: row.planProductId }))
  284. const delivery = line({ id: 'd', planProductId: 'pp2', aliasName: '片碱' })
  285. const initial = mirrorMobilePlanProducts(loads, previous)
  286. assert.equal(
  287. buildMobileProductLinks(loads, [{ products: [delivery] }], initial).deliveries[0][0]
  288. .planProductId,
  289. 'pp2'
  290. )
  291. if (change === 'delete') loads.splice(1, 1)
  292. else loads[1][change] = 'changed'
  293. const plans = mirrorMobilePlanProducts(loads, initial)
  294. const linked = buildMobileProductLinks(loads, [{ products: [delivery] }], plans)
  295. .deliveries[0][0]
  296. assert.equal(linked.planProductId, change === 'packageSpec' ? 'pp2' : 'pp1')
  297. assert.equal(linked.mobileProductMeta.aliasName, '片碱')
  298. const draft = buildMobileProductLinks([], [{ products: [delivery] }], [], { draft: true })
  299. .deliveries[0][0]
  300. assert.equal(draft.unlinkedDraft, true)
  301. const reopened = hydrateMobileStopMeta([line({ id: 'd' })], [{ ...draft, id: 'd' }])[0]
  302. assert.throws(
  303. () => buildMobileProductLinks([], [{ products: [reopened] }], []),
  304. /没有可匹配的装货产品/
  305. )
  306. const recovered = buildMobileProductLinks(loads, [{ products: [reopened] }], plans)
  307. .deliveries[0][0]
  308. assert.equal(recovered.planProductId, 'pp1')
  309. assert.equal(recovered.unlinkedDraft, undefined)
  310. assert.equal(recovered.mobileProductMeta.aliasName, '片碱')
  311. })
  312. }
  313. // Execute the real pure Python resolver and read serializer, with the actual Pydantic class
  314. // extracted to avoid importing application/DB startup. No API, ORM or network is involved.
  315. function resolvedBackendMetadata(clear: boolean, productName = '新别名') {
  316. const backend = fileURLToPath(new URL('../../backend/', import.meta.url))
  317. const code = `
  318. import ast, importlib.util, json, sys, types
  319. from pathlib import Path
  320. from types import SimpleNamespace as Row
  321. from typing import Optional
  322. from pydantic import BaseModel, Field
  323. path = Path('app/services/pl_mobile_stop_meta.py')
  324. spec = importlib.util.spec_from_file_location('isolated_meta', path)
  325. m = importlib.util.module_from_spec(spec); spec.loader.exec_module(m)
  326. 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')
  327. scope = dict(BaseModel=BaseModel, Field=Field, Optional=Optional)
  328. exec(compile(ast.Module(body=[node], type_ignores=[]), '<actual-meta-schema>', 'exec'), scope)
  329. module = types.ModuleType('app.schemas.pl.plan'); module.PlanStopProductMeta = scope['PlanStopProductMeta']
  330. sys.modules['app.schemas.pl.plan'] = module
  331. 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='')
  332. product = Row(**{k: getattr(old, k) for k in m.IDENTITY_FIELDS}, id='s', product_name=sys.argv[2], mobile_product_meta=None)
  333. plan = Row(product_name=sys.argv[2], brand_mode='any', brand_id='', brand_name='')
  334. if sys.argv[1] == 'clear':
  335. product.mobile_product_meta = Row(alias_name='', brand_mode='any', brand_id='', brand_name='')
  336. values = m.resolve_stop_meta([old], set(), 't', 'plan', 'load', 'load', product, plan)
  337. model = types.ModuleType('app.models.pl_mobile_stop_meta'); model.PlPlanStopProductMeta = Row
  338. utils = types.ModuleType('app.core.utils'); utils.generate_uuid = lambda: 'new-meta'
  339. sys.modules['app.models.pl_mobile_stop_meta'] = model
  340. sys.modules['app.core.utils'] = utils
  341. written = []
  342. m.write_stop_meta(Row(add=written.append), [old], set(), 't', 'plan', 'load', 'load', 's', product, plan, 'u', 'd', None)
  343. snapshot = written[0]
  344. assert snapshot.alias_name == values['alias_name']
  345. # A subsequent unrelated Web save must preserve the distinction too.
  346. product.mobile_product_meta = None
  347. values = m.resolve_stop_meta([snapshot], set(), 't', 'plan', 'load', 'load', product, plan)
  348. snapshot.__dict__.update(values)
  349. result = Row(load=Row(products=[Row(id='s')]), deliveries=[])
  350. m.attach_stop_meta(result, [snapshot])
  351. print(json.dumps(result.load.products[0].mobile_product_meta.model_dump(by_alias=True)))
  352. `
  353. return JSON.parse(
  354. execFileSync(`${backend}/venv/bin/python`, ['-c', code, clear ? 'clear' : 'web', productName], {
  355. cwd: backend,
  356. encoding: 'utf8'
  357. })
  358. )
  359. }
  360. for (const [clear, productName] of [
  361. [false, '新别名'],
  362. [false, '标准名'],
  363. [true, '新别名']
  364. ] as const) {
  365. test(`actual backend snapshot → hydrate → enrich → mirror → submit preserves ${clear ? 'mobile clear' : `Web edit to ${productName}`}`, () => {
  366. const meta = resolvedBackendMetadata(clear, productName)
  367. const expectedAlias = clear || productName === '标准名' ? '' : '新别名'
  368. const rows = hydrateMobileStopMeta(
  369. [line({ id: 's', planProductId: 'pp', productName })],
  370. [{ id: 's', mobileProductMeta: meta }]
  371. )
  372. const catalog = [
  373. { ...line(), id: 'pp', standardProductName: '标准名', aliasOptions: ['旧别名', '新别名'] }
  374. ]
  375. const enriched = enrichMobileStopProducts(rows, catalog as any, true)
  376. assert.equal(enriched[0].aliasName, expectedAlias)
  377. const mirrored = mirrorMobilePlanProducts(enriched, catalog as any)
  378. prepareLineAliasAndBrandForSubmit(mirrored[0])
  379. assert.equal(mirrored[0].productName, expectedAlias || '标准名')
  380. assert.equal(meta.aliasName, clear ? '' : null)
  381. const payload = buildMobileProductLinks(enriched, [], mirrored)
  382. assert.equal(payload.load[0].mobileProductMeta.aliasName, expectedAlias)
  383. })
  384. }