test_pl_child_differential_save.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328
  1. """Real ORM regressions; exclusively SQLite, never the configured business database."""
  2. import json
  3. from datetime import date
  4. from decimal import Decimal
  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.models.pl_mobile_stop_meta import PlPlanStopProductMeta
  11. from app.schemas.pl.plan import PlanLoadBody, PlanDeliveryBody, PlanStopProductBody, PlanContractLink
  12. from app.services.pl_plan_stop_service import replace_plan_stops
  13. from app.services.pl_plan_service import replace_plan_contracts
  14. from app.services.pl_brand_archive import persist_plan_product_brand_qtys
  15. from app.services.pl_feedback_brand_qty import persist_transport_product_feedback
  16. from app.services.pl_personnel_service import save_vehicle_personnel
  17. from app.schemas.pl.base_data import VehiclePersonnelRow
  18. @pytest.fixture
  19. def db():
  20. engine = create_engine('sqlite://')
  21. for table in m.PlPlan.metadata.sorted_tables:
  22. if table.name.startswith('pl_'):
  23. table.create(engine, checkfirst=True)
  24. with Session(engine, autoflush=False) as session:
  25. yield session
  26. engine.dispose()
  27. def seed(db):
  28. pp=m.PlPlanProduct(id='pp',tenant_no='t',plan_id='p',product_id='sku',product_no='SKU',product_name='产品')
  29. load=m.PlPlanLoad(id='l',tenant_no='t',plan_id='p',customer_name='安庆山峡')
  30. delivery=m.PlPlanDelivery(id='d',tenant_no='t',plan_id='p',customer_name='安庆山峡')
  31. lp=m.PlPlanLoadProduct(id='lp',tenant_no='t',plan_id='p',load_id='l',plan_product_id='pp',product_id='sku',product_no='SKU',product_name='产品',plan_qty=10,inbound_qty=8,archive_remark='保留归档')
  32. dp=m.PlPlanDeliveryProduct(id='dp',tenant_no='t',plan_id='p',delivery_id='d',plan_product_id='pp',product_id='sku',product_no='SKU',product_name='产品',plan_qty=10)
  33. db.add_all([pp,load,delivery,lp,dp]);db.flush()
  34. return pp,lp,dp
  35. def body(row_id):
  36. return PlanStopProductBody(id=row_id,planProductId='pp',productId='sku',productNo='SKU',productName='产品',planQty=12)
  37. def save_stops(db,pp,load=None,deliveries=None):
  38. replace_plan_stops(db,'t','p',load,deliveries,'u','dept',[pp])
  39. def test_stop_same_ids_keep_archive_and_creation(db):
  40. pp,lp,dp=seed(db);created=lp.create_time
  41. save_stops(db,pp,PlanLoadBody(id='l',products=[body('lp')]),[PlanDeliveryBody(id='d',products=[body('dp')])])
  42. db.flush();db.expire_all()
  43. assert db.get(m.PlPlanLoadProduct,'lp').del_flag==0
  44. assert db.get(m.PlPlanLoadProduct,'lp').inbound_qty==Decimal('8')
  45. assert db.get(m.PlPlanLoadProduct,'lp').archive_remark=='保留归档'
  46. assert db.get(m.PlPlanLoadProduct,'lp').create_time==created
  47. assert db.get(m.PlPlanLoadProduct,'lp').plan_qty==12
  48. assert db.query(m.PlPlanDeliveryProduct).count()==1
  49. def test_omitted_stop_collections_are_unchanged(db):
  50. pp,lp,dp=seed(db)
  51. save_stops(db,pp,PlanLoadBody(id='l',customer_name='更名'),None)
  52. db.flush();db.expire_all()
  53. assert db.get(m.PlPlanLoadProduct,'lp').del_flag==0
  54. assert db.get(m.PlPlanDeliveryProduct,'dp').del_flag==0
  55. @pytest.mark.parametrize('bad_ids',[['foreign'],['dp','dp'],['lp']])
  56. def test_invalid_duplicate_or_wrong_stop_id_rejected_before_mutation(db,bad_ids):
  57. pp,lp,dp=seed(db)
  58. with pytest.raises(HTTPException) as exc:
  59. save_stops(db,pp,PlanLoadBody(id='l',products=[body('lp')]),[PlanDeliveryBody(id='d',products=[body(i) for i in bad_ids])])
  60. assert exc.value.status_code==422
  61. assert not db.dirty and not db.new
  62. @pytest.mark.parametrize('reference',['brand','driver','site','snapshot','archive'])
  63. def test_stop_product_removal_rejects_references(db,reference):
  64. pp,lp,dp=seed(db)
  65. if reference=='brand':db.add(m.PlPlanProductBrandQty(id='b',tenant_no='t',plan_id='p',plan_product_id='pp',stop_type='delivery',stop_product_id='dp',brand_id='brand',qty=2))
  66. elif reference in ('driver','site'):
  67. model=m.PlTransportDriverStopFeedback if reference=='driver' else m.PlTransportSiteStopFeedback
  68. db.add(model(id='f',tenant_no='t',plan_id='p',transport_id='tr',stop_type='delivery',stop_id='d',product_feedbacks=json.dumps([{'stopProductId':'dp','actualQty':2}]),**({'business_type':'outbound'} if reference=='site' else {})))
  69. elif reference=='snapshot':db.add(PlPlanStopProductMeta(id='s',tenant_no='t',plan_id='p',stop_type='delivery',stop_id='d',stop_product_id='dp',product_id='sku'))
  70. else:dp.outbound_qty=2
  71. db.flush()
  72. with pytest.raises(HTTPException) as exc:
  73. save_stops(db,pp,None,[PlanDeliveryBody(id='d',products=[])])
  74. assert exc.value.status_code==409
  75. assert dp.del_flag==0
  76. def test_unreferenced_stop_product_deletes_only_removed_row(db):
  77. pp,lp,dp=seed(db)
  78. save_stops(db,pp,None,[PlanDeliveryBody(id='d',products=[])])
  79. db.flush()
  80. assert dp.del_flag==1 and lp.del_flag==0
  81. def test_contract_diff_keeps_other_sides_and_identity(db):
  82. old=m.PlPlanContract(id='c',tenant_no='t',plan_id='p',side='supplier',contract_id='contract')
  83. other=m.PlPlanContract(id='s',tenant_no='t',plan_id='p',side='load',stop_id='l',contract_id='stopcontract')
  84. db.add_all([old,other]);db.flush()
  85. replace_plan_contracts(db,tenant_no='t',plan_id='p',supplier_contracts=[PlanContractLink(contractId='contract')],demander_contracts=None,user_id='u',dept_id='dept')
  86. db.flush();db.expire_all()
  87. assert db.get(m.PlPlanContract,'c').del_flag==0
  88. assert db.get(m.PlPlanContract,'s').del_flag==0
  89. assert db.query(m.PlPlanContract).count()==2
  90. def test_brand_quantity_update_preserves_id(db):
  91. old=m.PlPlanProductBrandQty(id='b',tenant_no='t',plan_id='p',plan_product_id='pp',brand_id='brand',qty=2)
  92. db.add(old);db.flush()
  93. persist_plan_product_brand_qtys(db,tenant_no='t',plan_id='p',plan_product_id='pp',brand_mode='any',rows=[{'brandId':'brand','qty':3}],user_id='u')
  94. db.flush();db.expire_all()
  95. assert db.get(m.PlPlanProductBrandQty,'b').del_flag==0
  96. assert db.get(m.PlPlanProductBrandQty,'b').qty==3
  97. assert db.query(m.PlPlanProductBrandQty).count()==1
  98. def test_transport_feedback_and_brand_keep_both_ids(db):
  99. pp,_,_=seed(db);pp.brand_mode='any'
  100. tr=m.PlTransport(id='tr',tenant_no='t',transport_no='TR');db.add(tr)
  101. db.add(m.PlTransportProductFeedback(id='f',tenant_no='t',transport_id='tr',plan_id='p',plan_product_id='pp',actual_qty=2))
  102. db.add(m.PlTransportProductBrandQty(id='b',tenant_no='t',transport_id='tr',feedback_id='f',plan_product_id='pp',brand_id='brand',qty=2));db.flush()
  103. persist_transport_product_feedback(db,tenant_no='t',transport=tr,plan_id='p',products=[{'planProductId':'pp','actualQty':3,'brandQtys':[{'brandId':'brand','qty':3}]}])
  104. db.flush();db.expire_all()
  105. assert db.get(m.PlTransportProductFeedback,'f').del_flag==0
  106. assert db.get(m.PlTransportProductBrandQty,'b').del_flag==0
  107. assert db.get(m.PlTransportProductBrandQty,'b').feedback_id=='f'
  108. assert db.query(m.PlTransportProductFeedback).count()==1
  109. def test_vehicle_personnel_link_keeps_id(db):
  110. db.add(m.PlPersonnel(id='person',tenant_no='t',personnel_name='人员'))
  111. db.add(m.PlVehiclePersonnel(id='link',tenant_no='t',vehicle_id='v',personnel_id='person',role='driver'));db.flush()
  112. save_vehicle_personnel(db,'v',[VehiclePersonnelRow(personnelId='person',personnelName='人员',role='driver')],'t',user_id='u',dept_id='dept')
  113. db.flush();db.expire_all()
  114. assert db.get(m.PlVehiclePersonnel,'link').del_flag==0
  115. assert db.query(m.PlVehiclePersonnel).count()==1
  116. @pytest.mark.parametrize('kind',['inbound','outbound','feedback','dibang'])
  117. def test_main_product_preflight_blocks_downstream_removal(db,kind):
  118. from app.services import pl_plan_stop_service as service
  119. from app.schemas.pl.plan import PlanUpdate
  120. pp,lp,dp=seed(db)
  121. model={'inbound':m.PlInboundOrderItem,'outbound':m.PlOutboundOrderItem,
  122. 'feedback':m.PlTransportProductFeedback,'dibang':m.PlTransportDbLink}[kind]
  123. fields=dict(id='ref',tenant_no='t',plan_product_id='pp')
  124. if kind in ('inbound','outbound'):fields.update(order_id='order',product_id='sku',product_name='产品')
  125. else:fields.update(plan_id='p',transport_id='tr')
  126. if kind=='dibang':fields.update(db_inventory_id='inv',db_purchase_plan_id='month')
  127. db.add(model(**fields));db.flush()
  128. with pytest.raises(HTTPException) as exc:
  129. service.preflight_plan_child_update(db,'t','p',PlanUpdate(products=[]))
  130. assert exc.value.status_code==409
  131. assert not db.dirty and not db.new
  132. def test_new_product_prefilled_amounts_are_not_archive_evidence(db):
  133. from app.services.pl_child_references import assert_plan_products_removable
  134. pp=m.PlPlanProduct(id='pp',tenant_no='t',plan_id='p',product_id='sku',product_no='SKU',product_name='产品',inbound_amount=120,outbound_amount=120)
  135. db.add(pp);db.flush()
  136. assert_plan_products_removable(db,'t','p',[pp])
  137. assert not db.dirty and not db.new
  138. def test_main_product_preflight_rejects_duplicate_and_foreign_ids(db):
  139. from app.services import pl_plan_stop_service as service
  140. from app.schemas.pl.plan import PlanUpdate
  141. seed(db)
  142. for ids in (['pp','pp'],['foreign']):
  143. payload=PlanUpdate(products=[dict(id=i,productId='sku',productNo='SKU',productName='产品') for i in ids])
  144. with pytest.raises(HTTPException) as exc:
  145. service.preflight_plan_child_update(db,'t','p',payload)
  146. assert exc.value.status_code==422
  147. assert not db.dirty and not db.new
  148. def test_partial_stop_validation_keeps_omitted_products_and_deliveries(db):
  149. from app.services import pl_plan_stop_service as service
  150. from app.schemas.pl.plan import PlanUpdate
  151. seed(db)
  152. payload=PlanUpdate(load=PlanLoadBody(id='l',notes='新说明'))
  153. load, deliveries=service.complete_plan_stops_for_validation(db,'t','p',payload.load,payload.deliveries)
  154. assert [p.id for p in load.products]==['lp']
  155. assert [p.id for p in deliveries[0].products]==['dp']
  156. assert 'products' not in payload.load.model_fields_set
  157. def test_duplicate_brand_input_rejected_without_mutation(db):
  158. old=m.PlPlanProductBrandQty(id='b',tenant_no='t',plan_id='p',plan_product_id='pp',brand_id='brand',qty=2)
  159. db.add(old);db.flush()
  160. with pytest.raises(HTTPException) as exc:
  161. persist_plan_product_brand_qtys(db,tenant_no='t',plan_id='p',plan_product_id='pp',brand_mode='any',rows=[{'brandId':'brand','qty':3},{'brandId':'brand','qty':4}],user_id='u')
  162. assert exc.value.status_code==422
  163. assert old.qty==2 and not db.dirty
  164. def test_archive_omitted_brands_preserves_existing_brand_rows(db):
  165. from app.schemas.pl.plan import PlanArchiveProductItem
  166. from app.services.pl_plan_service import apply_archive_product_quantities
  167. pp,_,_=seed(db)
  168. plan=m.PlPlan(id='p',tenant_no='t',plan_no='P',create_date=date(2026,10,27),plan_type='inbound',belongs_company='shanxia')
  169. brand=m.PlPlanProductBrandQty(id='b',tenant_no='t',plan_id='p',plan_product_id='pp',brand_id='brand',qty=2)
  170. db.add_all([plan,brand]);db.flush()
  171. apply_archive_product_quantities(db,tenant_no='t',plan=plan,items=[PlanArchiveProductItem(id='pp',actualQty=2)],user_id='u',sync_from_io_order=False)
  172. db.flush()
  173. assert brand.del_flag==0
  174. def test_admin_feedback_omitted_brands_preserves_existing_brand_rows(db):
  175. from app.schemas.pl.plan import PlanDriverFeedbackUpdateBody
  176. from app.services.pl_plan_service import apply_plan_driver_feedback
  177. pp,_,_=seed(db)
  178. plan=m.PlPlan(id='p',tenant_no='t',plan_no='P',create_date=date(2026,10,27),plan_type='inbound',belongs_company='shanxia',transport_id='tr')
  179. db.add_all([plan,m.PlTransport(id='tr',tenant_no='t',transport_no='TR'),m.PlTransportProductFeedback(id='f',tenant_no='t',plan_id='p',transport_id='tr',plan_product_id='pp',actual_qty=2)])
  180. brand=m.PlTransportProductBrandQty(id='b',tenant_no='t',transport_id='tr',feedback_id='f',plan_product_id='pp',brand_id='brand',qty=2)
  181. db.add(brand);db.flush()
  182. apply_plan_driver_feedback(db,tenant_no='t',plan=plan,payload=PlanDriverFeedbackUpdateBody(products=[{'planProductId':'pp','actualQty':2}]),user_id='u')
  183. db.flush()
  184. assert brand.del_flag==0
  185. def test_main_product_partial_edit_keeps_brand_and_archive_fields(db):
  186. from app.schemas.pl.plan import PlanProductCreate
  187. from app.services.pl_plan_service import apply_plan_product_fields
  188. pp,_,_=seed(db)
  189. pp.brand_mode='any';pp.actual_qty=8;pp.inbound_price=10;pp.inbound_amount=80
  190. pp.model='原规格';db.flush()
  191. apply_plan_product_fields(pp,plan_type='inbound',prod=PlanProductCreate(id='pp',productId='sku',productNo='SKU',productName='新显示名'),order_num=0,user_id='u')
  192. assert pp.brand_mode=='any' and pp.model=='原规格'
  193. assert pp.actual_qty==8 and pp.inbound_price==10 and pp.inbound_amount==80
  194. def test_stop_feedback_omitted_nested_brands_survive_native_quantity_edit(db):
  195. from app.services.pl_transport_stop_feedback_service import save_driver_stop_feedback
  196. old=m.PlTransportDriverStopFeedback(id='feedback',tenant_no='t',plan_id='p',transport_id='tr',stop_type='delivery',stop_id='d',product_feedbacks=json.dumps([{'stopProductId':'dp','actualQty':2,'brandQtys':[{'brandId':'b','qty':2}]}]))
  197. db.add(old);db.flush()
  198. save_driver_stop_feedback(db,tenant_no='t',transport_id='tr',plan_id='p',stop_type='delivery',stop_id='d',attachment_urls=[],products=[{'stopProductId':'dp','actualQty':3}])
  199. assert json.loads(old.product_feedbacks)[0]['brandQtys']==[{'brandId':'b','qty':2}]
  200. def test_duplicate_stop_feedback_product_ids_rejected(db):
  201. from app.services.pl_transport_stop_feedback_service import save_driver_stop_feedback
  202. with pytest.raises(HTTPException) as exc:
  203. save_driver_stop_feedback(db,tenant_no='t',transport_id='tr',plan_id='p',stop_type='delivery',stop_id='d',attachment_urls=[],products=[{'stopProductId':'dp','actualQty':2}]*2)
  204. assert exc.value.status_code==422
  205. assert not db.new and not db.dirty
  206. def test_stop_product_add_keeps_existing_and_creates_only_new_row(db):
  207. pp,lp,dp=seed(db)
  208. save_stops(db,pp,None,[PlanDeliveryBody(id='d',products=[body('dp'),body(None)])])
  209. db.flush()
  210. rows=db.query(m.PlPlanDeliveryProduct).filter_by(del_flag=0).all()
  211. assert len(rows)==2 and {row.id for row in rows}>={'dp'}
  212. assert lp.del_flag==0
  213. def test_deleted_load_singleton_reused_with_consistent_contract_scope(db):
  214. old=m.PlPlanLoad(id='deleted-load',tenant_no='t',plan_id='p',del_flag=1)
  215. pp=m.PlPlanProduct(id='pp',tenant_no='t',plan_id='p',product_id='sku',product_no='SKU',product_name='产品')
  216. db.add_all([old,pp]);db.flush()
  217. save_stops(db,pp,PlanLoadBody(products=[body(None)],contracts=[PlanContractLink(contractId='c')]),[])
  218. db.flush()
  219. row=db.query(m.PlPlanLoadProduct).one();contract=db.query(m.PlPlanContract).one()
  220. assert row.load_id==contract.stop_id==old.id
  221. @pytest.mark.parametrize('part',['product','load','delivery','stop_product'])
  222. def test_create_rejects_carried_source_ids(part):
  223. from app.services import pl_plan_stop_service as service
  224. from app.schemas.pl.plan import PlanCreateFull
  225. payload=PlanCreateFull(belongsCompany='shanxia')
  226. if part=='product':
  227. from app.schemas.pl.plan import PlanProductCreate
  228. payload.products=[PlanProductCreate(id='source',productId='sku',productNo='SKU',productName='产品')]
  229. elif part=='load':payload.load=PlanLoadBody(id='source')
  230. elif part=='delivery':payload.deliveries=[PlanDeliveryBody(id='source')]
  231. else:payload.load=PlanLoadBody(products=[body('source')])
  232. with pytest.raises(HTTPException) as exc:
  233. service.assert_plan_create_child_ids(payload)
  234. assert exc.value.status_code==422
  235. @pytest.mark.parametrize('explicit',[True,False])
  236. def test_explicit_plan_product_link_cannot_point_to_another_product(db,explicit):
  237. from app.services.pl_plan_stop_service import _resolve_plan_product_id
  238. pp,_,_=seed(db)
  239. wrong=body(None).model_copy(update={'product_id':'different'})
  240. if not explicit:
  241. wrong=wrong.model_copy(update={'plan_product_id':None})
  242. with pytest.raises(HTTPException) as exc:
  243. _resolve_plan_product_id(wrong,[pp])
  244. assert exc.value.status_code==422
  245. def test_legacy_product_link_without_id_must_be_unambiguous(db):
  246. from app.services.pl_plan_stop_service import _resolve_plan_product_id
  247. pp,_,_=seed(db)
  248. another=m.PlPlanProduct(id='pp2',plan_id='p',tenant_no='t',product_id='sku',product_no='SKU',product_name='产品')
  249. with pytest.raises(HTTPException) as exc:
  250. _resolve_plan_product_id(body(None).model_copy(update={'plan_product_id':None}),[pp,another])
  251. assert exc.value.status_code==422
  252. def test_update_api_partial_product_edit_retains_plan_total(db,monkeypatch):
  253. from types import SimpleNamespace
  254. from app.api.pl import plan as api
  255. from app.schemas.pl.plan import PlanUpdate
  256. pp,_,_=seed(db);pp.tax_subtotal=120
  257. plan=m.PlPlan(id='p',tenant_no='t',plan_no='P',create_date=date(2026,10,27),belongs_company='shanxia',plan_type='inbound',total_amount=120)
  258. db.add(plan);db.flush()
  259. monkeypatch.setattr(api,'get_tenant_no',lambda *args:'t')
  260. monkeypatch.setattr(api,'_get_plan',lambda *args:plan)
  261. monkeypatch.setattr(api,'collect_hazard_product_ids',lambda *args:set())
  262. monkeypatch.setattr(api,'resolve_hazard_flags_from_crm',lambda *args,**kwargs:(0,0))
  263. monkeypatch.setattr(api,'sync_linked_dibang_dates_for_plan',lambda *args,**kwargs:[])
  264. monkeypatch.setattr(api,'load_plan_contracts_grouped',lambda *args,**kwargs:{})
  265. monkeypatch.setattr(api,'load_plan_stops_grouped',lambda *args,**kwargs:{})
  266. user=SimpleNamespace(id='u',dept_id='d',to_tenant_user_dict=lambda:{})
  267. result=api.update_plan('p',PlanUpdate(saveMode='draft',products=[{'id':'pp','productId':'sku','productNo':'SKU','productName':'新名'}]),db,user)
  268. assert result.total_amount==120 and pp.tax_subtotal==120