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
|
## Run the app
|
||||||
```bash
|
```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 install # backend deps + npm install
|
||||||
make backend # terminal 1 → FastAPI on :8000
|
make backend # terminal 1 → FastAPI on :8000
|
||||||
make frontend # terminal 2 → dashboard on http://localhost:5173
|
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
|
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**.
|
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)
|
## Run the engine headless (CLI)
|
||||||
```bash
|
```bash
|
||||||
cd backend && pip install -r requirements.txt
|
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
|
import os
|
||||||
from pathlib import Path
|
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
|
BASE_DIR = Path(__file__).resolve().parent.parent # .../backend
|
||||||
DATA_DIR = Path(os.environ.get("AR_DATA_DIR", str(BASE_DIR / "data")))
|
DATA_DIR = Path(os.environ.get("AR_DATA_DIR", str(BASE_DIR / "data")))
|
||||||
UPLOAD_DIR = DATA_DIR / "uploads"
|
UPLOAD_DIR = DATA_DIR / "uploads"
|
||||||
EXPORT_DIR = DATA_DIR / "exports"
|
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: temp uploads/exports older than this are purged (0 = keep forever).
|
||||||
RETENTION_DAYS = int(os.environ.get("AR_RETENTION_DAYS", "30"))
|
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 __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 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()
|
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(
|
ENGINE = create_engine(
|
||||||
f"sqlite:///{DB_PATH}",
|
mysql_url(),
|
||||||
connect_args={"check_same_thread": False},
|
pool_size=MYSQL_POOL_SIZE,
|
||||||
|
pool_recycle=MYSQL_POOL_RECYCLE,
|
||||||
|
pool_pre_ping=True,
|
||||||
future=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")
|
@event.listens_for(ENGINE, "after_cursor_execute")
|
||||||
def _sqlite_pragmas(dbapi_conn, _rec):
|
def _after_cursor_execute(conn, cursor, statement, parameters, context, executemany):
|
||||||
cur = dbapi_conn.cursor()
|
start = conn.info.pop("query_start_time", None)
|
||||||
cur.execute("PRAGMA journal_mode=WAL")
|
if start is None:
|
||||||
cur.execute("PRAGMA synchronous=NORMAL")
|
return
|
||||||
cur.execute("PRAGMA foreign_keys=ON")
|
elapsed_ms = (time.perf_counter() - start) * 1000
|
||||||
cur.execute("PRAGMA cache_size=-64000") # ~64 MB page cache
|
if elapsed_ms >= MYSQL_SLOW_QUERY_MS:
|
||||||
cur.execute("PRAGMA busy_timeout=30000") # wait (don't error) when another writer holds the lock
|
logger.warning(
|
||||||
cur.close()
|
"Slow query (%.0f ms): %s",
|
||||||
|
elapsed_ms,
|
||||||
|
statement[:500],
|
||||||
|
)
|
||||||
|
|
||||||
SessionLocal = sessionmaker(bind=ENGINE, autoflush=False, expire_on_commit=False, future=True)
|
SessionLocal = sessionmaker(bind=ENGINE, autoflush=False, expire_on_commit=False, future=True)
|
||||||
Base = declarative_base()
|
Base = declarative_base()
|
||||||
|
|
@ -37,13 +86,13 @@ def init_db() -> None:
|
||||||
|
|
||||||
|
|
||||||
def _migrate() -> 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 = {
|
added = {
|
||||||
"sessions": [
|
"sessions": [
|
||||||
("progress_rows_done", "INTEGER DEFAULT 0"),
|
("progress_rows_done", "INTEGER DEFAULT 0"),
|
||||||
("progress_rows_total", "INTEGER DEFAULT 0"),
|
("progress_rows_total", "INTEGER DEFAULT 0"),
|
||||||
("eta_seconds", "INTEGER DEFAULT 0"),
|
("eta_seconds", "INTEGER DEFAULT 0"),
|
||||||
("opening_mode", "VARCHAR DEFAULT 'zero'"),
|
("opening_mode", "VARCHAR(255) DEFAULT 'zero'"),
|
||||||
("opening_source_session_id", "INTEGER"),
|
("opening_source_session_id", "INTEGER"),
|
||||||
("blocked_reason", "TEXT DEFAULT ''"),
|
("blocked_reason", "TEXT DEFAULT ''"),
|
||||||
("payout_mode", "VARCHAR DEFAULT 'auto'"),
|
("payout_mode", "VARCHAR DEFAULT 'auto'"),
|
||||||
|
|
@ -73,19 +122,29 @@ def _migrate() -> None:
|
||||||
("tolerance", "FLOAT DEFAULT 1.0"),
|
("tolerance", "FLOAT DEFAULT 1.0"),
|
||||||
],
|
],
|
||||||
"exports": [
|
"exports": [
|
||||||
("kind", "VARCHAR DEFAULT 'full'"),
|
("kind", "VARCHAR(255) DEFAULT 'full'"),
|
||||||
],
|
],
|
||||||
"transactions": [
|
"transactions": [
|
||||||
("txn_type_en", "VARCHAR"),
|
("txn_type_en", "VARCHAR(255)"),
|
||||||
("storage_flag", "BOOLEAN DEFAULT 0"),
|
("storage_flag", "BOOLEAN DEFAULT 0"),
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
with ENGINE.begin() as conn:
|
with ENGINE.begin() as conn:
|
||||||
|
db_name = conn.execute(text("SELECT DATABASE()")).scalar()
|
||||||
for table, cols in added.items():
|
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:
|
for name, decl in cols:
|
||||||
if name not in existing:
|
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():
|
def get_db():
|
||||||
|
|
|
||||||
|
|
@ -18,31 +18,31 @@ def _now() -> dt.datetime:
|
||||||
class Session(Base):
|
class Session(Base):
|
||||||
__tablename__ = "sessions"
|
__tablename__ = "sessions"
|
||||||
id = Column(Integer, primary_key=True)
|
id = Column(Integer, primary_key=True)
|
||||||
name = Column(String, nullable=False)
|
name = Column(String(255), nullable=False)
|
||||||
reporting_month = Column(String) # "2026-01"
|
reporting_month = Column(String(32)) # "2026-01"
|
||||||
month_end_date = Column(Date)
|
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)
|
clearing_lag_days = Column(Integer, default=2)
|
||||||
rounding_tolerance = Column(Float, default=0.01)
|
rounding_tolerance = Column(Float, default=0.01)
|
||||||
allowance_for_returns = Column(Float, default=0.0)
|
allowance_for_returns = Column(Float, default=0.0)
|
||||||
manual_adjustment = 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
|
# 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)
|
opening_source_session_id = Column(Integer)
|
||||||
# draft|processing|processed|blocked|completed|error
|
# draft|processing|processed|blocked|completed|error
|
||||||
# "blocked" = processed, but a month-end control failed, so no receivable figure is
|
# "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.
|
# 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="")
|
blocked_reason = Column(Text, default="")
|
||||||
# How payouts count as received:
|
# How payouts count as received:
|
||||||
# auto — bank-receipt date when entered, clearing-lag heuristic otherwise (default)
|
# auto — bank-receipt date when entered, clearing-lag heuristic otherwise (default)
|
||||||
# manual — ONLY payouts with a bank-receipt date ≤ month-end count; no heuristic
|
# 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
|
# Receipts or payout mode changed after the last processing run — the classification on
|
||||||
# screen no longer reflects them until the closing is re-processed.
|
# screen no longer reflects them until the closing is re-processed.
|
||||||
needs_reprocess = Column(Boolean, default=False)
|
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_pct = Column(Float, default=0.0)
|
||||||
progress_rows_done = Column(Integer, default=0)
|
progress_rows_done = Column(Integer, default=0)
|
||||||
progress_rows_total = Column(Integer, default=0)
|
progress_rows_total = Column(Integer, default=0)
|
||||||
|
|
@ -62,18 +62,18 @@ class SessionFile(Base):
|
||||||
__tablename__ = "session_files"
|
__tablename__ = "session_files"
|
||||||
id = Column(Integer, primary_key=True)
|
id = Column(Integer, primary_key=True)
|
||||||
session_id = Column(Integer, ForeignKey("sessions.id"), nullable=False)
|
session_id = Column(Integer, ForeignKey("sessions.id"), nullable=False)
|
||||||
filename = Column(String)
|
filename = Column(String(512))
|
||||||
stored_path = Column(String)
|
stored_path = Column(String(1024))
|
||||||
size_bytes = Column(Integer)
|
size_bytes = Column(Integer)
|
||||||
sha256 = Column(String)
|
sha256 = Column(String(64))
|
||||||
worksheets = Column(Text) # JSON list
|
worksheets = Column(Text) # JSON list
|
||||||
data_sheet = Column(String)
|
data_sheet = Column(String(255))
|
||||||
imported_rows = Column(Integer, default=0)
|
imported_rows = Column(Integer, default=0)
|
||||||
min_date = Column(Date)
|
min_date = Column(Date)
|
||||||
max_date = Column(Date)
|
max_date = Column(Date)
|
||||||
currency = Column(String)
|
currency = Column(String(16))
|
||||||
marketplace = Column(String)
|
marketplace = Column(String(64))
|
||||||
status = Column(String, default="uploaded") # uploaded|parsed|invalid
|
status = Column(String(32), default="uploaded") # uploaded|parsed|invalid
|
||||||
message = Column(Text, default="")
|
message = Column(Text, default="")
|
||||||
# Control C1: the worksheet's own declared extent vs what we actually consumed.
|
# Control C1: the worksheet's own declared extent vs what we actually consumed.
|
||||||
sheet_last_row = Column(Integer, default=0)
|
sheet_last_row = Column(Integer, default=0)
|
||||||
|
|
@ -86,9 +86,9 @@ class Settlement(Base):
|
||||||
__tablename__ = "settlements"
|
__tablename__ = "settlements"
|
||||||
id = Column(Integer, primary_key=True)
|
id = Column(Integer, primary_key=True)
|
||||||
session_id = Column(Integer, ForeignKey("sessions.id"), nullable=False)
|
session_id = Column(Integer, ForeignKey("sessions.id"), nullable=False)
|
||||||
marketplace = Column(String)
|
marketplace = Column(String(64))
|
||||||
account_type = Column(String)
|
account_type = Column(String(64))
|
||||||
settlement_id = Column(String)
|
settlement_id = Column(String(255))
|
||||||
order_total = Column(Float, default=0.0)
|
order_total = Column(Float, default=0.0)
|
||||||
transfer_total = Column(Float, default=0.0)
|
transfer_total = Column(Float, default=0.0)
|
||||||
transfer_amount = Column(Float) # boundary/received transfer amount if any
|
transfer_amount = Column(Float) # boundary/received transfer amount if any
|
||||||
|
|
@ -97,7 +97,7 @@ class Settlement(Base):
|
||||||
row_count = Column(Integer, default=0)
|
row_count = Column(Integer, default=0)
|
||||||
first_date = Column(Date)
|
first_date = Column(Date)
|
||||||
last_date = Column(Date)
|
last_date = Column(Date)
|
||||||
status = Column(String) # paid|receivable
|
status = Column(String(32)) # paid|receivable
|
||||||
session = relationship("Session", back_populates="settlements")
|
session = relationship("Session", back_populates="settlements")
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -108,20 +108,20 @@ class Transaction(Base):
|
||||||
__tablename__ = "transactions"
|
__tablename__ = "transactions"
|
||||||
id = Column(Integer, primary_key=True)
|
id = Column(Integer, primary_key=True)
|
||||||
session_id = Column(Integer, ForeignKey("sessions.id"), nullable=False)
|
session_id = Column(Integer, ForeignKey("sessions.id"), nullable=False)
|
||||||
source_file = Column(String)
|
source_file = Column(String(512))
|
||||||
source_sheet = Column(String)
|
source_sheet = Column(String(255))
|
||||||
source_row = Column(Integer)
|
source_row = Column(Integer)
|
||||||
marketplace = Column(String)
|
marketplace = Column(String(64))
|
||||||
settlement_id = Column(String)
|
settlement_id = Column(String(255))
|
||||||
order_id = Column(String)
|
order_id = Column(String(255))
|
||||||
sku = Column(String)
|
sku = Column(String(255))
|
||||||
txn_type = Column(String) # original (possibly localized) type
|
txn_type = Column(String(255)) # original (possibly localized) type
|
||||||
txn_type_en = Column(String) # canonical English type
|
txn_type_en = Column(String(255)) # canonical English type
|
||||||
account_type = Column(String)
|
account_type = Column(String(64))
|
||||||
posted_date = Column(Date)
|
posted_date = Column(Date)
|
||||||
total = Column(Float, default=0.0)
|
total = Column(Float, default=0.0)
|
||||||
currency = Column(String, default="USD")
|
currency = Column(String(16), default="USD")
|
||||||
settlement_status = Column(String) # paid|receivable
|
settlement_status = Column(String(32)) # paid|receivable
|
||||||
receivable_flag = Column(Boolean, default=False)
|
receivable_flag = Column(Boolean, default=False)
|
||||||
storage_flag = Column(Boolean, default=False) # storage-fee detection drill-down
|
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."""
|
Lets Finance map new/renamed Amazon headers without code changes."""
|
||||||
__tablename__ = "mapping_rules"
|
__tablename__ = "mapping_rules"
|
||||||
id = Column(Integer, primary_key=True)
|
id = Column(Integer, primary_key=True)
|
||||||
normalized_header = Column(String, unique=True, nullable=False)
|
normalized_header = Column(String(255), unique=True, nullable=False)
|
||||||
field = Column(String, nullable=False)
|
field = Column(String(128), nullable=False)
|
||||||
created_at = Column(DateTime, default=_now)
|
created_at = Column(DateTime, default=_now)
|
||||||
Index("ix_txn_settlement", Transaction.session_id, Transaction.settlement_id)
|
Index("ix_txn_settlement", Transaction.session_id, Transaction.settlement_id)
|
||||||
Index("ix_txn_type", Transaction.session_id, Transaction.txn_type)
|
Index("ix_txn_type", Transaction.session_id, Transaction.txn_type)
|
||||||
|
|
@ -146,10 +146,10 @@ class Exception_(Base):
|
||||||
__tablename__ = "exceptions"
|
__tablename__ = "exceptions"
|
||||||
id = Column(Integer, primary_key=True)
|
id = Column(Integer, primary_key=True)
|
||||||
session_id = Column(Integer, ForeignKey("sessions.id"), nullable=False)
|
session_id = Column(Integer, ForeignKey("sessions.id"), nullable=False)
|
||||||
category = Column(String)
|
category = Column(String(128))
|
||||||
severity = Column(String) # error|warning|info
|
severity = Column(String(32)) # error|warning|info
|
||||||
detail = Column(Text)
|
detail = Column(Text)
|
||||||
source = Column(String, default="")
|
source = Column(String(512), default="")
|
||||||
session = relationship("Session", back_populates="exceptions")
|
session = relationship("Session", back_populates="exceptions")
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -157,17 +157,17 @@ class FxRate(Base):
|
||||||
__tablename__ = "fx_rates"
|
__tablename__ = "fx_rates"
|
||||||
id = Column(Integer, primary_key=True)
|
id = Column(Integer, primary_key=True)
|
||||||
session_id = Column(Integer, ForeignKey("sessions.id"), nullable=False)
|
session_id = Column(Integer, ForeignKey("sessions.id"), nullable=False)
|
||||||
marketplace = Column(String)
|
marketplace = Column(String(64))
|
||||||
currency = Column(String, default="USD")
|
currency = Column(String(16), default="USD")
|
||||||
rate = Column(Float, default=1.0)
|
rate = Column(Float, default=1.0)
|
||||||
source = Column(String, default="manual")
|
source = Column(String(64), default="manual")
|
||||||
rate_date = Column(Date)
|
rate_date = Column(Date)
|
||||||
# Control C5: a seeded default is a SUGGESTION, not a rate. Until someone confirms it for
|
# 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
|
# this reporting month the closing is blocked — otherwise a July close silently values EUR
|
||||||
# at the hardcoded January rate.
|
# at the hardcoded January rate.
|
||||||
confirmed_by = Column(String, default="")
|
confirmed_by = Column(String(255), default="")
|
||||||
confirmed_at = Column(DateTime)
|
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")
|
session = relationship("Session", back_populates="fx_rates")
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -183,13 +183,13 @@ class PayoutReceipt(Base):
|
||||||
__tablename__ = "payout_receipts"
|
__tablename__ = "payout_receipts"
|
||||||
id = Column(Integer, primary_key=True)
|
id = Column(Integer, primary_key=True)
|
||||||
session_id = Column(Integer, ForeignKey("sessions.id"), nullable=False)
|
session_id = Column(Integer, ForeignKey("sessions.id"), nullable=False)
|
||||||
marketplace = Column(String, nullable=False)
|
marketplace = Column(String(64), nullable=False)
|
||||||
account_type = Column(String, nullable=False) # bucket label, e.g. "(unspecified)"
|
account_type = Column(String(64), nullable=False) # bucket label, e.g. "(unspecified)"
|
||||||
settlement_id = Column(String, nullable=False)
|
settlement_id = Column(String(255), nullable=False)
|
||||||
bank_date = Column(Date, nullable=False)
|
bank_date = Column(Date, nullable=False)
|
||||||
bank_amount = Column(Float) # optional; None = same as Amazon amount
|
bank_amount = Column(Float) # optional; None = same as Amazon amount
|
||||||
note = Column(String, default="")
|
note = Column(String(512), default="")
|
||||||
entered_by = Column(String, default="")
|
entered_by = Column(String(255), default="")
|
||||||
updated_at = Column(DateTime, default=_now, onupdate=_now)
|
updated_at = Column(DateTime, default=_now, onupdate=_now)
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
Index("ix_payout_receipts_key", "session_id", "marketplace",
|
Index("ix_payout_receipts_key", "session_id", "marketplace",
|
||||||
|
|
@ -202,10 +202,10 @@ class ControlResult(Base):
|
||||||
__tablename__ = "control_results"
|
__tablename__ = "control_results"
|
||||||
id = Column(Integer, primary_key=True)
|
id = Column(Integer, primary_key=True)
|
||||||
session_id = Column(Integer, ForeignKey("sessions.id"), nullable=False)
|
session_id = Column(Integer, ForeignKey("sessions.id"), nullable=False)
|
||||||
key = Column(String) # "C1".."C6"
|
key = Column(String(32)) # "C1".."C6"
|
||||||
label = Column(String)
|
label = Column(String(255))
|
||||||
status = Column(String) # pass|fail|not_applicable
|
status = Column(String(32)) # pass|fail|not_applicable
|
||||||
severity = Column(String, default="error") # error|warning|info
|
severity = Column(String(32), default="error") # error|warning|info
|
||||||
detail = Column(Text, default="")
|
detail = Column(Text, default="")
|
||||||
evidence = Column(Text, default="") # JSON list of strings
|
evidence = Column(Text, default="") # JSON list of strings
|
||||||
checked_at = Column(DateTime, default=_now)
|
checked_at = Column(DateTime, default=_now)
|
||||||
|
|
@ -215,8 +215,8 @@ class Reserve(Base):
|
||||||
__tablename__ = "reserves"
|
__tablename__ = "reserves"
|
||||||
id = Column(Integer, primary_key=True)
|
id = Column(Integer, primary_key=True)
|
||||||
session_id = Column(Integer, ForeignKey("sessions.id"), nullable=False)
|
session_id = Column(Integer, ForeignKey("sessions.id"), nullable=False)
|
||||||
marketplace = Column(String)
|
marketplace = Column(String(64))
|
||||||
account_type = Column(String)
|
account_type = Column(String(64))
|
||||||
amount = Column(Float, default=0.0)
|
amount = Column(Float, default=0.0)
|
||||||
session = relationship("Session", back_populates="reserves")
|
session = relationship("Session", back_populates="reserves")
|
||||||
|
|
||||||
|
|
@ -225,14 +225,14 @@ class ReceivableResultRow(Base):
|
||||||
__tablename__ = "receivable_results"
|
__tablename__ = "receivable_results"
|
||||||
id = Column(Integer, primary_key=True)
|
id = Column(Integer, primary_key=True)
|
||||||
session_id = Column(Integer, ForeignKey("sessions.id"), nullable=False)
|
session_id = Column(Integer, ForeignKey("sessions.id"), nullable=False)
|
||||||
marketplace = Column(String)
|
marketplace = Column(String(64))
|
||||||
account_type = Column(String) # or "TOTAL"
|
account_type = Column(String(64)) # or "TOTAL"
|
||||||
additional_sales = Column(Float, default=0.0)
|
additional_sales = Column(Float, default=0.0)
|
||||||
reserve = Column(Float, default=0.0)
|
reserve = Column(Float, default=0.0)
|
||||||
receivable_local = Column(Float, default=0.0)
|
receivable_local = Column(Float, default=0.0)
|
||||||
fx_rate = Column(Float, default=1.0)
|
fx_rate = Column(Float, default=1.0)
|
||||||
receivable_usd = Column(Float, default=0.0)
|
receivable_usd = Column(Float, default=0.0)
|
||||||
currency = Column(String, default="USD")
|
currency = Column(String(16), default="USD")
|
||||||
|
|
||||||
|
|
||||||
class ReconciliationRow(Base):
|
class ReconciliationRow(Base):
|
||||||
|
|
@ -247,7 +247,7 @@ class ReconciliationRow(Base):
|
||||||
manual_adjustments = Column(Float)
|
manual_adjustments = Column(Float)
|
||||||
final_receivable_usd = Column(Float)
|
final_receivable_usd = Column(Float)
|
||||||
identity_difference = Column(Float)
|
identity_difference = Column(Float)
|
||||||
status = Column(String)
|
status = Column(String(64))
|
||||||
notes = Column(Text, default="")
|
notes = Column(Text, default="")
|
||||||
# AR roll-forward components
|
# AR roll-forward components
|
||||||
received_payouts = Column(Float, default=0.0) # Σ transfers received (negative)
|
received_payouts = Column(Float, default=0.0) # Σ transfers received (negative)
|
||||||
|
|
@ -259,10 +259,10 @@ class FxRateDaily(Base):
|
||||||
__tablename__ = "fx_rates_daily"
|
__tablename__ = "fx_rates_daily"
|
||||||
id = Column(Integer, primary_key=True)
|
id = Column(Integer, primary_key=True)
|
||||||
session_id = Column(Integer, ForeignKey("sessions.id"), nullable=False)
|
session_id = Column(Integer, ForeignKey("sessions.id"), nullable=False)
|
||||||
marketplace = Column(String)
|
marketplace = Column(String(64))
|
||||||
rate_date = Column(Date)
|
rate_date = Column(Date)
|
||||||
rate = Column(Float, default=1.0)
|
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)
|
Index("ix_fx_daily", FxRateDaily.session_id, FxRateDaily.marketplace, FxRateDaily.rate_date)
|
||||||
|
|
@ -278,7 +278,7 @@ class MarketPayout(Base):
|
||||||
__tablename__ = "market_payouts"
|
__tablename__ = "market_payouts"
|
||||||
id = Column(Integer, primary_key=True)
|
id = Column(Integer, primary_key=True)
|
||||||
session_id = Column(Integer, ForeignKey("sessions.id"), nullable=False)
|
session_id = Column(Integer, ForeignKey("sessions.id"), nullable=False)
|
||||||
marketplace = Column(String)
|
marketplace = Column(String(64))
|
||||||
received_payouts = Column(Float, default=0.0) # negative
|
received_payouts = Column(Float, default=0.0) # negative
|
||||||
all_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"
|
__tablename__ = "opening_balances"
|
||||||
id = Column(Integer, primary_key=True)
|
id = Column(Integer, primary_key=True)
|
||||||
session_id = Column(Integer, ForeignKey("sessions.id"), nullable=False)
|
session_id = Column(Integer, ForeignKey("sessions.id"), nullable=False)
|
||||||
marketplace = Column(String)
|
marketplace = Column(String(64))
|
||||||
amount = Column(Float, default=0.0)
|
amount = Column(Float, default=0.0)
|
||||||
reason = Column(String, default="")
|
reason = Column(String(512), default="")
|
||||||
source = Column(String, default="manual") # manual | carried_forward
|
source = Column(String(64), default="manual") # manual | carried_forward
|
||||||
updated_at = Column(DateTime, default=_now, onupdate=_now)
|
updated_at = Column(DateTime, default=_now, onupdate=_now)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -311,7 +311,7 @@ class FinanceControl(Base):
|
||||||
disbursements = Column(Float)
|
disbursements = Column(Float)
|
||||||
closing_receivable = Column(Float)
|
closing_receivable = Column(Float)
|
||||||
tolerance = Column(Float, default=1.0)
|
tolerance = Column(Float, default=1.0)
|
||||||
verified_by = Column(String, default="")
|
verified_by = Column(String(255), default="")
|
||||||
verified_at = Column(DateTime)
|
verified_at = Column(DateTime)
|
||||||
comment = Column(Text, default="")
|
comment = Column(Text, default="")
|
||||||
|
|
||||||
|
|
@ -320,15 +320,15 @@ class JournalEntry(Base):
|
||||||
__tablename__ = "journal_entries"
|
__tablename__ = "journal_entries"
|
||||||
id = Column(Integer, primary_key=True)
|
id = Column(Integer, primary_key=True)
|
||||||
session_id = Column(Integer, ForeignKey("sessions.id"), nullable=False, unique=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
|
data = Column(Text, default="") # JSON: periods + lines + receivable
|
||||||
# Two-step sign-off. Approval is what publishes this month's journal to the Accounts
|
# 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
|
# 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
|
# the numbers change — a sign-off only ever attests to figures the signer actually saw
|
||||||
# (entry_no is carried over; see jobs.run_processing).
|
# (entry_no is carried over; see jobs.run_processing).
|
||||||
reviewed_by = Column(String, default="")
|
reviewed_by = Column(String(255), default="")
|
||||||
reviewed_at = Column(DateTime)
|
reviewed_at = Column(DateTime)
|
||||||
approved_by = Column(String, default="")
|
approved_by = Column(String(255), default="")
|
||||||
approved_at = Column(DateTime)
|
approved_at = Column(DateTime)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -336,8 +336,8 @@ class ExportRecord(Base):
|
||||||
__tablename__ = "exports"
|
__tablename__ = "exports"
|
||||||
id = Column(Integer, primary_key=True)
|
id = Column(Integer, primary_key=True)
|
||||||
session_id = Column(Integer, ForeignKey("sessions.id"), nullable=False)
|
session_id = Column(Integer, ForeignKey("sessions.id"), nullable=False)
|
||||||
kind = Column(String, default="full") # full | summary
|
kind = Column(String(32), default="full") # full | summary
|
||||||
path = Column(String)
|
path = Column(String(1024))
|
||||||
sha256 = Column(String)
|
sha256 = Column(String(64))
|
||||||
size_bytes = Column(Integer)
|
size_bytes = Column(Integer)
|
||||||
generated_at = Column(DateTime, default=_now)
|
generated_at = Column(DateTime, default=_now)
|
||||||
|
|
|
||||||
|
|
@ -16,9 +16,10 @@ _TXN_COLS = (
|
||||||
"settlement_id", "order_id", "sku", "txn_type", "txn_type_en", "account_type",
|
"settlement_id", "order_id", "sku", "txn_type", "txn_type_en", "account_type",
|
||||||
"posted_date", "total", "currency", "storage_flag",
|
"posted_date", "total", "currency", "storage_flag",
|
||||||
)
|
)
|
||||||
|
# PyMySQL uses %-style placeholders for raw DBAPI executemany.
|
||||||
_INSERT_SQL = (
|
_INSERT_SQL = (
|
||||||
f"INSERT INTO transactions ({', '.join(_TXN_COLS)}) "
|
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.batch = batch
|
||||||
self._buf: list[tuple] = []
|
self._buf: list[tuple] = []
|
||||||
self._conn = ENGINE.raw_connection()
|
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
|
self.count = 0
|
||||||
|
|
||||||
def add(self, rec: dict[str, Any]) -> None:
|
def add(self, rec: dict[str, Any]) -> None:
|
||||||
|
|
@ -64,7 +59,7 @@ class TransactionSink:
|
||||||
cur.executemany(_INSERT_SQL, self._buf)
|
cur.executemany(_INSERT_SQL, self._buf)
|
||||||
cur.close()
|
cur.close()
|
||||||
# Commit each batch so we don't hold a long write lock that starves the progress
|
# 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._conn.commit()
|
||||||
self._buf.clear()
|
self._buf.clear()
|
||||||
|
|
||||||
|
|
@ -80,14 +75,14 @@ class TransactionSink:
|
||||||
recv = 1 if (st.status == "receivable"
|
recv = 1 if (st.status == "receivable"
|
||||||
and acct.lower() in RECEIVABLE_ACCOUNT_TYPES) else 0
|
and acct.lower() in RECEIVABLE_ACCOUNT_TYPES) else 0
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"UPDATE transactions SET settlement_status=?, receivable_flag=? "
|
"UPDATE transactions SET settlement_status=%s, receivable_flag=%s "
|
||||||
"WHERE session_id=? AND settlement_id=? AND marketplace=? AND account_type=?",
|
"WHERE session_id=%s AND settlement_id=%s AND marketplace=%s AND account_type=%s",
|
||||||
(st.status, recv, self.session_id, sid, mkt, acct),
|
(st.status, recv, self.session_id, sid, mkt, acct),
|
||||||
)
|
)
|
||||||
# transfer rows: never receivable (canonical type covers localized names)
|
# transfer rows: never receivable (canonical type covers localized names)
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"UPDATE transactions SET receivable_flag=0 "
|
"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,),
|
(self.session_id,),
|
||||||
)
|
)
|
||||||
cur.close()
|
cur.close()
|
||||||
|
|
@ -108,7 +103,7 @@ def clear_session_results(db: OrmSession, session_id: int) -> None:
|
||||||
db.commit()
|
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.
|
# so all children must go before the parent row.
|
||||||
_CHILD_MODELS = (
|
_CHILD_MODELS = (
|
||||||
models.Transaction, models.Settlement, models.ReceivableResultRow,
|
models.Transaction, models.Settlement, models.ReceivableResultRow,
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,9 @@ uvicorn[standard]==0.34.0
|
||||||
python-multipart==0.0.20
|
python-multipart==0.0.20
|
||||||
pydantic==2.10.4
|
pydantic==2.10.4
|
||||||
SQLAlchemy==2.0.36
|
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)
|
# Data helpers (optional / analysis)
|
||||||
pandas>=2.2
|
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({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
server: {
|
server: {
|
||||||
|
host: true,
|
||||||
port: 5173,
|
port: 5173,
|
||||||
proxy: {
|
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