test_pl_dispatch_calendar.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303
  1. """手工派车日历:独立记录、隔离、选择项及 HTTP 权限回归。"""
  2. from datetime import date, datetime, timedelta
  3. from importlib.util import find_spec
  4. import pytest
  5. from fastapi import HTTPException
  6. from pydantic import ValidationError
  7. from sqlalchemy import create_engine
  8. from sqlalchemy.orm import sessionmaker
  9. from app.core.db_auth import DbUserContext
  10. from app.models.crm_models import CrmProduct
  11. from app.models.pl_models import PlVehicle
  12. def modules():
  13. assert find_spec('app.services.pl_dispatch_calendar_service') is not None, '手工派车日历服务尚未实现'
  14. from app.models.pl_dispatch_calendar import PlDispatchCalendar
  15. from app.schemas.pl.dispatch_calendar import CreateBody, UpdateBody
  16. from app.services import pl_dispatch_calendar_service as svc
  17. return PlDispatchCalendar, CreateBody, UpdateBody, svc
  18. def user(uid='u1', tenant='t1', role='super_admin'):
  19. return DbUserContext(uid, '测试用户', 'd1', role, tenant)
  20. def payload(**kwargs):
  21. return {'dispatchDate': '2026-10-26', 'vehicleId': 'v1', 'productId': 'p1',
  22. 'origin': '宜昌', 'destination': '重庆', 'remark': None, **kwargs}
  23. @pytest.fixture
  24. def db():
  25. model, _, _, _ = modules()
  26. engine = create_engine('sqlite://')
  27. for table in (model, PlVehicle, CrmProduct):
  28. table.__table__.create(engine)
  29. with sessionmaker(bind=engine)() as session:
  30. session.add_all([
  31. PlVehicle(id='v1', license_plate='鄂E12345', vehicle_type='self', tenant_no='t1', create_by='u1'),
  32. PlVehicle(id='v2', license_plate='鄂E67890', vehicle_type='carrier', tenant_no='t1', create_by='u2'),
  33. PlVehicle(id='v3', license_plate='渝A12345', vehicle_type='self', tenant_no='t2', create_by='u1'),
  34. PlVehicle(id='v4', license_plate='鄂E00000', vehicle_type='self', tenant_no='t1', create_by='u1', del_flag=1),
  35. CrmProduct(id='p1', product_no='P001', product_name='液碱', tenant_no='t1', create_by='u1'),
  36. CrmProduct(id='p2', product_no='P002', product_name='盐酸', tenant_no='t1', create_by='u2'),
  37. CrmProduct(id='p3', product_no='P003', product_name='硫酸', tenant_no='t2', create_by='u1'),
  38. CrmProduct(id='p4', product_no='P004', product_name='氯酸钠', tenant_no='t1', create_by='u1', del_flag=1),
  39. ])
  40. session.commit()
  41. yield session
  42. engine.dispose()
  43. def create(db, caller=None, **kwargs):
  44. _, schema, _, svc = modules()
  45. return svc.create_record(db, 't1', caller or user(), schema(**payload(**kwargs)))
  46. def test_crud_duplicate_trips_snapshots_and_soft_delete(db):
  47. model, _, update, svc = modules()
  48. first = create(db)
  49. second = create(db)
  50. assert first.id != second.id
  51. assert first.tenant_no == 't1' and first.create_by == 'u1' and first.create_dept_id == 'd1'
  52. assert first.dispatch_date == date(2026, 10, 26)
  53. db.get(PlVehicle, 'v1').license_plate = '鄂E99999'
  54. db.get(CrmProduct, 'p1').product_name = '烧碱'
  55. db.commit()
  56. assert svc.get_record(db, 't1', user(), first.id).license_plate == '鄂E12345'
  57. updated = svc.update_record(db, 't1', user(), first.id, update(**payload(vehicleId='v2', productId='p2', origin='武汉')))
  58. result = svc._row_to_dict(updated)
  59. assert result['licensePlate'] == '鄂E67890' and result['productName'] == '盐酸'
  60. assert result['origin'] == '武汉'
  61. assert result['calendarKind'] == 'acid'
  62. assert set(result) == {'id', 'dispatchDate', 'vehicleId', 'licensePlate', 'productId', 'productName', 'origin', 'destination', 'quantity', 'calendarKind', 'remark', 'createTime', 'updateTime'}
  63. assert svc.delete_record(db, 't1', user(), first.id) == 1
  64. assert db.get(model, first.id).del_flag == 1
  65. assert svc.list_records(db, 't1', user(), month='2026-10')['total'] == 1
  66. with pytest.raises(HTTPException):
  67. svc.get_record(db, 't1', user(), first.id)
  68. def test_month_pagination_sort_and_vehicle_filter(db):
  69. _, _, _, svc = modules()
  70. first = create(db, dispatchDate='2026-10-01')
  71. first.create_time = datetime.now() - timedelta(days=2)
  72. second = create(db, dispatchDate='2026-10-31', vehicleId='v2')
  73. create(db, dispatchDate='2026-09-30')
  74. create(db, dispatchDate='2026-11-01')
  75. page1 = svc.list_records(db, 't1', user(), month='2026-10', page=1, page_size=1)
  76. page2 = svc.list_records(db, 't1', user(), month='2026-10', page=2, page_size=1)
  77. assert page1['total'] == 2 and page1['list'][0]['id'] == second.id
  78. assert page2['list'][0]['id'] == first.id
  79. assert svc.list_records(db, 't1', user(), month='2026-10', vehicle_id='v1')['total'] == 1
  80. @pytest.mark.parametrize('month', ['2026-13', '2026-1', 'abc', '1899-12', '2101-01'])
  81. def test_invalid_month_rejected(db, month):
  82. _, _, _, svc = modules()
  83. with pytest.raises(HTTPException) as exc:
  84. svc.list_records(db, 't1', user(), month=month)
  85. assert exc.value.status_code == 422
  86. def test_tenant_and_data_scope_cover_records_and_options(db, monkeypatch):
  87. _, _, update, svc = modules()
  88. first = create(db)
  89. other = create(db, caller=user('u2'))
  90. import app.core.data_scope as scope
  91. monkeypatch.setattr(scope, '_resolve_user_role_scopes', lambda *a: [{'data_scope': '4', 'details': []}])
  92. limited = user(role='admin')
  93. assert [r['id'] for r in svc.list_records(db, 't1', limited, month='2026-10')['list']] == [first.id]
  94. assert svc.list_records(db, 't2', user(tenant='t2'), month='2026-10')['total'] == 0
  95. for tenant, caller, record in [('t1', limited, other), ('t2', user(tenant='t2'), first)]:
  96. for action in [lambda: svc.get_record(db, tenant, caller, record.id),
  97. lambda: svc.update_record(db, tenant, caller, record.id, update(**payload())),
  98. lambda: svc.delete_record(db, tenant, caller, record.id)]:
  99. with pytest.raises(HTTPException) as exc:
  100. action()
  101. assert exc.value.status_code == 404
  102. assert svc.list_vehicles(db, 't1', limited)['list'] == [{'id': 'v1', 'licensePlate': '鄂E12345'}]
  103. assert svc.list_products(db, 't1', limited)['list'] == [{'id': 'p1', 'productName': '液碱'}]
  104. with pytest.raises(HTTPException):
  105. create(db, caller=limited, vehicleId='v2')
  106. with pytest.raises(HTTPException):
  107. create(db, caller=limited, productId='p2')
  108. monkeypatch.setattr(scope, '_resolve_user_role_scopes', lambda *a: [{'data_scope': '4', 'details': []}, {'data_scope': '1', 'details': []}])
  109. assert svc.list_records(db, 't1', limited, month='2026-10')['total'] == 2
  110. assert svc.list_vehicles(db, 't1', limited)['total'] == 2
  111. def test_options_full_library_types_search_pagination(db):
  112. _, _, _, svc = modules()
  113. assert svc.list_vehicles(db, 't1', user())['total'] == 2
  114. assert svc.list_vehicles(db, 't1', user(), q='67890')['list'] == [{'id': 'v2', 'licensePlate': '鄂E67890'}]
  115. assert len(svc.list_vehicles(db, 't1', user(), page_size=1)['list']) == 1
  116. assert svc.list_products(db, 't1', user(), q='盐酸')['list'] == [{'id': 'p2', 'productName': '盐酸'}]
  117. assert svc.list_products(db, 't1', user(), q='%')['total'] == 0
  118. @pytest.mark.parametrize('field,value', [('vehicleId', 'v3'), ('vehicleId', 'v4'), ('vehicleId', 'missing'), ('productId', 'p3'), ('productId', 'p4'), ('productId', 'missing')])
  119. def test_invalid_cross_tenant_and_deleted_relations_rejected(db, field, value):
  120. _, _, _, svc = modules()
  121. with pytest.raises(HTTPException) as exc:
  122. create(db, **{field: value})
  123. assert exc.value.status_code == 422
  124. assert svc.list_records(db, 't1', user(), month='2026-10')['total'] == 0
  125. @pytest.mark.parametrize('bad', [
  126. {'dispatchDate': '2026-02-30'}, {'dispatchDate': '1899-12-31'}, {'dispatchDate': '2101-01-01'},
  127. {'dispatchDate': 1792972800}, {'dispatchDate': '2026-10-26T00:00:00'}, {'origin': ' '}, {'destination': 'x' * 256},
  128. {'vehicleId': ''}, {'productId': ''}, {'remark': 'x' * 501}, {'calendarKind': 'salt'}, {'tenantNo': 't2'},
  129. {'licensePlate': '伪造'}, {'productName': '伪造'}, {'createBy': 'other'},
  130. ])
  131. def test_schema_rejects_bad_fields_and_client_snapshots(bad):
  132. _, schema, _, _ = modules()
  133. with pytest.raises(ValidationError):
  134. schema(**payload(**bad))
  135. def test_http_read_only_manage_permissions_both_clients_and_routes(db, monkeypatch):
  136. from fastapi import FastAPI
  137. from fastapi.testclient import TestClient
  138. from app.core.auth import get_current_user
  139. from app.core.database import get_db
  140. from app.api.pl import dispatch_calendar as calendar_api
  141. from app.api.pl.dispatch_calendar import router
  142. from sqlalchemy.pool import StaticPool
  143. model, _, _, _ = modules()
  144. engine = create_engine('sqlite://', connect_args={'check_same_thread': False}, poolclass=StaticPool)
  145. for table in (model, PlVehicle, CrmProduct):
  146. table.__table__.create(engine)
  147. factory = sessionmaker(bind=engine)
  148. with factory() as session:
  149. session.add(PlVehicle(id='v1', license_plate='鄂E12345', vehicle_type='self', tenant_no='t1'))
  150. session.add(CrmProduct(id='p1', product_no='P1', product_name='液碱', tenant_no='t1'))
  151. session.commit()
  152. def database():
  153. with factory() as session:
  154. yield session
  155. import app.core.data_scope as scope
  156. monkeypatch.setattr(scope, '_resolve_user_role_scopes', lambda *a: [{'data_scope': '1', 'details': []}])
  157. app = FastAPI()
  158. app.include_router(router, prefix='/api')
  159. app.dependency_overrides[get_db] = database
  160. path = '/api/transport/dispatch-calendar'
  161. codes = set()
  162. monkeypatch.setattr(calendar_api, 'iter_user_menu_perm_codes', lambda *a: codes)
  163. with TestClient(app) as client:
  164. assert client.get(path, params={'month': '2026-10'}).status_code == 401
  165. app.dependency_overrides[get_current_user] = lambda: {'role': 'admin', 'user_id': 'u1', 'tenant_no': 't1'}
  166. assert client.get(path, params={'month': '2026-10'}).status_code == 403
  167. for prefix in ('pl', 'mini:pl'):
  168. codes.clear()
  169. codes.add(prefix + ':dispatch-calendar:list')
  170. assert client.get(path, params={'month': '2026-10'}).status_code == 200
  171. assert client.get(path + '/vehicles').json()['total'] == 1
  172. assert client.get(path + '/products').json()['total'] == 1
  173. assert client.post(path, json=payload()).status_code == 403
  174. codes.add(prefix + ':dispatch-calendar:manage')
  175. response = client.post(path, json=payload())
  176. assert response.status_code == 200, response.text
  177. record_id = response.json()['id']
  178. assert client.get(f'{path}/{record_id}').json()['dispatchDate'] == '2026-10-26'
  179. assert client.put(f'{path}/{record_id}', json=payload(origin='武汉')).json()['origin'] == '武汉'
  180. codes.remove(prefix + ':dispatch-calendar:manage')
  181. assert client.put(f'{path}/{record_id}', json=payload()).status_code == 403
  182. assert client.delete(f'{path}/{record_id}').status_code == 403
  183. codes.add(prefix + ':dispatch-calendar:manage')
  184. assert client.delete(f'{path}/{record_id}').json() == {'deleted': 1}
  185. assert client.get(f'{path}/{record_id}').status_code == 404
  186. assert client.post(path, json=payload(tenantNo='t2')).status_code == 422
  187. assert client.get(path, params={'month': '2026-13'}).status_code == 422
  188. alkali = client.post(path, json=payload(calendarKind='alkali'))
  189. assert alkali.status_code == 200 and alkali.json()['calendarKind'] == 'alkali'
  190. assert client.get(path, params={'month': '2026-10'}).json()['total'] == 0
  191. assert client.get(path, params={'month': '2026-10', 'calendarKind': 'alkali'}).json()['total'] == 1
  192. assert client.get(path, params={'month': '2026-10', 'calendarKind': 'salt'}).status_code == 422
  193. assert client.put(f"{path}/{alkali.json()['id']}", json=payload(calendarKind='acid')).status_code == 422
  194. engine.dispose()
  195. def test_permissions_use_current_tenant_and_button_grants(db):
  196. from app.api.pl.dispatch_calendar import require_manage
  197. from app.models.models import SysRole, SysUserRole, SysMenu, SysRoleMenu
  198. for model in (SysRole, SysUserRole, SysMenu, SysRoleMenu):
  199. model.__table__.create(db.get_bind())
  200. db.add_all([
  201. SysRole(id='role1', role_name='调度员', tenant_no='t1'),
  202. SysUserRole(id='ur1', user_id='u1', role_id='role1', tenant_no='t1'),
  203. SysMenu(id='calendar', menu_name='派车日历', menu_type='C', parent_id='0',
  204. perms='pl:dispatch-calendar:list', tenant_no='t1'),
  205. SysMenu(id='manage', menu_name='维护派车日历', menu_type='F', parent_id='calendar',
  206. perms='pl:dispatch-calendar:manage', visible='1', tenant_no='t1'),
  207. SysRoleMenu(id='rm1', role_id='role1', menu_id='manage', tenant_no='t1'),
  208. ])
  209. db.commit()
  210. caller = {'role': 'admin', 'user_id': 'u1', 'tenant_no': 't1'}
  211. assert require_manage(caller, db) == caller
  212. with pytest.raises(HTTPException) as exc:
  213. require_manage({**caller, 'tenant_no': 't2'}, db)
  214. assert exc.value.status_code == 403
  215. def test_quantity_is_optional_text_and_keeps_two_decimal_places(db):
  216. _, schema, update, svc = modules()
  217. blank = create(db)
  218. assert blank.quantity is None
  219. assert svc._row_to_dict(blank)['quantity'] is None
  220. kept = svc.update_record(db, 't1', user(), blank.id, update(**payload(quantity='32.50')))
  221. assert kept.quantity == '32.50'
  222. assert svc._row_to_dict(kept)['quantity'] == '32.50'
  223. cleared = svc.update_record(db, 't1', user(), blank.id, update(**payload(quantity=' ')))
  224. assert cleared.quantity is None
  225. for bad in ('12.345', '-1', '1吨', '1.', 'abc', '1e2', 32.5):
  226. with pytest.raises(ValidationError):
  227. schema(**payload(quantity=bad))
  228. def test_acid_and_alkali_calendars_stay_separate(db):
  229. _, update, _, svc = modules()
  230. acid = create(db)
  231. alkali = create(db, calendarKind='alkali', origin='碱库')
  232. assert svc.list_records(db, 't1', user(), month='2026-10')['list'][0]['id'] == acid.id
  233. alkali_page = svc.list_records(db, 't1', user(), month='2026-10', calendar_kind='alkali')
  234. assert alkali_page['total'] == 1 and alkali_page['list'][0]['origin'] == '碱库'
  235. assert alkali_page['list'][0]['calendarKind'] == 'alkali'
  236. with pytest.raises(HTTPException) as exc:
  237. svc.list_records(db, 't1', user(), month='2026-10', calendar_kind='salt')
  238. assert exc.value.status_code == 422
  239. with pytest.raises(HTTPException) as moved:
  240. svc.update_record(db, 't1', user(), alkali.id, update(**payload(calendarKind='acid')))
  241. assert moved.value.status_code == 422
  242. assert svc.get_record(db, 't1', user(), alkali.id).calendar_kind == 'alkali'
  243. kept = svc.update_record(db, 't1', user(), alkali.id, update(**payload(calendarKind='alkali', destination='南京')))
  244. assert kept.destination == '南京' and kept.calendar_kind == 'alkali'
  245. def test_month_pagination_can_fetch_more_than_500_trips(db):
  246. model, _, _, svc = modules()
  247. db.add_all([model(id=f'bulk-{number:04}', dispatch_date=date(2026, 10, 26),
  248. vehicle_id='v1', license_plate='鄂E12345', product_id='p1', product_name='液碱',
  249. origin='宜昌', destination='重庆', tenant_no='t1', create_by='u1')
  250. for number in range(503)])
  251. db.commit()
  252. first = svc.list_records(db, 't1', user(), month='2026-10', page=1, page_size=500)
  253. second = svc.list_records(db, 't1', user(), month='2026-10', page=2, page_size=500)
  254. assert first['total'] == second['total'] == 503
  255. assert len(first['list']) == 500 and len(second['list']) == 3
  256. assert len({row['id'] for row in first['list'] + second['list']}) == 503
  257. def test_calendar_openapi_schema_can_be_generated():
  258. from fastapi import FastAPI
  259. from app.api.pl.dispatch_calendar import router
  260. app = FastAPI()
  261. app.include_router(router, prefix='/api')
  262. document = app.openapi()
  263. for schema in ('CreateBody', 'UpdateBody'):
  264. field = document['components']['schemas'][schema]['properties']['dispatchDate']
  265. assert field['type'] == 'string' and field['format'] == 'date'