Merge origin/main into new-changes.
Resolve models.py conflict by keeping session controls/payout fields and MySQL column lengths. Co-authored-by: Cursor <cursoragent@cursor.com>new-changes
commit
1fe6487681
|
|
@ -0,0 +1,16 @@
|
|||
.git
|
||||
.env
|
||||
.env.local
|
||||
**/.venv
|
||||
**/venv
|
||||
**/__pycache__
|
||||
**/*.pyc
|
||||
**/node_modules
|
||||
frontend/dist
|
||||
backend/data
|
||||
*.xlsx
|
||||
*.xls
|
||||
*.csv
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
|
|
@ -22,6 +22,14 @@ docs/
|
|||
|
||||
## Run the app
|
||||
```bash
|
||||
# 1. Configure MySQL (hosted RDS) + data paths
|
||||
cp example.env .env # then fill in MYSQL_* credentials
|
||||
|
||||
# Option A — Docker (backend + Vite hot reload)
|
||||
docker compose up --build
|
||||
# → http://localhost:5173 (API on :8000; uploads/exports on the ar_data volume)
|
||||
|
||||
# Option B — local processes
|
||||
make install # backend deps + npm install
|
||||
make backend # terminal 1 → FastAPI on :8000
|
||||
make frontend # terminal 2 → dashboard on http://localhost:5173
|
||||
|
|
@ -29,6 +37,9 @@ make frontend # terminal 2 → dashboard on http://localhost:5173
|
|||
Then open http://localhost:5173 → **New Closing** → pick the month → drag in the three Amazon
|
||||
files → **Run processing** → review → **Download Full A/R Aging Excel**.
|
||||
|
||||
Uploads and exports are stored under `AR_DATA_DIR` (default `backend/data` locally, `/data` in Docker).
|
||||
The app connects to MySQL using `MYSQL_*` variables from `.env`.
|
||||
|
||||
## Run the engine headless (CLI)
|
||||
```bash
|
||||
cd backend && pip install -r requirements.txt
|
||||
|
|
|
|||
|
|
@ -0,0 +1,16 @@
|
|||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
gcc \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["uvicorn", "app.api.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
|
||||
|
|
@ -3,12 +3,45 @@ from __future__ import annotations
|
|||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote_plus
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Load ar-aging-app/.env (or cwd) before reading settings.
|
||||
_APP_ROOT = Path(__file__).resolve().parent.parent.parent # .../ar-aging-app
|
||||
load_dotenv(_APP_ROOT / ".env")
|
||||
load_dotenv() # also allow cwd overrides
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent # .../backend
|
||||
DATA_DIR = Path(os.environ.get("AR_DATA_DIR", str(BASE_DIR / "data")))
|
||||
UPLOAD_DIR = DATA_DIR / "uploads"
|
||||
EXPORT_DIR = DATA_DIR / "exports"
|
||||
DB_PATH = Path(os.environ.get("AR_DB_PATH", str(DATA_DIR / "ar_aging.db")))
|
||||
|
||||
# MySQL (required)
|
||||
MYSQL_HOST = os.environ.get("MYSQL_HOST", "")
|
||||
MYSQL_PORT = int(os.environ.get("MYSQL_PORT", "3306"))
|
||||
MYSQL_USER = os.environ.get("MYSQL_USER", "")
|
||||
MYSQL_PASSWORD = os.environ.get("MYSQL_PASSWORD", "")
|
||||
MYSQL_DATABASE = os.environ.get("MYSQL_DATABASE", "")
|
||||
MYSQL_SLOW_QUERY_MS = int(os.environ.get("MYSQL_SLOW_QUERY_MS", "500"))
|
||||
MYSQL_POOL_SIZE = int(os.environ.get("MYSQL_POOL_SIZE", "10"))
|
||||
MYSQL_POOL_RECYCLE = int(os.environ.get("MYSQL_POOL_RECYCLE", "3600"))
|
||||
|
||||
|
||||
def mysql_url() -> str:
|
||||
if not all((MYSQL_HOST, MYSQL_USER, MYSQL_DATABASE)):
|
||||
raise RuntimeError(
|
||||
"MYSQL_HOST, MYSQL_USER, and MYSQL_DATABASE are required. "
|
||||
"Copy example.env to .env and fill in credentials."
|
||||
)
|
||||
user = quote_plus(MYSQL_USER)
|
||||
password = quote_plus(MYSQL_PASSWORD)
|
||||
return (
|
||||
f"mysql+pymysql://{user}:{password}"
|
||||
f"@{MYSQL_HOST}:{MYSQL_PORT}/{MYSQL_DATABASE}"
|
||||
f"?charset=utf8mb4"
|
||||
)
|
||||
|
||||
|
||||
# Retention: temp uploads/exports older than this are purged (0 = keep forever).
|
||||
RETENTION_DAYS = int(os.environ.get("AR_RETENTION_DAYS", "30"))
|
||||
|
|
|
|||
|
|
@ -1,30 +1,79 @@
|
|||
"""SQLite database setup (SQLAlchemy). Tuned for bulk transaction inserts."""
|
||||
"""MySQL database setup (SQLAlchemy)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import create_engine, event
|
||||
import logging
|
||||
import time
|
||||
from urllib.parse import quote_plus
|
||||
|
||||
from sqlalchemy import create_engine, event, text
|
||||
from sqlalchemy.orm import declarative_base, sessionmaker
|
||||
|
||||
from ..config import DB_PATH, ensure_dirs
|
||||
from ..config import (
|
||||
MYSQL_DATABASE,
|
||||
MYSQL_HOST,
|
||||
MYSQL_PASSWORD,
|
||||
MYSQL_POOL_RECYCLE,
|
||||
MYSQL_POOL_SIZE,
|
||||
MYSQL_PORT,
|
||||
MYSQL_SLOW_QUERY_MS,
|
||||
MYSQL_USER,
|
||||
ensure_dirs,
|
||||
mysql_url,
|
||||
)
|
||||
|
||||
ensure_dirs()
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _ensure_database() -> None:
|
||||
"""Create MYSQL_DATABASE if it does not exist yet."""
|
||||
user = quote_plus(MYSQL_USER)
|
||||
password = quote_plus(MYSQL_PASSWORD)
|
||||
server_url = (
|
||||
f"mysql+pymysql://{user}:{password}"
|
||||
f"@{MYSQL_HOST}:{MYSQL_PORT}/?charset=utf8mb4"
|
||||
)
|
||||
server_engine = create_engine(server_url, isolation_level="AUTOCOMMIT")
|
||||
try:
|
||||
with server_engine.connect() as conn:
|
||||
conn.execute(
|
||||
text(
|
||||
f"CREATE DATABASE IF NOT EXISTS `{MYSQL_DATABASE}` "
|
||||
"CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"
|
||||
)
|
||||
)
|
||||
finally:
|
||||
server_engine.dispose()
|
||||
|
||||
|
||||
_ensure_database()
|
||||
|
||||
ENGINE = create_engine(
|
||||
f"sqlite:///{DB_PATH}",
|
||||
connect_args={"check_same_thread": False},
|
||||
mysql_url(),
|
||||
pool_size=MYSQL_POOL_SIZE,
|
||||
pool_recycle=MYSQL_POOL_RECYCLE,
|
||||
pool_pre_ping=True,
|
||||
future=True,
|
||||
)
|
||||
|
||||
if MYSQL_SLOW_QUERY_MS > 0:
|
||||
@event.listens_for(ENGINE, "before_cursor_execute")
|
||||
def _before_cursor_execute(conn, cursor, statement, parameters, context, executemany):
|
||||
conn.info["query_start_time"] = time.perf_counter()
|
||||
|
||||
@event.listens_for(ENGINE, "connect")
|
||||
def _sqlite_pragmas(dbapi_conn, _rec):
|
||||
cur = dbapi_conn.cursor()
|
||||
cur.execute("PRAGMA journal_mode=WAL")
|
||||
cur.execute("PRAGMA synchronous=NORMAL")
|
||||
cur.execute("PRAGMA foreign_keys=ON")
|
||||
cur.execute("PRAGMA cache_size=-64000") # ~64 MB page cache
|
||||
cur.execute("PRAGMA busy_timeout=30000") # wait (don't error) when another writer holds the lock
|
||||
cur.close()
|
||||
|
||||
@event.listens_for(ENGINE, "after_cursor_execute")
|
||||
def _after_cursor_execute(conn, cursor, statement, parameters, context, executemany):
|
||||
start = conn.info.pop("query_start_time", None)
|
||||
if start is None:
|
||||
return
|
||||
elapsed_ms = (time.perf_counter() - start) * 1000
|
||||
if elapsed_ms >= MYSQL_SLOW_QUERY_MS:
|
||||
logger.warning(
|
||||
"Slow query (%.0f ms): %s",
|
||||
elapsed_ms,
|
||||
statement[:500],
|
||||
)
|
||||
|
||||
SessionLocal = sessionmaker(bind=ENGINE, autoflush=False, expire_on_commit=False, future=True)
|
||||
Base = declarative_base()
|
||||
|
|
@ -37,13 +86,13 @@ def init_db() -> None:
|
|||
|
||||
|
||||
def _migrate() -> None:
|
||||
"""Add columns introduced after a DB was first created (SQLite create_all won't)."""
|
||||
"""Add columns introduced after a DB was first created (create_all won't alter)."""
|
||||
added = {
|
||||
"sessions": [
|
||||
("progress_rows_done", "INTEGER DEFAULT 0"),
|
||||
("progress_rows_total", "INTEGER DEFAULT 0"),
|
||||
("eta_seconds", "INTEGER DEFAULT 0"),
|
||||
("opening_mode", "VARCHAR DEFAULT 'zero'"),
|
||||
("opening_mode", "VARCHAR(255) DEFAULT 'zero'"),
|
||||
("opening_source_session_id", "INTEGER"),
|
||||
("blocked_reason", "TEXT DEFAULT ''"),
|
||||
("payout_mode", "VARCHAR DEFAULT 'auto'"),
|
||||
|
|
@ -73,19 +122,29 @@ def _migrate() -> None:
|
|||
("tolerance", "FLOAT DEFAULT 1.0"),
|
||||
],
|
||||
"exports": [
|
||||
("kind", "VARCHAR DEFAULT 'full'"),
|
||||
("kind", "VARCHAR(255) DEFAULT 'full'"),
|
||||
],
|
||||
"transactions": [
|
||||
("txn_type_en", "VARCHAR"),
|
||||
("txn_type_en", "VARCHAR(255)"),
|
||||
("storage_flag", "BOOLEAN DEFAULT 0"),
|
||||
],
|
||||
}
|
||||
with ENGINE.begin() as conn:
|
||||
db_name = conn.execute(text("SELECT DATABASE()")).scalar()
|
||||
for table, cols in added.items():
|
||||
existing = {r[1] for r in conn.exec_driver_sql(f"PRAGMA table_info({table})")}
|
||||
existing = {
|
||||
r[0]
|
||||
for r in conn.execute(
|
||||
text(
|
||||
"SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS "
|
||||
"WHERE TABLE_SCHEMA = :schema AND TABLE_NAME = :table"
|
||||
),
|
||||
{"schema": db_name, "table": table},
|
||||
)
|
||||
}
|
||||
for name, decl in cols:
|
||||
if name not in existing:
|
||||
conn.exec_driver_sql(f"ALTER TABLE {table} ADD COLUMN {name} {decl}")
|
||||
conn.execute(text(f"ALTER TABLE `{table}` ADD COLUMN `{name}` {decl}"))
|
||||
|
||||
|
||||
def get_db():
|
||||
|
|
|
|||
|
|
@ -18,31 +18,31 @@ def _now() -> dt.datetime:
|
|||
class Session(Base):
|
||||
__tablename__ = "sessions"
|
||||
id = Column(Integer, primary_key=True)
|
||||
name = Column(String, nullable=False)
|
||||
reporting_month = Column(String) # "2026-01"
|
||||
name = Column(String(255), nullable=False)
|
||||
reporting_month = Column(String(32)) # "2026-01"
|
||||
month_end_date = Column(Date)
|
||||
reporting_currency = Column(String, default="USD")
|
||||
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, default="")
|
||||
manual_adjustment_note = Column(String(512), default="")
|
||||
# How the opening AR balance is established: zero (default) | carry_forward | manual
|
||||
opening_mode = Column(String, default="zero")
|
||||
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, default="draft")
|
||||
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, default="auto")
|
||||
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, default="")
|
||||
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)
|
||||
|
|
@ -62,18 +62,18 @@ class SessionFile(Base):
|
|||
__tablename__ = "session_files"
|
||||
id = Column(Integer, primary_key=True)
|
||||
session_id = Column(Integer, ForeignKey("sessions.id"), nullable=False)
|
||||
filename = Column(String)
|
||||
stored_path = Column(String)
|
||||
filename = Column(String(512))
|
||||
stored_path = Column(String(1024))
|
||||
size_bytes = Column(Integer)
|
||||
sha256 = Column(String)
|
||||
sha256 = Column(String(64))
|
||||
worksheets = Column(Text) # JSON list
|
||||
data_sheet = Column(String)
|
||||
data_sheet = Column(String(255))
|
||||
imported_rows = Column(Integer, default=0)
|
||||
min_date = Column(Date)
|
||||
max_date = Column(Date)
|
||||
currency = Column(String)
|
||||
marketplace = Column(String)
|
||||
status = Column(String, default="uploaded") # uploaded|parsed|invalid
|
||||
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)
|
||||
|
|
@ -86,9 +86,9 @@ class Settlement(Base):
|
|||
__tablename__ = "settlements"
|
||||
id = Column(Integer, primary_key=True)
|
||||
session_id = Column(Integer, ForeignKey("sessions.id"), nullable=False)
|
||||
marketplace = Column(String)
|
||||
account_type = Column(String)
|
||||
settlement_id = Column(String)
|
||||
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
|
||||
|
|
@ -97,7 +97,7 @@ class Settlement(Base):
|
|||
row_count = Column(Integer, default=0)
|
||||
first_date = Column(Date)
|
||||
last_date = Column(Date)
|
||||
status = Column(String) # paid|receivable
|
||||
status = Column(String(32)) # paid|receivable
|
||||
session = relationship("Session", back_populates="settlements")
|
||||
|
||||
|
||||
|
|
@ -108,20 +108,20 @@ class Transaction(Base):
|
|||
__tablename__ = "transactions"
|
||||
id = Column(Integer, primary_key=True)
|
||||
session_id = Column(Integer, ForeignKey("sessions.id"), nullable=False)
|
||||
source_file = Column(String)
|
||||
source_sheet = Column(String)
|
||||
source_file = Column(String(512))
|
||||
source_sheet = Column(String(255))
|
||||
source_row = Column(Integer)
|
||||
marketplace = Column(String)
|
||||
settlement_id = Column(String)
|
||||
order_id = Column(String)
|
||||
sku = Column(String)
|
||||
txn_type = Column(String) # original (possibly localized) type
|
||||
txn_type_en = Column(String) # canonical English type
|
||||
account_type = Column(String)
|
||||
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, default="USD")
|
||||
settlement_status = Column(String) # paid|receivable
|
||||
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
|
||||
|
||||
|
|
@ -134,8 +134,8 @@ class MappingRule(Base):
|
|||
Lets Finance map new/renamed Amazon headers without code changes."""
|
||||
__tablename__ = "mapping_rules"
|
||||
id = Column(Integer, primary_key=True)
|
||||
normalized_header = Column(String, unique=True, nullable=False)
|
||||
field = Column(String, nullable=False)
|
||||
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)
|
||||
|
|
@ -146,10 +146,10 @@ class Exception_(Base):
|
|||
__tablename__ = "exceptions"
|
||||
id = Column(Integer, primary_key=True)
|
||||
session_id = Column(Integer, ForeignKey("sessions.id"), nullable=False)
|
||||
category = Column(String)
|
||||
severity = Column(String) # error|warning|info
|
||||
category = Column(String(128))
|
||||
severity = Column(String(32)) # error|warning|info
|
||||
detail = Column(Text)
|
||||
source = Column(String, default="")
|
||||
source = Column(String(512), default="")
|
||||
session = relationship("Session", back_populates="exceptions")
|
||||
|
||||
|
||||
|
|
@ -157,17 +157,17 @@ class FxRate(Base):
|
|||
__tablename__ = "fx_rates"
|
||||
id = Column(Integer, primary_key=True)
|
||||
session_id = Column(Integer, ForeignKey("sessions.id"), nullable=False)
|
||||
marketplace = Column(String)
|
||||
currency = Column(String, default="USD")
|
||||
marketplace = Column(String(64))
|
||||
currency = Column(String(16), default="USD")
|
||||
rate = Column(Float, default=1.0)
|
||||
source = Column(String, default="manual")
|
||||
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, default="")
|
||||
confirmed_by = Column(String(255), default="")
|
||||
confirmed_at = Column(DateTime)
|
||||
confirmed_month = Column(String, default="") # reporting month the confirmation is for
|
||||
confirmed_month = Column(String(32), default="") # reporting month the confirmation is for
|
||||
session = relationship("Session", back_populates="fx_rates")
|
||||
|
||||
|
||||
|
|
@ -183,13 +183,13 @@ class PayoutReceipt(Base):
|
|||
__tablename__ = "payout_receipts"
|
||||
id = Column(Integer, primary_key=True)
|
||||
session_id = Column(Integer, ForeignKey("sessions.id"), nullable=False)
|
||||
marketplace = Column(String, nullable=False)
|
||||
account_type = Column(String, nullable=False) # bucket label, e.g. "(unspecified)"
|
||||
settlement_id = Column(String, 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, default="")
|
||||
entered_by = Column(String, default="")
|
||||
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",
|
||||
|
|
@ -202,10 +202,10 @@ class ControlResult(Base):
|
|||
__tablename__ = "control_results"
|
||||
id = Column(Integer, primary_key=True)
|
||||
session_id = Column(Integer, ForeignKey("sessions.id"), nullable=False)
|
||||
key = Column(String) # "C1".."C6"
|
||||
label = Column(String)
|
||||
status = Column(String) # pass|fail|not_applicable
|
||||
severity = Column(String, default="error") # error|warning|info
|
||||
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)
|
||||
|
|
@ -215,8 +215,8 @@ class Reserve(Base):
|
|||
__tablename__ = "reserves"
|
||||
id = Column(Integer, primary_key=True)
|
||||
session_id = Column(Integer, ForeignKey("sessions.id"), nullable=False)
|
||||
marketplace = Column(String)
|
||||
account_type = Column(String)
|
||||
marketplace = Column(String(64))
|
||||
account_type = Column(String(64))
|
||||
amount = Column(Float, default=0.0)
|
||||
session = relationship("Session", back_populates="reserves")
|
||||
|
||||
|
|
@ -225,14 +225,14 @@ class ReceivableResultRow(Base):
|
|||
__tablename__ = "receivable_results"
|
||||
id = Column(Integer, primary_key=True)
|
||||
session_id = Column(Integer, ForeignKey("sessions.id"), nullable=False)
|
||||
marketplace = Column(String)
|
||||
account_type = Column(String) # or "TOTAL"
|
||||
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, default="USD")
|
||||
currency = Column(String(16), default="USD")
|
||||
|
||||
|
||||
class ReconciliationRow(Base):
|
||||
|
|
@ -247,7 +247,7 @@ class ReconciliationRow(Base):
|
|||
manual_adjustments = Column(Float)
|
||||
final_receivable_usd = Column(Float)
|
||||
identity_difference = Column(Float)
|
||||
status = Column(String)
|
||||
status = Column(String(64))
|
||||
notes = Column(Text, default="")
|
||||
# AR roll-forward components
|
||||
received_payouts = Column(Float, default=0.0) # Σ transfers received (negative)
|
||||
|
|
@ -259,10 +259,10 @@ class FxRateDaily(Base):
|
|||
__tablename__ = "fx_rates_daily"
|
||||
id = Column(Integer, primary_key=True)
|
||||
session_id = Column(Integer, ForeignKey("sessions.id"), nullable=False)
|
||||
marketplace = Column(String)
|
||||
marketplace = Column(String(64))
|
||||
rate_date = Column(Date)
|
||||
rate = Column(Float, default=1.0)
|
||||
source = Column(String, default="manual")
|
||||
source = Column(String(64), default="manual")
|
||||
|
||||
|
||||
Index("ix_fx_daily", FxRateDaily.session_id, FxRateDaily.marketplace, FxRateDaily.rate_date)
|
||||
|
|
@ -278,7 +278,7 @@ class MarketPayout(Base):
|
|||
__tablename__ = "market_payouts"
|
||||
id = Column(Integer, primary_key=True)
|
||||
session_id = Column(Integer, ForeignKey("sessions.id"), nullable=False)
|
||||
marketplace = Column(String)
|
||||
marketplace = Column(String(64))
|
||||
received_payouts = Column(Float, default=0.0) # negative
|
||||
all_payouts = Column(Float, default=0.0) # negative
|
||||
|
||||
|
|
@ -290,10 +290,10 @@ class OpeningBalance(Base):
|
|||
__tablename__ = "opening_balances"
|
||||
id = Column(Integer, primary_key=True)
|
||||
session_id = Column(Integer, ForeignKey("sessions.id"), nullable=False)
|
||||
marketplace = Column(String)
|
||||
marketplace = Column(String(64))
|
||||
amount = Column(Float, default=0.0)
|
||||
reason = Column(String, default="")
|
||||
source = Column(String, default="manual") # manual | carried_forward
|
||||
reason = Column(String(512), default="")
|
||||
source = Column(String(64), default="manual") # manual | carried_forward
|
||||
updated_at = Column(DateTime, default=_now, onupdate=_now)
|
||||
|
||||
|
||||
|
|
@ -311,7 +311,7 @@ class FinanceControl(Base):
|
|||
disbursements = Column(Float)
|
||||
closing_receivable = Column(Float)
|
||||
tolerance = Column(Float, default=1.0)
|
||||
verified_by = Column(String, default="")
|
||||
verified_by = Column(String(255), default="")
|
||||
verified_at = Column(DateTime)
|
||||
comment = Column(Text, default="")
|
||||
|
||||
|
|
@ -320,15 +320,15 @@ 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, default="")
|
||||
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, default="")
|
||||
reviewed_by = Column(String(255), default="")
|
||||
reviewed_at = Column(DateTime)
|
||||
approved_by = Column(String, default="")
|
||||
approved_by = Column(String(255), default="")
|
||||
approved_at = Column(DateTime)
|
||||
|
||||
|
||||
|
|
@ -336,8 +336,8 @@ class ExportRecord(Base):
|
|||
__tablename__ = "exports"
|
||||
id = Column(Integer, primary_key=True)
|
||||
session_id = Column(Integer, ForeignKey("sessions.id"), nullable=False)
|
||||
kind = Column(String, default="full") # full | summary
|
||||
path = Column(String)
|
||||
sha256 = Column(String)
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -16,9 +16,10 @@ _TXN_COLS = (
|
|||
"settlement_id", "order_id", "sku", "txn_type", "txn_type_en", "account_type",
|
||||
"posted_date", "total", "currency", "storage_flag",
|
||||
)
|
||||
# PyMySQL uses %-style placeholders for raw DBAPI executemany.
|
||||
_INSERT_SQL = (
|
||||
f"INSERT INTO transactions ({', '.join(_TXN_COLS)}) "
|
||||
f"VALUES ({', '.join('?' * len(_TXN_COLS))})"
|
||||
f"VALUES ({', '.join(['%s'] * len(_TXN_COLS))})"
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -30,12 +31,6 @@ class TransactionSink:
|
|||
self.batch = batch
|
||||
self._buf: list[tuple] = []
|
||||
self._conn = ENGINE.raw_connection()
|
||||
# Bulk-import tuning: durability is not critical here (we can re-run a failed job).
|
||||
cur = self._conn.cursor()
|
||||
cur.execute("PRAGMA synchronous=OFF")
|
||||
cur.execute("PRAGMA temp_store=MEMORY")
|
||||
cur.execute("PRAGMA busy_timeout=30000")
|
||||
cur.close()
|
||||
self.count = 0
|
||||
|
||||
def add(self, rec: dict[str, Any]) -> None:
|
||||
|
|
@ -64,7 +59,7 @@ class TransactionSink:
|
|||
cur.executemany(_INSERT_SQL, self._buf)
|
||||
cur.close()
|
||||
# Commit each batch so we don't hold a long write lock that starves the progress
|
||||
# updater (a separate connection). synchronous=OFF keeps these commits cheap.
|
||||
# updater (a separate connection).
|
||||
self._conn.commit()
|
||||
self._buf.clear()
|
||||
|
||||
|
|
@ -80,14 +75,14 @@ class TransactionSink:
|
|||
recv = 1 if (st.status == "receivable"
|
||||
and acct.lower() in RECEIVABLE_ACCOUNT_TYPES) else 0
|
||||
cur.execute(
|
||||
"UPDATE transactions SET settlement_status=?, receivable_flag=? "
|
||||
"WHERE session_id=? AND settlement_id=? AND marketplace=? AND account_type=?",
|
||||
"UPDATE transactions SET settlement_status=%s, receivable_flag=%s "
|
||||
"WHERE session_id=%s AND settlement_id=%s AND marketplace=%s AND account_type=%s",
|
||||
(st.status, recv, self.session_id, sid, mkt, acct),
|
||||
)
|
||||
# transfer rows: never receivable (canonical type covers localized names)
|
||||
cur.execute(
|
||||
"UPDATE transactions SET receivable_flag=0 "
|
||||
"WHERE session_id=? AND txn_type_en='Transfer'",
|
||||
"WHERE session_id=%s AND txn_type_en='Transfer'",
|
||||
(self.session_id,),
|
||||
)
|
||||
cur.close()
|
||||
|
|
@ -108,7 +103,7 @@ def clear_session_results(db: OrmSession, session_id: int) -> None:
|
|||
db.commit()
|
||||
|
||||
|
||||
# Every table that references sessions.id. Foreign keys are enforced (PRAGMA foreign_keys=ON),
|
||||
# Every table that references sessions.id. Foreign keys are enforced,
|
||||
# so all children must go before the parent row.
|
||||
_CHILD_MODELS = (
|
||||
models.Transaction, models.Settlement, models.ReceivableResultRow,
|
||||
|
|
|
|||
|
|
@ -13,7 +13,9 @@ uvicorn[standard]==0.34.0
|
|||
python-multipart==0.0.20
|
||||
pydantic==2.10.4
|
||||
SQLAlchemy==2.0.36
|
||||
aiosqlite==0.20.0
|
||||
PyMySQL==1.1.1
|
||||
cryptography>=42.0.0
|
||||
python-dotenv==1.0.1
|
||||
|
||||
# Data helpers (optional / analysis)
|
||||
pandas>=2.2
|
||||
|
|
|
|||
|
|
@ -0,0 +1,29 @@
|
|||
services:
|
||||
backend:
|
||||
build: ./backend
|
||||
env_file: .env
|
||||
environment:
|
||||
AR_DATA_DIR: /data
|
||||
ports:
|
||||
- "8000:8000"
|
||||
volumes:
|
||||
- ./backend:/app
|
||||
- ar_data:/data
|
||||
command: uvicorn app.api.main:app --host 0.0.0.0 --port 8000 --reload
|
||||
|
||||
frontend:
|
||||
build: ./frontend
|
||||
ports:
|
||||
- "5173:5173"
|
||||
volumes:
|
||||
- ./frontend:/app
|
||||
- frontend_node_modules:/app/node_modules
|
||||
environment:
|
||||
CHOKIDAR_USEPOLLING: "true"
|
||||
VITE_API_PROXY: http://backend:8000
|
||||
depends_on:
|
||||
- backend
|
||||
|
||||
volumes:
|
||||
ar_data:
|
||||
frontend_node_modules:
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
MYSQL_HOST=your-mysql-host.example.com
|
||||
MYSQL_PORT=3306
|
||||
MYSQL_USER=your_mysql_user
|
||||
MYSQL_PASSWORD=your_mysql_password
|
||||
MYSQL_DATABASE=account_finance
|
||||
MYSQL_SLOW_QUERY_MS=500
|
||||
MYSQL_POOL_SIZE=10
|
||||
MYSQL_POOL_RECYCLE=3600
|
||||
|
||||
# Uploads/exports. Docker Compose sets AR_DATA_DIR=/data (named volume).
|
||||
# Leave unset locally to use backend/data.
|
||||
# AR_DATA_DIR=/data
|
||||
AR_CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm install
|
||||
|
||||
COPY . .
|
||||
|
||||
EXPOSE 5173
|
||||
|
||||
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0", "--port", "5173"]
|
||||
|
|
@ -4,9 +4,13 @@ import react from "@vitejs/plugin-react";
|
|||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
host: true,
|
||||
port: 5173,
|
||||
proxy: {
|
||||
"/api": { target: "http://localhost:8000", changeOrigin: true },
|
||||
"/api": {
|
||||
target: process.env.VITE_API_PROXY || "http://localhost:8000",
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue