"""CRM edits retain child identities; all persistence uses an isolated SQLite database.""" from datetime import date, datetime from decimal import Decimal import pytest from sqlalchemy import create_engine from sqlalchemy.orm import Session from app.crud.crm import contract_product as cp, quote_product as qp from app.models.crm_models import CrmContractProduct, CrmContractProductPriceTier, CrmQuoteProduct from app.models.pl_models import PlPlan, PlPlanLoadProduct, PlPlanDeliveryProduct from app.schemas.crm.contract import ContractUpdateBody from app.schemas.crm.quote import QuoteUpdateBody REAL_BRAND_RESOLVER = qp._resolve_line_brand_fields @pytest.fixture def db(monkeypatch): engine = create_engine('sqlite://') for model in (CrmContractProduct, CrmContractProductPriceTier, CrmQuoteProduct, PlPlan, PlPlanLoadProduct, PlPlanDeliveryProduct): model.__table__.create(engine) monkeypatch.setattr(cp, 'get_product', lambda *a: None) monkeypatch.setattr(qp, 'get_product', lambda *a: None) monkeypatch.setattr(cp, '_resolve_line_brand_fields', lambda *a, **k: (None, None, None)) monkeypatch.setattr(qp, '_resolve_line_brand_fields', lambda *a, **k: (None, None, None)) with Session(engine) as session: yield session def line(db, kind='contract', id='a', product='p', **extra): model = CrmContractProduct if kind == 'contract' else CrmQuoteProduct data = dict(id=id, tenant_no='t', product_id=product, product_name='Product', quantity=2, price=5, subtotal=10, unit='吨', package_spec='桶', create_by='creator', create_time=datetime(2020, 1, 1), del_flag=0) data[kind + '_id'] = 'parent' data.update(extra) obj = model(**data) db.add(obj); db.flush() return obj def reconcile(db, kind, products): module = cp if kind == 'contract' else qp fn = getattr(module, 'sync_' + kind + '_products') return fn(db, tenant_no='t', **{kind + '_id': 'parent'}, products=products, user_id='editor') @pytest.mark.parametrize('kind', ['contract', 'quote']) def test_update_add_remove_retains_existing_identity(db, kind): a = line(db, kind); b = line(db, kind, id='b', product='p2') reconcile(db, kind, [{'id':'a', 'quantity':3}, {'productId':'new', 'productName':'New', 'quantity':1}]) assert a.id == 'a' and a.del_flag == 0 and a.quantity == 3 assert a.create_by == 'creator' and a.create_time == datetime(2020, 1, 1) assert b.del_flag == 1 model = type(a) assert db.query(model).filter(model.del_flag == 0).count() == 2 @pytest.mark.parametrize('kind', ['contract', 'quote']) @pytest.mark.parametrize('bad', [[{'id':'a','quantity':99},{'id':'foreign'}], [{'id':'a'},{'id':'a'}]]) def test_invalid_ids_reject_before_mutating(db, kind, bad): a = line(db, kind) line(db, kind, id='foreign', tenant_no='other') with pytest.raises(ValueError): reconcile(db, kind, bad) assert a.quantity == 2 and a.del_flag == 0 and not db.dirty @pytest.mark.parametrize('kind', ['contract', 'quote']) def test_legacy_unique_identity_and_ambiguous_duplicate(db, kind): a = line(db, kind) payload = {'productId':'p','unit':'吨','packageSpec':'桶','quantity':3} reconcile(db, kind, [payload]) assert a.quantity == 3 and a.del_flag == 0 b = line(db, kind, id='b') with pytest.raises(ValueError): reconcile(db, kind, [payload]) assert a.del_flag == b.del_flag == 0 @pytest.mark.parametrize('kind', ['contract', 'quote']) def test_explicit_existing_line_allows_adding_same_product_as_new_line(db, kind): a = line(db, kind) reconcile(db, kind, [{'id':'a'}, {'productId':'p','productName':'Product', 'unit':'吨','packageSpec':'桶','quantity':5}]) assert a.del_flag == 0 and a.quantity == 2 rows = db.query(type(a)).filter(type(a).del_flag == 0).all() assert len(rows) == 2 and len({r.id for r in rows}) == 2 def test_contract_preserves_omitted_delivery_and_tiers(db): a = line(db, delivered_quantity=1, delivery_status='partial') tier = CrmContractProductPriceTier(id='tier',contract_id='parent',contract_product_id='a', tenant_no='t',start_date=date(2026,1,1),end_date=date(2026,2,1),price=3) db.add(tier); db.flush() reconcile(db, 'contract', [{'id':'a','quantity':4}]) assert a.delivered_quantity == 1 and a.delivery_status == 'partial' assert tier.del_flag == 0 reconcile(db, 'contract', [{'id':'a','priceTiers':[{'id':'tier','price':7}]}]) assert tier.price == 7 and tier.id == 'tier' and tier.del_flag == 0 reconcile(db, 'contract', [{'id':'a','priceTiers':[]}]) assert tier.del_flag == 1 def test_foreign_tier_rejects_before_any_product_mutation(db): a = line(db) with pytest.raises(ValueError): reconcile(db, 'contract', [{'id':'a','quantity':9,'priceTiers':[{'id':'foreign'}]}]) assert a.quantity == 2 and not db.dirty @pytest.mark.parametrize('kind', ['contract', 'quote']) def test_explicit_null_clears_nullable_text_and_brand(db, monkeypatch, kind): a = line(db, kind, description='old', brand_mode='required', brand_id='brand', brand_name='Brand') monkeypatch.setattr(qp, 'product_has_brands', lambda *a: True) # Restore the real resolver while keeping unrelated master-product access isolated. resolver = REAL_BRAND_RESOLVER monkeypatch.setattr(qp, 'product_has_brands', lambda *a: True) monkeypatch.setattr(qp, '_resolve_line_brand_fields', resolver) monkeypatch.setattr(cp, '_resolve_line_brand_fields', resolver) reconcile(db, kind, [{'id':'a','description':None,'brandMode':None,'brandId':None,'brandName':None}]) assert a.description is None and a.brand_id is None and a.brand_mode == 'any' def test_null_date_tier_remark_and_new_tier_default_order(db): a = line(db, delivery_date=date(2026,1,1)) tier = CrmContractProductPriceTier(id='tier',contract_id='parent',contract_product_id='a', tenant_no='t',start_date=date(2026,1,1),end_date=date(2026,2,1),price=3,remark='old') db.add(tier); db.flush() reconcile(db, 'contract', [{'id':'a','deliveryDate':None,'priceTiers':[ {'id':'tier','remark':None}, {'startDate':'2026-03-01','endDate':'2026-04-01','price':4}, {'startDate':'2026-05-01','endDate':'2026-06-01','price':5}]}]) assert a.delivery_date is None and tier.remark is None added = db.query(CrmContractProductPriceTier).filter(CrmContractProductPriceTier.id != 'tier').order_by(CrmContractProductPriceTier.start_date).all() assert [t.sort for t in added] == [2,3] @pytest.mark.parametrize('archive', ['draft', 'archived']) def test_contract_referenced_by_valid_plan_cannot_be_removed(db, archive): a = line(db) db.add(PlPlan(id='plan',plan_no='P',create_date=date.today(),plan_date=date.today(), tenant_no='t',belongs_company='company',plan_type='sales',follow_status='arranged',archive_status=archive)) db.add(PlPlanDeliveryProduct(id='delivery-product',delivery_id='delivery',plan_id='plan', plan_product_id='plan-product',product_id='p',product_no='P',product_name='Product', contract_id='parent',contract_product_id='a',tenant_no='t')) db.flush() with pytest.raises(ValueError, match='引用'): reconcile(db, 'contract', []) assert a.del_flag == 0 @pytest.mark.parametrize('kind', ['contract', 'quote']) def test_none_means_no_child_write(db, kind): a = line(db, kind) reconcile(db, kind, None) assert a.del_flag == 0 and not db.dirty @pytest.mark.parametrize('schema', [ContractUpdateBody, QuoteUpdateBody]) def test_update_schema_distinguishes_omitted_products(schema): body = schema(id='parent', customerId='customer', contractType='sale') assert body.products is None assert 'products' not in body.model_dump(by_alias=True, exclude_unset=True) @pytest.mark.parametrize('kind', ['contract', 'quote']) def test_whole_document_update_uses_diff_and_validates_before_parent_changes(db, monkeypatch, kind): from app.crud.crm import contract, quote from app.models.crm_models import CrmContract, CrmQuote module = contract if kind == 'contract' else quote parent = (CrmContract(id='parent', contract_type='sale', status='executing', customer_id='c', contract_no='', contract_name='Contract', amount=10) if kind == 'contract' else CrmQuote(id='parent', quote_type='sales', status='accepted', customer_id='c')) getter = '_get_contract_row_in_scope' if kind == 'contract' else '_get_quote_in_scope' monkeypatch.setattr(module, getter, lambda *a, **k: parent) for name in ('invalidate_all_approvals', 'assert_products_allowed_for_supplier', '_resolve_' + kind + '_product_ids', 'sync_approval_notifications'): monkeypatch.setattr(module, name, lambda *a, **k: None) monkeypatch.setattr(module, 'get_' + kind + '_detail', lambda *a, **k: {}) if kind == 'contract': monkeypatch.setattr(module, '_apply_contract_entity_fields', lambda *a, **k: None) monkeypatch.setattr(module, '_build_contract_name_from_parties', lambda *a, **k: 'Contract') else: for name in ('_enrich_contact_fields', '_enrich_supplier_name', 'calculate_quote_amount'): monkeypatch.setattr(module, name, lambda *a, **k: None) monkeypatch.setattr(module, 'get_customer', lambda *a, **k: None) monkeypatch.setattr(module, '_get_supplier_short_name', lambda *a, **k: '') a = line(db, kind) update = getattr(module, 'update_' + kind) original_status = parent.status with pytest.raises(ValueError): update(db, 'parent', tenant_no='t', data={'products':[{'id':'foreign'}]}) assert parent.status == original_status update(db, 'parent', tenant_no='t', data={'products':[{'id':'a','quantity':4}]}) assert a.del_flag == 0 and a.id == 'a' and a.quantity == 4 assert a.subtotal == 20