test_crm_child_differential_save.py 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196
  1. """CRM edits retain child identities; all persistence uses an isolated SQLite database."""
  2. from datetime import date, datetime
  3. from decimal import Decimal
  4. import pytest
  5. from sqlalchemy import create_engine
  6. from sqlalchemy.orm import Session
  7. from app.crud.crm import contract_product as cp, quote_product as qp
  8. from app.models.crm_models import CrmContractProduct, CrmContractProductPriceTier, CrmQuoteProduct
  9. from app.models.pl_models import PlPlan, PlPlanLoadProduct, PlPlanDeliveryProduct
  10. from app.schemas.crm.contract import ContractUpdateBody
  11. from app.schemas.crm.quote import QuoteUpdateBody
  12. REAL_BRAND_RESOLVER = qp._resolve_line_brand_fields
  13. @pytest.fixture
  14. def db(monkeypatch):
  15. engine = create_engine('sqlite://')
  16. for model in (CrmContractProduct, CrmContractProductPriceTier, CrmQuoteProduct,
  17. PlPlan, PlPlanLoadProduct, PlPlanDeliveryProduct):
  18. model.__table__.create(engine)
  19. monkeypatch.setattr(cp, 'get_product', lambda *a: None)
  20. monkeypatch.setattr(qp, 'get_product', lambda *a: None)
  21. monkeypatch.setattr(cp, '_resolve_line_brand_fields', lambda *a, **k: (None, None, None))
  22. monkeypatch.setattr(qp, '_resolve_line_brand_fields', lambda *a, **k: (None, None, None))
  23. with Session(engine) as session:
  24. yield session
  25. def line(db, kind='contract', id='a', product='p', **extra):
  26. model = CrmContractProduct if kind == 'contract' else CrmQuoteProduct
  27. data = dict(id=id, tenant_no='t', product_id=product, product_name='Product',
  28. quantity=2, price=5, subtotal=10, unit='吨', package_spec='桶',
  29. create_by='creator', create_time=datetime(2020, 1, 1), del_flag=0)
  30. data[kind + '_id'] = 'parent'
  31. data.update(extra)
  32. obj = model(**data)
  33. db.add(obj); db.flush()
  34. return obj
  35. def reconcile(db, kind, products):
  36. module = cp if kind == 'contract' else qp
  37. fn = getattr(module, 'sync_' + kind + '_products')
  38. return fn(db, tenant_no='t', **{kind + '_id': 'parent'}, products=products, user_id='editor')
  39. @pytest.mark.parametrize('kind', ['contract', 'quote'])
  40. def test_update_add_remove_retains_existing_identity(db, kind):
  41. a = line(db, kind); b = line(db, kind, id='b', product='p2')
  42. reconcile(db, kind, [{'id':'a', 'quantity':3}, {'productId':'new', 'productName':'New', 'quantity':1}])
  43. assert a.id == 'a' and a.del_flag == 0 and a.quantity == 3
  44. assert a.create_by == 'creator' and a.create_time == datetime(2020, 1, 1)
  45. assert b.del_flag == 1
  46. model = type(a)
  47. assert db.query(model).filter(model.del_flag == 0).count() == 2
  48. @pytest.mark.parametrize('kind', ['contract', 'quote'])
  49. @pytest.mark.parametrize('bad', [[{'id':'a','quantity':99},{'id':'foreign'}], [{'id':'a'},{'id':'a'}]])
  50. def test_invalid_ids_reject_before_mutating(db, kind, bad):
  51. a = line(db, kind)
  52. line(db, kind, id='foreign', tenant_no='other')
  53. with pytest.raises(ValueError): reconcile(db, kind, bad)
  54. assert a.quantity == 2 and a.del_flag == 0 and not db.dirty
  55. @pytest.mark.parametrize('kind', ['contract', 'quote'])
  56. def test_legacy_unique_identity_and_ambiguous_duplicate(db, kind):
  57. a = line(db, kind)
  58. payload = {'productId':'p','unit':'吨','packageSpec':'桶','quantity':3}
  59. reconcile(db, kind, [payload])
  60. assert a.quantity == 3 and a.del_flag == 0
  61. b = line(db, kind, id='b')
  62. with pytest.raises(ValueError): reconcile(db, kind, [payload])
  63. assert a.del_flag == b.del_flag == 0
  64. @pytest.mark.parametrize('kind', ['contract', 'quote'])
  65. def test_explicit_existing_line_allows_adding_same_product_as_new_line(db, kind):
  66. a = line(db, kind)
  67. reconcile(db, kind, [{'id':'a'}, {'productId':'p','productName':'Product',
  68. 'unit':'吨','packageSpec':'桶','quantity':5}])
  69. assert a.del_flag == 0 and a.quantity == 2
  70. rows = db.query(type(a)).filter(type(a).del_flag == 0).all()
  71. assert len(rows) == 2 and len({r.id for r in rows}) == 2
  72. def test_contract_preserves_omitted_delivery_and_tiers(db):
  73. a = line(db, delivered_quantity=1, delivery_status='partial')
  74. tier = CrmContractProductPriceTier(id='tier',contract_id='parent',contract_product_id='a',
  75. tenant_no='t',start_date=date(2026,1,1),end_date=date(2026,2,1),price=3)
  76. db.add(tier); db.flush()
  77. reconcile(db, 'contract', [{'id':'a','quantity':4}])
  78. assert a.delivered_quantity == 1 and a.delivery_status == 'partial'
  79. assert tier.del_flag == 0
  80. reconcile(db, 'contract', [{'id':'a','priceTiers':[{'id':'tier','price':7}]}])
  81. assert tier.price == 7 and tier.id == 'tier' and tier.del_flag == 0
  82. reconcile(db, 'contract', [{'id':'a','priceTiers':[]}])
  83. assert tier.del_flag == 1
  84. def test_foreign_tier_rejects_before_any_product_mutation(db):
  85. a = line(db)
  86. with pytest.raises(ValueError):
  87. reconcile(db, 'contract', [{'id':'a','quantity':9,'priceTiers':[{'id':'foreign'}]}])
  88. assert a.quantity == 2 and not db.dirty
  89. @pytest.mark.parametrize('kind', ['contract', 'quote'])
  90. def test_explicit_null_clears_nullable_text_and_brand(db, monkeypatch, kind):
  91. a = line(db, kind, description='old', brand_mode='required', brand_id='brand', brand_name='Brand')
  92. monkeypatch.setattr(qp, 'product_has_brands', lambda *a: True)
  93. # Restore the real resolver while keeping unrelated master-product access isolated.
  94. resolver = REAL_BRAND_RESOLVER
  95. monkeypatch.setattr(qp, 'product_has_brands', lambda *a: True)
  96. monkeypatch.setattr(qp, '_resolve_line_brand_fields', resolver)
  97. monkeypatch.setattr(cp, '_resolve_line_brand_fields', resolver)
  98. reconcile(db, kind, [{'id':'a','description':None,'brandMode':None,'brandId':None,'brandName':None}])
  99. assert a.description is None and a.brand_id is None and a.brand_mode == 'any'
  100. def test_null_date_tier_remark_and_new_tier_default_order(db):
  101. a = line(db, delivery_date=date(2026,1,1))
  102. tier = CrmContractProductPriceTier(id='tier',contract_id='parent',contract_product_id='a',
  103. tenant_no='t',start_date=date(2026,1,1),end_date=date(2026,2,1),price=3,remark='old')
  104. db.add(tier); db.flush()
  105. reconcile(db, 'contract', [{'id':'a','deliveryDate':None,'priceTiers':[
  106. {'id':'tier','remark':None},
  107. {'startDate':'2026-03-01','endDate':'2026-04-01','price':4},
  108. {'startDate':'2026-05-01','endDate':'2026-06-01','price':5}]}])
  109. assert a.delivery_date is None and tier.remark is None
  110. added = db.query(CrmContractProductPriceTier).filter(CrmContractProductPriceTier.id != 'tier').order_by(CrmContractProductPriceTier.start_date).all()
  111. assert [t.sort for t in added] == [2,3]
  112. @pytest.mark.parametrize('archive', ['draft', 'archived'])
  113. def test_contract_referenced_by_valid_plan_cannot_be_removed(db, archive):
  114. a = line(db)
  115. db.add(PlPlan(id='plan',plan_no='P',create_date=date.today(),plan_date=date.today(),
  116. tenant_no='t',belongs_company='company',plan_type='sales',follow_status='arranged',archive_status=archive))
  117. db.add(PlPlanDeliveryProduct(id='delivery-product',delivery_id='delivery',plan_id='plan',
  118. plan_product_id='plan-product',product_id='p',product_no='P',product_name='Product',
  119. contract_id='parent',contract_product_id='a',tenant_no='t'))
  120. db.flush()
  121. with pytest.raises(ValueError, match='引用'): reconcile(db, 'contract', [])
  122. assert a.del_flag == 0
  123. @pytest.mark.parametrize('kind', ['contract', 'quote'])
  124. def test_none_means_no_child_write(db, kind):
  125. a = line(db, kind)
  126. reconcile(db, kind, None)
  127. assert a.del_flag == 0 and not db.dirty
  128. @pytest.mark.parametrize('schema', [ContractUpdateBody, QuoteUpdateBody])
  129. def test_update_schema_distinguishes_omitted_products(schema):
  130. body = schema(id='parent', customerId='customer', contractType='sale')
  131. assert body.products is None
  132. assert 'products' not in body.model_dump(by_alias=True, exclude_unset=True)
  133. @pytest.mark.parametrize('kind', ['contract', 'quote'])
  134. def test_whole_document_update_uses_diff_and_validates_before_parent_changes(db, monkeypatch, kind):
  135. from app.crud.crm import contract, quote
  136. from app.models.crm_models import CrmContract, CrmQuote
  137. module = contract if kind == 'contract' else quote
  138. parent = (CrmContract(id='parent', contract_type='sale', status='executing', customer_id='c',
  139. contract_no='', contract_name='Contract', amount=10) if kind == 'contract'
  140. else CrmQuote(id='parent', quote_type='sales', status='accepted', customer_id='c'))
  141. getter = '_get_contract_row_in_scope' if kind == 'contract' else '_get_quote_in_scope'
  142. monkeypatch.setattr(module, getter, lambda *a, **k: parent)
  143. for name in ('invalidate_all_approvals', 'assert_products_allowed_for_supplier',
  144. '_resolve_' + kind + '_product_ids', 'sync_approval_notifications'):
  145. monkeypatch.setattr(module, name, lambda *a, **k: None)
  146. monkeypatch.setattr(module, 'get_' + kind + '_detail', lambda *a, **k: {})
  147. if kind == 'contract':
  148. monkeypatch.setattr(module, '_apply_contract_entity_fields', lambda *a, **k: None)
  149. monkeypatch.setattr(module, '_build_contract_name_from_parties', lambda *a, **k: 'Contract')
  150. else:
  151. for name in ('_enrich_contact_fields', '_enrich_supplier_name', 'calculate_quote_amount'):
  152. monkeypatch.setattr(module, name, lambda *a, **k: None)
  153. monkeypatch.setattr(module, 'get_customer', lambda *a, **k: None)
  154. monkeypatch.setattr(module, '_get_supplier_short_name', lambda *a, **k: '')
  155. a = line(db, kind)
  156. update = getattr(module, 'update_' + kind)
  157. original_status = parent.status
  158. with pytest.raises(ValueError):
  159. update(db, 'parent', tenant_no='t', data={'products':[{'id':'foreign'}]})
  160. assert parent.status == original_status
  161. update(db, 'parent', tenant_no='t', data={'products':[{'id':'a','quantity':4}]})
  162. assert a.del_flag == 0 and a.id == 'a' and a.quantity == 4
  163. assert a.subtotal == 20