test_pl_stop_dibang_differential.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273
  1. """SQLite coverage of real stop save/sync; never connect to business databases."""
  2. from datetime import date, datetime
  3. from decimal import Decimal
  4. import pymysql
  5. import pytest
  6. from sqlalchemy import create_engine, event
  7. from sqlalchemy.orm import Session
  8. from app.models.crm_models import CrmProduct
  9. from app.models.db_models import DbPurchasePlan, DbInboundOutbound, DbWeighingSlip, DbSequence
  10. from app.models.pl_models import (PlPlan, PlPlanProduct, PlTransport, PlTransportDbLink,
  11. PlPlanLoad, PlPlanDelivery, PlPlanLoadProduct, PlPlanDeliveryProduct)
  12. from app.schemas.pl.plan import PlanLoadBody
  13. from app.services.pl_plan_stop_service import replace_plan_stops
  14. from app.services import pl_dibang_bridge_service as bridge
  15. INTERNAL = '安庆市山峡化工有限公司'
  16. @pytest.fixture
  17. def db(monkeypatch):
  18. def forbidden(*a, **k):
  19. raise AssertionError('Business database connection forbidden')
  20. monkeypatch.setattr(pymysql, 'connect', forbidden)
  21. monkeypatch.setattr(pymysql.connections.Connection, 'connect', forbidden)
  22. engine = create_engine('sqlite://')
  23. for model in (CrmProduct, PlPlan, PlPlanProduct, PlTransport, PlTransportDbLink,
  24. PlPlanLoad, PlPlanDelivery, PlPlanLoadProduct, PlPlanDeliveryProduct,
  25. DbPurchasePlan, DbInboundOutbound, DbWeighingSlip, DbSequence):
  26. model.__table__.create(engine)
  27. with Session(engine, autoflush=False, expire_on_commit=False) as session:
  28. session.add(DbSequence(id='seq', tenant_no='t', seq_name='inbound_outbound', next_val=1))
  29. session.commit()
  30. yield session
  31. engine.dispose()
  32. def seed(db, quantities=(5,), pending=False):
  33. plan = PlPlan(id='p', tenant_no='t', plan_no='P', plan_type='outbound',
  34. create_date=date(2026, 10, 1), plan_date=date(2026, 10, 1), belongs_company='shanxia',
  35. transport_id='tr', license_plate='Plate')
  36. pp = PlPlanProduct(id='pp', tenant_no='t', plan_id='p', product_id='sku',
  37. product_no='SKU', product_name='Product', model='Spec', unit='吨', quantity=sum(quantities))
  38. tr = PlTransport(id='tr', tenant_no='t', transport_no='TR', license_plate='Plate', del_flag=0)
  39. db.add_all([plan, pp, tr, CrmProduct(id='sku', tenant_no='t', product_no='SKU',
  40. product_name='Product', model='Spec', status='on')]); db.commit()
  41. load = PlPlanLoad(id='load', tenant_no='t', plan_id='p', customer_name=INTERNAL,
  42. generate_dibang=0, dibang_direction='outbound', del_flag=0)
  43. db.add(load)
  44. for i, qty in enumerate(quantities):
  45. db.add(PlPlanLoadProduct(id=f'lp{i}', tenant_no='t', plan_id='p', load_id='load',
  46. plan_product_id='pp', product_id='sku', product_no='SKU', product_name='Product',
  47. model='Spec', unit='吨', package_spec=str(i), plan_qty=qty, del_flag=0))
  48. if not pending: db.commit()
  49. return plan, tr, load
  50. def sync(db, rows, options=None):
  51. plan, tr, load = rows
  52. return bridge.sync_dibang_for_plan_stops(db, tenant_no='t', plan=plan, transport=tr,
  53. stop_options=options if options is not None else [bridge.StopDibangOption('load', load.id, True)],
  54. user_id='editor', dept_id='dept')
  55. def record(db):
  56. return (db.query(PlTransportDbLink).filter_by(del_flag=0).one(),
  57. db.query(DbInboundOutbound).filter_by(del_flag=0).one(), db.query(DbPurchasePlan).one())
  58. def test_same_plan_product_rows_aggregate_and_repeat_keeps_ids_feedback_and_quantity(db):
  59. rows = seed(db)
  60. pp = db.get(PlPlanProduct, 'pp')
  61. body = PlanLoadBody(customerName=INTERNAL, generateDibang=True, products=[
  62. dict(productId='sku', productNo='SKU', productName='Product', model='Spec', unit='吨',
  63. id='lp0' if package == '桶' else None, planProductId='pp', planQty=qty, packageSpec=package) for qty, package in ((5,'桶'),(3,'袋'))])
  64. replace_plan_stops(db, 't', 'p', body, None, 'editor', 'dept', [pp])
  65. # Deliberately no flush: both real callers can have pending child edits.
  66. sync(db, rows)
  67. link, inv, monthly = record(db)
  68. identity = (link.id, inv.id, link.create_time, inv.create_time)
  69. inv.site_feedback_remark = 'retain'; inv.site_access_token = 'retain-token'; db.commit()
  70. sync(db, rows); db.commit()
  71. assert db.query(PlTransportDbLink).count() == db.query(DbInboundOutbound).count() == 1
  72. assert (link.id, inv.id, link.create_time, inv.create_time) == identity
  73. assert link.contributed_qty == inv.quantity == monthly.planned_quantity == 8
  74. assert (inv.site_feedback_remark, inv.site_access_token) == ('retain', 'retain-token')
  75. @pytest.mark.parametrize('bad', ['deleted_inventory', 'missing_inventory', 'duplicate_link',
  76. 'foreign_inventory', 'missing_month', 'deleted_month', 'wrong_month', 'wrong_link_plan'])
  77. def test_invalid_link_history_rejected_without_any_writes_or_rebuild(db, bad):
  78. rows = seed(db); sync(db, rows); db.commit(); link, inv, month = record(db)
  79. if bad == 'deleted_inventory': inv.del_flag = 1
  80. elif bad == 'missing_inventory': link.db_inventory_id = 'missing'
  81. elif bad == 'duplicate_link': db.add(PlTransportDbLink(id='duplicate', tenant_no='t', transport_id='tr',
  82. plan_id='p', plan_product_id='pp', stop_type='load', stop_id='load',
  83. db_inventory_id=inv.id, db_purchase_plan_id=month.id, contributed_qty=5))
  84. elif bad == 'foreign_inventory': inv.tenant_no = 'other'
  85. elif bad == 'missing_month': link.db_purchase_plan_id = 'missing'
  86. elif bad == 'deleted_month': month.del_flag = 1
  87. elif bad == 'wrong_month': inv.purchase_plan_id = 'other'
  88. else: link.plan_id = 'other'
  89. db.commit(); writes=[]
  90. def capture(conn, cursor, sql, *args):
  91. if sql.lstrip().upper().startswith(('UPDATE','INSERT','DELETE')): writes.append(sql)
  92. event.listen(db.bind, 'before_cursor_execute', capture)
  93. try:
  94. with pytest.raises(ValueError): sync(db, rows)
  95. finally: event.remove(db.bind, 'before_cursor_execute', capture)
  96. assert not writes and not db.dirty and not db.new
  97. assert link.del_flag == 0 and month.planned_quantity == 5
  98. @pytest.mark.parametrize('field,value', [('product_id','other'), ('model','Different'), ('unit','kg')])
  99. def test_conflicting_same_key_rows_rejected_before_stop_or_inventory_mutation(db, field, value):
  100. rows = seed(db, (5,3)); setattr(db.get(PlPlanLoadProduct,'lp1'), field, value); db.commit()
  101. with pytest.raises(ValueError): sync(db, rows)
  102. assert not db.dirty and not db.new and rows[2].generate_dibang == 0
  103. assert db.query(DbInboundOutbound).count() == 0
  104. def test_later_stop_error_does_not_update_first_stop_or_options(db):
  105. rows = seed(db); sync(db, rows); db.commit(); link, inv, month = record(db)
  106. db.get(PlPlanLoadProduct,'lp0').plan_qty = 9
  107. db.add(PlPlanDelivery(id='delivery', tenant_no='t', plan_id='p', seq=1,
  108. customer_name=INTERNAL, generate_dibang=0, dibang_direction='inbound'))
  109. db.add(PlPlanDeliveryProduct(id='dp', tenant_no='t', plan_id='p', delivery_id='delivery',
  110. plan_product_id='pp', product_id='missing-sku', product_no='SKU', product_name='Product', plan_qty=5))
  111. db.commit()
  112. with pytest.raises(ValueError): sync(db, rows, [bridge.StopDibangOption('load','load',True),
  113. bridge.StopDibangOption('delivery','delivery',True)])
  114. assert inv.quantity == month.planned_quantity == 5 and not db.dirty and not db.new
  115. assert db.get(PlPlanDelivery,'delivery').generate_dibang == 0
  116. @pytest.mark.parametrize('bad', ['foreign_stop', 'missing_stop', 'duplicate_option', 'foreign_product_parent'])
  117. def test_scope_and_option_validation(db, bad):
  118. rows = seed(db)
  119. options=[bridge.StopDibangOption('load','load',True)]
  120. if bad=='foreign_stop': rows[2].tenant_no='other'
  121. elif bad=='missing_stop': options=[bridge.StopDibangOption('load','missing',True)]
  122. elif bad=='duplicate_option': options*=2
  123. else: db.get(PlPlanLoadProduct,'lp0').plan_id='other'
  124. db.commit()
  125. with pytest.raises(ValueError):sync(db, rows, options)
  126. assert not db.dirty and not db.new and db.query(DbInboundOutbound).count()==0
  127. def test_pending_new_stop_and_transport_are_supported_without_outer_flush(db):
  128. rows=seed(db,(5,3),pending=True)
  129. sync(db,rows); db.commit()
  130. link,inv,month=record(db)
  131. assert inv.quantity==month.planned_quantity==8
  132. def test_transport_is_locked_before_first_inventory_write(db):
  133. from sqlalchemy.dialects import mysql
  134. rows=seed(db); locks=[]
  135. def observe(state): locks.append(str(state.statement.compile(dialect=mysql.dialect())))
  136. event.listen(db,'do_orm_execute',observe)
  137. try: sync(db,rows)
  138. finally:event.remove(db,'do_orm_execute',observe)
  139. assert any('FROM pl_transport ' in sql and 'FOR UPDATE' in sql for sql in locks)
  140. def test_new_transport_pending_without_outer_flush(db):
  141. rows=seed(db,(5,3))
  142. new=PlTransport(id='new-tr',tenant_no='t',transport_no='NEW',license_plate='New')
  143. db.add(new);rows[0].transport_id=new.id
  144. sync(db,(rows[0],new,rows[2])); db.commit()
  145. link,inv,month=record(db)
  146. assert link.transport_id=='new-tr' and inv.quantity==month.planned_quantity==8
  147. def test_explicit_cancel_then_reenable_is_a_new_business_action(db):
  148. rows=seed(db);sync(db,rows);db.commit();old,inv,month=record(db)
  149. sync(db,rows,[bridge.StopDibangOption('load','load',False)]);db.commit()
  150. assert old.del_flag==inv.del_flag==1 and month.planned_quantity==0
  151. sync(db,rows);db.commit();new,current,month=record(db)
  152. assert new.id!=old.id and current.id!=inv.id and month.planned_quantity==5
  153. def test_omitted_stops_and_empty_options_do_not_clear_existing_links(db):
  154. rows=seed(db);sync(db,rows);db.commit();link,inv,month=record(db)
  155. assert sync(db,rows,[])==[]
  156. assert link.del_flag==inv.del_flag==0 and month.planned_quantity==5
  157. def test_later_stop_dangling_link_does_not_write_earlier_stop(db):
  158. rows=seed(db);sync(db,rows);db.commit()
  159. db.add(PlPlanDelivery(id='delivery',tenant_no='t',plan_id='p',seq=1,customer_name=INTERNAL,
  160. generate_dibang=1,dibang_direction='inbound'))
  161. db.add(PlPlanDeliveryProduct(id='dp',tenant_no='t',plan_id='p',delivery_id='delivery',
  162. plan_product_id='pp',product_id='sku',product_no='SKU',product_name='Product',model='Spec',unit='吨',plan_qty=5))
  163. db.commit();options=[bridge.StopDibangOption('load','load',True),bridge.StopDibangOption('delivery','delivery',True)]
  164. sync(db,rows,options);db.commit()
  165. link=db.query(PlTransportDbLink).filter_by(stop_type='delivery').one();link.db_inventory_id='missing'
  166. db.get(PlPlanLoadProduct,'lp0').plan_qty=9;db.commit()
  167. with pytest.raises(ValueError):sync(db,rows,options)
  168. assert not db.dirty and not db.new
  169. assert db.query(DbInboundOutbound).filter_by(business_type='出库').one().quantity==5
  170. @pytest.mark.parametrize('bad', ['wrong_stop_type','month_business','inventory_multiple_owners'])
  171. def test_history_scope_details_are_not_repaired_by_saving(db,bad):
  172. rows=seed(db);sync(db,rows);db.commit();link,inv,month=record(db)
  173. if bad=='wrong_stop_type':link.stop_type='delivery'
  174. elif bad=='month_business':month.product_id='other-product'
  175. else:db.add(PlTransportDbLink(id='other-owner',tenant_no='t',transport_id='other-tr',plan_id='other-plan',
  176. plan_product_id='other-pp',stop_type='load',stop_id='other-stop',db_inventory_id=inv.id,
  177. db_purchase_plan_id=month.id,contributed_qty=0))
  178. db.commit()
  179. with pytest.raises(ValueError):sync(db,rows)
  180. assert not db.dirty and not db.new and month.planned_quantity==5
  181. def test_all_stop_link_and_month_existence_reads_are_current_reads(db):
  182. from sqlalchemy.dialects import mysql
  183. rows=seed(db);sql=[]
  184. def observe(state):
  185. sql.append(str(state.statement.compile(dialect=mysql.dialect())))
  186. event.listen(db,'do_orm_execute',observe)
  187. try:
  188. sync(db,rows);db.commit();sync(db,rows);db.commit()
  189. sync(db,rows,[bridge.StopDibangOption('load','load',False)])
  190. finally:event.remove(db,'do_orm_execute',observe)
  191. # Sequence / human-readable batch-number reads are unrelated to identity decisions.
  192. identity_reads=[s for s in sql if 'FROM pl_transport_db_link ' in s or 'FROM db_purchase_plan ' in s]
  193. assert identity_reads and all('FOR UPDATE' in s for s in identity_reads)
  194. def test_pending_month_contribution_is_not_overwritten_by_preflight_refresh(db):
  195. rows=seed(db);sync(db,rows);db.commit();link,inv,month=record(db)
  196. month.planned_quantity=12 # same-transaction date synchronizer/other contribution
  197. sync(db,rows)
  198. assert month.planned_quantity==12 and inv.quantity==5
  199. def test_zero_quantity_updates_existing_contribution_without_replacing_ids(db):
  200. rows=seed(db,(5,3));sync(db,rows);db.commit();link,inv,month=record(db)
  201. identity=(link.id,inv.id)
  202. for row in db.query(PlPlanLoadProduct):row.plan_qty=0
  203. sync(db,rows);db.commit()
  204. assert (link.id,inv.id)==identity and link.del_flag==inv.del_flag==0
  205. assert link.contributed_qty==inv.quantity==month.planned_quantity==0
  206. sync(db,rows)
  207. assert db.query(PlTransportDbLink).count()==db.query(DbInboundOutbound).count()==1
  208. assert month.planned_quantity==0
  209. def test_first_zero_quantity_does_not_create_empty_link_or_inventory(db):
  210. rows=seed(db,(0,));sync(db,rows)
  211. assert db.query(PlTransportDbLink).count()==db.query(DbInboundOutbound).count()==0
  212. def test_cancel_checks_weighing_with_current_tenant_scoped_read(db):
  213. from sqlalchemy.dialects import mysql
  214. rows=seed(db);sync(db,rows);db.commit();sql=[]
  215. def observe(state):sql.append(str(state.statement.compile(dialect=mysql.dialect())))
  216. event.listen(db,'do_orm_execute',observe)
  217. try:sync(db,rows,[bridge.StopDibangOption('load','load',False)])
  218. finally:event.remove(db,'do_orm_execute',observe)
  219. slips=[s for s in sql if 'FROM db_weighing_slip ' in s]
  220. assert slips and all('FOR UPDATE' in s and 'db_weighing_slip.tenant_no =' in s for s in slips)
  221. def test_refreshed_owner_cannot_sync_an_old_transport(db):
  222. from sqlalchemy import text
  223. rows=seed(db)
  224. db.execute(text("UPDATE pl_plan SET transport_id='newer-transport' WHERE id='p'"));db.commit()
  225. assert rows[0].transport_id=='tr'
  226. with pytest.raises(ValueError):sync(db,rows)
  227. assert not db.dirty and not db.new