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

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359
  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, ambiguous new association rejects instead of first productId', () => {
  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.throws(
  86. () => buildMobileProductLinks(loads, [{ products: [line()] }], plan),
  87. /无法唯一关联/
  88. )
  89. })
  90. test('mobile hydration uses independent stop snapshots, not first plan product brand', () => {
  91. const stop = line({ id: 's', planProductId: 'pp', productName: '送货别名' })
  92. const rows = hydrateMobileStopMeta(
  93. [stop],
  94. [
  95. {
  96. ...stop,
  97. mobileProductMeta: {
  98. aliasName: '送货别名',
  99. brandMode: 'required',
  100. brandId: 'delivery',
  101. brandName: '送货品牌'
  102. }
  103. }
  104. ]
  105. )
  106. const result = enrichMobileStopProducts(rows, [
  107. {
  108. id: 'pp',
  109. productId: 'p',
  110. productName: '标准名',
  111. standardProductName: '标准名',
  112. aliasName: '装货别名',
  113. aliasOptions: ['装货别名', '送货别名'],
  114. brandMode: 'required',
  115. brandId: 'load'
  116. }
  117. ] as any)
  118. assert.equal(result[0].aliasName, '送货别名')
  119. assert.equal(result[0].brandId, 'delivery')
  120. })
  121. test('copy without source ids creates only request keys and keeps metadata', () => {
  122. const loads = [line({ aliasName: '复制别名' })]
  123. const linked = buildMobileProductLinks(
  124. loads,
  125. [{ products: [line()] }],
  126. mirrorMobilePlanProducts(loads, [])
  127. )
  128. assert.equal(linked.load[0].planProductId, undefined)
  129. assert.equal(linked.load[0].id, undefined)
  130. assert.equal(linked.load[0].mobileProductMeta.aliasName, '复制别名')
  131. })
  132. test('same-packaging PO001 sample can explicitly link second load line and never silently relinks after deletion', async () => {
  133. const { mobileLoadProductKey } = await import('../src/views/m/pl/plan/mobilePlanPersistence')
  134. const loads = [
  135. line({ contractProductId: '002', contractNo: 'HT002', planQty: 1 }),
  136. line({ contractProductId: '003', contractNo: 'HT003', planQty: 2 })
  137. ]
  138. const delivery = line({
  139. contractProductId: '005',
  140. planQty: 2,
  141. mobileLoadProductKey: mobileLoadProductKey(loads[1])
  142. })
  143. const plans = mirrorMobilePlanProducts(loads, [])
  144. assert.equal(
  145. buildMobileProductLinks(loads, [{ products: [delivery] }], plans).deliveries[0][0]
  146. .planProductClientKey,
  147. 'product-2'
  148. )
  149. assert.throws(
  150. () => buildMobileProductLinks([loads[0]], [{ products: [delivery] }], [plans[0]]),
  151. /选择关联装货产品/
  152. )
  153. })
  154. test('mobile product cards provide an editable, searchable load association picker', async () => {
  155. const { readFile } = await import('node:fs/promises')
  156. const source = await readFile(
  157. new URL('../src/views/m/pl/plan/MobilePlanProductCards.vue', import.meta.url),
  158. 'utf8'
  159. )
  160. assert.match(source, /title="关联装货产品"/)
  161. assert.match(source, /mobileLoadProductKey/)
  162. assert.match(source, /resolveMobileDeliveryLoad/)
  163. })
  164. test('copy carries two same-packaging associations and one-to-many delivery references through id removal', async () => {
  165. const persistence = await import('../src/views/m/pl/plan/mobilePlanPersistence')
  166. assert.equal(typeof persistence.preserveMobileCopyAssociations, 'function')
  167. const loads = [line({ planProductId: 'pp1', id: 'l1' }), line({ planProductId: 'pp2', id: 'l2' })]
  168. const deliveries = [
  169. { products: [line({ planProductId: 'pp2', id: 'd1' })] },
  170. { products: [line({ planProductId: 'pp2', id: 'd2' })] }
  171. ]
  172. persistence.preserveMobileCopyAssociations(loads, deliveries)
  173. const copiedLoads = loads.map((row) => ({ ...row, id: undefined, planProductId: undefined }))
  174. const copiedDelivery = deliveries.map((stop) => ({
  175. products: stop.products.map((row) => ({ ...row, id: undefined, planProductId: undefined }))
  176. }))
  177. const links = buildMobileProductLinks(
  178. copiedLoads,
  179. copiedDelivery,
  180. mirrorMobilePlanProducts(copiedLoads, [])
  181. )
  182. assert.deepEqual(
  183. links.deliveries.map((rows) => rows[0].planProductClientKey),
  184. ['product-2', 'product-2']
  185. )
  186. assert.ok(links.products.every((row) => !row.id))
  187. })
  188. test('historically collapsed same-packaging load IDs require explicit repair instead of trusting duplicate foreign keys', () => {
  189. const loads = [line({ planProductId: 'collapsed' }), line({ planProductId: 'collapsed' })]
  190. const plans = mirrorMobilePlanProducts(loads, [
  191. { id: 'collapsed', productId: 'p', packageSpec: '桶' }
  192. ] as any)
  193. assert.throws(
  194. () =>
  195. buildMobileProductLinks(loads, [{ products: [line({ planProductId: 'collapsed' })] }], plans),
  196. /选择关联装货产品/
  197. )
  198. })
  199. test('mobile capability requires explicit version and rejects successful old-server responses', async () => {
  200. const { assertMobilePersistenceCapability } = await import(
  201. '../src/views/m/pl/plan/mobilePlanPersistence'
  202. )
  203. assert.equal(typeof assertMobilePersistenceCapability, 'function')
  204. assert.throws(() => assertMobilePersistenceCapability({}), /服务.*更新/)
  205. assert.throws(
  206. () => assertMobilePersistenceCapability({ mobileProductMetaVersion: 0 }),
  207. /服务.*更新/
  208. )
  209. assertMobilePersistenceCapability({ mobileProductMetaVersion: 1 })
  210. })
  211. test('incomplete draft keeps delivery product and independent metadata without creating a plan product', () => {
  212. const row = line({ aliasName: '待关联别名', brandMode: 'required', brandId: 'b' })
  213. const linked = buildMobileProductLinks([], [{ products: [row] }], [], { draft: true })
  214. assert.equal(linked.products.length, 0)
  215. assert.equal(linked.deliveries[0][0].unlinkedDraft, true)
  216. assert.equal(linked.deliveries[0][0].planProductClientKey, undefined)
  217. assert.equal(linked.deliveries[0][0].mobileProductMeta.brandId, 'b')
  218. assert.throws(() => buildMobileProductLinks([], [{ products: [row] }], []), /选择关联装货产品/)
  219. const loads = [line()]
  220. const formal = buildMobileProductLinks(
  221. loads,
  222. [{ products: [row] }],
  223. mirrorMobilePlanProducts(loads, [])
  224. )
  225. assert.equal(formal.deliveries[0][0].unlinkedDraft, undefined)
  226. assert.equal(formal.deliveries[0][0].planProductClientKey, 'product-1')
  227. })
  228. for (const change of ['delete', 'packageSpec', 'model', 'unit']) {
  229. test(`hydrated historical delivery stays pending after linked load ${change} until manual selection`, async () => {
  230. const { mobileLoadProductKey } = await import('../src/views/m/pl/plan/mobilePlanPersistence')
  231. const loads = [
  232. line({ planProductId: 'pp1', id: 'l1' }),
  233. line({ planProductId: 'pp2', id: 'l2' })
  234. ]
  235. const previous = loads.map((row) => ({ ...row, id: row.planProductId }))
  236. const delivery = hydrateMobileStopMeta(
  237. [line({ id: 'd', planProductId: 'pp2' })],
  238. [{ id: 'd' }]
  239. )[0]
  240. const initial = mirrorMobilePlanProducts(loads, previous as any)
  241. assert.equal(
  242. buildMobileProductLinks(loads, [{ products: [delivery] }], initial).deliveries[0][0]
  243. .planProductId,
  244. 'pp2'
  245. )
  246. if (change === 'delete') loads.splice(1, 1)
  247. else loads[1][change] = 'changed'
  248. const plans = mirrorMobilePlanProducts(loads, initial)
  249. assert.throws(
  250. () => buildMobileProductLinks(loads, [{ products: [delivery] }], plans),
  251. /选择关联装货产品/
  252. )
  253. const draft = buildMobileProductLinks(loads, [{ products: [delivery] }], plans, { draft: true })
  254. .deliveries[0][0]
  255. assert.equal(draft.unlinkedDraft, true)
  256. assert.equal(draft.planProductId, undefined)
  257. assert.equal(draft.planProductClientKey, undefined)
  258. // The regular stop mapper does not retain the API-only pending flag; mobile hydration must.
  259. const reopened = hydrateMobileStopMeta([line({ id: 'd' })], [{ ...draft, id: 'd' }])[0]
  260. assert.throws(
  261. () => buildMobileProductLinks(loads, [{ products: [reopened] }], plans),
  262. /选择关联装货产品/
  263. )
  264. ;(reopened as any).mobileLoadProductKey = mobileLoadProductKey(loads[0])
  265. const confirmed = buildMobileProductLinks(loads, [{ products: [reopened] }], plans)
  266. .deliveries[0][0]
  267. assert.equal(confirmed.planProductId, 'pp1')
  268. assert.equal(confirmed.unlinkedDraft, undefined)
  269. const fresh = buildMobileProductLinks([loads[0]], [{ products: [line()] }], [plans[0]])
  270. assert.equal(fresh.deliveries[0][0].planProductId, 'pp1')
  271. })
  272. }
  273. // Execute the real pure Python resolver and read serializer, with the actual Pydantic class
  274. // extracted to avoid importing application/DB startup. No API, ORM or network is involved.
  275. function resolvedBackendMetadata(clear: boolean, productName = '新别名') {
  276. const backend = fileURLToPath(new URL('../../backend/', import.meta.url))
  277. const code = `
  278. import ast, importlib.util, json, sys, types
  279. from pathlib import Path
  280. from types import SimpleNamespace as Row
  281. from typing import Optional
  282. from pydantic import BaseModel, Field
  283. path = Path('app/services/pl_mobile_stop_meta.py')
  284. spec = importlib.util.spec_from_file_location('isolated_meta', path)
  285. m = importlib.util.module_from_spec(spec); spec.loader.exec_module(m)
  286. 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')
  287. scope = dict(BaseModel=BaseModel, Field=Field, Optional=Optional)
  288. exec(compile(ast.Module(body=[node], type_ignores=[]), '<actual-meta-schema>', 'exec'), scope)
  289. module = types.ModuleType('app.schemas.pl.plan'); module.PlanStopProductMeta = scope['PlanStopProductMeta']
  290. sys.modules['app.schemas.pl.plan'] = module
  291. 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='')
  292. product = Row(**{k: getattr(old, k) for k in m.IDENTITY_FIELDS}, id='s', product_name=sys.argv[2], mobile_product_meta=None)
  293. plan = Row(product_name=sys.argv[2], brand_mode='any', brand_id='', brand_name='')
  294. if sys.argv[1] == 'clear':
  295. product.mobile_product_meta = Row(alias_name='', brand_mode='any', brand_id='', brand_name='')
  296. values = m.resolve_stop_meta([old], set(), 't', 'plan', 'load', 'load', product, plan)
  297. model = types.ModuleType('app.models.pl_mobile_stop_meta'); model.PlPlanStopProductMeta = Row
  298. utils = types.ModuleType('app.core.utils'); utils.generate_uuid = lambda: 'new-meta'
  299. sys.modules['app.models.pl_mobile_stop_meta'] = model
  300. sys.modules['app.core.utils'] = utils
  301. written = []
  302. m.write_stop_meta(Row(add=written.append), [old], set(), 't', 'plan', 'load', 'load', 's', product, plan, 'u', 'd', None)
  303. snapshot = written[0]
  304. assert snapshot.alias_name == values['alias_name']
  305. # A subsequent unrelated Web save must preserve the distinction too.
  306. product.mobile_product_meta = None
  307. values = m.resolve_stop_meta([snapshot], set(), 't', 'plan', 'load', 'load', product, plan)
  308. snapshot.__dict__.update(values)
  309. result = Row(load=Row(products=[Row(id='s')]), deliveries=[])
  310. m.attach_stop_meta(result, [snapshot])
  311. print(json.dumps(result.load.products[0].mobile_product_meta.model_dump(by_alias=True)))
  312. `
  313. return JSON.parse(
  314. execFileSync(`${backend}/venv/bin/python`, ['-c', code, clear ? 'clear' : 'web', productName], {
  315. cwd: backend,
  316. encoding: 'utf8'
  317. })
  318. )
  319. }
  320. for (const [clear, productName] of [
  321. [false, '新别名'],
  322. [false, '标准名'],
  323. [true, '新别名']
  324. ] as const) {
  325. test(`actual backend snapshot → hydrate → enrich → mirror → submit preserves ${clear ? 'mobile clear' : `Web edit to ${productName}`}`, () => {
  326. const meta = resolvedBackendMetadata(clear, productName)
  327. const expectedAlias = clear || productName === '标准名' ? '' : '新别名'
  328. const rows = hydrateMobileStopMeta(
  329. [line({ id: 's', planProductId: 'pp', productName })],
  330. [{ id: 's', mobileProductMeta: meta }]
  331. )
  332. const catalog = [
  333. { ...line(), id: 'pp', standardProductName: '标准名', aliasOptions: ['旧别名', '新别名'] }
  334. ]
  335. const enriched = enrichMobileStopProducts(rows, catalog as any, true)
  336. assert.equal(enriched[0].aliasName, expectedAlias)
  337. const mirrored = mirrorMobilePlanProducts(enriched, catalog as any)
  338. prepareLineAliasAndBrandForSubmit(mirrored[0])
  339. assert.equal(mirrored[0].productName, expectedAlias || '标准名')
  340. assert.equal(meta.aliasName, clear ? '' : null)
  341. const payload = buildMobileProductLinks(enriched, [], mirrored)
  342. assert.equal(payload.load[0].mobileProductMeta.aliasName, expectedAlias)
  343. })
  344. }