test_bx_child_differential_save.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324
  1. """Child identity and in-flight workflow guards, using isolated SQLite only."""
  2. from datetime import date, datetime
  3. from decimal import Decimal
  4. import pytest
  5. from fastapi import HTTPException
  6. from sqlalchemy import create_engine
  7. from sqlalchemy.orm import sessionmaker
  8. from app.core.bx_auth import BxUserContext
  9. from app.models.bx_models import (
  10. BxAdvanceApply, BxAttachment, BxInvoice, BxReimburseApply,
  11. BxReimburseDetail, BxReimburseInvoice, BxWorkflowDef, BxWorkflowNode,
  12. )
  13. from app.models.pl_models import PlVehicleRechargeApply
  14. from app.schemas.bx.reimburse import ReimburseDetailCreate, ReimburseInvoiceLinkIn
  15. from app.schemas.bx.workflow import WorkflowNodeCreate
  16. from app.services.bx.reimburse import ReimburseService
  17. from app.services.bx.workflow import WorkflowService
  18. USER = BxUserContext('u', 'user', 'dept', 'super_admin', 'tenant')
  19. OLD = datetime(2020, 1, 1)
  20. @pytest.fixture(autouse=True)
  21. def forbid_business_database(monkeypatch):
  22. def forbidden(*args, **kwargs):
  23. raise AssertionError('business database connections are forbidden')
  24. monkeypatch.setattr('pymysql.connect', forbidden)
  25. @pytest.fixture
  26. def db():
  27. engine = create_engine('sqlite://')
  28. for model in (BxWorkflowDef, BxWorkflowNode, BxReimburseApply, BxAdvanceApply,
  29. BxReimburseDetail, BxInvoice, BxAttachment, BxReimburseInvoice,
  30. PlVehicleRechargeApply):
  31. model.__table__.create(engine)
  32. with sessionmaker(bind=engine)() as session:
  33. yield session
  34. engine.dispose()
  35. def apply(db, id='a', tenant='tenant', status='draft'):
  36. row = BxReimburseApply(id=id, tenant_no=tenant, apply_code=id, title='custom',
  37. applicant_id='u', applicant_name='user', expense_id='e', expense_name='Expense', amount=30, status=status,
  38. reimburse_date=date(2026, 1, 1), wf_id='wf', create_by='u')
  39. db.add(row)
  40. db.flush()
  41. return row
  42. def detail(db, id, parent='a', tenant='tenant', amount=10):
  43. row = BxReimburseDetail(id=id, reimburse_id=parent, expense_id='e',
  44. expense_name='Expense', amount=amount, remark='reason', occur_date=date(2026, 1, 1),
  45. tenant_no=tenant, create_by='original', create_time=OLD, update_time=OLD)
  46. db.add(row)
  47. db.flush()
  48. return row
  49. def data(id=None, amount=10):
  50. return dict(id=id, expense_id='e', expense_name='Expense', amount=amount,
  51. remark='reason', occur_date=date(2026, 1, 1))
  52. def test_detail_diff_preserves_identity_metadata_and_fields(db):
  53. a = apply(db)
  54. old = detail(db, 'd1')
  55. removed = detail(db, 'd2', amount=20)
  56. new_line = {**data(amount=7), 'expense_id': 'new-expense'}
  57. ReimburseService.update_with_details(db, a.id, {}, [data('d1', 11), new_line], user=USER)
  58. live = db.query(BxReimburseDetail).filter_by(del_flag=0).all()
  59. assert old in live and old.id == 'd1' and old.create_time == OLD and old.create_by == 'original'
  60. assert old.amount == 11 and old.occur_date == date(2026, 1, 1) and old.remark == 'reason'
  61. assert removed.del_flag == 1 and len(live) == 2 and a.amount == Decimal('18')
  62. def test_detail_omitted_and_empty_have_distinct_meanings(db):
  63. a = apply(db)
  64. row = detail(db, 'd1')
  65. ReimburseService.update_with_details(db, a.id, {}, None, user=USER)
  66. assert row.del_flag == 0
  67. ReimburseService.update_with_details(db, a.id, {}, [], user=USER)
  68. db.refresh(row)
  69. assert row.del_flag == 1 and a.amount == 0
  70. @pytest.mark.parametrize('bad', ['foreign_parent', 'foreign_tenant', 'missing', 'duplicate', 'deleted'])
  71. def test_detail_bad_id_rejected_before_any_mutation(db, bad):
  72. a = apply(db)
  73. row = detail(db, 'd1')
  74. detail(db, 'foreign_parent', parent='other')
  75. detail(db, 'foreign_tenant', tenant='other')
  76. detail(db, 'deleted').del_flag = 1
  77. db.flush()
  78. payload = [data('d1'), data('d1' if bad == 'duplicate' else bad)]
  79. with pytest.raises(HTTPException):
  80. ReimburseService.update_with_details(db, a.id, {'remark': 'must not change'}, payload, user=USER)
  81. assert row.del_flag == 0 and a.remark is None
  82. assert db.query(BxReimburseDetail).count() == 4
  83. def test_legacy_detail_same_payload_preserves_identity_and_creation(db):
  84. a = apply(db)
  85. row = detail(db, 'd1')
  86. for _ in range(2):
  87. ReimburseService.update_with_details(db, a.id, {}, [data()], user=USER)
  88. assert row.del_flag == 0 and row.create_by == 'original' and row.create_time == OLD
  89. assert db.query(BxReimburseDetail).count() == 1
  90. @pytest.mark.parametrize('old_count,incoming_count', [(2, 1), (2, 2), (1, 2)])
  91. def test_legacy_repeated_expense_is_rejected_before_any_write(db, old_count, incoming_count):
  92. a = apply(db)
  93. for index in range(old_count):
  94. detail(db, f'd{index}', amount=10 + index)
  95. payload = [data(amount=10 + index) for index in range(incoming_count)]
  96. with pytest.raises(HTTPException) as error:
  97. ReimburseService.update_with_details(db, a.id, {'remark': 'must not change'}, payload, user=USER)
  98. assert error.value.status_code == 400
  99. assert a.remark is None and not db.dirty and not db.new
  100. @pytest.mark.parametrize('anonymous_first', [True, False])
  101. def test_mixed_explicit_and_legacy_ids_match_remaining_unique_expense(db, anonymous_first):
  102. a = apply(db)
  103. first, second = detail(db, 'd1'), detail(db, 'd2', amount=20)
  104. payload = [data('d1', 11), data(amount=21)]
  105. if anonymous_first:
  106. payload.reverse()
  107. ReimburseService.update_with_details(db, a.id, {}, payload, user=USER)
  108. assert first.amount == 11 and second.amount == 21
  109. assert first.del_flag == second.del_flag == 0
  110. assert db.query(BxReimburseDetail).count() == 2
  111. def test_explicit_existing_lines_allow_new_repeated_expense(db):
  112. a = apply(db)
  113. old = detail(db, 'd1')
  114. ReimburseService.update_with_details(db, a.id, {}, [data('d1'), data(amount=7)], user=USER)
  115. assert old.del_flag == 0
  116. assert db.query(BxReimburseDetail).filter_by(del_flag=0).count() == 2
  117. @pytest.mark.parametrize('explicit_null', [False, True])
  118. def test_detail_api_preserves_omitted_fields_but_applies_explicit_null(db, monkeypatch, explicit_null):
  119. from types import SimpleNamespace
  120. from app.api.bx.reimburse import _build_details_data
  121. a = apply(db)
  122. old = detail(db, 'd1')
  123. monkeypatch.setattr('app.api.bx.reimburse.ExpenseItemService.get_by_id',
  124. lambda *args, **kwargs: SimpleNamespace(item_name='Expense'))
  125. fields = {'remark': None, 'occurDate': None} if explicit_null else {}
  126. body = ReimburseDetailCreate(id='d1', expenseItemId='e', amount=11, **fields)
  127. payload = _build_details_data(db, 'tenant', [body], user=USER)
  128. ReimburseService.update_with_details(db, a.id, {}, payload, user=USER)
  129. assert old.amount == 11 and old.create_time == OLD
  130. assert old.remark == (None if explicit_null else 'reason')
  131. assert old.occur_date == (None if explicit_null else date(2026, 1, 1))
  132. def test_legacy_direct_patch_preserves_omitted_fields(db):
  133. a = apply(db)
  134. old = detail(db, 'd1')
  135. payload = dict(expense_id='e', expense_name='Expense', amount=11)
  136. ReimburseService.update_with_details(db, a.id, {}, [payload], user=USER)
  137. assert old.del_flag == 0 and old.amount == 11
  138. assert old.remark == 'reason' and old.occur_date == date(2026, 1, 1)
  139. def test_detail_schema_retains_id():
  140. assert ReimburseDetailCreate(id='d1', expenseItemId='e', amount=10).model_dump()['id'] == 'd1'
  141. def test_invoice_natural_key_diff_preserves_unchanged_links(db):
  142. apply(db)
  143. for id in ('i1', 'i2', 'i3'):
  144. db.add(BxInvoice(id=id, tenant_no='tenant', owner_user_id='u', owner_user_name='user', amount=10))
  145. old = BxReimburseInvoice(id='link1', reimburse_id='a', invoice_id='i1', tenant_no='tenant', create_time=OLD, create_by='original')
  146. removed = BxReimburseInvoice(id='link2', reimburse_id='a', invoice_id='i2', tenant_no='tenant')
  147. db.add_all([old, removed]); db.flush()
  148. ReimburseService.sync_reimburse_invoices(db, 'a', ['i1', 'i3'], 'tenant', USER, applicant_id='u')
  149. db.refresh(old); db.refresh(removed)
  150. assert old.del_flag == 0 and old.create_time == OLD and old.create_by == 'original'
  151. assert removed.del_flag == 1
  152. assert db.query(BxReimburseInvoice).count() == 3
  153. ReimburseService.sync_reimburse_invoices(db, 'a', ['i1', 'i3'], 'tenant', USER, applicant_id='u')
  154. assert db.query(BxReimburseInvoice).count() == 3
  155. def workflow(db):
  156. db.add(BxWorkflowDef(id='wf', biz_type='reimburse', wf_name='Workflow', tenant_no='tenant'))
  157. row = BxWorkflowNode(id='n1', wf_id='wf', tenant_no='tenant', node_name='Review',
  158. node_order=1, approver_id='u', approver_name='User', action_type='approve',
  159. create_by='original', create_time=OLD, update_time=OLD)
  160. db.add(row); db.flush()
  161. return row
  162. def node(id='n1', name='Review'):
  163. return dict(id=id, node_name=name, node_order=1, approver_id='u', approver_name='User', action_type='approve')
  164. @pytest.mark.parametrize('with_id', [True, False])
  165. def test_workflow_noop_preserves_node_even_with_pending(db, with_id):
  166. row = workflow(db); apply(db, status='pending')
  167. payload = node()
  168. if not with_id: payload.pop('id')
  169. saved = WorkflowService.save_nodes(db, 'wf', [payload], user=USER)
  170. assert saved[0].id == row.id == 'n1' and saved[0].create_time == OLD
  171. assert db.query(BxWorkflowNode).count() == 1
  172. @pytest.mark.parametrize('kind', ['reimburse', 'advance'])
  173. def test_workflow_change_rejected_for_pending_business(db, kind):
  174. row = workflow(db)
  175. if kind == 'reimburse': apply(db, status='pending')
  176. else:
  177. db.add(BxAdvanceApply(id='advance', apply_code='advance', applicant_id='u', applicant_name='user',
  178. amount=1, reason='test', status='pending', wf_id='wf', tenant_no='tenant'))
  179. db.flush()
  180. with pytest.raises(ValueError, match='审批'):
  181. WorkflowService.save_nodes(db, 'wf', [node(name='Changed')], user=USER)
  182. assert row.node_name == 'Review' and row.del_flag == 0
  183. def test_workflow_diff_updates_existing_soft_deletes_removed(db):
  184. row = workflow(db)
  185. WorkflowService.save_nodes(db, 'wf', [node(name='Changed')], user=USER)
  186. assert row.node_name == 'Changed' and row.create_time == OLD
  187. WorkflowService.save_nodes(db, 'wf', [], user=USER)
  188. assert row.del_flag == 1 and db.query(BxWorkflowNode).count() == 1
  189. @pytest.mark.parametrize('payload', [[node('missing')], [node(), node()], [node(), node(None)]])
  190. def test_workflow_rejects_unknown_duplicate_identity_before_changes(db, payload):
  191. row = workflow(db)
  192. with pytest.raises(ValueError): WorkflowService.save_nodes(db, 'wf', payload, user=USER)
  193. assert row.del_flag == 0 and db.query(BxWorkflowNode).count() == 1
  194. def test_workflow_input_schema_retains_node_id():
  195. assert WorkflowNodeCreate(**node()).model_dump()['id'] == 'n1'
  196. def test_workflow_metadata_change_and_delete_blocked_when_pending(db):
  197. workflow(db); apply(db, status='pending')
  198. with pytest.raises(ValueError, match='审批'):
  199. WorkflowService.update(db, 'wf', {'status': 0}, user=USER)
  200. with pytest.raises(ValueError, match='审批'):
  201. WorkflowService.soft_delete(db, 'wf', user=USER)
  202. def test_workflow_change_rejected_for_in_approval_recharge(db):
  203. row = workflow(db)
  204. db.get(BxWorkflowDef, 'wf').biz_type = 'vehicle_recharge'
  205. db.add(PlVehicleRechargeApply(id='r', apply_no='r', license_plate='test',
  206. recharge_type='etc', apply_date=date(2026, 1, 1), applicant_id='u',
  207. status='in_approval', wf_id='wf', current_node='n1', tenant_no='tenant'))
  208. db.flush()
  209. with pytest.raises(ValueError, match='审批'):
  210. WorkflowService.save_nodes(db, 'wf', [node(name='Changed')], user=USER)
  211. assert row.node_name == 'Review'
  212. def test_workflow_api_returns_business_error_for_pending_change(db):
  213. from app.api.bx.workflow import update_workflow
  214. from app.schemas.bx.workflow import WorkflowDefUpdate
  215. workflow(db); apply(db, status='pending'); db.commit()
  216. with pytest.raises(HTTPException) as exc:
  217. update_workflow('wf', WorkflowDefUpdate(status=0), db=db, user=USER)
  218. assert exc.value.status_code == 400
  219. assert db.get(BxWorkflowDef, 'wf').status == 1
  220. def test_create_rejects_source_detail_id_without_inserting_parent(db):
  221. with pytest.raises(HTTPException):
  222. ReimburseService.create_with_details(db, {}, [data('source-id')], user=USER)
  223. assert db.query(BxReimburseApply).count() == 0
  224. def test_advance_submit_locks_definition_before_application(db, monkeypatch):
  225. from app.services.bx import advance as svc
  226. workflow(db)
  227. db.add(BxAdvanceApply(id='advance', apply_code='advance', applicant_id='u', applicant_name='user',
  228. reason='test', amount=1, status='draft', wf_id='wf', tenant_no='tenant'))
  229. db.flush()
  230. locks = []
  231. original_lock = WorkflowService.lock_definition
  232. original_get = svc.AdvanceService.get_by_id
  233. def lock(*args, **kwargs):
  234. locks.append('definition')
  235. return original_lock(*args, **kwargs)
  236. def get(*args, **kwargs):
  237. if kwargs.get('for_update'): locks.append('application')
  238. return original_get(*args, **kwargs)
  239. monkeypatch.setattr(WorkflowService, 'lock_definition', lock)
  240. monkeypatch.setattr(svc.AdvanceService, 'get_by_id', get)
  241. monkeypatch.setattr(svc, 'sync_approval_notifications', lambda *args, **kwargs: None)
  242. result = svc.AdvanceService.submit(db, 'advance', USER)
  243. assert result.status == 'pending'
  244. assert locks[:2] == ['definition', 'application']
  245. @pytest.mark.parametrize('kind', ['advance', 'reimburse'])
  246. def test_submit_rejects_empty_configured_workflow_before_pending_or_invoice_debit(db, monkeypatch, kind):
  247. from app.services.bx import advance as advance_module
  248. from app.services.bx import reimburse as reimburse_module
  249. workflow(db).del_flag = 1
  250. if kind == 'advance':
  251. row = BxAdvanceApply(id='advance', apply_code='advance', applicant_id='u', applicant_name='user',
  252. reason='test', amount=1, status='draft', wf_id='wf', tenant_no='tenant')
  253. db.add(row); db.flush()
  254. service = advance_module.AdvanceService
  255. monkeypatch.setattr(advance_module, 'sync_approval_notifications', lambda *args, **kwargs: None)
  256. else:
  257. row = apply(db)
  258. service = ReimburseService
  259. monkeypatch.setattr(service, '_has_debit_reimburse', lambda *args, **kwargs: True)
  260. monkeypatch.setattr(reimburse_module, 'sync_approval_notifications', lambda *args, **kwargs: None)
  261. with pytest.raises(HTTPException, match='节点'):
  262. service.submit(db, row.id, USER)
  263. assert row.status == 'draft' and row.current_node is None