test_pl_parent_missing_identity.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341
  1. """Missing parent/master identities: real SQLite persistence, never business MySQL."""
  2. from datetime import date, datetime
  3. from types import SimpleNamespace
  4. import pymysql
  5. import pytest
  6. from fastapi import HTTPException
  7. from sqlalchemy import create_engine
  8. from sqlalchemy.orm import Session
  9. from app.models import pl_models as m
  10. from app.schemas.pl.plan import PlanUpdate, PlanDeliveryBody
  11. from app.services import pl_plan_stop_service as stops
  12. @pytest.fixture
  13. def db(monkeypatch):
  14. def forbidden(*args, **kwargs):
  15. raise AssertionError('Business MySQL connections are forbidden')
  16. monkeypatch.setattr(pymysql, 'connect', forbidden)
  17. engine = create_engine('sqlite://')
  18. for table in m.PlPlan.metadata.sorted_tables:
  19. if table.name.startswith('pl_'):
  20. table.create(engine, checkfirst=True)
  21. with Session(engine, autoflush=False) as session:
  22. yield session
  23. engine.dispose()
  24. @pytest.fixture
  25. def save(db, monkeypatch):
  26. from app.api.pl import plan as api
  27. plan = m.PlPlan(id='p', tenant_no='t', plan_no='P', create_date=date(2026, 10, 27),
  28. belongs_company='shanxia', plan_type='inbound')
  29. db.add(plan)
  30. db.flush()
  31. monkeypatch.setattr(api, 'get_tenant_no', lambda *args: 't')
  32. monkeypatch.setattr(api, '_get_plan', lambda *args: plan)
  33. monkeypatch.setattr(api, 'collect_hazard_product_ids', lambda *args: set())
  34. monkeypatch.setattr(api, 'resolve_hazard_flags_from_crm', lambda *args: (0, 0))
  35. monkeypatch.setattr(api, 'sync_linked_dibang_dates_for_plan', lambda *args, **kwargs: [])
  36. monkeypatch.setattr(api, 'load_plan_contracts_grouped', lambda *args, **kwargs: {})
  37. monkeypatch.setattr(api, 'load_plan_stops_grouped', lambda *args, **kwargs: {})
  38. # Address library is outside this PL identity test; validation and writes remain real.
  39. monkeypatch.setattr(stops, 'enrich_stop_addresses_from_library', lambda *args: None)
  40. user = SimpleNamespace(id='u', dept_id='dept', to_tenant_user_dict=lambda: {})
  41. return lambda **values: api.update_plan('p', PlanUpdate(saveMode='draft', **values), db, user)
  42. def main(db, row_id='pp', **kwargs):
  43. values = dict(id=row_id, tenant_no='t', plan_id='p', product_id='sku', product_no='SKU',
  44. product_name='产品', quantity=10, actual_qty=3, create_by='creator',
  45. create_time=datetime(2025, 1, 2))
  46. values.update(kwargs)
  47. row = m.PlPlanProduct(**values)
  48. db.add(row)
  49. db.flush()
  50. return row
  51. def product(**kwargs):
  52. return dict(productId='sku', productNo='SKU', productName='产品', **kwargs)
  53. def delivery(db, stop_id='d', child_id='dp', customer='c', pp='pp'):
  54. row = m.PlPlanDelivery(id=stop_id, tenant_no='t', plan_id='p', customer_id=customer,
  55. customer_name='安庆市山峡化工有限公司', seq=1, create_by='creator', create_time=datetime(2025, 1, 2))
  56. child = m.PlPlanDeliveryProduct(id=child_id, tenant_no='t', plan_id='p', delivery_id=stop_id,
  57. plan_product_id=pp, product_id='sku', product_no='SKU', product_name='产品',
  58. plan_qty=10, create_by='creator', create_time=datetime(2025, 1, 2))
  59. db.add_all([row, child])
  60. db.flush()
  61. return row, child
  62. def load(db):
  63. db.add(m.PlPlanLoad(id='l', tenant_no='t', plan_id='p', customer_id='internal', customer_name='安庆市山峡化工有限公司'))
  64. db.flush()
  65. def stop_body(**kwargs):
  66. values = dict(customerId='c', customerName='安庆市山峡化工有限公司', products=[product(planProductId='pp', planQty=12)])
  67. values.update(kwargs)
  68. return values
  69. def direct(db, rows):
  70. stops.replace_plan_stops(db, 't', 'p', None, [PlanDeliveryBody(**row) for row in rows], 'u', 'dept')
  71. db.flush()
  72. @pytest.mark.parametrize("archived_qty", [None, 2])
  73. def test_api_main_missing_id_repeated_save_keeps_identity_and_omitted_business_fields(db, save, archived_qty):
  74. old = main(db, model='规格', package_spec='袋', inbound_qty=archived_qty)
  75. for _ in range(2):
  76. save(products=[product(quantity=12)])
  77. assert db.query(m.PlPlanProduct).count() == 1
  78. assert old.del_flag == 0 and old.actual_qty == 3 and old.inbound_qty == archived_qty
  79. assert old.quantity == 12 and old.package_spec == '袋' and old.model == '规格'
  80. assert old.create_by == 'creator' and old.create_time == datetime(2025, 1, 2)
  81. def test_api_main_and_delivery_parent_child_missing_ids_repeated_save(db, save):
  82. old = main(db)
  83. load(db)
  84. parent, child = delivery(db)
  85. child.outbound_qty = 8
  86. db.flush()
  87. for _ in range(2):
  88. save(products=[product(quantity=12)], load={'id': 'l'}, deliveries=[stop_body()])
  89. assert db.query(m.PlPlanProduct).count() == 1
  90. assert db.query(m.PlPlanDelivery).count() == 1
  91. assert db.query(m.PlPlanDeliveryProduct).count() == 1
  92. assert (old.del_flag, parent.del_flag, child.del_flag) == (0, 0, 0)
  93. assert child.plan_qty == 12 and child.outbound_qty == 8
  94. assert child.create_time == datetime(2025, 1, 2)
  95. @pytest.mark.parametrize('fields', [{'model': 'A'}, {'packageSpec': '袋'}])
  96. def test_main_unique_variant_uses_only_submitted_discriminators(db, save, fields):
  97. first = main(db, model='A', package_spec='袋')
  98. second = main(db, 'pp2', model='B', package_spec='桶')
  99. save(products=[product(**fields), product(id='pp2')])
  100. assert first.del_flag == second.del_flag == 0
  101. assert db.query(m.PlPlanProduct).count() == 2
  102. @pytest.mark.parametrize('rows', [[product()], [product(), product()]])
  103. def test_main_ambiguous_or_reused_candidate_rejects_before_header_write(db, save, rows):
  104. main(db)
  105. if len(rows) == 1:
  106. main(db, 'pp2', model='B')
  107. with pytest.raises(HTTPException) as exc:
  108. save(remark='must not write', products=rows)
  109. assert exc.value.status_code == 422
  110. assert not db.new and not db.dirty
  111. def test_main_explicit_reserves_old_same_sku_new_row_is_allowed(db, save):
  112. old = main(db)
  113. save(products=[product(), product(id='pp')])
  114. assert old.del_flag == 0 and db.query(m.PlPlanProduct).count() == 2
  115. def test_main_add_remove_omitted_and_empty(db, save):
  116. old = main(db)
  117. save(remark='header only')
  118. assert old.del_flag == 0
  119. save(products=[dict(productId='new', productNo='NEW', productName='新增')])
  120. assert old.del_flag == 1
  121. assert db.query(m.PlPlanProduct).filter_by(del_flag=0).one().product_id == 'new'
  122. save(products=[])
  123. assert db.query(m.PlPlanProduct).filter_by(del_flag=0).count() == 0
  124. @pytest.mark.parametrize('kind', ['main', 'delivery'])
  125. @pytest.mark.parametrize('scope', [{'tenant_no': 'other'}, {'plan_id': 'other'}, {'del_flag': 1}])
  126. def test_invalid_explicit_id_rejects_without_mutation(db, save, kind, scope):
  127. main(db)
  128. if kind == 'main':
  129. main(db, 'bad', **scope)
  130. data = dict(products=[product(id='bad')])
  131. else:
  132. row, child = delivery(db, 'bad', 'badchild')
  133. for key, value in scope.items():
  134. setattr(row, key, value)
  135. db.flush()
  136. data = dict(deliveries=[stop_body(id='bad')])
  137. with pytest.raises(HTTPException) as exc:
  138. save(remark='must not write', **data)
  139. assert exc.value.status_code == 422
  140. assert not db.new and not db.dirty
  141. @pytest.mark.parametrize('with_child_ids', [False, True])
  142. def test_delivery_same_customer_multiple_stops_needs_reliable_child_ids(db, with_child_ids):
  143. main(db)
  144. first, first_child = delivery(db)
  145. second, second_child = delivery(db, 'd2', 'dp2')
  146. bodies = [stop_body(products=[product(id='dp2', planProductId='pp')])] if with_child_ids else [stop_body(seq=2)]
  147. if not with_child_ids:
  148. with pytest.raises(HTTPException) as exc:
  149. direct(db, bodies)
  150. assert exc.value.status_code == 422 and not db.new and not db.dirty
  151. else:
  152. direct(db, bodies)
  153. assert second.del_flag == second_child.del_flag == 0
  154. assert first.del_flag == first_child.del_flag == 1
  155. assert db.query(m.PlPlanDelivery).count() == 2
  156. def test_delivery_child_id_can_restore_parent_when_customer_omitted(db):
  157. main(db)
  158. parent, child = delivery(db)
  159. direct(db, [dict(products=[product(id='dp', planProductId='pp')], remark='edit')])
  160. assert parent.del_flag == child.del_flag == 0
  161. # Child identity resolves the parent; submitted parent scalars still use
  162. # the original full-form defaults when omitted.
  163. assert parent.customer_id is None
  164. assert parent.remark == 'edit'
  165. @pytest.mark.parametrize('rows', [[dict(remark='no identity')], [stop_body(), stop_body()]])
  166. def test_delivery_missing_key_or_double_claim_rejected(db, rows):
  167. main(db)
  168. delivery(db)
  169. with pytest.raises(HTTPException) as exc:
  170. direct(db, rows)
  171. assert exc.value.status_code == 422 and not db.new and not db.dirty
  172. def test_delivery_explicit_reserves_same_customer_for_new_parent(db):
  173. main(db)
  174. parent, child = delivery(db)
  175. direct(db, [stop_body(), stop_body(id='d')])
  176. assert parent.del_flag == child.del_flag == 0
  177. assert db.query(m.PlPlanDelivery).count() == 2
  178. def test_delivery_add_remove_omission_and_empty(db):
  179. main(db)
  180. parent, child = delivery(db)
  181. stops.replace_plan_stops(db, 't', 'p', None, None, 'u', 'dept')
  182. assert parent.del_flag == 0
  183. direct(db, [stop_body(customerId='new')])
  184. assert parent.del_flag == child.del_flag == 1
  185. direct(db, [])
  186. assert db.query(m.PlPlanDelivery).filter_by(del_flag=0).count() == 0
  187. def test_delivery_reference_blocks_true_removal_but_not_missing_id_update(db):
  188. main(db)
  189. parent, child = delivery(db)
  190. child.outbound_qty = 2
  191. db.flush()
  192. direct(db, [stop_body()])
  193. assert child.del_flag == 0 and child.outbound_qty == 2
  194. db.commit()
  195. with pytest.raises(HTTPException) as exc:
  196. direct(db, [])
  197. assert exc.value.status_code == 409 and not db.new and not db.dirty
  198. def test_api_new_master_and_parent_use_preflight_ids_and_client_key(db, save, monkeypatch):
  199. captured = {}
  200. original = stops.preflight_plan_child_update
  201. def capture(*args):
  202. result = original(*args)
  203. captured['products'], captured['stops'] = result
  204. return result
  205. monkeypatch.setattr(stops, 'preflight_plan_child_update', capture)
  206. save(products=[product(clientKey='request-product', quantity=10)],
  207. load={'customerId': 'internal', 'customerName': '安庆市山峡化工有限公司'},
  208. deliveries=[stop_body(products=[product(planProductClientKey='request-product', planQty=10)])])
  209. pp = db.query(m.PlPlanProduct).one()
  210. parent = db.query(m.PlPlanDelivery).one()
  211. child = db.query(m.PlPlanDeliveryProduct).one()
  212. load_row = db.query(m.PlPlanLoad).one()
  213. assert pp.id == captured['products'][0]
  214. assert set(captured['stops'].values()) == {parent.id, load_row.id}
  215. assert child.delivery_id == parent.id and child.plan_product_id == pp.id
  216. @pytest.mark.parametrize('scope', [{'tenant_no': 'other'}, {'plan_id': 'other'}, {'del_flag': 1}])
  217. def test_anonymous_main_never_reuses_out_of_scope_or_deleted_row(db, save, scope):
  218. old = main(db, 'foreign', **scope)
  219. save(products=[product()])
  220. active = db.query(m.PlPlanProduct).filter_by(tenant_no='t', plan_id='p', del_flag=0).one()
  221. assert active.id != old.id
  222. assert old.actual_qty == 3
  223. @pytest.mark.parametrize('scope', [{'tenant_no': 'other'}, {'plan_id': 'other'}, {'del_flag': 1}])
  224. def test_anonymous_delivery_child_identity_never_crosses_scope(db, scope):
  225. main(db)
  226. parent, child = delivery(db)
  227. for key, value in scope.items():
  228. setattr(child, key, value)
  229. db.flush()
  230. with pytest.raises(HTTPException) as exc:
  231. direct(db, [stop_body(products=[product(id='dp', planProductId='pp')])])
  232. assert exc.value.status_code == 422 and not db.new and not db.dirty
  233. def test_delivery_conflicting_children_cannot_merge_parents(db):
  234. main(db)
  235. delivery(db)
  236. delivery(db, 'd2', 'dp2')
  237. with pytest.raises(HTTPException) as exc:
  238. direct(db, [stop_body(products=[product(id='dp', planProductId='pp'), product(id='dp2', planProductId='pp')])])
  239. assert exc.value.status_code == 422 and not db.new and not db.dirty
  240. def test_api_last_ambiguous_delivery_rejects_before_all_changes(db, save):
  241. old = main(db)
  242. first, child = delivery(db)
  243. delivery(db, 'd2', 'dp2')
  244. with pytest.raises(HTTPException) as exc:
  245. save(remark='not written', products=[product(quantity=99)],
  246. deliveries=[stop_body(customerId='new'), stop_body()])
  247. assert exc.value.status_code == 422 and not db.new and not db.dirty
  248. assert old.quantity == 10 and first.del_flag == child.del_flag == 0
  249. def test_duplicate_explicit_main_and_delivery_ids_reject(db, save):
  250. main(db)
  251. delivery(db)
  252. for data in [dict(products=[product(id='pp'), product(id='pp')]),
  253. dict(deliveries=[stop_body(id='d'), stop_body(id='d')])]:
  254. with pytest.raises(HTTPException) as exc:
  255. save(remark='not written', **data)
  256. assert exc.value.status_code == 422 and not db.new and not db.dirty
  257. def test_main_real_removal_keeps_reference_guard(db, save):
  258. old = main(db, outbound_qty=2)
  259. with pytest.raises(HTTPException) as exc:
  260. save(products=[])
  261. assert exc.value.status_code == 409 and not db.new and not db.dirty
  262. assert old.del_flag == 0
  263. def test_two_anonymous_sku_variants_match_keys_after_reordering(db, save):
  264. first = main(db, model='A', package_spec='袋')
  265. second = main(db, 'pp2', model='B', package_spec='桶')
  266. save(products=[product(model='B', packageSpec='桶', quantity=20),
  267. product(model='A', packageSpec='袋', quantity=30)])
  268. assert db.query(m.PlPlanProduct).count() == 2
  269. assert first.del_flag == second.del_flag == 0
  270. assert first.quantity == 30 and second.quantity == 20
  271. def test_unique_delivery_customer_can_edit_sequence_without_new_identity(db):
  272. main(db)
  273. parent, child = delivery(db)
  274. direct(db, [stop_body(seq=9, address='新地址')])
  275. assert parent.id == 'd' and parent.seq == 9 and parent.address == '新地址'
  276. assert parent.del_flag == child.del_flag == 0
  277. assert db.query(m.PlPlanDelivery).count() == 1