210 lines
11 KiB
Python
210 lines
11 KiB
Python
from datetime import date
|
|
from decimal import Decimal
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
from fastapi import HTTPException
|
|
from sqlalchemy import create_engine
|
|
from sqlalchemy.orm import Session
|
|
|
|
|
|
@pytest.fixture(params=[False, True])
|
|
def case(request):
|
|
from app.models import pl_models as m
|
|
from app.services import pl_quota_sources as svc
|
|
engine = create_engine('sqlite://')
|
|
for model in (m.PlPlan, m.PlPlanProduct, m.PlPlanLoad, m.PlPlanLoadProduct, m.PlPlanDelivery, m.PlPlanDeliveryProduct):
|
|
model.__table__.create(engine)
|
|
db = Session(engine, autoflush=request.param)
|
|
user = SimpleNamespace(id='u', tenant_no='t', to_user_info=lambda: {'role':'super_admin','user_id':'u'})
|
|
db.add(m.PlPlan(id='plan', plan_no='PLAN', create_date=date.today(), plan_type='direct', belongs_company='shanxia', tenant_no='t'))
|
|
db.add(m.PlPlanProduct(id='pp', plan_id='plan', product_id='sku', product_no='SKU', product_name='硫酸', is_toxic=1, tenant_no='t'))
|
|
db.add(m.PlPlanLoad(id='load', plan_id='plan', tenant_no='t'))
|
|
db.add(m.PlPlanDelivery(id='delivery', plan_id='plan', tenant_no='t'))
|
|
db.add(m.PlPlanLoadProduct(id='lp', load_id='load', plan_id='plan', plan_product_id='pp', product_id='sku', product_no='SKU', product_name='硫酸', plan_qty=30, contract_id='purchase', contract_product_id='cp', tenant_no='t'))
|
|
db.add(m.PlPlanDeliveryProduct(id='dp', delivery_id='delivery', plan_id='plan', plan_product_id='pp', product_id='sku', product_no='SKU', product_name='硫酸', plan_qty=30, contract_id='sale', contract_product_id='cs', tenant_no='t', outbound_qty=0))
|
|
db.commit()
|
|
yield SimpleNamespace(db=db, user=user, m=m, svc=svc)
|
|
db.close(); engine.dispose()
|
|
|
|
|
|
def mapping():
|
|
return [{'sourceStopType':'delivery', 'sourceStopProductId':'dp', 'relatedStopProductIds':['lp']}]
|
|
|
|
|
|
def test_two_sides_require_explicit_pairing_and_count_once(case):
|
|
c = case
|
|
with pytest.raises(HTTPException): c.svc.extract_sources(c.db,c.user,'plan')
|
|
sources, actual = c.svc.extract_sources(c.db,c.user,'plan',mapping(),actual=True)
|
|
assert len(sources)==1 and sources[0].quantity==Decimal('30')
|
|
assert sources[0].contract_id=='sale' and sources[0].related_stop_products==['lp']
|
|
assert actual=={'delivery:dp':Decimal('0')}
|
|
|
|
|
|
def test_missing_actual_not_replaced_by_plan_quantity(case):
|
|
c = case
|
|
c.db.get(c.m.PlPlanDeliveryProduct,'dp').outbound_qty=None
|
|
c.db.flush()
|
|
with pytest.raises(HTTPException) as error:
|
|
c.svc.extract_sources(c.db,c.user,'plan',mapping(),actual=True)
|
|
assert error.value.status_code==409
|
|
|
|
|
|
def test_invalid_or_duplicate_pairing_and_omitted_opposite_rejected(case):
|
|
c = case
|
|
for value in (mapping()*2,[{'sourceStopType':'delivery','sourceStopProductId':'dp','relatedStopProductIds':[]}],
|
|
[{'sourceStopType':'delivery','sourceStopProductId':'dp','relatedStopProductIds':['foreign']}],
|
|
mapping()+[{'sourceStopType':'load','sourceStopProductId':'lp','relatedStopProductIds':['dp']}]):
|
|
with pytest.raises(HTTPException): c.svc.extract_sources(c.db,c.user,'plan',value)
|
|
|
|
|
|
def test_single_side_can_be_derived_and_fingerprint_tracks_plan_facts(case):
|
|
c = case
|
|
c.db.get(c.m.PlPlanLoadProduct,'lp').del_flag=1; c.db.flush()
|
|
first,_=c.svc.extract_sources(c.db,c.user,'plan')
|
|
c.db.get(c.m.PlPlan,'plan').license_plate='皖A12345'; c.db.flush()
|
|
second,_=c.svc.extract_sources(c.db,c.user,'plan')
|
|
assert first[0].fingerprint!=second[0].fingerprint
|
|
c.db.get(c.m.PlPlanDeliveryProduct,'dp').outbound_qty=18; c.db.flush()
|
|
third,_=c.svc.extract_sources(c.db,c.user,'plan',actual=True)
|
|
assert second[0].fingerprint==third[0].fingerprint
|
|
|
|
|
|
def test_explosive_only_needs_no_quota_and_wrong_tenant_rejected(case):
|
|
c = case
|
|
row=c.db.get(c.m.PlPlanProduct,'pp'); row.is_toxic=0; row.is_explosive=1; c.db.flush()
|
|
assert c.svc.extract_sources(c.db,c.user,'plan')==([], {})
|
|
other=SimpleNamespace(**{**c.user.__dict__,'tenant_no':'other'})
|
|
with pytest.raises(HTTPException): c.svc.extract_sources(c.db,other,'plan')
|
|
|
|
|
|
def test_orphan_effective_stop_product_blocks_instead_of_silently_omitted(case):
|
|
c=case
|
|
c.db.add(c.m.PlPlanDeliveryProduct(id='orphan',delivery_id='deleted-stop',plan_id='plan',
|
|
plan_product_id='pp',product_id='sku',product_no='SKU',product_name='硫酸',plan_qty=70,
|
|
contract_id='sale',contract_product_id='cs',tenant_no='t'))
|
|
c.db.flush()
|
|
with pytest.raises(HTTPException) as error:
|
|
c.svc.extract_sources(c.db,c.user,'plan',mapping())
|
|
assert error.value.status_code==409
|
|
|
|
|
|
def test_extraction_does_not_flush_pending_changes(case):
|
|
from sqlalchemy import event
|
|
c=case
|
|
c.db.get(c.m.PlPlan,'plan').remark='未保存'
|
|
statements=[]
|
|
def capture(conn,cursor,statement,parameters,context,executemany):
|
|
statements.append(statement)
|
|
event.listen(c.db.bind,'before_cursor_execute',capture)
|
|
try:
|
|
c.svc.extract_sources(c.db,c.user,'plan',mapping())
|
|
finally:
|
|
event.remove(c.db.bind,'before_cursor_execute',capture)
|
|
assert not any(sql.lstrip().upper().startswith(('UPDATE','INSERT','DELETE')) for sql in statements)
|
|
|
|
|
|
def test_preview_vehicle_has_same_fingerprint_as_formal_facts_without_mutation(case):
|
|
c = case
|
|
plan = c.db.get(c.m.PlPlan, 'plan')
|
|
original = plan.license_plate
|
|
first, _ = c.svc.extract_sources(c.db, c.user, 'plan', mapping(),
|
|
preview_vehicle={'licensePlate': '皖A12345', 'trailerNo': '皖A挂'})
|
|
assert plan.license_plate == original and not c.db.dirty
|
|
plan.license_plate = '皖A12345'; plan.trailer_no = '皖A挂'; c.db.flush()
|
|
second, _ = c.svc.extract_sources(c.db, c.user, 'plan', mapping())
|
|
assert first[0].fingerprint == second[0].fingerprint
|
|
|
|
|
|
def test_planning_context_and_options_return_readonly_authoritative_facts(case):
|
|
from app.services import pl_quota_planning as planning
|
|
from app.models.pl_quota_models import PlPlanCertUsage, PlPlanCertUsageItem
|
|
c = case
|
|
for model in (PlPlanCertUsage, PlPlanCertUsageItem):
|
|
model.__table__.create(c.db.bind)
|
|
from app.models.crm_models import CrmContract
|
|
CrmContract.__table__.create(c.db.bind)
|
|
info = planning.get_plan_quota(c.db, c.user, 'plan')
|
|
assert len(info['sourceOptions']) == 2
|
|
context = planning.configuration_context(c.db, c.user, 'plan', {'sourceMapping': mapping()},
|
|
{'licensePlate': '皖A12345'})
|
|
assert context['sources'][0]['snapshot']['vehicle'] == '皖A12345'
|
|
assert len(context['sourceFingerprint']) == 64
|
|
assert not c.db.dirty and not c.db.new
|
|
|
|
|
|
def test_overview_filters_real_usage_states_ignores_old_ready_and_other_tenant(case):
|
|
from app.services.pl_quota_overview import list_plans
|
|
from app.models.pl_quota_models import PlPlanCertUsage
|
|
c = case
|
|
PlPlanCertUsage.__table__.create(c.db.bind)
|
|
c.db.get(c.m.PlPlan, 'plan').hazard_cert_ready = 1
|
|
c.db.flush()
|
|
assert list_plans(c.db, c.user)['list'][0]['quotaStatus'] == 'needs_configuration'
|
|
c.db.add(PlPlanCertUsage(id='usage',tenant_no='t',plan_id='plan',plan_no_snapshot='PLAN',cert_type='purchase',cert_id='g',cert_no_snapshot='G',cert_revision=1,quantity_unit='吨',usage_status='reserved'))
|
|
c.db.flush()
|
|
assert list_plans(c.db, c.user, status='reserved')['total'] == 1
|
|
assert list_plans(c.db, c.user, status='needs_configuration')['total'] == 0
|
|
c.db.get(PlPlanCertUsage, 'usage').usage_status = 'settled'; c.db.flush()
|
|
assert list_plans(c.db, c.user)['list'][0]['quotaStatus'] == 'settled'
|
|
c.db.get(PlPlanCertUsage, 'usage').confirmation_snapshot = {'settlementError': '实际纠错失败'}; c.db.flush()
|
|
assert list_plans(c.db, c.user, status='exception')['total'] == 1
|
|
c.db.get(PlPlanCertUsage, 'usage').confirmation_snapshot = {}; c.db.flush()
|
|
assert list_plans(c.db, c.user, status='settled')['total'] == 1
|
|
c.db.get(PlPlanCertUsage, 'usage').usage_status = 'exception'; c.db.flush()
|
|
assert list_plans(c.db, c.user)['list'][0]['quotaStatus'] == 'exception'
|
|
other=SimpleNamespace(**{**c.user.__dict__,'tenant_no':'other'})
|
|
assert list_plans(c.db, other)['total'] == 0
|
|
c.db.get(c.m.PlPlanProduct, 'pp').is_toxic = 0; c.db.flush()
|
|
assert list_plans(c.db, c.user)['list'][0]['quotaStatus'] == 'not_required'
|
|
|
|
|
|
def test_per_stop_counts_all_sides_ignoring_old_client_selection(case):
|
|
c = case
|
|
c.db.get(c.m.PlPlanLoadProduct, 'lp').plan_qty = 100
|
|
c.db.get(c.m.PlPlanDeliveryProduct, 'dp').plan_qty = 40
|
|
c.db.add(c.m.PlPlanDelivery(id='delivery2', plan_id='plan', tenant_no='t', seq=2))
|
|
c.db.add(c.m.PlPlanDeliveryProduct(id='dp2', delivery_id='delivery2', plan_id='plan',
|
|
plan_product_id='pp', product_id='sku', product_no='SKU', product_name='硫酸',
|
|
plan_qty=60, contract_id='sale2', contract_product_id='cs2', tenant_no='t'))
|
|
c.db.flush()
|
|
sources, _ = c.svc.extract_sources(c.db, c.user, 'plan', mapping(), measurement_version='per_stop_v2')
|
|
assert sum(s.quantity for s in sources) == 200
|
|
assert {(s.contract_id, s.contract_product_id) for s in sources} == {('purchase', 'cp'), ('sale', 'cs'), ('sale2', 'cs2')}
|
|
assert all(s.snapshot['measurementVersion'] == 'per_stop_v2' and not s.related_stop_products for s in sources)
|
|
|
|
|
|
@pytest.mark.parametrize('history', ['draft', 'arranged', 'archived', 'ledger'])
|
|
def test_server_version_cannot_be_overridden_by_client(case, history):
|
|
from app.models.pl_quota_models import PlPlanCertUsage
|
|
from app.services.pl_quota_planning import configuration_context
|
|
c = case
|
|
PlPlanCertUsage.__table__.create(c.db.bind)
|
|
plan = c.db.get(c.m.PlPlan, 'plan')
|
|
if history == 'arranged': plan.transport_status = 'arranged'
|
|
if history == 'archived': plan.archive_status = 'archived'
|
|
if history == 'ledger':
|
|
c.db.add(PlPlanCertUsage(id='u', tenant_no='t', plan_id='plan', plan_no_snapshot='PLAN', cert_type='purchase', cert_id='p', cert_no_snapshot='P', cert_revision=1, quantity_unit='吨'))
|
|
c.db.flush()
|
|
config = {'sourceMapping': mapping(), 'measurementVersion': 'legacy_single_side' if history == 'draft' else 'per_stop_v2'}
|
|
result = configuration_context(c.db, c.user, 'plan', config)
|
|
assert result['measurementVersion'] == ('per_stop_v2' if history == 'draft' else 'legacy_single_side')
|
|
assert sum(Decimal(s['quantity']) for s in result['sources']) == (60 if history == 'draft' else 30)
|
|
|
|
|
|
def test_options_include_stop_contract_and_party_without_foreign_contract_leak(case):
|
|
from app.models.crm_models import CrmContract
|
|
from app.models.pl_quota_models import PlPlanCertUsage, PlPlanCertUsageItem
|
|
from app.services.pl_quota_planning import get_plan_quota
|
|
c = case
|
|
for model in (CrmContract, PlPlanCertUsage, PlPlanCertUsageItem): model.__table__.create(c.db.bind)
|
|
c.db.add(CrmContract(id='purchase', contract_no='PUR-1', contract_name='购买合同', customer_id='customer', contract_type='purchase', tenant_no='t'))
|
|
c.db.add(CrmContract(id='sale', contract_no='SECRET', contract_name='其他租户', customer_id='customer', contract_type='sale', tenant_no='other'))
|
|
c.db.get(c.m.PlPlanLoad, 'load').customer_name = '装货方'
|
|
c.db.flush()
|
|
options = {o['sourceStopType']: o for o in get_plan_quota(c.db, c.user, 'plan')['sourceOptions']}
|
|
assert options['load']['contractNo'] == 'PUR-1'
|
|
assert options['load']['partyName'] == '装货方'
|
|
assert options['load']['stopId'] == 'load' and options['load']['stopOrder'] == 1
|
|
assert options['delivery']['contractNo'] is None
|