test_pl_inventory_differential_archive.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363
  1. """Real SQLite inventory regressions; business databases are forbidden."""
  2. from datetime import date, datetime
  3. from decimal import Decimal
  4. import pymysql
  5. import pytest
  6. from fastapi import HTTPException
  7. from sqlalchemy import create_engine, event
  8. from sqlalchemy.orm import Session
  9. from app.models import pl_models as m
  10. from app.models.crm_models import CrmProduct
  11. from app.services.pl_inventory_service import (
  12. apply_archive_inventory_for_plan, apply_archive_inventory_for_plan_stops,
  13. reverse_plan_inventory_postings,
  14. )
  15. @pytest.fixture
  16. def db(monkeypatch):
  17. def forbidden(*a, **kw):
  18. raise AssertionError('Business DB forbidden')
  19. monkeypatch.setattr(pymysql, 'connect', forbidden)
  20. monkeypatch.setattr(pymysql.connections.Connection, 'connect', forbidden)
  21. engine = create_engine('sqlite://')
  22. for model in (CrmProduct, m.PlPlan, m.PlPlanProduct, m.PlPlanProductBrandQty,
  23. m.PlInventory, m.PlInventoryTransaction, m.PlPlanLoad, m.PlPlanDelivery,
  24. m.PlPlanLoadProduct, m.PlPlanDeliveryProduct):
  25. model.__table__.create(engine)
  26. with Session(engine, autoflush=False, expire_on_commit=False) as session:
  27. yield session
  28. engine.dispose()
  29. def seed(db, *, tenant='t', pid='p', qty='10', price='5', direction='inbound', master=True):
  30. if master and db.get(CrmProduct, 'sku') is None:
  31. db.add(CrmProduct(id='sku', tenant_no=tenant, product_no='SKU', product_name='product'))
  32. plan = m.PlPlan(id=pid, tenant_no=tenant, plan_no=pid, create_date=date(2020,1,1),
  33. belongs_company='shanxia', plan_type=direction, archive_status='archived')
  34. product = m.PlPlanProduct(id=pid+'-pp', tenant_no=tenant, plan_id=pid,
  35. product_id='sku', product_no='SKU', product_name='product', actual_qty=Decimal(qty),
  36. inbound_price=Decimal(price), outbound_price=Decimal(price))
  37. db.add_all([plan, product]); db.commit()
  38. return plan, product
  39. def archive(db, plan, *, stops=False, tenant='t'):
  40. fn = apply_archive_inventory_for_plan_stops if stops else apply_archive_inventory_for_plan
  41. fn(db, tenant_no=tenant, plan=plan, user_id='editor', dept_id='dept')
  42. db.flush()
  43. def rows(db, pid='p'):
  44. return db.query(m.PlInventoryTransaction).filter_by(plan_id=pid, del_flag=0).all()
  45. def inv(db):
  46. return db.query(m.PlInventory).filter_by(tenant_no='t', product_id='sku', brand_id='', del_flag=0).one()
  47. def test_noop_preserves_identity_creation_period_and_snapshot_without_writes(db):
  48. plan, product = seed(db); archive(db, plan)
  49. old = rows(db)[0]; old.create_by='creator'; old.create_time=datetime(2020,1,1); old.stock_after=42
  50. db.commit()
  51. before=(old.id,old.create_by,old.create_time,old.update_time,old.stock_after)
  52. writes=[]
  53. def record(conn,cursor,sql,*args):
  54. if sql.lstrip().upper().startswith(('UPDATE','INSERT','DELETE')): writes.append(sql)
  55. event.listen(db.bind, 'before_cursor_execute', record)
  56. try: archive(db,plan)
  57. finally: event.remove(db.bind, 'before_cursor_execute', record)
  58. now=rows(db)[0]
  59. assert (now.id,now.create_by,now.create_time,now.update_time,now.stock_after)==before
  60. assert db.query(m.PlInventoryTransaction).count()==1 and not writes
  61. def test_quantity_price_net_delta_does_not_revalue_unrelated_stock(db):
  62. plan,product=seed(db);archive(db,plan);db.commit()
  63. old=rows(db)[0]; identity=old.id
  64. inv(db).stock_qty+=100;inv(db).stock_amount+=1000;db.commit()
  65. product.actual_qty=12;product.inbound_price=7
  66. archive(db,plan)
  67. assert rows(db)[0].id==identity and old.qty==12 and old.amount==84
  68. assert inv(db).stock_qty==112 and inv(db).stock_amount==1084
  69. assert old.stock_after==12
  70. def test_price_only_keeps_stock_after(db):
  71. plan,product=seed(db);archive(db,plan);db.commit()
  72. old=rows(db)[0];old.stock_after=123;db.commit();product.inbound_price=8
  73. archive(db,plan)
  74. assert rows(db)[0].id==old.id and old.stock_after==123
  75. assert inv(db).stock_qty==10 and inv(db).stock_amount==80
  76. def test_explicit_zero_price_does_not_use_tax_price(db):
  77. plan,product=seed(db,price='0');product.tax_price=20
  78. archive(db,plan)
  79. assert rows(db)[0].unit_price==0 and rows(db)[0].amount==0 and inv(db).stock_amount==0
  80. def test_remove_and_reverse_do_not_replace_other_plan(db):
  81. plan,product=seed(db)
  82. other,_=seed(db,pid='other',qty='4',price='3')
  83. archive(db,plan);archive(db,other);db.commit()
  84. removed=rows(db)[0];other_id=rows(db,'other')[0].id
  85. assert inv(db).stock_amount==62
  86. product.actual_qty=0;archive(db,plan)
  87. assert removed.del_flag==1 and rows(db)==[]
  88. assert inv(db).stock_qty==4 and inv(db).stock_amount==12
  89. assert rows(db,'other')[0].id==other_id
  90. for _ in range(2):
  91. reverse_plan_inventory_postings(db,tenant_no='t',plan_id='other',user_id='u');db.flush()
  92. assert rows(db,'other')==[] and inv(db).stock_qty==0 and inv(db).stock_amount==0
  93. def delivery(db,plan,product,identity,qty,price):
  94. db.add(m.PlPlanDelivery(id=identity,tenant_no='t',plan_id=plan.id,
  95. customer_name='安庆市山峡化工有限公司',seq=1))
  96. row=m.PlPlanDeliveryProduct(id=identity+'-item',tenant_no='t',plan_id=plan.id,
  97. delivery_id=identity,plan_product_id=product.id,product_id='sku',product_no='SKU',
  98. product_name='product',outbound_qty=Decimal(qty),outbound_price=Decimal(price))
  99. db.add(row);db.flush();return row
  100. def test_multiple_same_direction_stops_keep_separate_lines(db):
  101. plan,product=seed(db,direction='direct')
  102. delivery(db,plan,product,'one','2','3')
  103. second=delivery(db,plan,product,'two','3','7')
  104. archive(db,plan,stops=True);db.commit()
  105. by_line={r.stop_product_id:r for r in rows(db)}
  106. assert set(by_line)=={'one-item','two-item'}
  107. assert by_line['one-item'].qty==2 and by_line['one-item'].amount==6 and by_line['one-item'].unit_price==3
  108. assert by_line['two-item'].qty==3 and by_line['two-item'].amount==21 and by_line['two-item'].unit_price==7
  109. assert inv(db).stock_qty==5 and inv(db).stock_amount==27
  110. identities={key:(row.id,row.create_time) for key,row in by_line.items()}
  111. archive(db,plan,stops=True)
  112. by_line={r.stop_product_id:r for r in rows(db)}
  113. assert {key:(row.id,row.create_time) for key,row in by_line.items()}==identities
  114. second.outbound_qty=4;archive(db,plan,stops=True)
  115. by_line={r.stop_product_id:r for r in rows(db)}
  116. assert by_line['one-item'].id==identities['one-item'][0] and by_line['one-item'].qty==2
  117. assert by_line['two-item'].id==identities['two-item'][0] and by_line['two-item'].qty==4
  118. assert by_line['two-item'].amount==28 and inv(db).stock_qty==6 and inv(db).stock_amount==34
  119. def test_legacy_stop_lines_bind_by_quantity_and_price(db):
  120. plan,product=seed(db,direction='direct')
  121. delivery(db,plan,product,'one','2','3')
  122. second=delivery(db,plan,product,'two','3','7')
  123. first=m.PlInventoryTransaction(id='legacy-one',tenant_no='t',product_id='sku',brand_id='',
  124. product_name='product',transaction_type='inbound',plan_id=plan.id,plan_no=plan.plan_no,
  125. qty=2,unit_price=3,amount=6,stock_after=2,create_time=datetime(2026,9,15))
  126. other=m.PlInventoryTransaction(id='legacy-two',tenant_no='t',product_id='sku',brand_id='',
  127. product_name='product',transaction_type='inbound',plan_id=plan.id,plan_no=plan.plan_no,
  128. qty=3,unit_price=7,amount=21,stock_after=5,create_time=datetime(2026,9,15))
  129. db.add_all([first,other,m.PlInventory(id='inv',tenant_no='t',product_id='sku',brand_id='',
  130. product_no='SKU',product_name='product',stock_qty=5,stock_amount=27)]);db.commit()
  131. second.outbound_qty=4
  132. archive(db,plan,stops=True)
  133. by_line={r.stop_product_id:r for r in rows(db)}
  134. assert by_line['one-item'].id=='legacy-one' and by_line['one-item'].qty==2
  135. assert by_line['two-item'].id=='legacy-two' and by_line['two-item'].qty==4 and by_line['two-item'].amount==28
  136. assert by_line['one-item'].create_time==datetime(2026,9,15)
  137. assert inv(db).stock_qty==6 and inv(db).stock_amount==34
  138. def test_legacy_stop_lines_reject_ambiguous_rematch_without_mutation(db):
  139. plan,product=seed(db,direction='direct')
  140. one=delivery(db,plan,product,'one','4','3')
  141. two=delivery(db,plan,product,'two','6','7')
  142. first=m.PlInventoryTransaction(id='legacy-one',tenant_no='t',product_id='sku',brand_id='',
  143. product_name='product',transaction_type='inbound',plan_id=plan.id,plan_no=plan.plan_no,
  144. qty=2,unit_price=3,amount=6,stock_after=2)
  145. other=m.PlInventoryTransaction(id='legacy-two',tenant_no='t',product_id='sku',brand_id='',
  146. product_name='product',transaction_type='inbound',plan_id=plan.id,plan_no=plan.plan_no,
  147. qty=3,unit_price=7,amount=21,stock_after=5)
  148. db.add_all([first,other,m.PlInventory(id='inv',tenant_no='t',product_id='sku',brand_id='',
  149. product_no='SKU',product_name='product',stock_qty=5,stock_amount=27)]);db.commit()
  150. with pytest.raises(HTTPException) as error: archive(db,plan,stops=True)
  151. assert error.value.status_code==409
  152. assert {r.id for r in rows(db)}=={'legacy-one','legacy-two'}
  153. assert first.qty==2 and other.qty==3 and first.stop_product_id=='' and other.stop_product_id==''
  154. assert one.outbound_qty==4 and two.outbound_qty==6 and inv(db).stock_qty==5
  155. assert not db.dirty and not db.new
  156. def test_merged_legacy_stop_line_is_not_split_automatically(db):
  157. plan,product=seed(db,direction='direct')
  158. delivery(db,plan,product,'one','2','3')
  159. delivery(db,plan,product,'two','3','7')
  160. merged=m.PlInventoryTransaction(id='merged',tenant_no='t',product_id='sku',brand_id='',
  161. product_name='product',transaction_type='inbound',plan_id=plan.id,plan_no=plan.plan_no,
  162. qty=5,unit_price=Decimal('5.4000'),amount=27,stock_after=5,create_time=datetime(2026,9,15))
  163. db.add_all([merged,m.PlInventory(id='inv',tenant_no='t',product_id='sku',brand_id='',
  164. product_no='SKU',product_name='product',stock_qty=5,stock_amount=27)]);db.commit()
  165. with pytest.raises(HTTPException) as error: archive(db,plan,stops=True)
  166. assert error.value.status_code==409 and '合并' in error.value.detail
  167. assert rows(db)[0].id=='merged' and merged.qty==5 and merged.stop_product_id==''
  168. assert inv(db).stock_qty==5 and not db.dirty and not db.new
  169. def test_opposite_directions_stay_distinct(db):
  170. plan,product=seed(db,direction='direct');delivery(db,plan,product,'delivery','5','7')
  171. db.add(m.PlPlanLoad(id='load',tenant_no='t',plan_id=plan.id,customer_name='安庆市山峡化工有限公司'))
  172. db.add(m.PlPlanLoadProduct(id='load-item',tenant_no='t',plan_id=plan.id,load_id='load',
  173. plan_product_id=product.id,product_id='sku',product_no='SKU',product_name='product',
  174. inbound_qty=3,inbound_price=2));db.flush()
  175. archive(db,plan,stops=True);db.commit()
  176. ids={r.transaction_type:r.id for r in rows(db)}
  177. assert set(ids)=={'inbound','outbound'}
  178. assert inv(db).stock_qty==2 and inv(db).stock_amount==29
  179. archive(db,plan,stops=True)
  180. assert {r.transaction_type:r.id for r in rows(db)}==ids
  181. def test_ambiguous_history_rejected_before_mutation(db):
  182. plan,product=seed(db);archive(db,plan);db.commit();old=rows(db)[0]
  183. db.add(m.PlInventoryTransaction(id='duplicate',tenant_no='t',product_id='sku',brand_id='',
  184. product_name='product',transaction_type='inbound',plan_id='p',plan_no='p',qty=1,
  185. unit_price=5,amount=5,stock_after=11));db.commit()
  186. with pytest.raises(HTTPException) as error: archive(db,plan)
  187. assert error.value.status_code==409
  188. assert old.del_flag==0 and len(rows(db))==2 and inv(db).stock_qty==10
  189. assert not db.dirty and not db.new
  190. def test_wrong_tenant_rejected(db):
  191. plan,product=seed(db,tenant='other')
  192. with pytest.raises(HTTPException): archive(db,plan)
  193. assert db.query(m.PlInventory).count()==0
  194. def test_missing_crm_master_does_not_block_historical_plan(db):
  195. plan,product=seed(db,master=False);archive(db,plan);db.commit();identity=rows(db)[0].id
  196. archive(db,plan)
  197. assert rows(db)[0].id==identity
  198. def test_outbound_signed_amount_net_change(db):
  199. plan,product=seed(db,direction='outbound',qty='3',price='7')
  200. db.add(m.PlInventory(id='inv',tenant_no='t',product_id='sku',brand_id='',
  201. product_no='SKU',product_name='product',stock_qty=10,stock_amount=50));db.commit()
  202. archive(db,plan)
  203. assert inv(db).stock_qty==7 and inv(db).stock_amount==29
  204. product.actual_qty=2;archive(db,plan)
  205. assert inv(db).stock_qty==8 and inv(db).stock_amount==36
  206. def test_rounding_noop_has_no_drift(db):
  207. plan,product=seed(db,qty='0.333',price='0.1111');archive(db,plan);db.commit();identity=rows(db)[0].id
  208. for _ in range(3):archive(db,plan);db.commit()
  209. assert len(rows(db))==1 and rows(db)[0].id==identity
  210. assert inv(db).stock_amount==Decimal('0.04')
  211. def test_archive_locks_plan_shared_product_and_postings_before_inventory_changes(db):
  212. from sqlalchemy.dialects import mysql
  213. plan,product=seed(db)
  214. statements=[]
  215. def observe(state):
  216. statements.append(str(state.statement.compile(dialect=mysql.dialect())))
  217. event.listen(db, 'do_orm_execute', observe)
  218. try: archive(db,plan)
  219. finally: event.remove(db, 'do_orm_execute', observe)
  220. locks=[sql for sql in statements if 'FOR UPDATE' in sql]
  221. assert any('FROM pl_plan ' in sql for sql in locks)
  222. assert any('FROM crm_product ' in sql for sql in locks)
  223. assert any('FROM pl_inventory_transaction ' in sql for sql in locks)
  224. assert any('FROM pl_inventory ' in sql for sql in locks)
  225. def test_archive_endpoint_locks_before_mutating_archive_fields():
  226. import ast
  227. from pathlib import Path
  228. from types import SimpleNamespace
  229. from app.schemas.pl.plan import PlanArchiveBody
  230. tree=ast.parse(Path('app/api/pl/plan.py').read_text())
  231. node=next(n for n in tree.body if isinstance(n,ast.FunctionDef) and n.name=='archive_plan')
  232. node.decorator_list=[];node.args.defaults=[ast.Constant(None) for _ in node.args.defaults]
  233. events=[]
  234. plan=SimpleNamespace(id='p',tenant_no='t',follow_status='completed',archive_status='archived',has_freight=0)
  235. user=SimpleNamespace(id='u',dept_id='',name='',to_tenant_user_dict=lambda:{})
  236. ns=dict(user_can_archive_plan=lambda *a:True,get_tenant_no=lambda *a:'t',_get_plan=lambda *a:plan,
  237. get_plan=lambda *a:plan,record_plan_change=lambda *a,**k:None,
  238. lock_archive_inventory=lambda *a,**k:events.append('lock'),
  239. apply_archive_product_quantities=lambda *a,**k:events.append('quantities'),
  240. apply_archive_inventory_for_plan=lambda *a,**k:events.append('inventory'))
  241. module=ast.Module(body=[ast.ImportFrom(module='__future__',names=[ast.alias(name='annotations')],level=0),node],type_ignores=[])
  242. exec(compile(ast.fix_missing_locations(module),'<current archive endpoint>','exec'),ns)
  243. ns['archive_plan']('p',PlanArchiveBody(products=[{'id':'pp','actualQty':10}]),SimpleNamespace(commit=lambda:None),user)
  244. assert events==['lock','quantities','inventory']
  245. def test_pending_new_and_removed_brand_rows_are_seen_without_autoflush(db):
  246. from app.schemas.pl.plan import PlanArchiveProductItem
  247. from app.services.pl_plan_service import apply_archive_product_quantities
  248. plan,product=seed(db);product.brand_mode='any';db.commit()
  249. def update_brands(values):
  250. apply_archive_product_quantities(db,tenant_no='t',plan=plan,user_id='u',sync_from_io_order=False,
  251. items=[PlanArchiveProductItem(id=product.id,actualQty=10,brandQtys=values)])
  252. archive(db,plan)
  253. update_brands([{'brandId':'b1','qty':4,'unitPrice':2},{'brandId':'b2','qty':6,'unitPrice':3}])
  254. assert {r.brand_id for r in rows(db)}=={'b1','b2'}
  255. by_brand={r.brand_id:r for r in rows(db)}
  256. assert by_brand['b1'].amount==8 and by_brand['b2'].amount==18
  257. b2_id=by_brand['b2'].id
  258. update_brands([{'brandId':'b2','qty':10,'unitPrice':4}])
  259. assert len(rows(db))==1 and rows(db)[0].id==b2_id and rows(db)[0].amount==40
  260. assert by_brand['b1'].del_flag==1
  261. def test_api_lock_refreshes_plan_state_but_service_lock_preserves_pending_fields(db):
  262. from sqlalchemy import text
  263. from app.services.pl_inventory_service import lock_archive_inventory
  264. plan,product=seed(db)
  265. db.execute(text("UPDATE pl_plan SET follow_status='cancelled', archive_status='cancelled' WHERE id='p'"))
  266. db.commit()
  267. assert plan.follow_status!='cancelled'
  268. lock_archive_inventory(db,tenant_no='t',plan=plan,refresh_plan=True)
  269. assert plan.follow_status=='cancelled' and plan.archive_status=='cancelled'
  270. plan.remark='pending application change'
  271. lock_archive_inventory(db,tenant_no='t',plan=plan)
  272. assert plan.remark=='pending application change'
  273. def test_outbound_price_edit_uses_outbound_price_not_stale_inbound_price(db):
  274. plan,product=seed(db,direction='outbound')
  275. archive(db,plan);db.commit();identity=rows(db)[0].id
  276. product.outbound_price=8
  277. archive(db,plan)
  278. assert rows(db)[0].id==identity and rows(db)[0].amount==80
  279. assert inv(db).stock_amount==Decimal('-80')
  280. def test_api_refresh_reads_latest_product_and_stop_fields(db):
  281. from sqlalchemy import text
  282. from app.services.pl_inventory_service import lock_archive_inventory
  283. plan,product=seed(db,direction='direct')
  284. stop_product=delivery(db,plan,product,'one','2','3');db.commit()
  285. db.execute(text("UPDATE pl_plan_product SET inbound_price=9 WHERE id='p-pp'"))
  286. db.execute(text("UPDATE pl_plan_delivery_product SET outbound_qty=7 WHERE id='one-item'"));db.commit()
  287. assert product.inbound_price==5 and stop_product.outbound_qty==2
  288. lock_archive_inventory(db,tenant_no='t',plan=plan,refresh_plan=True)
  289. assert product.inbound_price==9 and stop_product.outbound_qty==7
  290. def test_other_tenant_postings_and_inventory_untouched(db):
  291. plan,product=seed(db)
  292. other=m.PlInventoryTransaction(id='foreign',tenant_no='other',product_id='sku',brand_id='',
  293. product_name='product',transaction_type='inbound',plan_id=plan.id,plan_no='p',
  294. qty=50,unit_price=2,amount=100,stock_after=50)
  295. foreign_inv=m.PlInventory(id='foreign-inv',tenant_no='other',product_id='sku',brand_id='',
  296. product_no='SKU',product_name='product',stock_qty=50,stock_amount=100)
  297. db.add_all([other,foreign_inv]);db.commit()
  298. archive(db,plan);archive(db,plan)
  299. assert other.del_flag==0 and other.qty==50 and other.stock_after==50
  300. assert foreign_inv.stock_qty==50 and foreign_inv.stock_amount==100
  301. def test_missing_inventory_for_existing_postings_rejected_without_repair(db):
  302. plan,product=seed(db);archive(db,plan);db.commit();old=rows(db)[0]
  303. inv(db).del_flag=1;db.commit()
  304. with pytest.raises(HTTPException) as exc: archive(db,plan)
  305. assert exc.value.status_code==409
  306. assert old.del_flag==0 and db.query(m.PlInventory).count()==1