test_pl_stop_missing_identity.py 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183
  1. """Legacy stop detail payloads must not silently replace persisted identities."""
  2. from datetime import datetime
  3. from decimal import Decimal
  4. import pytest
  5. from fastapi import HTTPException
  6. from sqlalchemy import create_engine
  7. from sqlalchemy.orm import Session
  8. from app.models import pl_models as m
  9. from app.schemas.pl.plan import PlanDeliveryBody, PlanLoadBody, PlanStopProductBody, PlanUpdate
  10. from app.services.pl_plan_stop_service import preflight_plan_child_update, replace_plan_stops
  11. @pytest.fixture
  12. def db(monkeypatch):
  13. import pymysql
  14. def forbidden(*args, **kwargs):
  15. raise AssertionError('Business database connections are forbidden')
  16. monkeypatch.setattr(pymysql, 'connect', forbidden)
  17. monkeypatch.setattr(pymysql.connections.Connection, 'connect', forbidden)
  18. engine = create_engine('sqlite://')
  19. for table in m.PlPlan.metadata.sorted_tables:
  20. if table.name.startswith('pl_'):
  21. table.create(engine, checkfirst=True)
  22. with Session(engine, autoflush=False) as session:
  23. yield session
  24. engine.dispose()
  25. def seed(db, kind, *, archive=False):
  26. pp = m.PlPlanProduct(id='pp', tenant_no='t', plan_id='p', product_id='sku',
  27. product_no='SKU', product_name='产品')
  28. model = m.PlPlanLoadProduct if kind == 'load' else m.PlPlanDeliveryProduct
  29. stop_model = m.PlPlanLoad if kind == 'load' else m.PlPlanDelivery
  30. fk = 'load_id' if kind == 'load' else 'delivery_id'
  31. stop = stop_model(id='stop', tenant_no='t', plan_id='p', customer_name='客户')
  32. detail = model(id='old', tenant_no='t', plan_id='p', **{fk: 'stop'},
  33. plan_product_id='pp', product_id='sku', product_no='SKU',
  34. product_name='产品', plan_qty=10, create_by='creator',
  35. create_time=datetime(2026, 1, 1), remark='旧备注',
  36. **{('inbound_qty' if kind == 'load' else 'outbound_qty'): 8 if archive else None})
  37. db.add_all([pp, stop, detail])
  38. db.flush()
  39. return pp, detail
  40. def product(**values):
  41. return PlanStopProductBody(**dict(
  42. {'planProductId': 'pp', 'productId': 'sku', 'productNo': 'SKU',
  43. 'productName': '产品', 'planQty': 12}, **values))
  44. def save(db, pp, kind, products):
  45. cls = PlanLoadBody if kind == 'load' else PlanDeliveryBody
  46. stop = cls(id='stop', products=products)
  47. replace_plan_stops(db, 't', 'p', stop if kind == 'load' else None,
  48. [stop] if kind == 'delivery' else None, 'editor', 'dept', [pp])
  49. db.flush()
  50. @pytest.mark.parametrize('kind', ['load', 'delivery'])
  51. @pytest.mark.parametrize('archive', [False, True])
  52. def test_missing_id_reuses_unique_detail_and_preserves_history(db, kind, archive):
  53. pp, old = seed(db, kind, archive=archive)
  54. save(db, pp, kind, [product()])
  55. db.expire_all()
  56. assert old.del_flag == 0
  57. assert old.plan_qty == 12
  58. assert old.create_time == datetime(2026, 1, 1)
  59. assert old.create_by == 'creator' and old.remark == '旧备注'
  60. assert getattr(old, 'inbound_qty' if kind == 'load' else 'outbound_qty') == (Decimal('8') if archive else None)
  61. assert db.query(type(old)).count() == 1
  62. def test_preflight_accepts_unique_legacy_identity_without_mutation(db):
  63. pp, old = seed(db, 'load', archive=True)
  64. payload = PlanUpdate(load=PlanLoadBody(id='stop', products=[product()]))
  65. preflight_plan_child_update(db, 't', 'p', payload)
  66. assert old.del_flag == 0 and old.plan_qty == 10
  67. assert not db.dirty and not db.new
  68. def test_multiple_existing_candidates_require_explicit_ids(db):
  69. pp, old = seed(db, 'delivery')
  70. db.add(m.PlPlanDeliveryProduct(id='other', tenant_no='t', plan_id='p', delivery_id='stop',
  71. plan_product_id='pp', product_id='sku', product_no='SKU', product_name='产品'))
  72. db.flush()
  73. with pytest.raises(HTTPException) as exc:
  74. save(db, pp, 'delivery', [product()])
  75. assert exc.value.status_code == 422
  76. assert not db.dirty and not db.new
  77. def test_multiple_missing_ids_cannot_compete_for_one_old_row(db):
  78. pp, old = seed(db, 'delivery')
  79. with pytest.raises(HTTPException) as exc:
  80. save(db, pp, 'delivery', [product(), product(planQty=20)])
  81. assert exc.value.status_code == 422
  82. assert not db.dirty and not db.new
  83. def test_explicit_ids_reserve_old_rows_before_matching_legacy_rows(db):
  84. pp, old = seed(db, 'delivery')
  85. db.add(m.PlPlanDeliveryProduct(id='other', tenant_no='t', plan_id='p', delivery_id='stop',
  86. plan_product_id='pp', product_id='sku', product_no='SKU', product_name='产品'))
  87. db.flush()
  88. save(db, pp, 'delivery', [product(), product(id='other', planQty=20)])
  89. assert old.del_flag == 0 and old.plan_qty == 12
  90. assert db.get(m.PlPlanDeliveryProduct, 'other').plan_qty == 20
  91. assert db.query(m.PlPlanDeliveryProduct).count() == 2
  92. def test_explicit_old_row_allows_adding_same_product_as_new_detail(db):
  93. pp, old = seed(db, 'delivery')
  94. save(db, pp, 'delivery', [product(), product(id='old')])
  95. assert old.del_flag == 0
  96. assert db.query(m.PlPlanDeliveryProduct).filter_by(del_flag=0).count() == 2
  97. def test_contract_key_disambiguates_rows_and_keeps_omitted_contract_fields(db):
  98. pp, old = seed(db, 'delivery')
  99. old.contract_id, old.contract_product_id = 'c', 'line-a'
  100. other = m.PlPlanDeliveryProduct(id='other', tenant_no='t', plan_id='p', delivery_id='stop',
  101. plan_product_id='pp', product_id='sku', product_no='SKU', product_name='产品',
  102. contract_id='c', contract_product_id='line-b')
  103. db.add(other)
  104. db.flush()
  105. save(db, pp, 'delivery', [product(contractProductId='line-a'), product(id='other')])
  106. assert old.del_flag == 0 and old.contract_id == 'c' and old.contract_product_id == 'line-a'
  107. assert db.query(m.PlPlanDeliveryProduct).count() == 2
  108. @pytest.mark.parametrize('scope', ['tenant', 'parent', 'stop', 'deleted'])
  109. def test_fallback_never_reuses_out_of_scope_or_deleted_rows(db, scope):
  110. pp, old = seed(db, 'delivery')
  111. changes = {'tenant': {'tenant_no': 'other'}, 'parent': {'plan_id': 'other'},
  112. 'stop': {'delivery_id': 'other'}, 'deleted': {'del_flag': 1}}[scope]
  113. for key, value in changes.items():
  114. setattr(old, key, value)
  115. db.flush()
  116. save(db, pp, 'delivery', [product()])
  117. assert old.plan_qty == 10
  118. assert db.query(m.PlPlanDeliveryProduct).count() == 2
  119. def test_unique_legacy_match_and_new_key_insert_keep_existing_identity(db):
  120. pp, old = seed(db, 'delivery')
  121. save(db, pp, 'delivery', [product(), product(contractId='new-c', contractProductId='new-line')])
  122. assert old.del_flag == 0
  123. assert db.query(m.PlPlanDeliveryProduct).filter_by(del_flag=0).count() == 2
  124. @pytest.mark.parametrize('pending', [False, True])
  125. def test_draft_without_detail_or_product_link_id_preserves_unique_identity(db, pending):
  126. pp, old = seed(db, 'delivery')
  127. old.unit = '吨'
  128. if pending:
  129. old.plan_product_id = ''
  130. db.flush()
  131. replace_plan_stops(db, 't', 'p', None,
  132. [PlanDeliveryBody(id='stop', products=[product(planProductId=None)])],
  133. 'editor', 'dept', [pp], preserve_pending_draft=True)
  134. db.flush()
  135. assert old.del_flag == 0 and old.plan_product_id == ('' if pending else 'pp')
  136. assert db.query(m.PlPlanDeliveryProduct).count() == 1
  137. def test_missing_link_must_not_guess_between_pending_and_linked_rows(db):
  138. pp, old = seed(db, 'delivery')
  139. old.unit = '吨'
  140. db.add(m.PlPlanDeliveryProduct(id='pending', tenant_no='t', plan_id='p', delivery_id='stop',
  141. plan_product_id='', product_id='sku', product_no='SKU',
  142. product_name='产品', unit='吨'))
  143. db.flush()
  144. with pytest.raises(HTTPException) as exc:
  145. replace_plan_stops(db, 't', 'p', None,
  146. [PlanDeliveryBody(id='stop', products=[product(planProductId=None)])],
  147. 'editor', 'dept', [pp], preserve_pending_draft=True)
  148. assert exc.value.status_code == 422
  149. assert not db.dirty and not db.new