test_crm_missing_identity_patch.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264
  1. """CRM missing-ID partial saves: real SQLite child writes, forbidden business MySQL."""
  2. from datetime import date, datetime
  3. import pymysql
  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. @pytest.fixture
  13. def db(monkeypatch):
  14. def forbidden(*args, **kwargs):
  15. raise AssertionError('Business MySQL connections are forbidden')
  16. monkeypatch.setattr(pymysql, 'connect', forbidden)
  17. engine = create_engine('sqlite://')
  18. for model in (CrmContractProduct, CrmContractProductPriceTier, CrmQuoteProduct,
  19. PlPlan, PlPlanLoadProduct, PlPlanDeliveryProduct):
  20. model.__table__.create(engine)
  21. # Product-library enrichment is outside identity reconciliation.
  22. monkeypatch.setattr(cp, 'get_product', lambda *args: None)
  23. monkeypatch.setattr(qp, 'get_product', lambda *args: None)
  24. def brand_fields(*args, **kwargs):
  25. data = kwargs['data']
  26. keys = ('brandMode', 'brandId', 'brandName', 'brand_mode', 'brand_id', 'brand_name')
  27. if not any(key in data for key in keys):
  28. return kwargs.get('defaults', (None, None, None))
  29. return tuple(qp._get_field(data, snake, camel) for snake, camel in
  30. (('brand_mode', 'brandMode'), ('brand_id', 'brandId'), ('brand_name', 'brandName')))
  31. for module in (cp, qp):
  32. monkeypatch.setattr(module, '_resolve_line_brand_fields', brand_fields)
  33. with Session(engine, autoflush=False) as session:
  34. yield session
  35. engine.dispose()
  36. @pytest.fixture(params=['quote', 'contract'])
  37. def kind(request):
  38. return request.param
  39. def line(db, kind, row_id='old', **extra):
  40. cls = CrmContractProduct if kind == 'contract' else CrmQuoteProduct
  41. values = dict(id=row_id, tenant_no='t', product_id='sku', product_name='Product',
  42. model='A', unit='吨', package_spec='桶', quantity=2, price=5, subtotal=10,
  43. brand_mode='required', brand_id='brand', brand_name='Brand', create_by='creator',
  44. create_time=datetime(2020, 1, 2), del_flag=0)
  45. values[kind + '_id'] = 'parent'
  46. values.update(extra)
  47. row = cls(**values)
  48. db.add(row)
  49. db.flush()
  50. return row
  51. def product(**kwargs):
  52. return dict(productId='sku', productName='Product', **kwargs)
  53. def sync(db, kind, products):
  54. module = cp if kind == 'contract' else qp
  55. getattr(module, 'sync_' + kind + '_products')(
  56. db, tenant_no='t', **{kind + '_id': 'parent'}, products=products, user_id='editor')
  57. db.flush()
  58. def test_omitted_identity_fields_reuse_unique_old_line_twice(db, kind):
  59. old = line(db, kind)
  60. for quantity in (3, 4):
  61. sync(db, kind, [product(quantity=quantity)])
  62. assert db.query(type(old)).count() == 1
  63. assert old.del_flag == 0 and old.quantity == 4 and old.subtotal == 20
  64. assert (old.model, old.unit, old.package_spec, old.brand_id) == ('A', '吨', '桶', 'brand')
  65. assert old.create_by == 'creator' and old.create_time == datetime(2020, 1, 2)
  66. @pytest.mark.parametrize('field', ['unit', 'model', 'brandId', 'brandMode', 'packageSpec'])
  67. def test_each_omitted_key_is_not_defaulted_for_matching(db, kind, field):
  68. old = line(db, kind)
  69. body = product(unit='吨', model='A', brandId='brand', brandMode='required', packageSpec='桶', quantity=4)
  70. body.pop(field)
  71. sync(db, kind, [body])
  72. assert old.del_flag == 0 and old.quantity == 4
  73. assert db.query(type(old)).count() == 1
  74. @pytest.mark.parametrize('discriminator,old_fields,first_value,second_value', [
  75. ('unit', 'unit', '吨', '千克'), ('model', 'model', 'A', 'B'),
  76. ('brandId', 'brand_id', 'brand', 'brand2'), ('packageSpec', 'package_spec', '桶', '袋'),
  77. ])
  78. def test_same_sku_variants_match_submitted_fields_after_reordering(db, kind, discriminator, old_fields, first_value, second_value):
  79. first = line(db, kind, **{old_fields: first_value})
  80. second = line(db, kind, 'second', **{old_fields: second_value})
  81. sync(db, kind, [product(**{discriminator: second_value}, quantity=6),
  82. product(**{discriminator: first_value}, quantity=7)])
  83. assert first.quantity == 7 and second.quantity == 6
  84. assert first.del_flag == second.del_flag == 0 and db.query(type(first)).count() == 2
  85. @pytest.mark.parametrize('payload', [[product()], [product(), product()]])
  86. def test_ambiguous_or_competing_anonymous_rows_reject_before_writes(db, kind, payload):
  87. old = line(db, kind)
  88. if len(payload) == 1:
  89. line(db, kind, 'second', model='B')
  90. with pytest.raises(ValueError):
  91. sync(db, kind, payload)
  92. assert not db.new and not db.dirty and old.del_flag == 0
  93. @pytest.mark.parametrize('product_id', [None, '', ' '])
  94. def test_missing_product_id_cannot_guess_from_name(db, kind, product_id):
  95. old = line(db, kind)
  96. with pytest.raises(ValueError):
  97. sync(db, kind, [{'productName': 'Product', 'productId': product_id}])
  98. assert not db.new and not db.dirty and old.del_flag == 0
  99. def test_explicit_id_reserved_before_same_product_anonymous_new_row(db, kind):
  100. old = line(db, kind)
  101. sync(db, kind, [product(quantity=3), {'id': 'old', 'quantity': 4}])
  102. assert old.del_flag == 0 and old.quantity == 4
  103. assert db.query(type(old)).count() == 2
  104. @pytest.mark.parametrize('field', ['unit', 'model', 'brandId', 'packageSpec'])
  105. def test_explicit_null_is_a_submitted_key_not_an_omission(db, kind, field):
  106. old = line(db, kind)
  107. mapping = {'unit': 'unit', 'model': 'model', 'brandId': 'brand_id', 'packageSpec': 'package_spec'}
  108. empty = line(db, kind, 'empty', **{mapping[field]: None})
  109. sync(db, kind, [{'id': 'old'}, product(**{field: None}, quantity=8)])
  110. assert old.del_flag == empty.del_flag == 0 and empty.quantity == 8
  111. assert db.query(type(old)).count() == 2
  112. def test_explicit_id_can_clear_nullable_identity_fields_without_new_line(db, kind):
  113. old = line(db, kind)
  114. sync(db, kind, [{'id': 'old', 'model': None, 'brandMode': None, 'brandId': None, 'brandName': None}])
  115. assert old.del_flag == 0 and not old.model and old.brand_id is None
  116. assert db.query(type(old)).count() == 1
  117. def test_omitted_collection_preserves_and_empty_deletes(db, kind):
  118. old = line(db, kind)
  119. sync(db, kind, None)
  120. assert old.del_flag == 0
  121. sync(db, kind, [])
  122. assert old.del_flag == 1
  123. def test_new_different_product_and_remove_old(db, kind):
  124. old = line(db, kind)
  125. sync(db, kind, [{'productId': 'other', 'productName': 'Other', 'quantity': 3}])
  126. assert old.del_flag == 1 and db.query(type(old)).filter_by(del_flag=0).one().product_id == 'other'
  127. @pytest.mark.parametrize('scope', [{'tenant_no': 'other'}, {'del_flag': 1}, {'parent': 'other'}])
  128. def test_explicit_id_must_be_active_and_owned(db, kind, scope):
  129. values = dict(scope)
  130. if 'parent' in values:
  131. values[kind + '_id'] = values.pop('parent')
  132. line(db, kind, **values)
  133. with pytest.raises(ValueError):
  134. sync(db, kind, [{'id': 'old', 'quantity': 4}])
  135. assert not db.new and not db.dirty
  136. def test_duplicate_explicit_id_rejected_before_writes(db, kind):
  137. old = line(db, kind)
  138. with pytest.raises(ValueError):
  139. sync(db, kind, [{'id': 'old', 'quantity': 9}, {'id': 'old'}])
  140. assert not db.new and not db.dirty and old.quantity == 2
  141. def test_contract_downstream_reference_allows_partial_match_but_not_removal(db):
  142. old = line(db, 'contract', delivered_quantity=1, delivery_status='partial')
  143. db.add(PlPlan(id='plan', tenant_no='t', plan_no='P', follow_status='completed', archive_status='archived', create_date=date(2026, 1, 1), belongs_company='shanxia', plan_type='outbound'))
  144. db.add(PlPlanDeliveryProduct(id='ref', tenant_no='t', plan_id='plan', delivery_id='stop',
  145. contract_product_id='old', plan_product_id='pp', product_id='sku', product_no='SKU', product_name='Product'))
  146. db.flush()
  147. sync(db, 'contract', [product(quantity=4)])
  148. assert old.del_flag == 0 and old.delivered_quantity == 1 and old.delivery_status == 'partial'
  149. db.commit()
  150. with pytest.raises(ValueError):
  151. sync(db, 'contract', [])
  152. assert old.del_flag == 0 and not db.new and not db.dirty
  153. def test_tier_natural_key_matching_and_parent_partial_identity_are_independent(db):
  154. old = line(db, 'contract')
  155. tier = CrmContractProductPriceTier(id='tier', tenant_no='t', contract_id='parent',
  156. contract_product_id='old', start_date=date(2026, 1, 1), end_date=date(2026, 2, 1), price=2)
  157. db.add(tier)
  158. db.flush()
  159. sync(db, 'contract', [product(priceTiers=[{'startDate': '2026-01-01', 'endDate': '2026-02-01', 'price': 3}])])
  160. assert old.del_flag == tier.del_flag == 0 and tier.price == 3
  161. assert db.query(CrmContractProductPriceTier).count() == 1
  162. db.commit()
  163. with pytest.raises(ValueError):
  164. sync(db, 'contract', [product(priceTiers=[{'startDate': '2026-01-01', 'price': 4}])])
  165. assert not db.new and not db.dirty
  166. @pytest.fixture
  167. def save_whole(db, kind, monkeypatch):
  168. from app.crud.crm import contract, quote
  169. from app.models.crm_models import CrmContract, CrmQuote
  170. module = contract if kind == 'contract' else quote
  171. parent = (CrmContract(id='parent', contract_type='sale', status='executing', customer_id='c',
  172. contract_no='', contract_name='Contract', amount=10) if kind == 'contract'
  173. else CrmQuote(id='parent', quote_type='sales', status='accepted', customer_id='c'))
  174. getter = '_get_contract_row_in_scope' if kind == 'contract' else '_get_quote_in_scope'
  175. monkeypatch.setattr(module, getter, lambda *a, **k: parent)
  176. for name in ('invalidate_all_approvals', 'assert_products_allowed_for_supplier',
  177. '_resolve_' + kind + '_product_ids', 'sync_approval_notifications'):
  178. monkeypatch.setattr(module, name, lambda *a, **k: None)
  179. monkeypatch.setattr(module, 'get_' + kind + '_detail', lambda *a, **k: {})
  180. if kind == 'contract':
  181. monkeypatch.setattr(module, '_apply_contract_entity_fields', lambda *a, **k: None)
  182. monkeypatch.setattr(module, '_build_contract_name_from_parties', lambda *a, **k: 'Contract')
  183. else:
  184. for name in ('_enrich_contact_fields', '_enrich_supplier_name', 'calculate_quote_amount'):
  185. monkeypatch.setattr(module, name, lambda *a, **k: None)
  186. monkeypatch.setattr(module, 'get_customer', lambda *a, **k: None)
  187. monkeypatch.setattr(module, '_get_supplier_short_name', lambda *a, **k: '')
  188. def save(products):
  189. schema = ContractUpdateBody if kind == 'contract' else QuoteUpdateBody
  190. data = schema(id='parent', customerId='c', contractType='sale', products=products).model_dump(by_alias=True, exclude_unset=True)
  191. data.pop('id')
  192. return getattr(module, 'update_' + kind)(db, 'parent', tenant_no='t', data=data)
  193. return save, parent
  194. def test_schema_to_whole_update_keeps_missing_unit_and_other_fields(db, kind, save_whole):
  195. old = line(db, kind)
  196. save, parent = save_whole
  197. for _ in range(2):
  198. save([product(model='A', packageSpec='桶', quantity=3, price=5)])
  199. assert old.del_flag == 0 and old.unit == '吨' and old.brand_id == 'brand'
  200. assert db.query(type(old)).count() == 1 and old.quantity == 3
  201. def test_whole_ambiguous_partial_identity_rejected_before_parent_status_changes(db, kind, save_whole):
  202. line(db, kind)
  203. line(db, kind, 'second', model='B')
  204. save, parent = save_whole
  205. status = parent.status
  206. with pytest.raises(ValueError):
  207. save([product(packageSpec='桶', quantity=3)])
  208. assert parent.status == status and not db.new and not db.dirty
  209. @pytest.mark.parametrize('field', ['unit', 'model', 'brandId', 'packageSpec'])
  210. def test_explicit_null_disambiguates_empty_key_from_nonempty_key(db, kind, field):
  211. old = line(db, kind)
  212. mapping = {'unit': 'unit', 'model': 'model', 'brandId': 'brand_id', 'packageSpec': 'package_spec'}
  213. empty = line(db, kind, 'empty', **{mapping[field]: None})
  214. sync(db, kind, [product(**{field: None}, quantity=8)])
  215. assert old.del_flag == 1 and empty.del_flag == 0 and empty.quantity == 8
  216. assert db.query(type(old)).count() == 2