193 lines
9.2 KiB
Python
193 lines
9.2 KiB
Python
"""Fast unit tests for the engine (synthetic data, no large files)."""
|
||
from __future__ import annotations
|
||
|
||
from datetime import date
|
||
|
||
from app.core.dates import parse_amazon_date, parse_amazon_datetime
|
||
from app.core.column_map import build_mapping, normalize_header, resolve_field
|
||
from app.core.settlements import aggregate, classify
|
||
from app.core.receivable import compute_receivable, classify_aging
|
||
|
||
|
||
# --------------------------------------------------------------------------- dates
|
||
def test_parse_amazon_dates():
|
||
assert parse_amazon_date("Jan 15, 2026 5:45:40 PM PST") == date(2026, 1, 15)
|
||
assert parse_amazon_date("Jan 30, 2026 9:40:25 PM PST") == date(2026, 1, 30)
|
||
assert parse_amazon_date("Dec 1, 2025 12:00:00 AM PST") == date(2025, 12, 1)
|
||
assert parse_amazon_date("2026-01-31 23:59:59") == date(2026, 1, 31)
|
||
assert parse_amazon_date("garbage") is None
|
||
dt, tz = parse_amazon_datetime("Jan 15, 2026 5:45:40 PM PST")
|
||
assert dt.hour == 17 and tz == "PST"
|
||
|
||
|
||
# --------------------------------------------------------------------------- mapping
|
||
def test_column_mapping_normalizes_headers():
|
||
assert normalize_header("Order-ID") == "order id"
|
||
assert normalize_header(" Total ") == "total"
|
||
header = [("A", "date/time"), ("B", "settlement id"), ("C", "type"),
|
||
("I", "account type"), ("AD", "total"), ("Z", "SomeUnknownCol")]
|
||
m = build_mapping(header, header_row=8)
|
||
assert m.is_valid
|
||
assert m.field_to_col["settlement_id"] == "B"
|
||
assert m.field_to_col["total"] == "AD"
|
||
assert "Z" in m.unmapped
|
||
|
||
|
||
def test_column_mapping_missing_required():
|
||
m = build_mapping([("A", "date/time"), ("B", "sku")], header_row=8)
|
||
assert not m.is_valid
|
||
assert "settlement_id" in m.missing_required
|
||
assert "total" in m.missing_required
|
||
|
||
|
||
def test_reference_workbook_aliases_resolve():
|
||
"""Every header variant from the finance team's per-marketplace reference workbook
|
||
must resolve. Doubles as a collision guard: _ALIAS_TO_FIELD is first-wins, so an
|
||
alias later hijacked by an earlier field makes the expected mapping here fail."""
|
||
expected = [
|
||
# promotional rebates family
|
||
("Promotional Discounts", "promotional_rebates"),
|
||
("Total Discounts", "promotional_rebates"),
|
||
("promosyon indirimleri", "promotional_rebates"),
|
||
("Tax on Promotional Discounts", "promotional_rebates_tax"),
|
||
# shipping credits family
|
||
("Shipping Credit", "shipping_credits"),
|
||
("kargo kredileri", "shipping_credits"),
|
||
("Tax on Shipping Credit", "shipping_credits_tax"),
|
||
("Tax on Shipping Credits", "shipping_credits_tax"),
|
||
# gift wrap family
|
||
("Gift Wrap Credit", "gift_wrap_credits"),
|
||
("Tax on Gift Wrap Credit", "giftwrap_credits_tax"),
|
||
("Tax on Gift Wrap Credits", "giftwrap_credits_tax"),
|
||
# other amount columns
|
||
("Marketplace Withheld VAT", "marketplace_withheld_tax"),
|
||
("ürün satışları", "product_sales"),
|
||
("satış ücretleri", "selling_fees"),
|
||
("Amazon Lojistik ücretleri", "fba_fees"),
|
||
("diğer işlem ücretleri", "other_transaction_fees"),
|
||
("diğer", "other"),
|
||
# transaction release date translations
|
||
("Freigabedatum der Transaktion", "transaction_release_date"),
|
||
("Date de sortie de la transaction", "transaction_release_date"),
|
||
("Data di rilascio della transazione", "transaction_release_date"),
|
||
("Fecha de liberación de la transacción", "transaction_release_date"),
|
||
("Publicatiedatum van transactie", "transaction_release_date"),
|
||
("Data zrealizowania transakcji", "transaction_release_date"),
|
||
("Transaktionens utgivningsdatum", "transaction_release_date"),
|
||
("İşlem çıkış tarihi", "transaction_release_date"),
|
||
# transaction status translations
|
||
("Transactiestatus", "transaction_status"),
|
||
("Status transakcji", "transaction_status"),
|
||
("İşlem durumu", "transaction_status"),
|
||
# location / fulfillment variants
|
||
("Order State/Province", "order_state"),
|
||
("State/Province", "order_state"),
|
||
("Order Region/Province", "order_state"),
|
||
("Order Province/State", "order_state"),
|
||
("Order Region/Autonomous Community", "order_state"),
|
||
("sipariş durumu", "order_state"),
|
||
("Order Postal Code", "order_postal"),
|
||
("sipariş postası", "order_postal"),
|
||
("sipariş şehri", "order_city"),
|
||
("Shipping/Fulfillment", "fulfillment"),
|
||
("Fulfillment/Shipping", "fulfillment"),
|
||
("gönderim", "fulfillment"),
|
||
]
|
||
for header, want in expected:
|
||
assert resolve_field(header) == want, f"{header!r} -> {resolve_field(header)!r}, want {want!r}"
|
||
# Pre-existing aliases that must not be hijacked by the additions above.
|
||
assert resolve_field("shipping") == "shipping_credits"
|
||
assert resolve_field("Transaktionsstatus") == "transaction_status"
|
||
assert resolve_field("total des réductions") == "promotional_rebates"
|
||
|
||
|
||
# --------------------------------------------------------------------------- aging
|
||
def test_aging_bands():
|
||
assert classify_aging(None) == "Current"
|
||
assert classify_aging(0) == "Current"
|
||
assert classify_aging(15) == "1-30"
|
||
assert classify_aging(45) == "31-60"
|
||
assert classify_aging(75) == "61-90"
|
||
assert classify_aging(120) == "91-Over"
|
||
|
||
|
||
def test_aging_band_schemes():
|
||
from app.core.receivable import AGING_BANDS, aging_bands
|
||
|
||
assert aging_bands("monthly") == AGING_BANDS # default stays the classic bands
|
||
assert aging_bands("weekly") == ("Current", "1-7", "8-14", "15-21", "22-28", "29-Over")
|
||
assert aging_bands("half_year") == ("Current", "1-180", "181-360", "361-540", "541-Over")
|
||
assert aging_bands("yearly") == ("Current", "1-365", "366-730", "731-1095", "1096-Over")
|
||
assert aging_bands("nonsense") == AGING_BANDS # unknown scheme falls back
|
||
|
||
assert classify_aging(5, "weekly") == "1-7"
|
||
assert classify_aging(14, "weekly") == "8-14"
|
||
assert classify_aging(35, "weekly") == "29-Over"
|
||
assert classify_aging(120, "half_year") == "1-180"
|
||
assert classify_aging(400, "yearly") == "366-730"
|
||
assert classify_aging(2000, "yearly") == "1096-Over"
|
||
assert classify_aging(0, "weekly") == "Current"
|
||
|
||
|
||
# --------------------------------------------------------------------------- settlements
|
||
def _rec(total, ttype, acct, sid, d, mkt="USA"):
|
||
return {
|
||
"total": total, "txn_type": ttype, "account_type": acct,
|
||
"settlement_id": sid, "_date": d, "_marketplace": mkt,
|
||
"date_time": d.isoformat() if d else None,
|
||
}
|
||
|
||
|
||
def _scenario():
|
||
# Standard: 100 paid (payout tagged 200, Jan 6 received),
|
||
# 200 receivable (payout tagged 300, Jan 30 in-transit),
|
||
# 300 receivable (no payout yet).
|
||
recs = []
|
||
recs += [_rec(1000.0, "Order", "Standard Orders", "100", date(2026, 1, 5))]
|
||
recs += [_rec(2000.0, "Order", "Standard Orders", "200", date(2026, 1, 15))]
|
||
recs += [_rec(500.0, "Order", "Standard Orders", "300", date(2026, 1, 31))]
|
||
# transfers (tagged to the next/open settlement)
|
||
recs += [_rec(-1000.0, "Transfer", "Standard Orders", "200", date(2026, 1, 6))]
|
||
recs += [_rec(-2000.0, "Transfer", "Standard Orders", "300", date(2026, 1, 30))]
|
||
# an Invoiced chain, independent
|
||
recs += [_rec(300.0, "Order", "Invoiced Orders", "150", date(2026, 1, 10))]
|
||
recs += [_rec(-300.0, "Transfer", "Invoiced Orders", "250", date(2026, 1, 12))]
|
||
recs += [_rec(80.0, "Order", "Invoiced Orders", "250", date(2026, 1, 20))]
|
||
return recs
|
||
|
||
|
||
def test_classification_and_receivable():
|
||
agg = aggregate(_scenario())
|
||
cls = classify(agg, month_end=date(2026, 1, 31), clearing_lag_days=2)
|
||
|
||
def status(acct, sid):
|
||
return agg.settlements[("USA", acct, sid)].status
|
||
|
||
assert status("Standard Orders", "100") == "paid"
|
||
assert status("Standard Orders", "200") == "receivable"
|
||
assert status("Standard Orders", "300") == "receivable"
|
||
assert status("Invoiced Orders", "150") == "paid"
|
||
assert status("Invoiced Orders", "250") == "receivable"
|
||
|
||
# Jan-30 transfer is in-transit; Jan-6 transfer received.
|
||
by_sid = {(t.account_type, t.settlement_id): t for t in agg.transfers}
|
||
assert by_sid[("Standard Orders", "300")].received is False
|
||
assert by_sid[("Standard Orders", "200")].received is True
|
||
|
||
rec = compute_receivable(agg, cls)
|
||
usa = rec.marketplaces["USA"]
|
||
assert usa.accounts["Standard Orders"].additional_sales == 2500.0 # 2000 + 500
|
||
assert usa.accounts["Invoiced Orders"].additional_sales == 80.0
|
||
assert usa.receivable_local == 2580 # rounded total
|
||
|
||
|
||
def test_clearing_lag_override_moves_boundary():
|
||
# With lag=0, the Jan-30 transfer counts as received -> settlement 300 becomes paid.
|
||
agg = aggregate(_scenario())
|
||
cls = classify(agg, month_end=date(2026, 1, 31), clearing_lag_days=0)
|
||
assert agg.settlements[("USA", "Standard Orders", "300")].status == "receivable"
|
||
# boundary = max received tagged = 300 -> only >=300 receivable; 200 becomes paid
|
||
assert agg.settlements[("USA", "Standard Orders", "200")].status == "paid"
|
||
rec = compute_receivable(agg, cls)
|
||
assert rec.marketplaces["USA"].accounts["Standard Orders"].additional_sales == 500.0
|