fix isuses
parent
60c4489415
commit
c322437599
|
|
@ -20,6 +20,7 @@
|
||||||
*.xls
|
*.xls
|
||||||
*.csv
|
*.csv
|
||||||
*.tsv
|
*.tsv
|
||||||
|
Test Files/
|
||||||
Amazon Transactions reports*/
|
Amazon Transactions reports*/
|
||||||
Accounts Receivable*/
|
Accounts Receivable*/
|
||||||
!ar-aging-app/backend/tests/fixtures/*.xlsx
|
!ar-aging-app/backend/tests/fixtures/*.xlsx
|
||||||
|
|
|
||||||
|
|
@ -177,10 +177,20 @@ def ledger_detail(session_id: int, marketplace: str | None = None, granularity:
|
||||||
frm, to = _parse_date(date_from, "date_from"), _parse_date(date_to, "date_to")
|
frm, to = _parse_date(date_from, "date_from"), _parse_date(date_to, "date_to")
|
||||||
rows = _daily_rows(db, session_id, mkt, frm, to)
|
rows = _daily_rows(db, session_id, mkt, frm, to)
|
||||||
|
|
||||||
|
# Both currencies: USD is converted at each TRANSACTION DATE's rate (a daily override
|
||||||
|
# when one exists, the marketplace month rate otherwise). The opening balance has no
|
||||||
|
# transaction date, so it converts at the month rate — the closing's official rate.
|
||||||
|
month_rate, daily = _fx_for(db, session_id, mkt)
|
||||||
|
|
||||||
|
def rate_of(d: dt.date | None) -> float:
|
||||||
|
return daily.get(d, (month_rate, ""))[0] if d else month_rate
|
||||||
|
|
||||||
def new_bucket(key: str, label: str) -> dict:
|
def new_bucket(key: str, label: str) -> dict:
|
||||||
return {"key": key, "label": label, "revenue": 0.0,
|
return {"key": key, "label": label, "revenue": 0.0,
|
||||||
"payouts_received": 0.0, "payouts_in_transit": 0.0,
|
"payouts_received": 0.0, "payouts_in_transit": 0.0,
|
||||||
"bank_dated": 0.0, "rows": 0}
|
"bank_dated": 0.0, "rows": 0,
|
||||||
|
"revenue_usd": 0.0, "payouts_received_usd": 0.0,
|
||||||
|
"payouts_in_transit_usd": 0.0}
|
||||||
|
|
||||||
# Revenue buckets by transaction date; payouts by their EFFECTIVE date — the bank
|
# Revenue buckets by transaction date; payouts by their EFFECTIVE date — the bank
|
||||||
# receipt's date when Finance entered one, Amazon's transfer date otherwise.
|
# receipt's date when Finance entered one, Amazon's transfer date otherwise.
|
||||||
|
|
@ -189,6 +199,7 @@ def ledger_detail(session_id: int, marketplace: str | None = None, granularity:
|
||||||
key, label = _bucket(d, granularity)
|
key, label = _bucket(d, granularity)
|
||||||
b = buckets.setdefault(key, new_bucket(key, label))
|
b = buckets.setdefault(key, new_bucket(key, label))
|
||||||
b["revenue"] += revenue
|
b["revenue"] += revenue
|
||||||
|
b["revenue_usd"] += revenue * rate_of(d)
|
||||||
b["rows"] += n
|
b["rows"] += n
|
||||||
for d, amount, received, bank_dated in _payout_events(db, s, mkt):
|
for d, amount, received, bank_dated in _payout_events(db, s, mkt):
|
||||||
if frm and (d is None or d < frm):
|
if frm and (d is None or d < frm):
|
||||||
|
|
@ -199,16 +210,21 @@ def ledger_detail(session_id: int, marketplace: str | None = None, granularity:
|
||||||
b = buckets.setdefault(key, new_bucket(key, label))
|
b = buckets.setdefault(key, new_bucket(key, label))
|
||||||
if amount:
|
if amount:
|
||||||
b["payouts_received" if received else "payouts_in_transit"] += amount
|
b["payouts_received" if received else "payouts_in_transit"] += amount
|
||||||
|
b["payouts_received_usd" if received else "payouts_in_transit_usd"] += \
|
||||||
|
amount * rate_of(d)
|
||||||
if bank_dated:
|
if bank_dated:
|
||||||
b["bank_dated"] += amount
|
b["bank_dated"] += amount
|
||||||
b["rows"] += 1
|
b["rows"] += 1
|
||||||
|
|
||||||
opening = mv["opening"]
|
opening = mv["opening"]
|
||||||
running = opening
|
running = opening
|
||||||
|
opening_usd = opening * month_rate
|
||||||
|
running_usd = opening_usd
|
||||||
out = []
|
out = []
|
||||||
for key in sorted(buckets):
|
for key in sorted(buckets):
|
||||||
b = buckets[key]
|
b = buckets[key]
|
||||||
running += b["revenue"] + b["payouts_received"]
|
running += b["revenue"] + b["payouts_received"]
|
||||||
|
running_usd += b["revenue_usd"] + b["payouts_received_usd"]
|
||||||
out.append({
|
out.append({
|
||||||
"key": b["key"], "label": b["label"],
|
"key": b["key"], "label": b["label"],
|
||||||
"revenue": round(b["revenue"], 2),
|
"revenue": round(b["revenue"], 2),
|
||||||
|
|
@ -217,6 +233,10 @@ def ledger_detail(session_id: int, marketplace: str | None = None, granularity:
|
||||||
"bank_dated": round(b["bank_dated"], 2), # payout amounts placed by bank date
|
"bank_dated": round(b["bank_dated"], 2), # payout amounts placed by bank date
|
||||||
"rows": b["rows"],
|
"rows": b["rows"],
|
||||||
"balance": round(running, 2),
|
"balance": round(running, 2),
|
||||||
|
"revenue_usd": round(b["revenue_usd"], 2),
|
||||||
|
"payouts_received_usd": round(b["payouts_received_usd"], 2),
|
||||||
|
"payouts_in_transit_usd": round(b["payouts_in_transit_usd"], 2),
|
||||||
|
"balance_usd": round(running_usd, 2),
|
||||||
})
|
})
|
||||||
|
|
||||||
filtered = bool(frm or to)
|
filtered = bool(frm or to)
|
||||||
|
|
@ -235,6 +255,12 @@ def ledger_detail(session_id: int, marketplace: str | None = None, granularity:
|
||||||
"session_closing": mv["closing"],
|
"session_closing": mv["closing"],
|
||||||
"filtered": filtered,
|
"filtered": filtered,
|
||||||
"in_transit_total": round(sum(p["payouts_in_transit"] for p in out), 2),
|
"in_transit_total": round(sum(p["payouts_in_transit"] for p in out), 2),
|
||||||
|
"month_rate": month_rate,
|
||||||
|
"opening_usd": round(opening_usd, 2),
|
||||||
|
# Roll-forward valued at transaction-date rates; differs from closing × month rate
|
||||||
|
# whenever daily overrides exist — that spread is the FX effect of the month.
|
||||||
|
"closing_usd": round(running_usd, 2),
|
||||||
|
"in_transit_total_usd": round(sum(p["payouts_in_transit_usd"] for p in out), 2),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -251,7 +277,7 @@ def fx_daily(session_id: int, marketplace: str | None = None,
|
||||||
date_from: str | None = None, date_to: str | None = None,
|
date_from: str | None = None, date_to: str | None = None,
|
||||||
db: OrmSession = Depends(db_dep)) -> dict:
|
db: OrmSession = Depends(db_dep)) -> dict:
|
||||||
"""Per-date local value, the USD rate applied, and the USD equivalent."""
|
"""Per-date local value, the USD rate applied, and the USD equivalent."""
|
||||||
get_session_or_404(session_id, db)
|
s = get_session_or_404(session_id, db)
|
||||||
mv = _movement_for(db, session_id, marketplace)
|
mv = _movement_for(db, session_id, marketplace)
|
||||||
if not mv.get("available"):
|
if not mv.get("available"):
|
||||||
return {"available": False}
|
return {"available": False}
|
||||||
|
|
@ -260,11 +286,27 @@ def fx_daily(session_id: int, marketplace: str | None = None,
|
||||||
month_rate, daily = _fx_for(db, session_id, mkt)
|
month_rate, daily = _fx_for(db, session_id, mkt)
|
||||||
frm, to = _parse_date(date_from, "date_from"), _parse_date(date_to, "date_to")
|
frm, to = _parse_date(date_from, "date_from"), _parse_date(date_to, "date_to")
|
||||||
|
|
||||||
rows = []
|
# Revenue by transaction date; payouts by their EFFECTIVE date (bank receipt when one
|
||||||
tot_local = tot_usd = 0.0
|
# was entered, Amazon's transfer date otherwise) — the same placement the ledger uses,
|
||||||
for d, revenue, payout, n in _daily_rows(db, session_id, mkt, frm, to):
|
# so this table converts exactly the movement the ledger shows.
|
||||||
|
per: dict[dt.date, list[float]] = defaultdict(lambda: [0.0, 0.0, 0]) # revenue, payouts, rows
|
||||||
|
for d, revenue, n in _daily_rows(db, session_id, mkt, frm, to):
|
||||||
if d is None:
|
if d is None:
|
||||||
continue
|
continue
|
||||||
|
slot = per[d]
|
||||||
|
slot[0] += revenue
|
||||||
|
slot[2] += n
|
||||||
|
for d, amount, _received, _bank_dated in _payout_events(db, s, mkt):
|
||||||
|
if d is None or (frm and d < frm) or (to and d > to):
|
||||||
|
continue
|
||||||
|
slot = per[d]
|
||||||
|
slot[1] += amount
|
||||||
|
slot[2] += 1
|
||||||
|
|
||||||
|
rows = []
|
||||||
|
tot_local = tot_usd = 0.0
|
||||||
|
for d in sorted(per):
|
||||||
|
revenue, payout, n = per[d]
|
||||||
local = revenue + payout
|
local = revenue + payout
|
||||||
rate, source = daily.get(d, (month_rate, "month rate"))
|
rate, source = daily.get(d, (month_rate, "month rate"))
|
||||||
usd = local * rate
|
usd = local * rate
|
||||||
|
|
@ -272,7 +314,7 @@ def fx_daily(session_id: int, marketplace: str | None = None,
|
||||||
tot_usd += usd
|
tot_usd += usd
|
||||||
rows.append({
|
rows.append({
|
||||||
"date": d.isoformat(), "local": round(local, 2), "rate": rate,
|
"date": d.isoformat(), "local": round(local, 2), "rate": rate,
|
||||||
"usd": round(usd, 2), "source": source, "rows": n,
|
"usd": round(usd, 2), "source": source, "rows": int(n),
|
||||||
"revenue": round(revenue, 2), "payouts": round(payout, 2),
|
"revenue": round(revenue, 2), "payouts": round(payout, 2),
|
||||||
})
|
})
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ class CalamineReader:
|
||||||
self.header_row = 0 # 1-based (Excel)
|
self.header_row = 0 # 1-based (Excel)
|
||||||
self.column_mapping: ColumnMapping | None = None
|
self.column_mapping: ColumnMapping | None = None
|
||||||
self._field_to_idx: dict[str, int] = {}
|
self._field_to_idx: dict[str, int] = {}
|
||||||
|
self._sum_field_idx: dict[str, list[int]] = {}
|
||||||
self.file_meta = FileMeta(filename=self.filename)
|
self.file_meta = FileMeta(filename=self.filename)
|
||||||
|
|
||||||
# -- lifecycle --
|
# -- lifecycle --
|
||||||
|
|
@ -98,11 +99,17 @@ class CalamineReader:
|
||||||
self._field_to_idx = {
|
self._field_to_idx = {
|
||||||
fld: _letter_to_idx(col) for col, fld in mapping.col_to_field.items()
|
fld: _letter_to_idx(col) for col, fld in mapping.col_to_field.items()
|
||||||
}
|
}
|
||||||
|
# Extra amount columns folded into an already-mapped field (ColumnMapping.sum_cols).
|
||||||
|
self._sum_field_idx = {
|
||||||
|
fld: [_letter_to_idx(col) for col, _hdr in cols]
|
||||||
|
for fld, cols in mapping.sum_cols.items()
|
||||||
|
}
|
||||||
self.file_meta.data_sheet = name
|
self.file_meta.data_sheet = name
|
||||||
self.file_meta.header_row = self.header_row
|
self.file_meta.header_row = self.header_row
|
||||||
self.file_meta.unmapped_headers = mapping.unmapped
|
self.file_meta.unmapped_headers = mapping.unmapped
|
||||||
self.file_meta.missing_required = mapping.missing_required
|
self.file_meta.missing_required = mapping.missing_required
|
||||||
self.file_meta.duplicate_fields = mapping.duplicate_fields
|
self.file_meta.duplicate_fields = mapping.duplicate_fields
|
||||||
|
self.file_meta.summed_fields = mapping.sum_cols
|
||||||
self.file_meta.sheet_last_row = self._safe_height(name) # control C1
|
self.file_meta.sheet_last_row = self._safe_height(name) # control C1
|
||||||
return mapping
|
return mapping
|
||||||
|
|
||||||
|
|
@ -126,10 +133,15 @@ class CalamineReader:
|
||||||
self.detect()
|
self.detect()
|
||||||
assert self._sheet is not None
|
assert self._sheet is not None
|
||||||
idx_map = self._field_to_idx
|
idx_map = self._field_to_idx
|
||||||
|
sum_map = self._sum_field_idx
|
||||||
if only_fields is not None:
|
if only_fields is not None:
|
||||||
idx_map = {f: i for f, i in idx_map.items() if f in only_fields}
|
idx_map = {f: i for f, i in idx_map.items() if f in only_fields}
|
||||||
|
sum_map = {f: v for f, v in sum_map.items() if f in only_fields}
|
||||||
items = list(idx_map.items())
|
items = list(idx_map.items())
|
||||||
|
sum_items = list(sum_map.items())
|
||||||
mapped_idx = set(self._field_to_idx.values())
|
mapped_idx = set(self._field_to_idx.values())
|
||||||
|
for _idxs in self._sum_field_idx.values():
|
||||||
|
mapped_idx.update(_idxs)
|
||||||
unmapped_sums = self.file_meta.unmapped_amount_sums
|
unmapped_sums = self.file_meta.unmapped_amount_sums
|
||||||
hdr = self.header_row # 1-based; data starts at hdr+1 (Excel) => row index hdr (0-based)
|
hdr = self.header_row # 1-based; data starts at hdr+1 (Excel) => row index hdr (0-based)
|
||||||
min_d: date | None = None
|
min_d: date | None = None
|
||||||
|
|
@ -153,6 +165,12 @@ class CalamineReader:
|
||||||
# this reader emitted trailing blank rows the other reader dropped.
|
# this reader emitted trailing blank rows the other reader dropped.
|
||||||
if v not in (None, ""):
|
if v not in (None, ""):
|
||||||
has_value = True
|
has_value = True
|
||||||
|
for fld, idxs in sum_items:
|
||||||
|
for i in idxs:
|
||||||
|
v = row[i] if i < len(row) else None
|
||||||
|
if v not in (None, ""):
|
||||||
|
rec[fld] = (rec.get(fld) or 0.0) + _conv_cal(fld, v)
|
||||||
|
has_value = True
|
||||||
if not has_value:
|
if not has_value:
|
||||||
self.file_meta.blank_rows_skipped += 1
|
self.file_meta.blank_rows_skipped += 1
|
||||||
continue
|
continue
|
||||||
|
|
|
||||||
|
|
@ -346,6 +346,11 @@ class ColumnMapping:
|
||||||
# Only the first is used, so the second column's amounts would vanish from the journal.
|
# Only the first is used, so the second column's amounts would vanish from the journal.
|
||||||
# Surfaced as an error rather than silently demoted to `unmapped`.
|
# Surfaced as an error rather than silently demoted to `unmapped`.
|
||||||
duplicate_fields: dict[str, list[tuple[str, str]]] = field(default_factory=dict)
|
duplicate_fields: dict[str, list[tuple[str, str]]] = field(default_factory=dict)
|
||||||
|
# Extra AMOUNT columns folded into an already-mapped field: field -> [(col, header)].
|
||||||
|
# Amazon splits one concept across columns in some schemas (AU: "sales tax collected"
|
||||||
|
# + "low value goods", both inside the row `total`), so readers SUM these instead of
|
||||||
|
# dropping them.
|
||||||
|
sum_cols: dict[str, list[tuple[str, str]]] = field(default_factory=dict)
|
||||||
header_row: int = 0
|
header_row: int = 0
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|
@ -379,11 +384,18 @@ def build_mapping(
|
||||||
m.col_to_field[col] = fld
|
m.col_to_field[col] = fld
|
||||||
m.field_to_col[fld] = col
|
m.field_to_col[fld] = col
|
||||||
elif fld:
|
elif fld:
|
||||||
# Collision: first column wins and this one is dropped. Record both so the close
|
if FIELD_KIND.get(fld) == "amount":
|
||||||
# can raise, instead of quietly excluding a whole amount column.
|
# A second amount column for the same concept (AU "low value goods" next
|
||||||
first_col = m.field_to_col[fld]
|
# to "sales tax collected") — readers ADD it into the field, because the
|
||||||
m.duplicate_fields.setdefault(fld, [(first_col, "")]).append((col, str(text)))
|
# row `total` includes both and dropping it fails control C2.
|
||||||
m.unmapped[col] = str(text)
|
m.sum_cols.setdefault(fld, []).append((col, str(text)))
|
||||||
|
else:
|
||||||
|
# Collision on a non-amount field: first column wins and this one is
|
||||||
|
# dropped. Record both so the close can raise, instead of quietly
|
||||||
|
# excluding a whole column.
|
||||||
|
first_col = m.field_to_col[fld]
|
||||||
|
m.duplicate_fields.setdefault(fld, [(first_col, "")]).append((col, str(text)))
|
||||||
|
m.unmapped[col] = str(text)
|
||||||
elif text and str(text).strip():
|
elif text and str(text).strip():
|
||||||
m.unmapped[col] = str(text)
|
m.unmapped[col] = str(text)
|
||||||
m.missing_required = [f for f in REQUIRED_FIELDS if f not in m.field_to_col]
|
m.missing_required = [f for f in REQUIRED_FIELDS if f not in m.field_to_col]
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,17 @@ _CURRENCY_RE = re.compile(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Amazon's localized reports are not all ASCII: Sweden writes negatives with a real
|
||||||
|
# MINUS SIGN (U+2212, "\u221278 690,40") and several locales group thousands with
|
||||||
|
# non-breaking / narrow spaces. float() rejects U+2212, and the fallback below would
|
||||||
|
# silently turn the cell into 0.0 \u2014 which dropped every negative amount (fees, taxes,
|
||||||
|
# transfers) of an entire Swedish month while the positives kept adding up.
|
||||||
|
_AMOUNT_CLEANUP = str.maketrans({
|
||||||
|
"\u2212": "-", "\u2010": "-", "\u2011": "-", "\u2013": "-", # minus / dash variants
|
||||||
|
"\u00a0": None, "\u202f": None, "\u2009": None, " ": None, # space variants
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
def parse_amount(raw) -> float:
|
def parse_amount(raw) -> float:
|
||||||
"""Parse Amazon amount cells, including European '1.234,56' / '13,49' forms."""
|
"""Parse Amazon amount cells, including European '1.234,56' / '13,49' forms."""
|
||||||
if raw is None or raw == "":
|
if raw is None or raw == "":
|
||||||
|
|
@ -33,7 +44,7 @@ def parse_amount(raw) -> float:
|
||||||
return 0.0
|
return 0.0
|
||||||
if isinstance(raw, (int, float)):
|
if isinstance(raw, (int, float)):
|
||||||
return float(raw)
|
return float(raw)
|
||||||
s = str(raw).strip().replace("\u00a0", "").replace(" ", "")
|
s = str(raw).strip().translate(_AMOUNT_CLEANUP)
|
||||||
if not s:
|
if not s:
|
||||||
return 0.0
|
return 0.0
|
||||||
# European: decimal comma, optional thousands dots / spaces.
|
# European: decimal comma, optional thousands dots / spaces.
|
||||||
|
|
@ -93,6 +104,7 @@ class CsvReader:
|
||||||
self.header_row = 0 # 1-based, matching Excel readers
|
self.header_row = 0 # 1-based, matching Excel readers
|
||||||
self.column_mapping: ColumnMapping | None = None
|
self.column_mapping: ColumnMapping | None = None
|
||||||
self._field_to_idx: dict[str, int] = {}
|
self._field_to_idx: dict[str, int] = {}
|
||||||
|
self._sum_field_idx: dict[str, list[int]] = {}
|
||||||
self.file_meta = FileMeta(filename=self.filename)
|
self.file_meta = FileMeta(filename=self.filename)
|
||||||
|
|
||||||
def open(self) -> None:
|
def open(self) -> None:
|
||||||
|
|
@ -172,11 +184,17 @@ class CsvReader:
|
||||||
self._field_to_idx = {
|
self._field_to_idx = {
|
||||||
fld: _col_to_idx(col) for col, fld in mapping.col_to_field.items()
|
fld: _col_to_idx(col) for col, fld in mapping.col_to_field.items()
|
||||||
}
|
}
|
||||||
|
# Extra amount columns folded into an already-mapped field (ColumnMapping.sum_cols).
|
||||||
|
self._sum_field_idx = {
|
||||||
|
fld: [_col_to_idx(col) for col, _hdr in cols]
|
||||||
|
for fld, cols in mapping.sum_cols.items()
|
||||||
|
}
|
||||||
self.file_meta.data_sheet = self.sheet_name
|
self.file_meta.data_sheet = self.sheet_name
|
||||||
self.file_meta.header_row = self.header_row
|
self.file_meta.header_row = self.header_row
|
||||||
self.file_meta.unmapped_headers = mapping.unmapped
|
self.file_meta.unmapped_headers = mapping.unmapped
|
||||||
self.file_meta.missing_required = mapping.missing_required
|
self.file_meta.missing_required = mapping.missing_required
|
||||||
self.file_meta.duplicate_fields = mapping.duplicate_fields
|
self.file_meta.duplicate_fields = mapping.duplicate_fields
|
||||||
|
self.file_meta.summed_fields = mapping.sum_cols
|
||||||
self.file_meta.sheet_last_row = len(self._rows)
|
self.file_meta.sheet_last_row = len(self._rows)
|
||||||
return mapping
|
return mapping
|
||||||
|
|
||||||
|
|
@ -185,10 +203,15 @@ class CsvReader:
|
||||||
self.detect()
|
self.detect()
|
||||||
assert self._rows is not None
|
assert self._rows is not None
|
||||||
idx_map = self._field_to_idx
|
idx_map = self._field_to_idx
|
||||||
|
sum_map = self._sum_field_idx
|
||||||
if only_fields is not None:
|
if only_fields is not None:
|
||||||
idx_map = {f: i for f, i in idx_map.items() if f in only_fields}
|
idx_map = {f: i for f, i in idx_map.items() if f in only_fields}
|
||||||
|
sum_map = {f: v for f, v in sum_map.items() if f in only_fields}
|
||||||
items = list(idx_map.items())
|
items = list(idx_map.items())
|
||||||
|
sum_items = list(sum_map.items())
|
||||||
mapped_idx = set(self._field_to_idx.values())
|
mapped_idx = set(self._field_to_idx.values())
|
||||||
|
for _idxs in self._sum_field_idx.values():
|
||||||
|
mapped_idx.update(_idxs)
|
||||||
unmapped_sums = self.file_meta.unmapped_amount_sums
|
unmapped_sums = self.file_meta.unmapped_amount_sums
|
||||||
hdr = self.header_row
|
hdr = self.header_row
|
||||||
min_d: date | None = None
|
min_d: date | None = None
|
||||||
|
|
@ -211,6 +234,12 @@ class CsvReader:
|
||||||
rec[fld] = _convert(fld, v)
|
rec[fld] = _convert(fld, v)
|
||||||
if v not in (None, ""):
|
if v not in (None, ""):
|
||||||
has_value = True
|
has_value = True
|
||||||
|
for fld, idxs in sum_items:
|
||||||
|
for i in idxs:
|
||||||
|
v = row[i] if i < len(row) else None
|
||||||
|
if v not in (None, ""):
|
||||||
|
rec[fld] = (rec.get(fld) or 0.0) + parse_amount(v)
|
||||||
|
has_value = True
|
||||||
if not has_value:
|
if not has_value:
|
||||||
self.file_meta.blank_rows_skipped += 1
|
self.file_meta.blank_rows_skipped += 1
|
||||||
continue
|
continue
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,9 @@ class FileMeta:
|
||||||
missing_required: list[str] = field(default_factory=list)
|
missing_required: list[str] = field(default_factory=list)
|
||||||
# canonical field -> the columns that both claimed it (only the first is used)
|
# canonical field -> the columns that both claimed it (only the first is used)
|
||||||
duplicate_fields: dict[str, list] = field(default_factory=dict)
|
duplicate_fields: dict[str, list] = field(default_factory=dict)
|
||||||
|
# canonical AMOUNT field -> extra [(col, header)] whose amounts were ADDED into it
|
||||||
|
# (Amazon splits one concept across columns, e.g. AU "low value goods" tax).
|
||||||
|
summed_fields: dict[str, list] = field(default_factory=dict)
|
||||||
# Finance-added translation/helper header rows found below the real header and skipped.
|
# Finance-added translation/helper header rows found below the real header and skipped.
|
||||||
helper_rows_skipped: int = 0
|
helper_rows_skipped: int = 0
|
||||||
# column-letter -> Σ of numeric values seen in columns with NO mapped field.
|
# column-letter -> Σ of numeric values seen in columns with NO mapped field.
|
||||||
|
|
@ -253,6 +256,7 @@ class TransactionReader:
|
||||||
self.file_meta.unmapped_headers = mapping.unmapped
|
self.file_meta.unmapped_headers = mapping.unmapped
|
||||||
self.file_meta.missing_required = mapping.missing_required
|
self.file_meta.missing_required = mapping.missing_required
|
||||||
self.file_meta.duplicate_fields = mapping.duplicate_fields
|
self.file_meta.duplicate_fields = mapping.duplicate_fields
|
||||||
|
self.file_meta.summed_fields = mapping.sum_cols
|
||||||
self.file_meta.sheet_last_row = self._declared_last_row(part)
|
self.file_meta.sheet_last_row = self._declared_last_row(part)
|
||||||
return mapping
|
return mapping
|
||||||
|
|
||||||
|
|
@ -284,6 +288,9 @@ class TransactionReader:
|
||||||
assert self.column_mapping is not None and self._zip is not None
|
assert self.column_mapping is not None and self._zip is not None
|
||||||
shared = self._shared_strings()
|
shared = self._shared_strings()
|
||||||
col_to_field = self.column_mapping.col_to_field
|
col_to_field = self.column_mapping.col_to_field
|
||||||
|
# Extra amount columns folded into an already-mapped field (see ColumnMapping.sum_cols).
|
||||||
|
sum_col_to_field = {c: f for f, cols in self.column_mapping.sum_cols.items()
|
||||||
|
for c, _hdr in cols}
|
||||||
want = only_fields
|
want = only_fields
|
||||||
min_d: date | None = None
|
min_d: date | None = None
|
||||||
max_d: date | None = None
|
max_d: date | None = None
|
||||||
|
|
@ -303,6 +310,14 @@ class TransactionReader:
|
||||||
for col, val in cells.items():
|
for col, val in cells.items():
|
||||||
fld = col_to_field.get(col)
|
fld = col_to_field.get(col)
|
||||||
if not fld:
|
if not fld:
|
||||||
|
sfld = sum_col_to_field.get(col)
|
||||||
|
if sfld is not None:
|
||||||
|
# Cells iterate in column order, so the field's primary column has
|
||||||
|
# already been converted (when present) — add, don't assign.
|
||||||
|
if (want is None or sfld in want) and val not in (None, ""):
|
||||||
|
rec[sfld] = (rec.get(sfld) or 0.0) + _convert(sfld, val)
|
||||||
|
has_value = True
|
||||||
|
continue
|
||||||
# No amount is silently excluded: sum numeric data in unmapped columns.
|
# No amount is silently excluded: sum numeric data in unmapped columns.
|
||||||
if val not in (None, ""):
|
if val not in (None, ""):
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
|
|
@ -287,6 +287,13 @@ def _exceptions_from(result: ProcessResult) -> list[dict]:
|
||||||
f"used, so the others are excluded from every total — "
|
f"used, so the others are excluded from every total — "
|
||||||
f"correct the header mapping before relying on this close."),
|
f"correct the header mapping before relying on this close."),
|
||||||
"source": m.filename})
|
"source": m.filename})
|
||||||
|
for fld, cols in (getattr(m, "summed_fields", None) or {}).items():
|
||||||
|
cols_txt = ", ".join(f"{c}{f' ({t})' if t else ''}" for c, t in cols)
|
||||||
|
out.append({"category": "summed_column_mapping", "severity": "info",
|
||||||
|
"detail": (f"Column(s) {cols_txt} were added into '{fld}' — Amazon "
|
||||||
|
f"splits this concept across columns and the row total "
|
||||||
|
f"includes both."),
|
||||||
|
"source": m.filename})
|
||||||
for col, s in (getattr(m, "unmapped_amount_sums", None) or {}).items():
|
for col, s in (getattr(m, "unmapped_amount_sums", None) or {}).items():
|
||||||
if abs(s) > 0.005:
|
if abs(s) > 0.005:
|
||||||
out.append({"category": "unmapped_amounts", "severity": "error",
|
out.append({"category": "unmapped_amounts", "severity": "error",
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,8 @@ from __future__ import annotations
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
from app.core.csv_reader import CsvReader, parse_amount
|
from app.core.csv_reader import CsvReader, parse_amount
|
||||||
from app.core.readers import make_reader
|
from app.core.readers import make_reader
|
||||||
|
|
||||||
|
|
@ -16,6 +18,47 @@ def test_parse_amount_european():
|
||||||
assert parse_amount("") == 0.0
|
assert parse_amount("") == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_amount_unicode_minus_and_spaces():
|
||||||
|
"""Sweden writes negatives with U+2212 and groups thousands with (narrow) NBSP.
|
||||||
|
|
||||||
|
float() rejects both, and the parser's fallback returned 0.0 — every negative
|
||||||
|
kronor amount (fees, taxes, transfers) of the Jan-2026 file silently vanished
|
||||||
|
while the positives kept adding up (control C2 caught the +165,810.53 drift)."""
|
||||||
|
assert parse_amount("−35,70") == -35.70 # −35,70
|
||||||
|
assert parse_amount("−78 690,40") == -78690.40 # −78 690,40 (NBSP)
|
||||||
|
assert parse_amount("−1 234,56") == -1234.56 # narrow NBSP thousands
|
||||||
|
assert parse_amount("1 234,56") == 1234.56
|
||||||
|
assert parse_amount("–6,24") == -6.24 # en dash used as minus
|
||||||
|
|
||||||
|
|
||||||
|
def test_second_amount_column_is_summed_not_dropped(tmp_path: Path):
|
||||||
|
"""Australia carries BOTH 'sales tax collected' and 'low value goods' (LVIG GST);
|
||||||
|
the row `total` includes both. Dropping the second column failed control C2 by its
|
||||||
|
sum. Amount-field collisions are summed; only non-amount collisions stay errors."""
|
||||||
|
body = (
|
||||||
|
'"preamble"\n'
|
||||||
|
'"date/time","settlement ID","type","order ID","sales tax collected",'
|
||||||
|
'"low value goods","total"\n'
|
||||||
|
'"1 Jan 2026 00:00:00 UTC","123","Order","o-1","10,00","-6,60","3,40"\n'
|
||||||
|
)
|
||||||
|
p = tmp_path / "2026JanMonthlyTransaction.csv"
|
||||||
|
p.write_text(body, encoding="utf-8-sig")
|
||||||
|
|
||||||
|
reader = CsvReader(str(p))
|
||||||
|
mapping = reader.detect()
|
||||||
|
assert mapping.sum_cols == {"sales_tax_collected": [("F", "low value goods")]}
|
||||||
|
assert not mapping.duplicate_fields
|
||||||
|
assert "F" not in mapping.unmapped
|
||||||
|
|
||||||
|
rows = list(reader.iter_records())
|
||||||
|
assert len(rows) == 1
|
||||||
|
assert rows[0]["sales_tax_collected"] == pytest.approx(3.40) # 10.00 + (-6.60)
|
||||||
|
assert rows[0]["total"] == 3.40
|
||||||
|
assert reader.file_meta.summed_fields == mapping.sum_cols
|
||||||
|
assert not reader.file_meta.unmapped_amount_sums
|
||||||
|
reader.close()
|
||||||
|
|
||||||
|
|
||||||
def test_make_reader_routes_csv(tmp_path: Path):
|
def test_make_reader_routes_csv(tmp_path: Path):
|
||||||
p = tmp_path / "sample.csv"
|
p = tmp_path / "sample.csv"
|
||||||
p.write_text(
|
p.write_text(
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,78 @@
|
||||||
|
"""
|
||||||
|
Dual-currency dashboard figures.
|
||||||
|
|
||||||
|
The AR Ledger shows every movement in the marketplace's local currency AND in USD,
|
||||||
|
converted at each TRANSACTION DATE's exchange rate (a daily override when one exists,
|
||||||
|
the marketplace month rate otherwise). The opening balance has no transaction date, so
|
||||||
|
it converts at the month rate.
|
||||||
|
|
||||||
|
Also the regression for the fx-daily endpoint: after payouts moved out of _daily_rows
|
||||||
|
into _payout_events, fx_daily still unpacked 4-tuples and crashed on every session with
|
||||||
|
data — the "Daily exchange rates" table never rendered.
|
||||||
|
|
||||||
|
Fixture dates (make_amazon_xlsx, USA, month-end 2026-01-31, lag 2 → cutoff Jan 29):
|
||||||
|
revenue: Jan 5 +1000 · Jan 10 +300 · Jan 15 +2000 · Jan 20 +80 · Jan 31 +500
|
||||||
|
payouts: Jan 6 −1000 (received) · Jan 12 −300 (received) · Jan 30 −2000 (in transit)
|
||||||
|
→ closing = 3880 − 1300 = 2580
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app.api.main import app
|
||||||
|
from app.db.database import init_db
|
||||||
|
from tests.test_payout_receipts import _fresh
|
||||||
|
|
||||||
|
|
||||||
|
def test_fx_daily_no_longer_crashes_and_covers_payout_dates():
|
||||||
|
init_db()
|
||||||
|
with TestClient(app) as c:
|
||||||
|
sid = _fresh(c, "fx daily regression")
|
||||||
|
r = c.get(f"/api/sessions/{sid}/fx-daily")
|
||||||
|
assert r.status_code == 200
|
||||||
|
data = r.json()
|
||||||
|
assert data["available"] is True
|
||||||
|
|
||||||
|
by_date = {row["date"]: row for row in data["rows"]}
|
||||||
|
# Revenue sits on its transaction date…
|
||||||
|
assert by_date["2026-01-15"]["revenue"] == 2000.0
|
||||||
|
# …and payouts on their effective date, in the same table.
|
||||||
|
assert by_date["2026-01-06"]["payouts"] == -1000.0
|
||||||
|
assert by_date["2026-01-30"]["payouts"] == -2000.0
|
||||||
|
|
||||||
|
# USA converts 1:1 — USD total equals local total = 3880 − 3300 net movement.
|
||||||
|
assert data["month_rate"] == 1.0
|
||||||
|
assert data["total_local"] == 580.0
|
||||||
|
assert data["total_usd"] == data["total_local"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_ledger_detail_shows_usd_at_transaction_date_rates():
|
||||||
|
init_db()
|
||||||
|
with TestClient(app) as c:
|
||||||
|
sid = _fresh(c, "dual currency ledger")
|
||||||
|
# Daily override for the big revenue day; every other date uses the month rate.
|
||||||
|
assert c.put(f"/api/sessions/{sid}/fx-daily", json=[
|
||||||
|
{"marketplace": "USA", "rate_date": "2026-01-15", "rate": 1.25},
|
||||||
|
]).status_code == 200
|
||||||
|
|
||||||
|
d = c.get(f"/api/sessions/{sid}/ledger-detail").json()
|
||||||
|
per = {p["key"]: p for p in d["periods"]}
|
||||||
|
|
||||||
|
# Jan 15's revenue converts at ITS OWN day's rate…
|
||||||
|
assert per["2026-01-15"]["revenue"] == 2000.0
|
||||||
|
assert per["2026-01-15"]["revenue_usd"] == 2500.0
|
||||||
|
# …every other date at the month rate (1.0 for USA).
|
||||||
|
assert per["2026-01-05"]["revenue_usd"] == per["2026-01-05"]["revenue"] == 1000.0
|
||||||
|
assert per["2026-01-06"]["payouts_received_usd"] == -1000.0
|
||||||
|
assert per["2026-01-30"]["payouts_in_transit_usd"] == -2000.0
|
||||||
|
|
||||||
|
# Opening has no transaction date → month rate; the USD running balance then
|
||||||
|
# absorbs the daily-rate spread: closing_usd = closing + 2000 × (1.25 − 1).
|
||||||
|
assert d["month_rate"] == 1.0
|
||||||
|
assert d["opening_usd"] == 0.0
|
||||||
|
assert d["closing"] == 2580.0
|
||||||
|
assert d["closing_usd"] == 3080.0
|
||||||
|
assert d["in_transit_total_usd"] == -2000.0
|
||||||
|
|
||||||
|
# The local-currency figures are untouched by the daily override.
|
||||||
|
assert per["2026-01-15"]["balance"] == per["2026-01-15"]["balance_usd"] - 500.0
|
||||||
|
|
@ -279,6 +279,9 @@ export interface FinanceSummaryT extends BlockableT {
|
||||||
export interface LedgerPeriodT {
|
export interface LedgerPeriodT {
|
||||||
key: string; label: string; revenue: number; payouts_received: number;
|
key: string; label: string; revenue: number; payouts_received: number;
|
||||||
payouts_in_transit: number; rows: number; balance: number;
|
payouts_in_transit: number; rows: number; balance: number;
|
||||||
|
/** USD equivalents, converted at each transaction date's FX rate. */
|
||||||
|
revenue_usd: number; payouts_received_usd: number;
|
||||||
|
payouts_in_transit_usd: number; balance_usd: number;
|
||||||
}
|
}
|
||||||
export interface LedgerDetailT {
|
export interface LedgerDetailT {
|
||||||
available: boolean;
|
available: boolean;
|
||||||
|
|
@ -286,6 +289,13 @@ export interface LedgerDetailT {
|
||||||
granularity?: string; date_from?: string | null; date_to?: string | null;
|
granularity?: string; date_from?: string | null; date_to?: string | null;
|
||||||
opening?: number; periods?: LedgerPeriodT[]; closing?: number;
|
opening?: number; periods?: LedgerPeriodT[]; closing?: number;
|
||||||
session_closing?: number; filtered?: boolean; in_transit_total?: number;
|
session_closing?: number; filtered?: boolean; in_transit_total?: number;
|
||||||
|
/** The marketplace month rate; the opening balance converts at this rate. */
|
||||||
|
month_rate?: number;
|
||||||
|
opening_usd?: number;
|
||||||
|
/** Roll-forward valued at transaction-date rates — differs from closing × month rate
|
||||||
|
* whenever daily overrides exist. */
|
||||||
|
closing_usd?: number;
|
||||||
|
in_transit_total_usd?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FxDailyRowT {
|
export interface FxDailyRowT {
|
||||||
|
|
@ -563,7 +573,8 @@ export const api = {
|
||||||
putReserves: (id: number, items: { marketplace: string; account_type: string; amount: number }[]) =>
|
putReserves: (id: number, items: { marketplace: string; account_type: string; amount: number }[]) =>
|
||||||
req(`/sessions/${id}/reserves`, { method: "PUT", body: JSON.stringify(items) }),
|
req(`/sessions/${id}/reserves`, { method: "PUT", body: JSON.stringify(items) }),
|
||||||
getFx: (id: number) =>
|
getFx: (id: number) =>
|
||||||
req<{ marketplace: string; currency: string; rate: number }[]>(`/sessions/${id}/fx`),
|
req<{ marketplace: string; currency: string; rate: number;
|
||||||
|
source: string; rate_date: string | null }[]>(`/sessions/${id}/fx`),
|
||||||
putFx: (id: number, items: { marketplace: string; currency: string; rate: number }[]) =>
|
putFx: (id: number, items: { marketplace: string; currency: string; rate: number }[]) =>
|
||||||
req(`/sessions/${id}/fx`, { method: "PUT", body: JSON.stringify(items) }),
|
req(`/sessions/${id}/fx`, { method: "PUT", body: JSON.stringify(items) }),
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,13 @@ export default function ArLedger() {
|
||||||
const mkt = mv.marketplace ?? "USA";
|
const mkt = mv.marketplace ?? "USA";
|
||||||
const cur = mv.currency ?? "USD";
|
const cur = mv.currency ?? "USD";
|
||||||
const m = (v: number | null | undefined, dp = 0) => money(v, cur, dp);
|
const m = (v: number | null | undefined, dp = 0) => money(v, cur, dp);
|
||||||
|
// USD-reporting marketplaces would just repeat every figure — show the pair only when
|
||||||
|
// the local currency actually differs.
|
||||||
|
const dual = cur !== "USD";
|
||||||
|
const inUsd = (v: number | null | undefined, dp = 2) =>
|
||||||
|
dual && v != null ? (
|
||||||
|
<div className="text-[11px] leading-tight text-subink">{money(v, "USD", dp)}</div>
|
||||||
|
) : null;
|
||||||
const opening = openings?.find((o) => o.marketplace === mkt);
|
const opening = openings?.find((o) => o.marketplace === mkt);
|
||||||
const diff = mv.difference_vs_settlement ?? 0;
|
const diff = mv.difference_vs_settlement ?? 0;
|
||||||
const reconciled = Math.abs(diff) < 1;
|
const reconciled = Math.abs(diff) < 1;
|
||||||
|
|
@ -100,6 +107,14 @@ export default function ArLedger() {
|
||||||
<span className="font-semibold text-primary">= Closing receivable</span>
|
<span className="font-semibold text-primary">= Closing receivable</span>
|
||||||
<span className="num text-lg font-semibold text-primary">{m(mv.closing)}</span>
|
<span className="num text-lg font-semibold text-primary">{m(mv.closing)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
{dual && detail?.month_rate != null && (
|
||||||
|
<div className="flex items-center justify-between text-xs text-subink">
|
||||||
|
<span>in USD @ month rate {num(detail.month_rate, 6)}</span>
|
||||||
|
<span className="num font-medium">
|
||||||
|
{money((mv.closing ?? 0) * detail.month_rate, "USD", 2)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<p className="text-xs text-subink pt-2">
|
<p className="text-xs text-subink pt-2">
|
||||||
In-transit payouts of <span className="num">{m(mv.in_transit_payouts)}</span> remain
|
In-transit payouts of <span className="num">{m(mv.in_transit_payouts)}</span> remain
|
||||||
in receivable (not yet cleared).
|
in receivable (not yet cleared).
|
||||||
|
|
@ -165,7 +180,8 @@ export default function ArLedger() {
|
||||||
|
|
||||||
{/* ---------------- date-filtered movement ---------------- */}
|
{/* ---------------- date-filtered movement ---------------- */}
|
||||||
<Section title="Movement by date"
|
<Section title="Movement by date"
|
||||||
subtitle="Daily, weekly or monthly view of the same ledger. Unfiltered, the running balance ends at the closing receivable.">
|
subtitle={`Daily, weekly or monthly view of the same ledger. Unfiltered, the running balance ends at the closing receivable.${
|
||||||
|
dual ? " USD figures (grey) are converted at each transaction date's exchange rate." : ""}`}>
|
||||||
<div className="p-4 flex flex-wrap items-end gap-3 border-b border-line">
|
<div className="p-4 flex flex-wrap items-end gap-3 border-b border-line">
|
||||||
<div className="flex gap-1 p-1 rounded-xl bg-neutralbg">
|
<div className="flex gap-1 p-1 rounded-xl bg-neutralbg">
|
||||||
{(["day", "week", "month"] as Gran[]).map((g) => (
|
{(["day", "week", "month"] as Gran[]).map((g) => (
|
||||||
|
|
@ -213,26 +229,40 @@ export default function ArLedger() {
|
||||||
<tr className="bg-neutralbg/50 font-medium">
|
<tr className="bg-neutralbg/50 font-medium">
|
||||||
<td className="td">Opening</td>
|
<td className="td">Opening</td>
|
||||||
<td className="td" /><td className="td" /><td className="td" /><td className="td" />
|
<td className="td" /><td className="td" /><td className="td" /><td className="td" />
|
||||||
<td className="td text-right num">{m(detail?.opening)}</td>
|
<td className="td text-right num">
|
||||||
|
{m(detail?.opening)}
|
||||||
|
{inUsd(detail?.opening_usd)}
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{(detail?.periods ?? []).map((p) => (
|
{(detail?.periods ?? []).map((p) => (
|
||||||
<tr key={p.key}>
|
<tr key={p.key}>
|
||||||
<td className="td">{p.label}</td>
|
<td className="td">{p.label}</td>
|
||||||
<td className="td text-right num text-xs text-subink">{p.rows.toLocaleString()}</td>
|
<td className="td text-right num text-xs text-subink">{p.rows.toLocaleString()}</td>
|
||||||
<td className="td text-right num">{acct(p.revenue)}</td>
|
<td className="td text-right num">
|
||||||
|
{acct(p.revenue)}
|
||||||
|
{inUsd(p.revenue_usd)}
|
||||||
|
</td>
|
||||||
<td className="td text-right num text-bad">
|
<td className="td text-right num text-bad">
|
||||||
{p.payouts_received ? acct(p.payouts_received) : ""}
|
{p.payouts_received ? acct(p.payouts_received) : ""}
|
||||||
|
{p.payouts_received ? inUsd(p.payouts_received_usd) : null}
|
||||||
</td>
|
</td>
|
||||||
<td className="td text-right num text-warn">
|
<td className="td text-right num text-warn">
|
||||||
{p.payouts_in_transit ? acct(p.payouts_in_transit) : ""}
|
{p.payouts_in_transit ? acct(p.payouts_in_transit) : ""}
|
||||||
|
{p.payouts_in_transit ? inUsd(p.payouts_in_transit_usd) : null}
|
||||||
|
</td>
|
||||||
|
<td className="td text-right num font-medium">
|
||||||
|
{m(p.balance)}
|
||||||
|
{inUsd(p.balance_usd)}
|
||||||
</td>
|
</td>
|
||||||
<td className="td text-right num font-medium">{m(p.balance)}</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
<tr className="bg-primary-soft/50 font-semibold">
|
<tr className="bg-primary-soft/50 font-semibold">
|
||||||
<td className="td text-primary">Closing</td>
|
<td className="td text-primary">Closing</td>
|
||||||
<td className="td" /><td className="td" /><td className="td" /><td className="td" />
|
<td className="td" /><td className="td" /><td className="td" /><td className="td" />
|
||||||
<td className="td text-right num text-primary">{m(detail?.closing)}</td>
|
<td className="td text-right num text-primary">
|
||||||
|
{m(detail?.closing)}
|
||||||
|
{inUsd(detail?.closing_usd)}
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
|
||||||
|
|
@ -23,27 +23,45 @@ export default function Controls() {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const [who, setWho] = useState("");
|
const [who, setWho] = useState("");
|
||||||
|
|
||||||
|
const [edits, setEdits] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
const { data, isLoading } = useQuery({
|
const { data, isLoading } = useQuery({
|
||||||
queryKey: ["controls", id], queryFn: () => api.controls(id),
|
queryKey: ["controls", id], queryFn: () => api.controls(id),
|
||||||
});
|
});
|
||||||
|
const fxFailing = (data?.controls ?? []).some((r) => r.key === "C5" && r.status === "fail");
|
||||||
|
const { data: fx } = useQuery({
|
||||||
|
queryKey: ["fx", id], queryFn: () => api.getFx(id), enabled: fxFailing,
|
||||||
|
});
|
||||||
|
|
||||||
const invalidate = () => {
|
const invalidate = () => {
|
||||||
qc.invalidateQueries({ queryKey: ["controls", id] });
|
qc.invalidateQueries({ queryKey: ["controls", id] });
|
||||||
qc.invalidateQueries({ queryKey: ["session", id] });
|
qc.invalidateQueries({ queryKey: ["session", id] });
|
||||||
qc.invalidateQueries({ queryKey: ["summary", id] });
|
qc.invalidateQueries({ queryKey: ["summary", id] });
|
||||||
qc.invalidateQueries({ queryKey: ["sessions"] });
|
qc.invalidateQueries({ queryKey: ["sessions"] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["fx", id] });
|
||||||
};
|
};
|
||||||
const rerun = useMutation({ mutationFn: () => api.runControls(id), onSuccess: invalidate });
|
const rerun = useMutation({ mutationFn: () => api.runControls(id), onSuccess: invalidate });
|
||||||
const confirmFx = useMutation({
|
const confirmFx = useMutation({
|
||||||
mutationFn: () => api.confirmAllFx(id, who.trim()), onSuccess: invalidate,
|
mutationFn: () => api.confirmAllFx(id, who.trim()), onSuccess: invalidate,
|
||||||
});
|
});
|
||||||
|
// Saving marks the rates source=manual and (by design) withdraws any prior confirmation
|
||||||
|
// for a changed rate — the person then confirms the corrected value below.
|
||||||
|
const saveFx = useMutation({
|
||||||
|
mutationFn: () => api.putFx(id, (fx ?? []).map((r) => ({
|
||||||
|
marketplace: r.marketplace, currency: r.currency,
|
||||||
|
rate: Number(edits[r.marketplace] ?? r.rate),
|
||||||
|
}))),
|
||||||
|
onSuccess: () => { setEdits({}); invalidate(); },
|
||||||
|
});
|
||||||
|
|
||||||
if (isLoading) return <div className="p-6 flex items-center gap-2 text-subink"><Spinner /> Loading controls…</div>;
|
if (isLoading) return <div className="p-6 flex items-center gap-2 text-subink"><Spinner /> Loading controls…</div>;
|
||||||
if (!data?.available)
|
if (!data?.available)
|
||||||
return <EmptyState title="No controls have run yet."
|
return <EmptyState title="No controls have run yet."
|
||||||
hint="Process the closing — the month-end controls run automatically at the end of processing." />;
|
hint="Process the closing — the month-end controls run automatically at the end of processing." />;
|
||||||
|
|
||||||
const fxFailing = data.controls.some((r) => r.key === "C5" && r.status === "fail");
|
const dirty = Object.entries(edits).some(
|
||||||
|
([m, v]) => Number(v) !== (fx ?? []).find((r) => r.marketplace === m)?.rate);
|
||||||
|
const invalid = Object.values(edits).some((v) => !Number.isFinite(Number(v)) || Number(v) <= 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
|
@ -76,23 +94,60 @@ export default function Controls() {
|
||||||
{fxFailing && (
|
{fxFailing && (
|
||||||
<Section title="Confirm exchange rates"
|
<Section title="Confirm exchange rates"
|
||||||
subtitle={`Control C5 requires a rate confirmed for ${session.reporting_month ?? "this month"}. Seeded defaults are a January-2026 snapshot and are treated as missing.`}>
|
subtitle={`Control C5 requires a rate confirmed for ${session.reporting_month ?? "this month"}. Seeded defaults are a January-2026 snapshot and are treated as missing.`}>
|
||||||
|
<div className="overflow-x-auto border-b border-line">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead><tr>
|
||||||
|
<th className="th">Marketplace</th><th className="th">Currency</th>
|
||||||
|
<th className="th text-right">Rate → USD</th><th className="th">Source</th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{(fx ?? []).map((r) => (
|
||||||
|
<tr key={r.marketplace}>
|
||||||
|
<td className="td font-medium">{r.marketplace}</td>
|
||||||
|
<td className="td">{r.currency}</td>
|
||||||
|
<td className="td text-right">
|
||||||
|
<input className="input num w-36 py-1 text-right"
|
||||||
|
value={edits[r.marketplace] ?? String(r.rate)}
|
||||||
|
onChange={(e) =>
|
||||||
|
setEdits((p) => ({ ...p, [r.marketplace]: e.target.value }))} />
|
||||||
|
</td>
|
||||||
|
<td className="td text-xs text-subink">{r.source}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{!fx?.length && (
|
||||||
|
<tr><td className="td text-subink" colSpan={4}>No rates yet — process the closing first.</td></tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
<div className="p-4 flex flex-wrap items-end gap-3">
|
<div className="p-4 flex flex-wrap items-end gap-3">
|
||||||
|
{dirty && (
|
||||||
|
<button className="btn-ghost" disabled={invalid || saveFx.isPending}
|
||||||
|
onClick={() => saveFx.mutate()}>
|
||||||
|
{saveFx.isPending ? <Spinner /> : null} Save corrected rates
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<label className="text-sm">
|
<label className="text-sm">
|
||||||
<span className="block text-xs font-medium text-subink mb-1">Confirmed by</span>
|
<span className="block text-xs font-medium text-subink mb-1">Confirmed by</span>
|
||||||
<input className="input" placeholder="Your name" value={who}
|
<input className="input" placeholder="Your name" value={who}
|
||||||
onChange={(e) => setWho(e.target.value)} />
|
onChange={(e) => setWho(e.target.value)} />
|
||||||
</label>
|
</label>
|
||||||
<button className="btn-primary" disabled={!who.trim() || confirmFx.isPending}
|
<button className="btn-primary"
|
||||||
|
disabled={!who.trim() || dirty || confirmFx.isPending}
|
||||||
onClick={() => confirmFx.mutate()}>
|
onClick={() => confirmFx.mutate()}>
|
||||||
{confirmFx.isPending ? <Spinner /> : <CheckCircle2 size={15} />}
|
{confirmFx.isPending ? <Spinner /> : <CheckCircle2 size={15} />}
|
||||||
Confirm all rates for {session.reporting_month ?? "this month"}
|
Confirm all rates for {session.reporting_month ?? "this month"}
|
||||||
</button>
|
</button>
|
||||||
<p className="text-xs text-subink flex-1 min-w-[220px]">
|
<p className="text-xs text-subink flex-1 min-w-[220px]">
|
||||||
Review the rates on the Settings tab first — confirming records who accepted them and when.
|
{dirty
|
||||||
|
? "Save the corrected rates first, then confirm them."
|
||||||
|
: "Correct any rate that changed, then confirm — confirming records who accepted these rates and when."}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{confirmFx.isError && (
|
{(confirmFx.isError || saveFx.isError) && (
|
||||||
<p className="px-4 pb-4 text-sm text-bad">{(confirmFx.error as Error).message}</p>
|
<p className="px-4 pb-4 text-sm text-bad">
|
||||||
|
{((confirmFx.error || saveFx.error) as Error).message}
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
</Section>
|
</Section>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
@echo off
|
||||||
|
rem Double-click launcher: starts backend (8010) + frontend (5174) and opens the app.
|
||||||
|
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0start.ps1"
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
# Launch the A/R Aging app on Windows.
|
||||||
|
# Ports 8010/5174 because the defaults (8000/5173) are used by the ATS project.
|
||||||
|
# Run with: powershell -ExecutionPolicy Bypass -File start.ps1
|
||||||
|
|
||||||
|
$app = $PSScriptRoot
|
||||||
|
$python = "$env:LOCALAPPDATA\anaconda3\envs\Talha\python.exe"
|
||||||
|
|
||||||
|
Start-Process powershell -ArgumentList @(
|
||||||
|
"-NoExit", "-Command",
|
||||||
|
"cd '$app\backend'; & '$python' -m uvicorn app.api.main:app --host 127.0.0.1 --port 8010 --reload"
|
||||||
|
)
|
||||||
|
Start-Process powershell -ArgumentList @(
|
||||||
|
"-NoExit", "-Command",
|
||||||
|
"cd '$app\frontend'; `$env:VITE_API_PROXY = 'http://localhost:8010'; npx vite --port 5174 --strictPort"
|
||||||
|
)
|
||||||
|
|
||||||
|
Start-Sleep -Seconds 4
|
||||||
|
Start-Process "http://localhost:5174"
|
||||||
Loading…
Reference in New Issue