"""The invoice-draft record family, apart from the service that fills it.
These records are read by the on-host inference package, which holds no
repository handle of its own. Their previous home, ``evidence_draft``, also
wires :class:`~cadrumo.adapters.persistence.profile.invoices.InvoiceCatalogueRepository`
and :class:`~cadrumo.adapters.persistence.storage.attachment.AttachmentStore`, so importing
a draft record pulled the whole persistence subtree into every consumer that
only wanted the shape -- including the outbound LLM adapter, whose distance from
persistence is what the operator's in-memory inference exemption rests on.
This module therefore imports neither persistence nor the inference package,
and must keep it that way. Every dependency below was checked to reach neither
before the split.
"""
from __future__ import annotations
from decimal import Decimal
from typing import Self
from pydantic import BaseModel, Field, PrivateAttr, model_validator
from ...core.draft_discrepancy import DraftDiscrepancyKind
from ...core.errors.hierarchy import pydantic_validation_boundary
from ...core.field_grounding import FieldGroundingOutcome
from ...core.field_origin import FieldOrigin
from ...core.identity.digest import ContentDigest
from ...core.identity.tax_id import TaxIdIdentityToken
from ...core.models import STRICT_FROZEN_CONFIG
from ...domain.iva.classification import InvoiceKind
from ...domain.iva.supply_nature import SupplyNature
from .structured_invoice_ports import StructuredInvoiceClassification, StructuredInvoiceClassificationKind
__all__ = [
"DraftDiscrepancyFinding",
"FieldAmbiguityCandidate",
"FieldProvenance",
"InvoiceDraft",
"InvoiceDraftIdentityDocumentFields",
"InvoiceDraftLine",
"InvoiceDraftRateBreakdown",
]
[docs]
class InvoiceDraftLine(BaseModel):
"""One line item recovered from a structured invoice document.
Only a structured reader can populate this: a text or vision reader
recovers printed totals, not the document's own line decomposition. The
rate is carried as a bare percentage :class:`~decimal.Decimal` (``21``, not
``0.21``) because the draft is pre-confirm operator-facing data; mapping it
onto the closed ``IvaRate`` slot enum happens at the parse boundary, which
refuses loudly rather than rounding to the nearest slot.
Attributes:
description: Line description as printed, or ``None``.
quantity: Billed quantity, or ``None`` when the document omits it.
unit_price: Price per unit before tax, or ``None``.
taxable_base: Line taxable base before IVA.
iva_rate: Line IVA percentage as a whole-number Decimal.
iva_amount: Line IVA cuota, or ``None`` when the document states only
the rate and lets the total carry the cuota.
recargo_rate: Recargo de equivalencia percentage, or ``None``.
recargo_amount: Recargo de equivalencia cuota, or ``None``.
"""
model_config = STRICT_FROZEN_CONFIG
description: str | None = None
quantity: Decimal | None = None
unit_price: Decimal | None = None
taxable_base: Decimal | None = None
iva_rate: Decimal | None = None
iva_amount: Decimal | None = None
recargo_rate: Decimal | None = None
recargo_amount: Decimal | None = None
[docs]
class InvoiceDraftRateBreakdown(BaseModel):
"""Per-rate tax subtotal as the document itself declares it.
This is the field that makes the multi-rate silent collapse detectable. A
draft carrying only the flat ``taxable_base`` / ``iva_rate`` /
``iva_amount`` triple structurally cannot represent an invoice charging
two rates: the two bases sum into one figure and one of the rates is simply
lost, producing an invoice whose printed total no longer reconciles with
its declared cuota.
Attributes:
iva_rate: The rate this subtotal is charged at, as a whole-number
percentage Decimal.
taxable_base: Base charged at this rate.
iva_amount: Cuota charged at this rate.
recargo_rate: Recargo de equivalencia percentage for this rate.
recargo_amount: Recargo de equivalencia cuota for this rate.
"""
model_config = STRICT_FROZEN_CONFIG
iva_rate: Decimal | None = None
taxable_base: Decimal | None = None
iva_amount: Decimal | None = None
recargo_rate: Decimal | None = None
recargo_amount: Decimal | None = None
[docs]
class FieldAmbiguityCandidate(BaseModel):
"""One competing reading a grounding pass could not decide between.
Recorded rather than resolved. An ambiguity collapsed by taking the first
match is indistinguishable downstream from a fact, so the candidates travel
to the operator, who is the only party with the document in front of them.
Attributes:
value: The candidate reading, as a string. Deliberately not the field's
own type: a candidate exists precisely because the value could not
be established, and coercing an undecided reading into a
``Decimal`` or a ``date`` would assert the parse that was in
question.
anchor: The verbatim printed form this candidate was read from, or
``None`` when the candidate is a normalisation of another reading
rather than a distinct occurrence.
note: Why this candidate competed, in operator-facing terms.
"""
model_config = STRICT_FROZEN_CONFIG
value: str
anchor: str | None = None
note: str = ""
[docs]
class FieldProvenance(BaseModel):
"""The provenance envelope for exactly one field of a draft.
Per FIELD, not per document. A draft is routinely assembled from several
readers -- a structured record for the parties, an arithmetic derivation for
a cuota, an operator correction at confirm -- so a single document-level
stamp would claim one origin for values that did not share one. That is the
laundering this record exists to prevent: an exactly-read value must stay
distinguishable from a model-read one all the way to the operator's screen.
Carries no numeric confidence, deliberately and permanently. See
:class:`~core.field_origin.FieldOrigin` and :class:`~core.field_grounding.FieldGroundingOutcome` for the
two axes that are facts: how the value was obtained, and what checking it
survived.
Attributes:
field: Name of the :class:`InvoiceDraft` field this envelope describes.
Validated against the draft's own fields rather than a hand-listed
enum, so a renamed or removed draft field invalidates its stale
envelopes instead of leaving them pointing at nothing.
origin: How the value was obtained.
grounding: What verification the value passed, or failed.
anchor: The verbatim printed form the value was read from, exactly as it
appears in the source -- ``"1.234,56 €"``, not ``1234.56``. This is
the whole anti-fabrication mechanism: a value nobody can point at in
the document has ``None`` here and an ``UNANCHORED`` outcome, and
the operator sees both.
candidates: Competing readings, when the grounding outcome is
``AMBIGUOUS``. Empty otherwise.
refused_anchor: The printed form the reader DID offer, kept here when the
check looked for it and the document does not carry it. The anchor
field is cleared on that outcome, deliberately -- a form the document
does not print is not evidence, and every consumer reading
:attr:`anchor` reads it as evidence. Clearing it alone, though,
leaves a refused claim indistinguishable from an absent one, and the
two are different operator situations: a reader that offered nothing
is a reader limitation, while a reader that offered something the
document does not carry is a possible misread or the wrong document
entirely. So the refused form is preserved HERE, where nothing can
mistake it for a corroborated one, and the operator surface can say
which of the two happened. ``None`` whenever no check ran or the
anchor was found.
anchor_self_reported: ``True`` when the anchor was asserted by the same
reader that produced the value, with nothing independent to check it
against -- the vision lane, which reads image to fields in one call
and has no transcription. Such an anchor is recorded because it is
still useful to an operator, but it can never carry an ``ANCHORED``
outcome; see the validator below.
role_evidence: For an identity field, the printed context the reader
copied that assigns this value to its party role -- a heading, a
label, the line the identifier sits under. ``None`` where the
document showed nothing that does, and on every field that names no
party. This is a SECOND anchor rather than a note: it is a printed
excerpt, so it is checkable against the transcription exactly the
way :attr:`anchor` is, and it is only trusted once that check has
run. The reader's own account of what it did ("I assigned this to
supplier_tax_id") is not role evidence and must never be recorded
here -- always-truthy text in this slot permanently satisfies the
guard that exists to refuse an unevidenced identity, which is a
measured defect rather than a hypothetical one.
attribution_unverified: ``True`` when nothing checked WHICH PARTY this
value belongs to. Party attribution is its own axis, distinct from
whether the value was read correctly: a postal code can be copied
perfectly off the page and still be filed under the wrong party,
and every anchor check in this record would pass. Role evidence
answers the attribution question for the identity fields, and the
document's own record answers it for a structured read, where the
element path names the party. For a model-read address value there
is no such answer today -- the reader's assignment is final and
unchecked -- so the value is stamped here and the operator is told.
Per FIELD for the same reason everything else on this envelope is:
one party's postal code may be attributed while their country is
not, and a draft-level or party-level flag could not say so. When
deterministic co-location lands, an attributed value simply stops
carrying the stamp; nothing here changes shape.
note: Operator-facing explanation, e.g. which identity contradicted the
value.
"""
model_config = STRICT_FROZEN_CONFIG
field: str = Field(min_length=1)
origin: FieldOrigin
grounding: FieldGroundingOutcome
anchor: str | None = None
refused_anchor: str | None = None
candidates: tuple[FieldAmbiguityCandidate, ...] = ()
anchor_self_reported: bool = False
derived_from: tuple[str, ...] = ()
role_evidence: str | None = None
attribution_unverified: bool = False
note: str = ""
@model_validator(mode="after")
@pydantic_validation_boundary
def _a_derived_value_cites_its_inputs_and_never_an_anchor(self) -> Self:
"""Tie ``DERIVED`` to the inputs it followed from, and bar it from ANCHORED.
A derived value was never on the page, so there is nothing in the
document to point at and an ``ANCHORED`` stamp would assert a printed
form that does not exist. What an auditor needs instead is the input
set: the derivation is deterministic, so naming its inputs makes the
conclusion reproducible by hand, which is the derived equivalent of
showing the anchor.
Enforced in both directions, for the same reason the ambiguity rule is.
A ``DERIVED`` envelope with no inputs claims a conclusion it cannot show
its working for, and inputs recorded under any other origin describe a
derivation that did not happen.
"""
if self.origin is FieldOrigin.DERIVED:
if self.grounding is FieldGroundingOutcome.ANCHORED:
raise ValueError(
"a derived value cannot be ANCHORED: it was concluded from other values rather "
"than read from the document, so no printed form anchors it",
)
if not self.derived_from:
raise ValueError("a derived value must record the inputs it was derived from")
elif self.derived_from:
raise ValueError(
f"derived_from is only meaningful for a derived value; got origin={self.origin.value!r}",
)
return self
@model_validator(mode="after")
@pydantic_validation_boundary
def _a_self_reported_anchor_can_never_read_as_verified(self) -> Self:
"""Refuse an ``ANCHORED`` outcome on an anchor nothing independent confirmed.
The two reading lanes do not supply the same STRENGTH of evidence, and
collapsing them is how an anti-fabrication check becomes decoration.
On the text lane the anchor is matched against a transcription produced
by a different reader than the one that proposed the value, so the match
is a genuine external check. On the vision lane there is no transcription
at all -- the model reads image to fields in one call -- so the anchor is
the model's own claim about what it saw. Matching that claim against the
model's own reply confirms only that the model is self-consistent, which
a fabricating model also is.
Enforcing the invariant HERE rather than in the checker means no reading
path can launder a self-reported anchor into a verified-looking one, even
by constructing the envelope directly. When a vision transcription stage
lands, that path stops setting this flag and earns ``ANCHORED`` through
the same check the text lane already passes -- no change to this rule.
"""
if self.anchor_self_reported and self.grounding is FieldGroundingOutcome.ANCHORED:
raise ValueError(
"a self-reported anchor cannot be ANCHORED: the anchor was asserted by the same "
"reader that produced the value, so nothing independent confirmed it",
)
return self
@model_validator(mode="after")
@pydantic_validation_boundary
def _ambiguity_carries_its_candidates(self) -> Self:
"""Tie the ``AMBIGUOUS`` outcome to the candidates that justify it.
Both directions are enforced. An ``AMBIGUOUS`` envelope with fewer than
two candidates asserts an ambiguity it cannot show, and candidates under
any other outcome record competing readings while claiming the field was
decided -- each is a stamp that says something the record does not
support.
"""
if self.grounding is FieldGroundingOutcome.AMBIGUOUS:
if len(self.candidates) < 2:
raise ValueError("an ambiguous field must record at least two competing candidates")
elif self.candidates:
raise ValueError(
f"candidates are only meaningful for an ambiguous field; got grounding={self.grounding.value!r}",
)
return self
@model_validator(mode="after")
@pydantic_validation_boundary
def _anchor_matches_the_outcome(self) -> Self:
"""Refuse an ``ANCHORED`` claim with no anchor to show for it."""
if self.grounding is FieldGroundingOutcome.ANCHORED and self.anchor is None:
raise ValueError("an anchored field must carry the verbatim anchor it was anchored to")
return self
@model_validator(mode="after")
@pydantic_validation_boundary
def _a_refused_anchor_is_never_also_a_carried_one(self) -> Self:
"""Keep the refused form out of every slot a consumer reads as evidence.
Both directions, for the reason the sibling rules are two-directional. An
envelope carrying the same claim in both slots would let a consumer that
reads :attr:`anchor` treat a refused form as a located one, which is the
laundering clearing the anchor exists to prevent. And a refused anchor
under a grounding outcome that means the check PASSED describes a
verdict that did not happen: the check cannot both locate the form and
report it absent.
"""
if self.refused_anchor is None:
return self
if self.anchor is not None:
raise ValueError(
"an anchor cannot be both carried and refused: the refused form is recorded only "
"because the check did not locate it, so nothing may read it as evidence",
)
if self.grounding in {FieldGroundingOutcome.ANCHORED, FieldGroundingOutcome.RECONCILED}:
raise ValueError(
f"a refused anchor cannot sit under grounding={self.grounding.value!r}: the check "
"reported the printed form absent, so it did not corroborate the value",
)
return self
[docs]
class DraftDiscrepancyFinding(BaseModel):
"""One deterministic check the read document failed.
Distinct from :class:`PrintedTotalDiscrepancy`, which compares the document
against the invoice that was actually WRITTEN and therefore only exists at
confirm. This record is a finding about the document alone, available the
moment it is read, so the operator meets it during review rather than after
a record has been minted from it.
Attributes:
kind: Which identity failed.
field: The draft field the finding is about, or ``None`` when the
finding is about a relationship between several.
detail: Operator-facing explanation naming the figures involved.
expected: What the identity required, when the check is arithmetic.
observed: What the document stated instead.
"""
model_config = STRICT_FROZEN_CONFIG
kind: DraftDiscrepancyKind
field: str | None = None
detail: str = ""
expected: Decimal | None = None
observed: Decimal | None = None
[docs]
def facturae_invoice_class_findings(
*,
declared: StructuredInvoiceClassification | None,
rectifies_invoice_number: str | None,
) -> tuple[DraftDiscrepancyFinding, ...]:
"""Report Facturae class gaps and contradictions without choosing a side."""
if declared is None:
return ()
findings: list[DraftDiscrepancyFinding] = []
if declared.kind is StructuredInvoiceClassificationKind.SUMMARY:
findings.append(
DraftDiscrepancyFinding(
kind=DraftDiscrepancyKind.INVOICE_CLASS_UNMODELLED,
detail=(
f"Facturae InvoiceClass {declared.source_code!r} declares recapitulativa, which is not modelled"
),
),
)
declares_correction = declared.kind is StructuredInvoiceClassificationKind.CORRECTIVE
carries_correction = rectifies_invoice_number is not None
if declares_correction != carries_correction:
findings.append(
DraftDiscrepancyFinding(
kind=DraftDiscrepancyKind.INVOICE_CLASS_CONTRADICTED,
detail=(
f"Facturae InvoiceClass {declared.source_code!r} and Corrective/InvoiceNumber "
f"presence={carries_correction!r} disagree"
),
),
)
return tuple(findings)
[docs]
class InvoiceDraftIdentityDocumentFields(BaseModel):
"""Identity and printed-document fields shared by draft projections."""
supplier_tax_id: TaxIdIdentityToken | None = None
supplier_name: str | None = None
customer_tax_id: TaxIdIdentityToken | None = None
customer_name: str | None = None
supplier_postal_code: str | None = None
customer_postal_code: str | None = None
supplier_country: str | None = None
customer_country: str | None = None
supplier_country_code: str | None = None
customer_country_code: str | None = None
supplier_stated_country_code: str | None = None
customer_stated_country_code: str | None = None
invoice_number: str | None = None
invoice_series: str | None = None
rectifies_invoice_number: str | None = None
[docs]
class InvoiceDraft(InvoiceDraftIdentityDocumentFields):
"""Best-effort invoice fields extracted from an on-host PDF text layer.
Every field is optional: a field the extractor cannot ground in the
document's text is left ``None`` rather than guessed. The operator reviews
this draft and supplies or corrects fields before any
:class:`~domain.invoices.models.Invoice` is minted from it -- this model is
never itself persisted as a filing-grade record.
Attributes:
supplier_tax_id: Canonical Spanish NIF / NIE / CIF recovered from the
text, or ``None`` when no valid tax identifier was found.
supplier_name: The issuing party's stated name, or ``None``.
customer_tax_id: The receiving party's tax identifier, or ``None``.
Populated only by a structured reader, which is the only one that
can tell the two parties apart; a text or vision reader recovers a
single identifier and cannot say whose it is.
customer_name: The receiving party's stated name, or ``None``.
supplier_postal_code: The postal code printed in the issuing party's
address, copied verbatim, or ``None``. Transcriptive like every
other copied field: it carries the printed code and never the
territory read off it. That reading belongs to
:func:`~domain.iva.establishment.territorial_scope_for_spanish_postal_code`,
which is the deterministic evidence separating the three Spanish
IVA territories -- the first two digits of a Spanish code are the
province -- and which refuses an unreadable code rather than
defaulting it to the peninsula.
customer_postal_code: The same for the party billed by the invoice.
Carried separately because establishment is asked of each party
independently: an issuer in Las Palmas invoicing a customer in
Madrid crosses a territorial boundary that one shared code could
not express.
supplier_country: The country NAME printed in the issuing party's
address, copied verbatim in whatever language the document set it,
or ``None``. Never an alpha-2 code: a country prints as
"Alemania", "Deutschland" or "Allemagne", so recording a code would
mean the reading stage translated, and translation is inference.
The match against the bounded vocabulary belongs to
:func:`~domain.iva.establishment.country_code_for_printed_country_name`, which is
a deterministic lookup rather than a judgement.
customer_country: The same for the party billed by the invoice, carried
separately for the reason the postal codes are: an issuer in Las
Palmas billing a customer in Berlin cannot be expressed by one
shared field.
supplier_country_code: The country code the issuing party's address
states, as an ISO 3166-1 alpha-2 code, or ``None``. Populated only
by the structured reader: a machine-readable record states the
country as a CODE, where a printed document states it as a name and
asking a reader for the code would be asking it to translate.
Carried beside the postal code because the two answer different
halves of one question -- the country says which State, the postal
code separates the three Spanish IVA territories inside it.
**Alpha-2 even where the document stated alpha-3.** Facturae states
``ESP`` and UBL states ``ES``; both arrive here in the single form
every country surface downstream is keyed by, resolved through the
registry correspondence in
:func:`~domain.iva.establishment.country_code_for_stated_country_code`. Normalising
at the boundary rather than downstream is what keeps a Facturae
document from failing an alpha-2 shape check in silence, with its
country element present, read, and establishing nothing.
The translated form is deliberately NOT anchorable in a Facturae
record, and that falls out rather than being asserted: the anchor
check looks for the value as a whole token in the document's own
text, and ``ES`` is not a token in a record that states ``ESP``. So
a stated alpha-2 arrives ANCHORED and a translated one UNANCHORED,
which is the honest distinction between what a document said and
what was derived from it.
customer_country_code: The same for the party billed by the invoice,
carried separately for the reason the postal codes are: a supplier
in Barcelona invoicing a customer in Lisbon states two countries.
supplier_stated_country_code: The country token the issuing party's
address element carries, EXACTLY as the record states it -- ``ESP``
from Facturae, ``ES`` from UBL, ``THA`` from a Thai supplier's
Facturae invoice -- or ``None`` where the record's country element
was absent or empty.
**This is the field that keeps "stated something we cannot place"
distinguishable from "stated nothing".** The resolved code beside it
is contracted alpha-2 and resolves only through the bundled
vocabulary, so a token that vocabulary does not carry leaves it
``None`` -- byte-identical to a document with no country element at
all. That collapse reached the operator: no value, no provenance
envelope, and the country advisory reading an empty field and
staying silent. A genuine Thai export therefore arrived carrying no
country and nothing said so.
Carried verbatim rather than normalised, and never coerced towards
the alpha-2 contract: ``THA`` is not an alpha-2 code and putting it
in a field typed as one would trade a silent absence for a silent
lie. Consumers that need a country ask the resolved field; consumers
that need to know what the DOCUMENT said ask this one.
customer_stated_country_code: The same for the party billed by the
invoice, carried separately for the reason every other party field
is: each side states its own country and either may be the one the
vocabulary cannot place.
invoice_number: Invoice number recovered from a labelled line, or
``None``.
invoice_series: The series half of the invoice's identity, stated
separately by Facturae as ``InvoiceSeriesCode``, or ``None``. Kept
beside the number rather than concatenated into it: composing the
printed reference from the two is always possible, while splitting a
composed string back into them is not.
invoice_date: Day-first invoice date recovered from the text, or
``None``.
taxable_base: Labelled "base imponible" amount, or ``None``.
iva_rate: IVA percentage recovered from a "IVA NN%" label, expressed
as a whole-number :class:`~decimal.Decimal` (e.g. ``21``), or
``None``.
iva_amount: Labelled IVA cuota amount, or ``None``.
grand_total: Labelled invoice total amount, or ``None``.
regime_legend: The statutory mention the document prints to state that
a special regime applies (RD 1619/2012 art. 6.1), copied verbatim,
or ``None`` when the document prints none. Transcriptive and
anchorable like every other copied field: it carries what the paper
says, never a category derived from it. The derivation belongs to
the deterministic classifier downstream, because an
:class:`~domain.iva.schema.IvaCategory` token is printed on no invoice and
a reading stage asked for one would have to infer.
currency: ISO-4217 code for the currency the amounts are printed in,
or ``None`` when the document shows no currency marker. Left
``None`` rather than defaulted to euro: a foreign-currency
invoice silently read as euro would carry its face value into a
filing unconverted, so an absent marker must stay absent and be
resolved by the operator.
retencion_rate: IRPF retención percentage as a whole-number Decimal, or
``None``. Carried beside the amount rather than derived from it: a
document may print either, and deriving the missing half would
manufacture a figure the document does not state.
retencion_amount: IRPF withheld at source. Subtracted from the total to
reach the cash actually paid, so a draft that drops it reconciles
against the wrong figure.
suplidos_amount: Sums advanced on the customer's behalf. Outside the
base imponible by law, so folding them into the base over-declares
IVA on money that was never the issuer's revenue.
suggested_kind: Which side of the invoice the filer is on, as the
reading path SUGGESTS it. Never the decision: the draft is
deliberately pre-direction data and the direction is decided by the
operator at confirm, where ``--kind`` selects the counterparty side.
A suggestion the operator does not act on has no effect.
transcription_sha256: Content address of the stage-one transcription
this draft was read from, tying the draft to the exact artefact that
produced it. The address, never the text: a transcription holds the
document's readable contents and has exactly one sanctioned durable
route (:class:`~application.ledger.document_transcription.DocumentTranscription`), so
embedding it here would open a second one.
provenance: One :class:`FieldProvenance` envelope per field the reading
path established. Absent for a field means no envelope was recorded,
which is itself reviewable -- it never means the value was exact.
discrepancies: Deterministic checks the document failed, available at
read time rather than at confirm.
raw_text_length: Length of the on-host extracted text, kept as an
honest signal of how much source material the heuristics had to
work with (zero means the PDF carried no usable text layer for
this evidence and the operator should route to the on-host vision
reader instead).
"""
model_config = STRICT_FROZEN_CONFIG
proposed_supply_nature: SupplyNature | None = None
"""A model's PROPOSAL about goods-or-services, for a person to accept or discard.
Carried on the draft rather than inside the extracted fields because it is a
JUDGEMENT and not a transcription: it has no printed form to anchor to, and
the anchor check the extraction contract rests on would have nothing to
point at. Folding it in would put an unanchorable value inside the model
whose whole guarantee is that values are copied.
``None`` is the ordinary state. It is populated only when the operator asked
for a proposal, and never reaches the classifier by itself -- the value that
does is the one they state at confirm, which is why the classifier's inputs
stay facts.
"""
invoice_date: str | None = None
taxable_base: Decimal | None = None
iva_rate: Decimal | None = None
iva_amount: Decimal | None = None
grand_total: Decimal | None = None
currency: str | None = None
regime_legend: str | None = None
recargo_amount: Decimal | None = None
retencion_rate: Decimal | None = None
retencion_amount: Decimal | None = None
suplidos_amount: Decimal | None = None
lines: tuple[InvoiceDraftLine, ...] = ()
iva_breakdown: tuple[InvoiceDraftRateBreakdown, ...] = ()
iva_category: str | None = None
suggested_kind: InvoiceKind | None = None
transcription_sha256: ContentDigest | None = None
provenance: tuple[FieldProvenance, ...] = ()
discrepancies: tuple[DraftDiscrepancyFinding, ...] = ()
raw_text_length: int = 0
_facturae_invoice_class: StructuredInvoiceClassification | None = PrivateAttr(default=None)
@property
def facturae_invoice_class(self) -> StructuredInvoiceClassification | None:
"""Return the structured reader's document-class fact, when present."""
return self._facturae_invoice_class
[docs]
def set_facturae_invoice_class(self, value: StructuredInvoiceClassification | None) -> None:
"""Attach the structured reader's document-class fact to this draft."""
self._facturae_invoice_class = value
@model_validator(mode="after")
@pydantic_validation_boundary
def _provenance_names_real_fields(self) -> Self:
"""Refuse an envelope naming a field this draft does not have.
The provenance tuple is keyed by field NAME rather than by a parallel
enum on purpose: an enum would be a second declaration of the draft's
own shape and would drift the first time a field is renamed, leaving
envelopes that validate while describing nothing. Validating against
``model_fields`` cannot drift, because there is only one declaration.
Duplicates are refused for the same reason an ambiguity is: two
envelopes for one field are two provenance claims, and nothing
downstream can say which is the record's answer.
"""
seen: set[str] = set()
for envelope in self.provenance:
if envelope.field not in type(self).model_fields:
known = ", ".join(sorted(type(self).model_fields))
raise ValueError(
f"provenance names unknown draft field {envelope.field!r}; known fields are: {known}",
)
if envelope.field in seen:
raise ValueError(f"provenance carries two envelopes for field {envelope.field!r}")
seen.add(envelope.field)
return self