269 lines
12 KiB
Python
269 lines
12 KiB
Python
"""
|
||
End-to-end Excel export test on small synthetic data (no large files).
|
||
|
||
Generates a miniature Amazon transaction workbook (pivot 'Sheet1' + raw data sheet,
|
||
7 preamble rows, header row 8), runs the pipeline, exports the A/R Aging workbook, then
|
||
reopens it to verify structure, cross-sheet formulas, and subtotal values.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
from datetime import date
|
||
|
||
import openpyxl
|
||
import pytest
|
||
|
||
from app.core.excel_export import export_workbook, FIELD_ORDER, PREAMBLE, HEADER_LABELS
|
||
from app.core.pipeline import process
|
||
|
||
TOTAL_IDX = FIELD_ORDER.index("total")
|
||
|
||
|
||
def _row(date_time, settlement_id, txn_type, account_type, total, order_id=""):
|
||
vals = [None] * len(FIELD_ORDER)
|
||
vals[FIELD_ORDER.index("date_time")] = date_time
|
||
vals[FIELD_ORDER.index("settlement_id")] = settlement_id
|
||
vals[FIELD_ORDER.index("txn_type")] = txn_type
|
||
vals[FIELD_ORDER.index("order_id")] = order_id
|
||
vals[FIELD_ORDER.index("account_type")] = account_type
|
||
vals[FIELD_ORDER.index("marketplace")] = "amazon.com"
|
||
vals[FIELD_ORDER.index("product_sales")] = total
|
||
vals[TOTAL_IDX] = total
|
||
return vals
|
||
|
||
|
||
def make_amazon_xlsx(path: str, order_rows: int = 12) -> None:
|
||
wb = openpyxl.Workbook()
|
||
pivot = wb.active
|
||
pivot.title = "Sheet1"
|
||
pivot["A1"] = "user pivot to ignore"
|
||
|
||
ws = wb.create_sheet("USA Amazon Transactions")
|
||
for line in PREAMBLE:
|
||
ws.append([line])
|
||
ws.append([HEADER_LABELS[f] for f in FIELD_ORDER]) # header row 8
|
||
|
||
# transfers (received boundary for 200; in-transit for 300)
|
||
ws.append(_row("Jan 6, 2026 9:00:00 AM PST", 200, "Transfer", "Standard Orders", -1000.0))
|
||
ws.append(_row("Jan 30, 2026 9:00:00 AM PST", 300, "Transfer", "Standard Orders", -2000.0))
|
||
ws.append(_row("Jan 12, 2026 9:00:00 AM PST", 250, "Transfer", "Invoiced Orders", -300.0))
|
||
|
||
# paid settlement 100 (Standard)
|
||
ws.append(_row("Jan 5, 2026 1:00:00 PM PST", 100, "Order", "Standard Orders", 1000.0, "o-100"))
|
||
# receivable settlement 200 (Standard): `order_rows` rows summing to 2000
|
||
per = 2000.0 / order_rows
|
||
for i in range(order_rows):
|
||
ws.append(_row("Jan 15, 2026 1:00:00 PM PST", 200, "Order", "Standard Orders", per, f"o200-{i}"))
|
||
# receivable settlement 300 (Standard): 500
|
||
ws.append(_row("Jan 31, 2026 1:00:00 PM PST", 300, "Order", "Standard Orders", 500.0, "o-300"))
|
||
# Invoiced: 150 paid, 250 receivable 80
|
||
ws.append(_row("Jan 10, 2026 1:00:00 PM PST", 150, "Order", "Invoiced Orders", 300.0, "i-150"))
|
||
ws.append(_row("Jan 20, 2026 1:00:00 PM PST", 250, "Order", "Invoiced Orders", 80.0, "i-250"))
|
||
wb.save(path)
|
||
|
||
|
||
@pytest.fixture()
|
||
def synth_file(tmp_path):
|
||
p = tmp_path / "USA synthetic.xlsx"
|
||
make_amazon_xlsx(str(p), order_rows=12)
|
||
return str(p)
|
||
|
||
|
||
_SUMMARY = {
|
||
"available": True, "marketplace": "USA", "currency": "USD", "reporting_month": "2026-01",
|
||
"opening_balance": 1000.0, "net_revenue": 2500.0, "disbursements": -500.0,
|
||
"closing_receivable": 3000.0,
|
||
"ledger": [
|
||
{"period": "Opening", "description": "Previous month closing balance",
|
||
"debit": None, "credit": None, "balance": 1000.0},
|
||
{"period": "Closing", "description": "Month-end receivable",
|
||
"debit": None, "credit": None, "balance": 3000.0},
|
||
],
|
||
}
|
||
_JOURNAL = {
|
||
"periods": [{"label": "01–31 Jan 2026"}],
|
||
"lines": [{"key": "Sales", "gl_account": "Sales:Amazon USA", "values": [2500.0], "total": 2500.0}],
|
||
"receivable": {"key": "Receivable", "gl_account": "AR:Amazon USA",
|
||
"values": [3000.0], "total": 3000.0},
|
||
}
|
||
|
||
|
||
def _run(synth_file, row_limit, with_reports=True):
|
||
result = process([synth_file], month_end=date(2026, 1, 31), clearing_lag_days=2)
|
||
out = str(synth_file) + ".aging.xlsx"
|
||
export_workbook(result, [synth_file], out,
|
||
reserves={("USA", "Standard Orders"): 0.0}, row_limit=row_limit,
|
||
summary=_SUMMARY if with_reports else None,
|
||
journal=_JOURNAL if with_reports else None)
|
||
return result, out
|
||
|
||
|
||
def test_export_structure_and_values(synth_file):
|
||
result, out = _run(synth_file, row_limit=1_048_576)
|
||
wb = openpyxl.load_workbook(out) # keep formulas
|
||
# sheet order
|
||
assert wb.sheetnames[:3] == ["Summary", "Detail", "COA"]
|
||
assert "USA" in wb.sheetnames
|
||
assert "Reconciliation" in wb.sheetnames
|
||
assert "Exceptions" in wb.sheetnames
|
||
assert "Processing Audit Trail" in wb.sheetnames
|
||
|
||
usa = wb["USA"]
|
||
# preamble + header
|
||
assert usa["A8"].value == "date/time"
|
||
assert usa["AD8"].value == "total"
|
||
# transfer rows at top have blank account type (excluded from SUMIFS)
|
||
assert usa["C9"].value == "Transfer"
|
||
assert usa["I9"].value in (None, "")
|
||
# subtotal formula references present
|
||
formulas = [c.value for col in usa.iter_cols(min_col=30, max_col=30)
|
||
for c in col if isinstance(c.value, str) and c.value.startswith("=SUMIFS")]
|
||
assert any("Standard Orders" in f for f in formulas)
|
||
assert any("Invoiced Orders" in f for f in formulas)
|
||
|
||
# Detail references marketplace subtotal cells & rounds
|
||
detail = wb["Detail"]
|
||
add_formula = detail["B5"].value
|
||
assert isinstance(add_formula, str) and "'USA'!" in add_formula
|
||
assert detail["B7"].value.startswith("=ROUND(")
|
||
|
||
# Summary references Detail USD cell
|
||
assert "Detail!" in wb["Summary"]["B6"].value
|
||
|
||
|
||
def test_receivable_value_computed_by_openpyxl(synth_file):
|
||
"""Load the generated tab, evaluate the SUMIFS ourselves, confirm 2500 + 80."""
|
||
result, out = _run(synth_file, row_limit=1_048_576)
|
||
wb = openpyxl.load_workbook(out)
|
||
usa = wb["USA"]
|
||
# sum the AD data cells by account type from column I
|
||
std = inv = 0.0
|
||
for r in range(9, usa.max_row + 1):
|
||
acct = usa.cell(row=r, column=FIELD_ORDER.index("account_type") + 1).value
|
||
tot = usa.cell(row=r, column=TOTAL_IDX + 1).value
|
||
if not isinstance(tot, (int, float)):
|
||
continue
|
||
if acct == "Standard Orders":
|
||
std += tot
|
||
elif acct == "Invoiced Orders":
|
||
inv += tot
|
||
assert std == pytest.approx(2500.0, abs=0.01)
|
||
assert inv == pytest.approx(80.0, abs=0.01)
|
||
# engine agrees
|
||
assert result.receivable.marketplaces["USA"].receivable_local == 2580
|
||
|
||
|
||
def test_full_workbook_includes_ledger_and_journal(synth_file):
|
||
"""The full audit workbook carries the AR Ledger and Journal Entry sheets."""
|
||
_, out = _run(synth_file, row_limit=1_048_576)
|
||
wb = openpyxl.load_workbook(out)
|
||
assert "AR Ledger" in wb.sheetnames
|
||
assert "Journal Entry" in wb.sheetnames
|
||
ledger = [c.value for row in wb["AR Ledger"].iter_rows() for c in row]
|
||
assert "Opening" in ledger and "Closing" in ledger and 3000.0 in ledger
|
||
journal = [c.value for row in wb["Journal Entry"].iter_rows() for c in row]
|
||
assert "Sales" in journal and "Receivable" in journal
|
||
|
||
|
||
def test_full_workbook_without_reports_still_valid(synth_file):
|
||
"""Ledger/journal sheets are optional — the workbook still builds without them."""
|
||
_, out = _run(synth_file, row_limit=1_048_576, with_reports=False)
|
||
wb = openpyxl.load_workbook(out)
|
||
assert "AR Ledger" not in wb.sheetnames
|
||
assert wb.sheetnames[:3] == ["Summary", "Detail", "COA"]
|
||
|
||
|
||
def test_sheet_split_when_over_row_limit(synth_file):
|
||
"""A tiny row_limit forces the USA marketplace across multiple sheets."""
|
||
result, out = _run(synth_file, row_limit=25)
|
||
wb = openpyxl.load_workbook(out)
|
||
usa_sheets = [n for n in wb.sheetnames if n.startswith("USA")]
|
||
assert len(usa_sheets) >= 2, wb.sheetnames
|
||
# Detail Additional-sales formula sums subtotal cells from every USA sheet
|
||
add = wb["Detail"]["B5"].value
|
||
for s in usa_sheets:
|
||
assert f"'{s}'!" in add
|
||
|
||
|
||
# ------------------------------------------------------------------ workbook must tie
|
||
def test_detail_references_every_account_stream(synth_file):
|
||
"""
|
||
Detail/Summary must reference EVERY account stream's subtotal cell.
|
||
|
||
compute_layouts() planned the subtotal rows with a stride of 2 while
|
||
_finalize_marketplace_subtotals() writes them consecutively, so every stream after the
|
||
first pointed at an empty cell. USA is the only marketplace with two streams, so the
|
||
whole Invoiced Orders receivable silently vanished from Detail and Summary (Jan-2026:
|
||
67,854.71) while Reconciliation and COA in the same workbook showed it.
|
||
"""
|
||
result = process([synth_file], month_end=date(2026, 1, 31), clearing_lag_days=2)
|
||
out = synth_file.replace(".xlsx", "-streams.xlsx")
|
||
export_workbook(result, [synth_file], out)
|
||
wb = openpyxl.load_workbook(out)
|
||
ws = wb["USA"]
|
||
|
||
# Where the subtotal formulas actually landed.
|
||
actual = {}
|
||
for row in ws.iter_rows():
|
||
for c in row:
|
||
if isinstance(c.value, str) and c.value.startswith("=SUMIFS"):
|
||
actual[ws.cell(row=c.row, column=c.column - 1).value] = c.coordinate
|
||
streams = set(result.receivable.marketplaces["USA"].accounts)
|
||
assert set(actual) == streams, f"a stream has no subtotal row: {actual} vs {streams}"
|
||
|
||
detail_formula = wb["Detail"]["B5"].value
|
||
for stream, coord in actual.items():
|
||
assert f"'USA'!{coord}" in detail_formula, (
|
||
f"Detail!B5 ({detail_formula}) does not reference the {stream} subtotal at {coord}"
|
||
)
|
||
|
||
|
||
def test_summary_net_receivable_is_not_circular(synth_file):
|
||
"""'Net Receivable' referenced its own cell, so Excel warned and showed 0."""
|
||
result = process([synth_file], month_end=date(2026, 1, 31), clearing_lag_days=2)
|
||
out = synth_file.replace(".xlsx", "-net.xlsx")
|
||
export_workbook(result, [synth_file], out, allowance_for_returns=-100.0)
|
||
ws = openpyxl.load_workbook(out)["Summary"]
|
||
rows = {ws.cell(row=r, column=1).value: r for r in range(1, ws.max_row + 1)}
|
||
net_row = rows["Net Receivable"]
|
||
formula = ws.cell(row=net_row, column=7).value
|
||
assert f"G{net_row}" not in formula, f"circular reference: G{net_row} in {formula}"
|
||
assert f"G{rows['TOTAL']}" in formula and f"G{rows['Allowance for Sales Returns']}" in formula
|
||
|
||
|
||
def test_every_received_payout_appears_on_the_tab(synth_file):
|
||
"""
|
||
A month can have several received payouts per stream; the workbook used to lift only the
|
||
boundary one, so earlier bank receipts were absent from the entire file and the tab could
|
||
not be hand-footed against the bank statement.
|
||
"""
|
||
result = process([synth_file], month_end=date(2026, 1, 31), clearing_lag_days=2)
|
||
out = synth_file.replace(".xlsx", "-payouts.xlsx")
|
||
export_workbook(result, [synth_file], out)
|
||
ws = openpyxl.load_workbook(out)["USA"]
|
||
in_book = sorted(r[TOTAL_IDX] for r in ws.iter_rows(min_row=9, values_only=True)
|
||
if r and r[FIELD_ORDER.index("txn_type")] == "Transfer")
|
||
received = sorted(t.amount for t in result.aggregation.transfers if t.received)
|
||
assert in_book == received, f"workbook payouts {in_book} != received payouts {received}"
|
||
|
||
|
||
def test_settled_settlements_sheet_reconciles(synth_file):
|
||
"""The excluded settlements are listed and their rows + the included rows = every row."""
|
||
result = process([synth_file], month_end=date(2026, 1, 31), clearing_lag_days=2)
|
||
out = synth_file.replace(".xlsx", "-settled.xlsx")
|
||
export_workbook(result, [synth_file], out)
|
||
ws = openpyxl.load_workbook(out)["Settled Settlements"]
|
||
labels = {ws.cell(row=r, column=1).value: r for r in range(1, ws.max_row + 1)}
|
||
excluded = ws.cell(row=labels["Excluded (settled) order rows"], column=6).value
|
||
included = ws.cell(row=labels["Included (open) order rows — in the marketplace tabs"],
|
||
column=6).value
|
||
total = ws.cell(row=labels["Total order rows in the source files"], column=6).value
|
||
assert excluded + included == total
|
||
engine_total = sum(st.row_count - st.transfer_count
|
||
for st in result.aggregation.settlements.values())
|
||
assert total == engine_total, f"sheet says {total} order rows, engine has {engine_total}"
|
||
# Every settled settlement is named, so the omission is documented rather than silent.
|
||
listed = {ws.cell(row=r, column=3).value for r in range(5, ws.max_row + 1)}
|
||
settled = {sid for (m, a, sid), st in result.aggregation.settlements.items()
|
||
if st.status != "receivable" and (st.row_count - st.transfer_count) > 0}
|
||
assert settled <= listed, f"settled settlements missing from the sheet: {settled - listed}"
|