197 lines
9.7 KiB
Python
197 lines
9.7 KiB
Python
"""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
|