@@ -0,0 +1,381 @@
""" Сверка внешней CRM/1С-выгрузки с утверждёнными интервалами dossier.
Сверка выполняется по исходным суммам и датам на Decimal/date. Отсутствие
известного SKU не блокирует проверку договора; неизвестный VAT rate, напротив,
не позволяет объявить цены совпавшими, если стороны указали разные VAT modes.
"""
import re
from datetime import date , datetime , timedelta
from decimal import Decimal , InvalidOperation , ROUND_HALF_UP
from db import dossiers as db_dossiers
from db import exceptions as db_exceptions
from db import timeline as db_timeline
from services . catalog_matcher import match_catalog_sku
from services . metrics import normalize_date
from services . service_identity import analyze_service_row , match_service_item
MONEY_TOLERANCE = Decimal ( " 0.01 " )
def _decimal ( value ) :
""" Преобразовать внешнее или договорное число в конечный Decimal. """
if value is None or value == " " or isinstance ( value , bool ) :
return None
try :
result = Decimal ( str ( value ) . replace ( " \xa0 " , " " ) . replace ( " " , " " ) . replace ( " , " , " . " ) )
except ( InvalidOperation , ValueError , TypeError ) :
return None
return result if result . is_finite ( ) else None
def _date ( value ) :
""" Разобрать поддерживаемую ISO/DD.MM.YYYY дату без принятия мусорных значений. """
if not value :
return None
try :
return datetime . strptime ( normalize_date ( str ( value ) . strip ( ) ) , " % Y- % m- %d " ) . date ( )
except ( TypeError , ValueError ) :
return None
def _window ( target_date_or_period ) :
""" Нормализовать точечную дату или полуоткрытый период [start, end). """
if target_date_or_period is None :
point = date . today ( )
return { " kind " : " point " , " date " : point , " start " : point , " end " : point + timedelta ( days = 1 ) }
if isinstance ( target_date_or_period , ( tuple , list ) ) and len ( target_date_or_period ) == 2 :
start , end = map ( _date , target_date_or_period )
elif isinstance ( target_date_or_period , dict ) :
start = _date ( target_date_or_period . get ( " start " ) or target_date_or_period . get ( " period_start " ) )
end = _date ( target_date_or_period . get ( " end " ) or target_date_or_period . get ( " period_end " ) )
else :
point = _date ( target_date_or_period )
if point is None :
raise ValueError ( " target date must be YYYY-MM-DD or DD.MM.YYYY " )
return { " kind " : " point " , " date " : point , " start " : point , " end " : point + timedelta ( days = 1 ) }
if start is None or end is None or end < = start :
raise ValueError ( " target period must have valid start < end " )
return { " kind " : " period " , " date " : None , " start " : start , " end " : end }
def _interval_overlaps ( interval , window ) :
""" Проверить активность интервала на дату или пересечение с period window. """
start = _date ( interval . get ( " valid_from " ) )
end = _date ( interval . get ( " valid_to " ) )
if start is None :
return False
return start < window [ " end " ] and ( end is None or end > window [ " start " ] )
def _external_end ( row ) :
""" Согласовать inclusive date_end CRM со stored exclusive valid_to. """
exclusive = _date ( row . get ( " date_end_exclusive " ) )
if exclusive :
return exclusive
inclusive = _date ( row . get ( " date_end " ) or row . get ( " valid_to " ) )
if not inclusive :
return None
return inclusive + timedelta ( days = 1 ) if row . get ( " date_end_inclusive " , True ) else inclusive
def _amount ( row , sum_key = " sum " , price_key = " price " , qty_key = " qty " ) :
""" Получить сумму напрямую либо вычислить её как точное price × qty. """
total = _decimal ( row . get ( sum_key ) )
if total is not None :
return total
price , qty = _decimal ( row . get ( price_key ) ) , _decimal ( row . get ( qty_key ) )
return price * qty if price is not None and qty is not None else None
def _vat_mode ( value ) :
""" Свести известные подписи режима НДС к gross/net/unknown. """
mode = str ( value or " unknown " ) . strip ( ) . lower ( )
if mode in { " with_vat " , " vat_included " , " inclusive " , " gross " , " с ндс" , " включен " } :
return " gross "
if mode in { " without_vat " , " vat_excluded " , " exclusive " , " net " , " без ндс " , " не включен " } :
return " net "
return " unknown "
def _vat_rate ( row ) :
""" Прочитать только явно переданную ставку НДС; не выводить её по календарю. """
value = _decimal ( row . get ( " vat_rate " ) )
if value is None :
return None
if value > 1 :
value / = Decimal ( " 100 " )
if value < 0 or value > 1 :
return None
return value
def _to_gross ( amount , mode , rate ) :
""" Нормализовать amount к gross, округляя по правилам денежных копеек. """
normalized_mode = _vat_mode ( mode )
if amount is None :
return None
if normalized_mode == " gross " :
return amount . quantize ( Decimal ( " 0.01 " ) , rounding = ROUND_HALF_UP )
if normalized_mode == " net " and rate is not None :
return ( amount * ( Decimal ( " 1 " ) + rate ) ) . quantize ( Decimal ( " 0.01 " ) , rounding = ROUND_HALF_UP )
return None
def _vat_comparable ( contract , external ) :
""" Разрешить денежную сверку при одинаковом режиме или двух известных ставках. """
contract_mode , external_mode = _vat_mode ( contract . get ( " vat_mode " ) ) , _vat_mode ( external . get ( " vat_mode " ) )
if contract_mode == external_mode and contract_mode != " unknown " :
return True
if contract_mode == " unknown " and external_mode == " unknown " :
return True
return _vat_rate ( contract ) is not None and _vat_rate ( external ) is not None
def _money_comparison ( contract , external ) :
""" Сравнить amount, price и qty с точным VAT normalization. """
if not _vat_comparable ( contract , external ) :
return { " comparable " : False , " matches " : False , " reason " : " VAT mode/rate is not comparable " }
contract_rate , external_rate = _vat_rate ( contract ) , _vat_rate ( external )
contract_amount = _to_gross ( _amount ( contract ) , contract . get ( " vat_mode " ) , contract_rate )
external_amount = _to_gross ( _amount ( external ) , external . get ( " vat_mode " ) , external_rate )
if contract_amount is None or external_amount is None :
return { " comparable " : False , " matches " : False , " reason " : " amount is missing " }
contract_qty , external_qty = _decimal ( contract . get ( " qty " ) ) , _decimal ( external . get ( " qty " ) )
if contract_qty is not None and external_qty is not None and contract_qty != external_qty :
return { " comparable " : True , " matches " : False , " reason " : " quantity differs " }
contract_price = _to_gross ( _decimal ( contract . get ( " price " ) ) , contract . get ( " vat_mode " ) , contract_rate )
external_price = _to_gross ( _decimal ( external . get ( " price " ) ) , external . get ( " vat_mode " ) , external_rate )
if contract_price is not None and external_price is not None :
if abs ( contract_price - external_price ) > MONEY_TOLERANCE :
return { " comparable " : True , " matches " : False , " reason " : " unit price differs " }
if abs ( contract_amount - external_amount ) > MONEY_TOLERANCE :
return { " comparable " : True , " matches " : False , " reason " : " sum differs " }
return { " comparable " : True , " matches " : True , " reason " : " " }
def _find_service ( external , items , catalog_entries ) :
""" Сопоставить external row по явному ID, SKU или семейству/anchor. """
explicit_id = external . get ( " service_item_id " )
if explicit_id :
exact = [ item for item in items if item . get ( " id " ) == explicit_id ]
if exact :
return { " status " : " MATCHED " , " service_item " : exact [ 0 ] , " candidates " : exact }
external_sku = re . sub ( r " [^0-9A-ZА -ЯЁ-] " , " " , str ( external . get ( " sku " ) or " " ) . upper ( ) )
if external_sku :
sku_matches = [
item for item in items
if re . sub ( r " [^0-9A-ZА -ЯЁ-] " , " " , str ( item . get ( " catalog_sku " ) or " " ) . upper ( ) ) == external_sku
]
if len ( sku_matches ) == 1 :
return { " status " : " MATCHED " , " service_item " : sku_matches [ 0 ] , " candidates " : sku_matches }
raw_name = external . get ( " name " ) or external . get ( " service_name " ) or " "
if external_sku and catalog_entries :
sku_candidates = [ ]
for item in items :
catalog_result = match_catalog_sku (
{ * * item , " charge_type " : external . get ( " charge_type " ) } , catalog_entries ,
)
if catalog_result . get ( " sku " ) and str ( catalog_result [ " sku " ] ) . upper ( ) == external_sku :
sku_candidates . append ( item )
if len ( sku_candidates ) == 1 :
return { " status " : " MATCHED " , " service_item " : sku_candidates [ 0 ] , " candidates " : sku_candidates }
if sku_candidates :
return { " status " : " AMBIGUOUS " , " service_item " : None , " candidates " : sku_candidates }
identity = analyze_service_row ( raw_name , external . get ( " charge_type " ) )
return match_service_item ( identity , items )
def _date_comparison ( interval , external ) :
""" Сверить точные границы external row с интервалом договора. """
contract_start = _date ( interval . get ( " valid_from " ) )
external_start = _date ( external . get ( " date_start " ) or external . get ( " valid_from " ) )
contract_end = _date ( interval . get ( " valid_to " ) )
external_end = _external_end ( external )
if external_start != contract_start or external_end != contract_end :
return {
" matches " : False ,
" contract_start " : contract_start . isoformat ( ) if contract_start else None ,
" external_start " : external_start . isoformat ( ) if external_start else None ,
" contract_end_exclusive " : contract_end . isoformat ( ) if contract_end else None ,
" external_end_exclusive " : external_end . isoformat ( ) if external_end else None ,
}
return { " matches " : True }
def _finding ( status , external = None , service_item = None , interval = None , reason = " " , * * details ) :
""" Собрать отчётный finding только из JSON-совместимых значений. """
return {
" status " : status ,
" external_row " : _json_safe ( external ) ,
" service_item_id " : service_item . get ( " id " ) if service_item else None ,
" service_name " : ( interval or { } ) . get ( " raw_name " ) or ( service_item or { } ) . get ( " canonical_name " ) ,
" interval_id " : ( interval or { } ) . get ( " id " ) ,
" reason " : reason ,
* * details ,
}
def _json_safe ( value ) :
""" Сериализовать Decimal/date в точные строки для Flask JSON response. """
if isinstance ( value , Decimal ) :
return format ( value , " f " )
if isinstance ( value , ( date , datetime ) ) :
return value . isoformat ( )
if isinstance ( value , dict ) :
return { key : _json_safe ( item ) for key , item in value . items ( ) }
if isinstance ( value , ( list , tuple ) ) :
return [ _json_safe ( item ) for item in value ]
return value
def reconcile_dossier ( dossier_id , external_rows , target_date_or_period = None ,
catalog_entries = None ) :
""" Сверить CRM/1С строки с активными обязательствами dossier.
Входные даты `date_end` считаются включительными; `date_end_exclusive` можно
передать для явной полуоткрытой границы. Неоднозначные service matches и
открытые исключения получают NEEDS_HUMAN_DECISION. SKU UNKNOWN не меняет
результат сверки дат, количеств и денежных сумм.
"""
dossier = db_dossiers . get_dossier ( dossier_id )
if dossier is None :
return { " ok " : False , " error " : " dossier not found " , " dossier_id " : dossier_id }
window = _window ( target_date_or_period )
items = db_timeline . list_service_items ( dossier_id )
intervals = [
interval for interval in db_timeline . list_timeline ( dossier_id )
if _interval_overlaps ( interval , window )
]
open_exceptions = db_exceptions . list_exceptions ( dossier_id = dossier_id , status = " OPEN " )
exceptions_by_item = { }
for issue in open_exceptions :
service_item_id = issue . get ( " service_item_id " )
if service_item_id :
exceptions_by_item . setdefault ( service_item_id , [ ] ) . append ( issue )
findings = [ ]
observed_items = set ( )
uncertain_items = set ( )
for row_index , external in enumerate ( external_rows or [ ] , start = 1 ) :
if not isinstance ( external , dict ) :
findings . append ( _finding ( " NEEDS_HUMAN_DECISION " , reason = " external row must be an object " , row_index = row_index ) )
continue
match = _find_service ( external , items , catalog_entries or [ ] )
if match [ " status " ] == " AMBIGUOUS " :
candidate_ids = [ item [ " id " ] for item in match [ " candidates " ] ]
uncertain_items . update ( candidate_ids )
findings . append ( _finding (
" NEEDS_HUMAN_DECISION " , external = external , reason = " service match is ambiguous " ,
candidate_service_item_ids = candidate_ids ,
) )
continue
service_item = match . get ( " service_item " )
if service_item is None :
findings . append ( _finding (
" UNCONTRACTED_BILLING " , external = external ,
reason = " no matching service item exists in dossier " ,
) )
continue
observed_items . add ( service_item [ " id " ] )
catalog_status = service_item . get ( " sku_status " ) or " UNKNOWN "
if catalog_entries :
catalog_result = match_catalog_sku (
{ * * service_item , " charge_type " : external . get ( " charge_type " ) } ,
catalog_entries ,
)
catalog_status = catalog_result [ " status " ]
if service_item [ " id " ] in exceptions_by_item :
findings . append ( _finding (
" NEEDS_HUMAN_DECISION " , external = external , service_item = service_item ,
reason = " service item has open dossier exceptions " ,
sku_status = catalog_status ,
exception_ids = [ issue [ " id " ] for issue in exceptions_by_item [ service_item [ " id " ] ] ] ,
) )
continue
matching_intervals = [
interval for interval in intervals
if interval . get ( " service_item_id " ) == service_item [ " id " ]
and ( not external . get ( " charge_type " ) or interval . get ( " charge_type " ) == external . get ( " charge_type " ) )
]
if not matching_intervals :
findings . append ( _finding (
" UNCONTRACTED_BILLING " , external = external , service_item = service_item ,
reason = " service item has no contractual interval in the target window " ,
sku_status = catalog_status ,
) )
continue
if len ( matching_intervals ) > 1 :
exact = [ interval for interval in matching_intervals if _date_comparison ( interval , external ) [ " matches " ] ]
if len ( exact ) == 1 :
matching_intervals = exact
else :
uncertain_items . add ( service_item [ " id " ] )
findings . append ( _finding (
" NEEDS_HUMAN_DECISION " , external = external , service_item = service_item ,
reason = " external period overlaps multiple contractual intervals " ,
sku_status = catalog_status ,
candidate_interval_ids = [ interval [ " id " ] for interval in matching_intervals ] ,
) )
continue
interval = matching_intervals [ 0 ]
dates = _date_comparison ( interval , external )
if not dates [ " matches " ] :
findings . append ( _finding (
" DATE_MISMATCH " , external = external , service_item = service_item ,
interval = interval , reason = " date range differs " , sku_status = catalog_status , * * dates ,
) )
continue
money = _money_comparison ( interval , external )
if not money [ " comparable " ] :
findings . append ( _finding (
" NEEDS_HUMAN_DECISION " , external = external , service_item = service_item ,
interval = interval , reason = money [ " reason " ] , sku_status = catalog_status ,
) )
elif not money [ " matches " ] :
findings . append ( _finding (
" PRICE_OR_QTY_MISMATCH " , external = external , service_item = service_item ,
interval = interval , reason = money [ " reason " ] , sku_status = catalog_status ,
) )
else :
findings . append ( _finding (
" MATCH " , external = external , service_item = service_item , interval = interval ,
reason = " dates, quantity and amount match " , sku_status = catalog_status ,
) )
for interval in intervals :
service_item_id = interval . get ( " service_item_id " )
if service_item_id in observed_items or service_item_id in uncertain_items :
continue
if interval . get ( " charge_type " ) == " payg " :
continue
service_item = next ( ( item for item in items if item . get ( " id " ) == service_item_id ) , None )
if service_item_id in exceptions_by_item :
findings . append ( _finding (
" NEEDS_HUMAN_DECISION " , service_item = service_item , interval = interval ,
reason = " service item has open dossier exceptions " ,
exception_ids = [ issue [ " id " ] for issue in exceptions_by_item [ service_item_id ] ] ,
) )
else :
findings . append ( _finding (
" UNBILLED_SERVICE " , service_item = service_item , interval = interval ,
reason = " contractual service is active but absent from external rows " ,
) )
summary = { }
for finding in findings :
summary [ finding [ " status " ] ] = summary . get ( finding [ " status " ] , 0 ) + 1
return {
" ok " : True ,
" dossier_id " : dossier_id ,
" target " : {
" kind " : window [ " kind " ] ,
" start " : window [ " start " ] . isoformat ( ) ,
" end_exclusive " : window [ " end " ] . isoformat ( ) ,
} ,
" summary " : summary ,
" findings " : findings ,
}