test_pl_mobile_stop_meta.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308
  1. """Pure mobile snapshot preservation tests; no DB imports or connections."""
  2. from pathlib import Path
  3. from types import SimpleNamespace as Row
  4. import importlib.util
  5. import pytest
  6. def service():
  7. path = Path(__file__).parents[1] / 'app/services/pl_mobile_stop_meta.py'
  8. assert path.exists()
  9. spec=importlib.util.spec_from_file_location('mobile_meta_test_module',path)
  10. module=importlib.util.module_from_spec(spec);spec.loader.exec_module(module)
  11. return module
  12. def snapshot(**overrides):
  13. fields=dict(id='meta',tenant_no='t',plan_id='plan',stop_type='delivery',stop_id='stop-a',stop_product_id='old-row',contract_id='c',contract_product_id='line-a',product_id='p',package_spec='桶',model='',unit='吨',alias_name='送货别名',brand_mode='required',brand_id='delivery-brand',brand_name='送货品牌',product_name='送货别名',source_product_name='装货别名',source_brand_mode='any',source_brand_id='',source_brand_name='')
  14. return Row(**(fields|overrides))
  15. def incoming(**overrides):
  16. return Row(**(dict(id=None,contract_id='c',contract_product_id='line-a',product_id='p',package_spec='桶',model='',unit='吨',product_name='送货别名',mobile_product_meta=None)|overrides))
  17. def plan_product(**overrides):
  18. return Row(**(dict(product_name='装货别名',brand_mode='any',brand_id='',brand_name='')|overrides))
  19. def test_disabled_mobile_gate_explains_without_loading_models_or_db():
  20. m=service()
  21. m.assert_mobile_meta_enabled(False,False)
  22. with pytest.raises(Exception) as error:m.assert_mobile_meta_enabled(False,True)
  23. assert error.value.status_code == 503
  24. assert '启用' in error.value.detail
  25. def test_web_unrelated_edit_carries_delivery_snapshot_and_consumes_once():
  26. m=service();used=set()
  27. result=m.resolve_stop_meta([snapshot()],used,'t','plan','delivery','stop-a',incoming(),plan_product())
  28. assert result==dict(alias_name='送货别名',brand_mode='required',brand_id='delivery-brand',brand_name='送货品牌')
  29. assert m.resolve_stop_meta([snapshot()],used,'t','plan','delivery','stop-a',incoming(),plan_product()) is None
  30. @pytest.mark.parametrize('change',[dict(contract_product_id='line-b'),dict(package_spec='散装'),dict(product_id='q')])
  31. def test_replaced_line_does_not_inherit_snapshot(change):
  32. assert service().resolve_stop_meta([snapshot()],set(),'t','plan','delivery','stop-a',incoming(**change),plan_product()) is None
  33. @pytest.mark.parametrize('tenant,stop', [('other','stop-a'),('t','stop-b')])
  34. def test_cross_tenant_or_stop_cannot_carry(tenant,stop):
  35. assert service().resolve_stop_meta([snapshot()],set(),tenant,'plan','delivery',stop,incoming(),plan_product()) is None
  36. def test_web_explicit_legal_name_and_brand_changes_win():
  37. result=service().resolve_stop_meta([snapshot()],set(),'t','plan','delivery','stop-a',incoming(product_name='新名称'),plan_product(brand_mode='required',brand_id='new',brand_name='新品牌'))
  38. assert result==dict(alias_name=None,brand_mode='required',brand_id='new',brand_name='新品牌')
  39. def test_mobile_explicit_metadata_wins_and_web_ambiguous_mapping_rejects():
  40. m=service();old=[snapshot(),snapshot(id='m2',stop_product_id='old2')]
  41. meta=Row(alias_name='独立',brand_mode='any',brand_id='',brand_name='')
  42. result=m.resolve_stop_meta(old,set(),'t','plan','delivery','stop-a',incoming(mobile_product_meta=meta),plan_product())
  43. assert result['alias_name']=='独立'
  44. with pytest.raises(Exception) as error:m.resolve_stop_meta(old,set(),'t','plan','delivery','stop-a',incoming(),plan_product())
  45. assert error.value.status_code==422
  46. def test_disabled_loading_never_uses_database():
  47. class NoDatabase:
  48. def query(self,*args):raise AssertionError('old Web must not query side table')
  49. assert service().load_stop_meta(NoDatabase(),'t','plan',False)==[]
  50. @pytest.mark.parametrize("pending", [False, True])
  51. def test_actual_replace_writes_metadata_for_both_sides_without_extra_plan_products(pending):
  52. import ast
  53. from typing import Optional
  54. from datetime import datetime
  55. path=Path(__file__).parents[1]/'app/services/pl_plan_stop_service.py'
  56. tree=ast.parse(path.read_text())
  57. fn=next(n for n in tree.body if isinstance(n,ast.FunctionDef) and n.name=='replace_plan_stops')
  58. class Field:
  59. def __eq__(self,value):return self
  60. def in_(self,value):return self
  61. class Delivery:
  62. plan_id=tenant_no=Field()
  63. def __init__(self,**kw):self.__dict__.update(kw)
  64. class DB:
  65. def __init__(self):self.added=[]
  66. def add(self,row):self.added.append(row)
  67. def query(self,*args):return self
  68. def filter(self,*args):return self
  69. def all(self):return []
  70. db=DB();calls=[];old=snapshot()
  71. scope=dict(Session=object,Optional=Optional,PlanLoadBody=Row,PlanDeliveryBody=Row,
  72. datetime=datetime,settings=Row(PL_MOBILE_PLAN_PRODUCT_META_ENABLED=True),
  73. load_stop_meta=lambda *args:[old],write_stop_meta=lambda *args:calls.append(args),
  74. _soft_delete_load_products_and_stop_contracts=lambda *a:None,
  75. _resolve_or_create_plan_load=lambda *a:'load',_resolve_plan_product_id=lambda p,rows,*args:p.plan_product_id or "",
  76. generate_uuid=lambda:'new-stop-product',PlPlanLoadProduct=Row,PlPlanDeliveryProduct=Row,PlPlanDelivery=Delivery,
  77. _write_stop_contracts=lambda *a:0,PLAN_CONTRACT_SIDE_LOAD='load',PLAN_CONTRACT_SIDE_DELIVERY='delivery',
  78. normalize_stop_dibang_fields=lambda *a:(0,'none'),resolve_delivery_id_for_upsert=lambda **kw:('stop-a',None),
  79. _soft_delete_delivery_products=lambda *a:None,is_existing_pending_delivery=lambda *a:False)
  80. exec(compile(ast.Module(body=[fn],type_ignores=[]),str(path),'exec'),scope)
  81. prod=incoming(plan_product_id=None if pending else 'pp',unlinked_draft=pending,product_no='no',delivery_method='',plan_qty=3,remark='')
  82. load=Row(products=[] if pending else [prod],contracts=[])
  83. delivery=Row(id=None,seq=1,customer_id='c',customer_name='C',address='',lng=None,lat=None,delivery_time=None,contact='',notes='',generate_dibang=0,dibang_direction='none',remark='',contracts=[],products=[prod])
  84. scope['replace_plan_stops'](db,'t','plan',load,[delivery],'u','d',[] if pending else [Row(id='pp')],allow_unlinked_draft=pending)
  85. assert len(calls)==(1 if pending else 2)
  86. assert [args[5] for args in calls]==(['delivery'] if pending else ['load','delivery'])
  87. if pending:
  88. assert calls[0][9] is None
  89. assert next(row for row in db.added if hasattr(row,'delivery_id')).plan_product_id == ''
  90. assert old.del_flag==1
  91. assert sum(row.plan_qty for row in db.added if hasattr(row,'load_id'))==(0 if pending else 3)
  92. assert sum(row.plan_qty for row in db.added if hasattr(row,'delivery_id'))==3
  93. @pytest.mark.parametrize('route_name',['create_plan_full','update_plan'])
  94. def test_actual_mobile_draft_route_branch_preserves_partial_stops_and_does_not_swallow_error(route_name):
  95. import ast
  96. from fastapi import HTTPException
  97. path=Path(__file__).parents[1]/'app/api/pl/plan.py'
  98. fn=next(n for n in ast.parse(path.read_text()).body if isinstance(n,ast.FunctionDef) and n.name==route_name)
  99. branch=next(n for n in fn.body if isinstance(n,ast.If) and ast.unparse(n.test)=='is_draft' and 'validate_plan_stops_write' in ast.unparse(n))
  100. load=Row(products=[incoming()],contracts=[])
  101. def strict(*args,**kwargs):raise HTTPException(422,'missing required fields')
  102. def run(light):
  103. state=dict(PlanContractLink=Row,is_draft=True,multi_stop=True,payload=Row(mobile_product_meta=True,belongs_company='x',create_date='date',load=load,deliveries=[],products=[]),db=object(),tenant_no='t',plan=Row(),id='p',validate_draft_min_fields=lambda **kw:None,validate_plan_stops_write=strict,validate_mobile_draft_stops=light,collect_stop_qty_warnings=lambda *a:[],sync_plan_legacy_from_stops=lambda *a:None,HTTPException=HTTPException,load_plan_contracts_grouped=lambda *a:{})
  104. exec(compile(ast.Module(body=[branch],type_ignores=[]),str(path),'exec'),state)
  105. return state
  106. result=run(lambda *args:(load,[]))
  107. assert result['load_body'] is load
  108. with pytest.raises(HTTPException):run(strict)
  109. def test_mobile_draft_accepts_missing_fields_zero_and_validates_present_sources():
  110. import ast
  111. from typing import Optional
  112. from decimal import Decimal
  113. from fastapi import HTTPException
  114. path=Path(__file__).parents[1]/'app/services/pl_plan_stop_service.py'
  115. fn=next((n for n in ast.parse(path.read_text()).body if isinstance(n,ast.FunctionDef) and n.name=='validate_mobile_draft_stops'),None)
  116. assert fn is not None
  117. calls=[]
  118. scope=dict(Session=object,Optional=Optional,PlanLoadBody=Row,PlanDeliveryBody=Row,Decimal=Decimal,HTTPException=HTTPException,
  119. validate_mobile_stop_sources=lambda *args:calls.append(args))
  120. exec(compile(ast.Module(body=[fn],type_ignores=[]),str(path),'exec'),scope)
  121. load=Row(products=[Row(plan_qty=0)],customer_id='',customer_name='',contracts=[])
  122. assert scope['validate_mobile_draft_stops'](object(),'t',load,[],[Row(quantity=0)])==(load,[])
  123. assert len(calls)==1
  124. for qty in [-1,Decimal('NaN'),Decimal('Infinity')]:
  125. load.products[0].plan_qty=qty
  126. with pytest.raises(HTTPException):scope['validate_mobile_draft_stops'](object(),'t',load,[],[])
  127. def test_opt_in_requires_complete_keys_and_nonnegative_finite_quantities():
  128. m=service()
  129. payload=Row(mobile_product_meta=True,products=[Row(client_key='a',quantity=0)],load=Row(products=[Row(plan_product_client_key='a',plan_qty=0,mobile_product_meta=Row())]),deliveries=[])
  130. m.validate_mobile_meta_request(payload,True)
  131. payload.load.products[0].plan_product_client_key=''
  132. with pytest.raises(Exception) as error:m.validate_mobile_meta_request(payload,True)
  133. assert error.value.status_code==422
  134. payload.load.products[0].plan_product_client_key='a'
  135. for qty in [-1,float('nan'),float('inf')]:
  136. payload.load.products[0].plan_qty=qty
  137. with pytest.raises(Exception) as error:m.validate_mobile_meta_request(payload,True)
  138. assert error.value.status_code==422
  139. @pytest.mark.parametrize('alias', [None, '', '送货别名'])
  140. def test_actual_snapshot_writer_preserves_same_packaging_contract_lines_across_web_rebuild(monkeypatch, alias):
  141. import sys
  142. import types
  143. from datetime import datetime
  144. model=types.ModuleType('app.models.pl_mobile_stop_meta');model.PlPlanStopProductMeta=Row
  145. utils=types.ModuleType('app.core.utils');sequence=iter(['new-meta-2','new-meta-1']);utils.generate_uuid=lambda:next(sequence)
  146. monkeypatch.setitem(sys.modules,'app.models.pl_mobile_stop_meta',model)
  147. monkeypatch.setitem(sys.modules,'app.core.utils',utils)
  148. added=[];db=Row(add=added.append);m=service();used=set()
  149. old=[snapshot(id='m1',contract_product_id='line1',brand_id='b1',alias_name=alias),snapshot(id='m2',contract_product_id='line2',brand_id='b2',alias_name=alias)]
  150. for line in ['line2','line1']:
  151. m.write_stop_meta(db,old,used,'t','plan','delivery','stop-a','new-'+line,incoming(contract_product_id=line),plan_product(),'u','d',datetime.now())
  152. assert [(row.stop_product_id,row.brand_id) for row in added]==[('new-line2','b2'),('new-line1','b1')]
  153. assert all(row.tenant_no=='t' and row.plan_id=='plan' for row in added)
  154. assert used=={'m1','m2'}
  155. assert all(row.alias_name == alias for row in added)
  156. def test_sql_and_model_match_and_only_new_side_table_declares_metadata_fields():
  157. import ast,re
  158. root=Path(__file__).parents[2]
  159. path=root/'backend/app/models/pl_mobile_stop_meta.py'
  160. cls=next(n for n in ast.parse(path.read_text()).body if isinstance(n,ast.ClassDef))
  161. columns=[n.targets[0].id for n in cls.body if isinstance(n,ast.Assign) and isinstance(n.value,ast.Call) and isinstance(n.value.func,ast.Name) and n.value.func.id=='Column']
  162. sql=(root/'docs/sql/20261023_mobile_plan_stop_product_meta.sql').read_text()
  163. sql_columns=re.findall(r'^ (\w+) (?:VARCHAR|INT|DATETIME|SMALLINT)\b',sql,re.M)
  164. assert columns==sql_columns
  165. assert columns[-9:]==['tenant_no','order_num','remark','create_by','create_dept_id','create_time','update_by','update_time','del_flag']
  166. assert 'ALTER TABLE' not in sql
  167. alias_column = next(n.value for n in cls.body if isinstance(n,ast.Assign) and n.targets[0].id == 'alias_name')
  168. assert {kw.arg: ast.literal_eval(kw.value) for kw in alias_column.keywords} == {'nullable': True}
  169. assert "alias_name VARCHAR(200) DEFAULT NULL" in sql
  170. @pytest.mark.parametrize('enabled',[False,True])
  171. def test_actual_capability_route_requires_flag_and_readable_table(enabled):
  172. import ast
  173. from fastapi import HTTPException
  174. path=Path(__file__).parents[1]/'app/api/pl/plan.py'
  175. node=next(n for n in ast.parse(path.read_text()).body if isinstance(n,ast.FunctionDef) and n.name=='get_mobile_product_capability')
  176. node.decorator_list=[];node.returns=None
  177. for arg in node.args.args:arg.annotation=None
  178. node.args.defaults=[ast.Constant(None) for _ in node.args.defaults];ast.fix_missing_locations(node)
  179. reads=[];m=service()
  180. scope=dict(get_tenant_no=lambda *a:'tenant',settings=Row(PL_MOBILE_PLAN_PRODUCT_META_ENABLED=enabled),assert_mobile_meta_enabled=m.assert_mobile_meta_enabled,load_stop_meta=lambda *a:reads.append(a))
  181. exec(compile(ast.Module(body=[node],type_ignores=[]),str(path),'exec'),scope)
  182. if enabled:
  183. assert scope[node.name](object(),Row(to_tenant_user_dict=lambda:{}))=={'mobileProductMetaVersion':1}
  184. assert reads[0][1:]==('tenant','',True)
  185. else:
  186. with pytest.raises(HTTPException) as error:scope[node.name](object(),Row(to_tenant_user_dict=lambda:{}))
  187. assert error.value.status_code==503
  188. assert reads==[]
  189. def test_actual_arrange_route_rejects_pending_stop_before_vehicle_checks_or_writes():
  190. import ast
  191. from fastapi import HTTPException
  192. path=Path(__file__).parents[1]/'app/api/pl/transport.py'
  193. node=next(n for n in ast.parse(path.read_text()).body if isinstance(n,ast.FunctionDef) and n.name=='arrange_transport')
  194. node.decorator_list=[];node.returns=None
  195. for arg in node.args.args:arg.annotation=None
  196. node.args.defaults=[ast.Constant(None) for _ in node.args.defaults];ast.fix_missing_locations(node)
  197. class Field:
  198. def __eq__(self,value):return self
  199. model=Row(id=Field(),tenant_no=Field(),del_flag=Field())
  200. class DB:
  201. def query(self,*a):return self
  202. def filter(self,*a):return self
  203. def first(self):return Row(transport_id=None)
  204. def add(self,*a):raise AssertionError('no write')
  205. def commit(self):raise AssertionError('no commit')
  206. def reject(*args):raise HTTPException(422,'请先补齐待关联产品')
  207. def vehicle(*args,**kwargs):raise AssertionError('must reject before vehicle arrangement')
  208. scope=dict(PlPlan=model,get_tenant_no=lambda *a:'tenant',assert_pl_row_in_scope=lambda *a:None,assert_no_pending_stop_products=reject,_is_rearrange=lambda *a:False,_assert_vehicle_carrier_eligible=vehicle,HTTPException=HTTPException)
  209. exec(compile(ast.Module(body=[node],type_ignores=[]),str(path),'exec'),scope)
  210. with pytest.raises(HTTPException) as error:scope[node.name]('p',Row(vehicle_id='v'),DB(),Row(to_tenant_user_dict=lambda:{}))
  211. assert error.value.status_code==422
  212. @pytest.mark.parametrize('alias', [None, ''])
  213. def test_web_unrelated_save_preserves_pending_alias_versus_explicit_mobile_clear(alias):
  214. result=service().resolve_stop_meta([snapshot(alias_name=alias)],set(),'t','plan','delivery','stop-a',incoming(),plan_product())
  215. assert result['alias_name'] is alias
  216. def test_mobile_explicit_clear_is_not_pending_web_name_inference():
  217. meta=Row(alias_name='',brand_mode='any',brand_id='',brand_name='')
  218. result=service().resolve_stop_meta([snapshot()],set(),'t','plan','delivery','stop-a',incoming(product_name='新名称',mobile_product_meta=meta),plan_product())
  219. assert result['alias_name']==''
  220. @pytest.mark.parametrize('route_name', ['create_plan_full', 'update_plan'])
  221. @pytest.mark.parametrize('delivery_shape', ['empty', 'omitted', 'present'])
  222. @pytest.mark.parametrize('with_products', [False, True])
  223. def test_mobile_draft_route_uses_real_legacy_sync_with_optional_deliveries(route_name, delivery_shape, with_products):
  224. import ast
  225. from decimal import Decimal
  226. from typing import Optional
  227. from fastapi import HTTPException
  228. root=Path(__file__).parents[1]
  229. service_path=root/'app/services/pl_plan_stop_service.py'
  230. service_tree=ast.parse(service_path.read_text())
  231. functions=[n for n in service_tree.body if isinstance(n,ast.FunctionDef) and n.name in ('sync_plan_legacy_from_stops','validate_mobile_draft_stops')]
  232. source_checks=[]
  233. scope=dict(PlPlan=Row,PlanLoadBody=Row,PlanDeliveryBody=Row,Session=object,Optional=Optional,
  234. Decimal=Decimal,HTTPException=HTTPException,validate_mobile_stop_sources=lambda *a:source_checks.append(a))
  235. exec(compile(ast.Module(body=functions,type_ignores=[]),str(service_path),'exec'),scope)
  236. load_products=[Row(product_id='p',plan_qty=0)] if with_products else []
  237. plan_products=[Row(product_id='p',quantity=0)] if with_products else []
  238. load=Row(products=load_products,contracts=[],customer_id='supplier',customer_name='装货客户',address='装货地址',lng=1,lat=2,load_time='load-time',contact='装货联系',notes='装货备注')
  239. delivery=Row(products=[],contracts=[],customer_id='customer',customer_name='送货客户',address='送货地址',lng=3,lat=4,delivery_time='delivery-time',contact='送货联系',notes='送货备注')
  240. # Omitted Optional deliveries deserialize to None in the actual request schema.
  241. deliveries={'empty':[], 'omitted':None, 'present':[delivery]}[delivery_shape]
  242. customer_fields=('customer_id','customer_name','destination_id','destination_name','destination_address','destination_lng','destination_lat','delivery_time','delivery_contact','destination_notes')
  243. plan=Row(**{field:'stale-customer-value' for field in customer_fields},products=plan_products)
  244. payload=Row(mobile_product_meta=True,save_mode='draft',belongs_company='x',create_date=None,load=load,deliveries=deliveries,products=plan_products)
  245. path=root/'app/api/pl/plan.py'
  246. route=next(n for n in ast.parse(path.read_text()).body if isinstance(n,ast.FunctionDef) and n.name==route_name)
  247. branch=next(n for n in route.body if isinstance(n,ast.If) and ast.unparse(n.test)=='is_draft' and 'validate_plan_stops_write' in ast.unparse(n))
  248. statements=[branch]
  249. if route_name=='create_plan_full':
  250. # Creation syncs after constructing the plan, outside the draft validation branch.
  251. statements.append(next(n for n in route.body if isinstance(n,ast.If) and ast.unparse(n.test)=='multi_stop and load_body is not None' and 'sync_plan_legacy_from_stops' in ast.unparse(n)))
  252. def strict(*args,**kwargs):raise AssertionError('mobile incomplete draft must not use strict validation')
  253. scope.update(PlanContractLink=Row,is_draft=True,multi_stop=True,payload=payload,db=object(),tenant_no='t',plan=plan,id='plan',validate_draft_min_fields=lambda **kw:None,validate_plan_stops_write=strict,collect_stop_qty_warnings=lambda *a:[],load_plan_contracts_grouped=lambda *a:{})
  254. exec(compile(ast.Module(body=statements,type_ignores=[]),str(path),'exec'),scope)
  255. assert len(source_checks)==1
  256. assert scope['load_body'] is load
  257. assert scope['load_body'].products is load_products
  258. assert plan.products is plan_products
  259. assert plan.supplier_id=='supplier' and plan.pickup_address=='装货地址'
  260. assert plan.pickup_time=='load-time' and plan.pickup_contact=='装货联系'
  261. assert scope['delivery_bodies']==(deliveries or [])
  262. if delivery_shape=='present':
  263. assert [getattr(plan,f) for f in customer_fields]==['customer','送货客户','customer','送货客户','送货地址',3,4,'delivery-time','送货联系','送货备注']
  264. else:
  265. assert all(getattr(plan,f) is None for f in customer_fields)
  266. assert scope['delivery_bodies']==[]