344 lines
15 KiB
Python
344 lines
15 KiB
Python
"""SQLAlchemy ORM models for month-end closing sessions and their results."""
|
|
from __future__ import annotations
|
|
|
|
import datetime as dt
|
|
|
|
from sqlalchemy import (
|
|
Boolean, Column, Date, DateTime, Float, ForeignKey, Integer, String, Text, Index
|
|
)
|
|
from sqlalchemy.orm import relationship
|
|
|
|
from .database import Base
|
|
|
|
|
|
def _now() -> dt.datetime:
|
|
return dt.datetime.utcnow()
|
|
|
|
|
|
class Session(Base):
|
|
__tablename__ = "sessions"
|
|
id = Column(Integer, primary_key=True)
|
|
name = Column(String(255), nullable=False)
|
|
reporting_month = Column(String(32)) # "2026-01"
|
|
month_end_date = Column(Date)
|
|
reporting_currency = Column(String(16), default="USD")
|
|
clearing_lag_days = Column(Integer, default=2)
|
|
rounding_tolerance = Column(Float, default=0.01)
|
|
allowance_for_returns = Column(Float, default=0.0)
|
|
manual_adjustment = Column(Float, default=0.0)
|
|
manual_adjustment_note = Column(String(512), default="")
|
|
# How the opening AR balance is established: zero (default) | carry_forward | manual
|
|
opening_mode = Column(String(32), default="zero")
|
|
opening_source_session_id = Column(Integer)
|
|
# draft|processing|processed|blocked|completed|error
|
|
# "blocked" = processed, but a month-end control failed, so no receivable figure is
|
|
# released to the dashboard or to an export until it is resolved.
|
|
status = Column(String(32), default="draft")
|
|
blocked_reason = Column(Text, default="")
|
|
# How payouts count as received:
|
|
# auto — bank-receipt date when entered, clearing-lag heuristic otherwise (default)
|
|
# manual — ONLY payouts with a bank-receipt date ≤ month-end count; no heuristic
|
|
payout_mode = Column(String(32), default="auto")
|
|
# Receipts or payout mode changed after the last processing run — the classification on
|
|
# screen no longer reflects them until the closing is re-processed.
|
|
needs_reprocess = Column(Boolean, default=False)
|
|
progress_stage = Column(String(255), default="")
|
|
progress_pct = Column(Float, default=0.0)
|
|
progress_rows_done = Column(Integer, default=0)
|
|
progress_rows_total = Column(Integer, default=0)
|
|
eta_seconds = Column(Integer, default=0)
|
|
error = Column(Text, default="")
|
|
created_at = Column(DateTime, default=_now)
|
|
updated_at = Column(DateTime, default=_now, onupdate=_now)
|
|
|
|
files = relationship("SessionFile", back_populates="session", cascade="all, delete-orphan")
|
|
settlements = relationship("Settlement", back_populates="session", cascade="all, delete-orphan")
|
|
exceptions = relationship("Exception_", back_populates="session", cascade="all, delete-orphan")
|
|
fx_rates = relationship("FxRate", back_populates="session", cascade="all, delete-orphan")
|
|
reserves = relationship("Reserve", back_populates="session", cascade="all, delete-orphan")
|
|
|
|
|
|
class SessionFile(Base):
|
|
__tablename__ = "session_files"
|
|
id = Column(Integer, primary_key=True)
|
|
session_id = Column(Integer, ForeignKey("sessions.id"), nullable=False)
|
|
filename = Column(String(512))
|
|
stored_path = Column(String(1024))
|
|
size_bytes = Column(Integer)
|
|
sha256 = Column(String(64))
|
|
worksheets = Column(Text) # JSON list
|
|
data_sheet = Column(String(255))
|
|
imported_rows = Column(Integer, default=0)
|
|
min_date = Column(Date)
|
|
max_date = Column(Date)
|
|
currency = Column(String(16))
|
|
marketplace = Column(String(64))
|
|
status = Column(String(32), default="uploaded") # uploaded|parsed|invalid
|
|
message = Column(Text, default="")
|
|
# Control C1: the worksheet's own declared extent vs what we actually consumed.
|
|
sheet_last_row = Column(Integer, default=0)
|
|
blank_rows_skipped = Column(Integer, default=0)
|
|
helper_rows_skipped = Column(Integer, default=0)
|
|
session = relationship("Session", back_populates="files")
|
|
|
|
|
|
class Settlement(Base):
|
|
__tablename__ = "settlements"
|
|
id = Column(Integer, primary_key=True)
|
|
session_id = Column(Integer, ForeignKey("sessions.id"), nullable=False)
|
|
marketplace = Column(String(64))
|
|
account_type = Column(String(64))
|
|
settlement_id = Column(String(255))
|
|
order_total = Column(Float, default=0.0)
|
|
transfer_total = Column(Float, default=0.0)
|
|
transfer_amount = Column(Float) # boundary/received transfer amount if any
|
|
transfer_date = Column(Date)
|
|
transfer_received = Column(Boolean)
|
|
row_count = Column(Integer, default=0)
|
|
first_date = Column(Date)
|
|
last_date = Column(Date)
|
|
status = Column(String(32)) # paid|receivable
|
|
session = relationship("Session", back_populates="settlements")
|
|
|
|
|
|
Index("ix_settlement_session", Settlement.session_id, Settlement.marketplace, Settlement.account_type)
|
|
|
|
|
|
class Transaction(Base):
|
|
__tablename__ = "transactions"
|
|
id = Column(Integer, primary_key=True)
|
|
session_id = Column(Integer, ForeignKey("sessions.id"), nullable=False)
|
|
source_file = Column(String(512))
|
|
source_sheet = Column(String(255))
|
|
source_row = Column(Integer)
|
|
marketplace = Column(String(64))
|
|
settlement_id = Column(String(255))
|
|
order_id = Column(String(255))
|
|
sku = Column(String(255))
|
|
txn_type = Column(String(255)) # original (possibly localized) type
|
|
txn_type_en = Column(String(255)) # canonical English type
|
|
account_type = Column(String(64))
|
|
posted_date = Column(Date)
|
|
total = Column(Float, default=0.0)
|
|
currency = Column(String(16), default="USD")
|
|
settlement_status = Column(String(32)) # paid|receivable
|
|
receivable_flag = Column(Boolean, default=False)
|
|
storage_flag = Column(Boolean, default=False) # storage-fee detection drill-down
|
|
|
|
|
|
Index("ix_txn_session", Transaction.session_id)
|
|
|
|
|
|
class MappingRule(Base):
|
|
"""Admin-defined header alias: normalized source header -> canonical field.
|
|
Lets Finance map new/renamed Amazon headers without code changes."""
|
|
__tablename__ = "mapping_rules"
|
|
id = Column(Integer, primary_key=True)
|
|
normalized_header = Column(String(255), unique=True, nullable=False)
|
|
field = Column(String(128), nullable=False)
|
|
created_at = Column(DateTime, default=_now)
|
|
Index("ix_txn_settlement", Transaction.session_id, Transaction.settlement_id)
|
|
Index("ix_txn_type", Transaction.session_id, Transaction.txn_type)
|
|
Index("ix_txn_recv", Transaction.session_id, Transaction.receivable_flag)
|
|
|
|
|
|
class Exception_(Base):
|
|
__tablename__ = "exceptions"
|
|
id = Column(Integer, primary_key=True)
|
|
session_id = Column(Integer, ForeignKey("sessions.id"), nullable=False)
|
|
category = Column(String(128))
|
|
severity = Column(String(32)) # error|warning|info
|
|
detail = Column(Text)
|
|
source = Column(String(512), default="")
|
|
session = relationship("Session", back_populates="exceptions")
|
|
|
|
|
|
class FxRate(Base):
|
|
__tablename__ = "fx_rates"
|
|
id = Column(Integer, primary_key=True)
|
|
session_id = Column(Integer, ForeignKey("sessions.id"), nullable=False)
|
|
marketplace = Column(String(64))
|
|
currency = Column(String(16), default="USD")
|
|
rate = Column(Float, default=1.0)
|
|
source = Column(String(64), default="manual")
|
|
rate_date = Column(Date)
|
|
# Control C5: a seeded default is a SUGGESTION, not a rate. Until someone confirms it for
|
|
# this reporting month the closing is blocked — otherwise a July close silently values EUR
|
|
# at the hardcoded January rate.
|
|
confirmed_by = Column(String(255), default="")
|
|
confirmed_at = Column(DateTime)
|
|
confirmed_month = Column(String(32), default="") # reporting month the confirmation is for
|
|
session = relationship("Session", back_populates="fx_rates")
|
|
|
|
|
|
class PayoutReceipt(Base):
|
|
"""
|
|
When a payout actually reached the BANK — entered by Finance per payout.
|
|
|
|
Amazon's Transfer row carries the date Amazon *initiated* the payout; the money lands
|
|
3-5 working days later. A receipt overrides the clearing-lag heuristic for its payout:
|
|
received iff bank_date ≤ month-end. Keyed exactly like the engine's transfer overrides,
|
|
(marketplace, account_type bucket, settlement_id).
|
|
"""
|
|
__tablename__ = "payout_receipts"
|
|
id = Column(Integer, primary_key=True)
|
|
session_id = Column(Integer, ForeignKey("sessions.id"), nullable=False)
|
|
marketplace = Column(String(64), nullable=False)
|
|
account_type = Column(String(64), nullable=False) # bucket label, e.g. "(unspecified)"
|
|
settlement_id = Column(String(255), nullable=False)
|
|
bank_date = Column(Date, nullable=False)
|
|
bank_amount = Column(Float) # optional; None = same as Amazon amount
|
|
note = Column(String(512), default="")
|
|
entered_by = Column(String(255), default="")
|
|
updated_at = Column(DateTime, default=_now, onupdate=_now)
|
|
__table_args__ = (
|
|
Index("ix_payout_receipts_key", "session_id", "marketplace",
|
|
"account_type", "settlement_id", unique=True),
|
|
)
|
|
|
|
|
|
class ControlResult(Base):
|
|
"""Outcome of one month-end control (see core/controls.py). One row per control per close."""
|
|
__tablename__ = "control_results"
|
|
id = Column(Integer, primary_key=True)
|
|
session_id = Column(Integer, ForeignKey("sessions.id"), nullable=False)
|
|
key = Column(String(32)) # "C1".."C6"
|
|
label = Column(String(255))
|
|
status = Column(String(32)) # pass|fail|not_applicable
|
|
severity = Column(String(32), default="error") # error|warning|info
|
|
detail = Column(Text, default="")
|
|
evidence = Column(Text, default="") # JSON list of strings
|
|
checked_at = Column(DateTime, default=_now)
|
|
|
|
|
|
class Reserve(Base):
|
|
__tablename__ = "reserves"
|
|
id = Column(Integer, primary_key=True)
|
|
session_id = Column(Integer, ForeignKey("sessions.id"), nullable=False)
|
|
marketplace = Column(String(64))
|
|
account_type = Column(String(64))
|
|
amount = Column(Float, default=0.0)
|
|
session = relationship("Session", back_populates="reserves")
|
|
|
|
|
|
class ReceivableResultRow(Base):
|
|
__tablename__ = "receivable_results"
|
|
id = Column(Integer, primary_key=True)
|
|
session_id = Column(Integer, ForeignKey("sessions.id"), nullable=False)
|
|
marketplace = Column(String(64))
|
|
account_type = Column(String(64)) # or "TOTAL"
|
|
additional_sales = Column(Float, default=0.0)
|
|
reserve = Column(Float, default=0.0)
|
|
receivable_local = Column(Float, default=0.0)
|
|
fx_rate = Column(Float, default=1.0)
|
|
receivable_usd = Column(Float, default=0.0)
|
|
currency = Column(String(16), default="USD")
|
|
|
|
|
|
class ReconciliationRow(Base):
|
|
__tablename__ = "reconciliation"
|
|
id = Column(Integer, primary_key=True)
|
|
session_id = Column(Integer, ForeignKey("sessions.id"), nullable=False, unique=True)
|
|
uploaded_total = Column(Float)
|
|
receivable_orders = Column(Float)
|
|
paid_orders = Column(Float)
|
|
transfers_total = Column(Float)
|
|
reserve_total = Column(Float)
|
|
manual_adjustments = Column(Float)
|
|
final_receivable_usd = Column(Float)
|
|
identity_difference = Column(Float)
|
|
status = Column(String(64))
|
|
notes = Column(Text, default="")
|
|
# AR roll-forward components
|
|
received_payouts = Column(Float, default=0.0) # Σ transfers received (negative)
|
|
all_payouts = Column(Float, default=0.0) # Σ all transfers (negative)
|
|
|
|
|
|
class FxRateDaily(Base):
|
|
"""Optional per-date FX override. Falls back to the marketplace's month rate."""
|
|
__tablename__ = "fx_rates_daily"
|
|
id = Column(Integer, primary_key=True)
|
|
session_id = Column(Integer, ForeignKey("sessions.id"), nullable=False)
|
|
marketplace = Column(String(64))
|
|
rate_date = Column(Date)
|
|
rate = Column(Float, default=1.0)
|
|
source = Column(String(64), default="manual")
|
|
|
|
|
|
Index("ix_fx_daily", FxRateDaily.session_id, FxRateDaily.marketplace, FxRateDaily.rate_date)
|
|
|
|
|
|
class MarketPayout(Base):
|
|
"""Per-marketplace disbursement totals for the AR roll-forward.
|
|
|
|
Transfers are attributed to the OWNER of their tagged settlement, not the file they
|
|
appear in — Amazon repeats account-level payouts across country reports (e.g. the
|
|
Belgium chain's transfers sit inside the Germany file).
|
|
"""
|
|
__tablename__ = "market_payouts"
|
|
id = Column(Integer, primary_key=True)
|
|
session_id = Column(Integer, ForeignKey("sessions.id"), nullable=False)
|
|
marketplace = Column(String(64))
|
|
received_payouts = Column(Float, default=0.0) # negative
|
|
all_payouts = Column(Float, default=0.0) # negative
|
|
|
|
|
|
Index("ix_market_payout_session", MarketPayout.session_id, MarketPayout.marketplace)
|
|
|
|
|
|
class OpeningBalance(Base):
|
|
__tablename__ = "opening_balances"
|
|
id = Column(Integer, primary_key=True)
|
|
session_id = Column(Integer, ForeignKey("sessions.id"), nullable=False)
|
|
marketplace = Column(String(64))
|
|
amount = Column(Float, default=0.0)
|
|
reason = Column(String(512), default="")
|
|
source = Column(String(64), default="manual") # manual | carried_forward
|
|
updated_at = Column(DateTime, default=_now, onupdate=_now)
|
|
|
|
|
|
Index("ix_opening_session", OpeningBalance.session_id, OpeningBalance.marketplace)
|
|
|
|
|
|
class FinanceControl(Base):
|
|
"""Finance's manual control-sheet amounts + sign-off, for the reconciliation section."""
|
|
__tablename__ = "finance_control"
|
|
id = Column(Integer, primary_key=True)
|
|
session_id = Column(Integer, ForeignKey("sessions.id"), nullable=False, unique=True)
|
|
gross_sales = Column(Float)
|
|
refunds = Column(Float)
|
|
net_revenue = Column(Float)
|
|
disbursements = Column(Float)
|
|
closing_receivable = Column(Float)
|
|
tolerance = Column(Float, default=1.0)
|
|
verified_by = Column(String(255), default="")
|
|
verified_at = Column(DateTime)
|
|
comment = Column(Text, default="")
|
|
|
|
|
|
class JournalEntry(Base):
|
|
__tablename__ = "journal_entries"
|
|
id = Column(Integer, primary_key=True)
|
|
session_id = Column(Integer, ForeignKey("sessions.id"), nullable=False, unique=True)
|
|
entry_no = Column(String(128), default="")
|
|
data = Column(Text, default="") # JSON: periods + lines + receivable
|
|
# Two-step sign-off. Approval is what publishes this month's journal to the Accounts
|
|
# Summary. Re-processing rebuilds the journal row, so both clear automatically whenever
|
|
# the numbers change — a sign-off only ever attests to figures the signer actually saw
|
|
# (entry_no is carried over; see jobs.run_processing).
|
|
reviewed_by = Column(String(255), default="")
|
|
reviewed_at = Column(DateTime)
|
|
approved_by = Column(String(255), default="")
|
|
approved_at = Column(DateTime)
|
|
|
|
|
|
class ExportRecord(Base):
|
|
__tablename__ = "exports"
|
|
id = Column(Integer, primary_key=True)
|
|
session_id = Column(Integer, ForeignKey("sessions.id"), nullable=False)
|
|
kind = Column(String(32), default="full") # full | summary
|
|
path = Column(String(1024))
|
|
sha256 = Column(String(64))
|
|
size_bytes = Column(Integer)
|
|
generated_at = Column(DateTime, default=_now)
|