diff --git a/api/src/domain/quality_metrics.py b/api/src/domain/quality_metrics.py index 0f72174..b7bf258 100644 --- a/api/src/domain/quality_metrics.py +++ b/api/src/domain/quality_metrics.py @@ -33,11 +33,16 @@ _AFTER_CONFIRM = frozenset({QualityOutcome.DELETED_AFTER_CONFIRM, QualityOutcome @dataclass(frozen=True, slots=True) class QualityOutcomeFactRow: - """Минимум полей `quality_outcome_fact`, нужных агрегации (вызывающий сервис строит его из SELECT).""" + """Минимум полей `quality_outcome_fact`, нужных агрегации (вызывающий сервис строит его из SELECT). + + `received_at` — дата приёма результата (`quality_intake_fact.intake_at`, design §7: период по дате приёма, когорта). + Поля нет в `quality_outcome_fact`: Task 15 подставляет его join'ом intake <-> outcome по `document_id`. + """ document_id: uuid.UUID outcome: QualityOutcome outcome_at: datetime.datetime + received_at: datetime.datetime primary_cause: QualityCause | None @@ -59,7 +64,7 @@ class MatrixResult: @dataclass(frozen=True, slots=True) class MonthPoint: - """Точка ряда: `month` — первое число месяца `outcome_at`; `share` — доля причины от знаменателя месяца.""" + """Точка ряда: `month` — первое число месяца `received_at`; `share` — доля причины от знаменателя месяца.""" month: datetime.date count: int @@ -67,12 +72,26 @@ class MonthPoint: share: float +def _tie_rank(outcome: QualityOutcome) -> int: + """Приоритет при равном `outcome_at`: `*_AFTER_CONFIRM` (2) > подтверждение (1) > удаление/замена до него (0).""" + if outcome in _AFTER_CONFIRM: + return 2 + return 1 if outcome in _IN_DENOMINATOR else 0 + + def _latest_per_document(outcomes: Sequence[QualityOutcomeFactRow]) -> list[QualityOutcomeFactRow]: - """Последний по `outcome_at` факт каждого документа (design §4.2); при равенстве времени — поздний во входе.""" + """Последний по `outcome_at` факт каждого документа (design §4.2). + + При равном `outcome_at` побеждает исход с большим `_tie_rank` (результат не зависит от порядка входа: «после + подтверждения» — позднее по смыслу); при равенстве и ранга — поздний во входе. + """ latest: dict[uuid.UUID, QualityOutcomeFactRow] = {} for row in outcomes: current = latest.get(row.document_id) - if current is None or row.outcome_at >= current.outcome_at: + if current is None or (row.outcome_at, _tie_rank(row.outcome)) >= ( + current.outcome_at, + _tie_rank(current.outcome), + ): latest[row.document_id] = row return list(latest.values()) @@ -98,6 +117,8 @@ def compute_matrix(outcomes: Sequence[QualityOutcomeFactRow]) -> MatrixResult: before += 1 elif row.outcome in _AFTER_CONFIRM: after += 1 + else: + raise ValueError(f"неизвестный исход {row.outcome!r} документа {row.document_id}") denominator = sum(counts.values()) return MatrixResult( counts=counts, @@ -113,9 +134,10 @@ def _next_month(month: datetime.date) -> datetime.date: def compute_monthly_series(outcomes: Sequence[QualityOutcomeFactRow], cause: QualityCause) -> list[MonthPoint]: - """§14.3 / design §7: ряд по месяцам `outcome_at` для выбранной причины. + """§14.3 / design §7: ряд по месяцам даты приёма (`received_at`, когорта) для выбранной причины. - В ряд входят только документы со знаменателем (`AUTO_CONFIRMED` / `CONFIRMED`, последний факт документа). + В ряд входят только документы со знаменателем (`AUTO_CONFIRMED` / `CONFIRMED`, последний по `outcome_at` факт + документа); месяц берётся по `received_at`, не по `outcome_at`. Месяцы между первым и последним месяцем данных без документов заполняются нулями (непрерывная ось). """ count_by_month: dict[datetime.date, int] = {} @@ -123,7 +145,7 @@ def compute_monthly_series(outcomes: Sequence[QualityOutcomeFactRow], cause: Qua for row in _latest_per_document(outcomes): if row.outcome not in _IN_DENOMINATOR: continue - month = datetime.date(row.outcome_at.year, row.outcome_at.month, 1) + month = datetime.date(row.received_at.year, row.received_at.month, 1) total_by_month[month] = total_by_month.get(month, 0) + 1 if _cause(row) is cause: count_by_month[month] = count_by_month.get(month, 0) + 1 diff --git a/api/tests/test_domain_quality_metrics.py b/api/tests/test_domain_quality_metrics.py index d475bcb..8fba158 100644 --- a/api/tests/test_domain_quality_metrics.py +++ b/api/tests/test_domain_quality_metrics.py @@ -17,17 +17,20 @@ def make_outcome_row( primary_cause: QualityCause | None = QualityCause.AS_IS, outcome: QualityOutcome = QualityOutcome.CONFIRMED, outcome_at: datetime.datetime | None = None, + received_at: datetime.datetime | None = None, document_id: uuid.UUID | None = None, ) -> QualityOutcomeFactRow: + resolved_outcome_at = outcome_at or datetime.datetime(2026, 10, 15, tzinfo=datetime.UTC) return QualityOutcomeFactRow( document_id=document_id or uuid.uuid4(), outcome=outcome, - outcome_at=outcome_at or datetime.datetime(2026, 10, 15, tzinfo=datetime.UTC), + outcome_at=resolved_outcome_at, + received_at=received_at or resolved_outcome_at, primary_cause=primary_cause, ) -def test_compute_matrix_priority_service_over_our_error(): +def test_compute_matrix_groups_by_stored_primary_cause(): # атрибуция уже в факте — матрица только группирует matrix = compute_matrix( [ @@ -88,10 +91,10 @@ def test_monthly_series_fills_gaps_and_ignores_removed(): return datetime.datetime(2026, month, 5, tzinfo=datetime.UTC) outcomes = [ - make_outcome_row(primary_cause=QualityCause.OUR_ERROR, outcome_at=at(8)), - make_outcome_row(primary_cause=QualityCause.AS_IS, outcome_at=at(8)), - make_outcome_row(primary_cause=QualityCause.OUR_ERROR, outcome_at=at(10)), - make_outcome_row(primary_cause=None, outcome=QualityOutcome.DELETED, outcome_at=at(12)), + make_outcome_row(primary_cause=QualityCause.OUR_ERROR, received_at=at(8), outcome_at=at(11)), + make_outcome_row(primary_cause=QualityCause.AS_IS, received_at=at(8), outcome_at=at(9)), + make_outcome_row(primary_cause=QualityCause.OUR_ERROR, received_at=at(10), outcome_at=at(10)), + make_outcome_row(primary_cause=None, outcome=QualityOutcome.DELETED, received_at=at(12), outcome_at=at(12)), ] series = compute_monthly_series(outcomes, QualityCause.OUR_ERROR) assert [(p.month.month, p.count, p.denominator, p.share) for p in series] == [ @@ -104,8 +107,37 @@ def test_monthly_series_fills_gaps_and_ignores_removed(): def test_monthly_series_empty_and_year_rollover(): assert compute_monthly_series([], QualityCause.AS_IS) == [] outcomes = [ - make_outcome_row(outcome_at=datetime.datetime(2026, 12, 31, tzinfo=datetime.UTC)), - make_outcome_row(outcome_at=datetime.datetime(2027, 1, 1, tzinfo=datetime.UTC)), + make_outcome_row(received_at=datetime.datetime(2026, 12, 31, tzinfo=datetime.UTC)), + make_outcome_row(received_at=datetime.datetime(2027, 1, 1, tzinfo=datetime.UTC)), ] months = [p.month for p in compute_monthly_series(outcomes, QualityCause.AS_IS)] assert months == [datetime.date(2026, 12, 1), datetime.date(2027, 1, 1)] + + +def test_compute_matrix_equal_outcome_at_after_confirm_wins_regardless_of_order(): + doc = uuid.uuid4() + same_time = datetime.datetime(2026, 10, 20, tzinfo=datetime.UTC) + confirmed = make_outcome_row(document_id=doc, outcome_at=same_time) + deleted = make_outcome_row( + document_id=doc, primary_cause=None, outcome=QualityOutcome.DELETED_AFTER_CONFIRM, outcome_at=same_time + ) + for ordered in ([confirmed, deleted], [deleted, confirmed]): + matrix = compute_matrix(ordered) + assert matrix.denominator == 0 + assert matrix.removed_after_confirm == 1 + + +def test_compute_matrix_unknown_outcome_raises(): + row = make_outcome_row() + object.__setattr__(row, "outcome", "UNKNOWN") + with pytest.raises(ValueError, match="неизвестный исход"): + compute_matrix([row]) + + +def test_monthly_series_uses_received_month_not_outcome_month(): + row = make_outcome_row( + received_at=datetime.datetime(2026, 8, 20, tzinfo=datetime.UTC), + outcome_at=datetime.datetime(2026, 10, 2, tzinfo=datetime.UTC), + ) + series = compute_monthly_series([row], QualityCause.AS_IS) + assert [(p.month, p.count, p.denominator) for p in series] == [(datetime.date(2026, 8, 1), 1, 1)]