Compare commits
12 Commits
feat/go-m5
...
709a2f2335
| Author | SHA1 | Date | |
|---|---|---|---|
| 709a2f2335 | |||
| 58973473c9 | |||
| b68d95d217 | |||
| 07ca1cd9e1 | |||
| 5dcac25c13 | |||
| fc47606b1c | |||
| 65694ad378 | |||
| 092dff25a5 | |||
| 56c21bcf03 | |||
| 208f762c18 | |||
| 4d035213b5 | |||
| 723152cdad |
17
CHANGELOG.md
17
CHANGELOG.md
@@ -1,5 +1,22 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 2026-05-07 23:51 CEST — feat(py): M5.4 fix #2 — add vs and sync_id to payments tx projection
|
||||||
|
|
||||||
|
- `scripts/match_payments.py`: `fetch_sheet_data` now reads `VS` and `Sync ID` columns and includes `vs`/`sync_id` keys in every tx dict. Previously only 9 columns were projected, causing `make parity` to report extra `vs`/`sync_id` fields on every raw payment row emitted by the Go backend. Values flow through `group_payments_by_person` → `_unwrap_view_model_for_api` to `raw_payments` (adults/juniors) and `grouped_payments` (payments) automatically.
|
||||||
|
- `tests/test_app.py`: updated `/api/*` mock fixtures to include `vs`/`sync_id` keys for realism.
|
||||||
|
- **Cache note**: after deploying, hit `POST /flush-cache` once so the in-process cache is cleared and the next request picks up the new column lookups.
|
||||||
|
|
||||||
|
## 2026-05-07 23:37 CEST — fix(go): accept single-digit day/month in attendance date headers
|
||||||
|
|
||||||
|
- `go/internal/services/membership/sources.go`: `parseDates` now uses Go time formats `2.1.2006` and `1/2/2006` (single-digit reference forms, which accept both padded and unpadded inputs) instead of `02.01.2006` and `01/02/2006`. The Czech attendance sheet headers contain dates like `1.6.2026`, `23.3.2026`, `6.4.2026` — Go silently dropped those columns under the strict zero-padded format, while Python's `strptime("%d.%m.%Y")` accepted them. Effect was a missing `2026-06` month entirely on `/api/juniors` plus undercounted attendance for any month with single-digit columns; both surfaced as diffs in `make parity`.
|
||||||
|
- `sources_test.go::TestParseDates_SingleDigitDayMonth` added as a regression guard covering both Czech and US format flavours with and without leading zeros.
|
||||||
|
|
||||||
|
## 2026-05-07 23:17 CEST — fix(go): pass raw value to FormatDate so numeric serial-day dates format
|
||||||
|
|
||||||
|
- `go/internal/services/membership/sources.go`: transaction-row parser now passes `row[idxDate]` directly to `matching.FormatDate` (via a new `getRaw` helper) instead of stringifying first via `getVal`. The Sheets API returns numeric serial-day values as `float64` for date-formatted cells; pre-stringifying them defeated `FormatDate`'s `case float64:` dispatch, causing all numeric dates to leak through as `"46147"` style strings instead of `"2026-05-05"`.
|
||||||
|
- Surfaced by `make parity` (M5.4): every `transactions[].date` field on `/api/adults` and `/api/juniors` differed between Python and Go.
|
||||||
|
- `sources_test.go::TestLoadTransactions` extended with a numeric-serial-day row covering the regression.
|
||||||
|
|
||||||
## 2026-05-07 23:05 CEST — fix(go): default CacheDir to `tmp/go` to avoid Python collision
|
## 2026-05-07 23:05 CEST — fix(go): default CacheDir to `tmp/go` to avoid Python collision
|
||||||
|
|
||||||
- `go/internal/config/config.go`: `CacheDir` default changed from `tmp` to `tmp/go`. Override via `CACHE_DIR` env var still works.
|
- `go/internal/config/config.go`: `CacheDir` default changed from `tmp` to `tmp/go`. Override via `CACHE_DIR` env var still works.
|
||||||
|
|||||||
@@ -142,7 +142,13 @@ func parseDates(header []string) []struct {
|
|||||||
}
|
}
|
||||||
var dt time.Time
|
var dt time.Time
|
||||||
var err error
|
var err error
|
||||||
for _, fmt_ := range []string{"02.01.2006", "01/02/2006"} {
|
// Use the unpadded reference forms ("2.1" and "1/2"): Go's time.Parse
|
||||||
|
// accepts both single-digit and zero-padded inputs against them, so
|
||||||
|
// "1.6.2026", "01.06.2026", "23.3.2026" all parse. Czech sheet authors
|
||||||
|
// drop the leading zero on dates ≤ 9 — Python's strptime is lenient
|
||||||
|
// the same way; the previous "02.01.2006" form silently dropped those
|
||||||
|
// columns and undercounted attendance.
|
||||||
|
for _, fmt_ := range []string{"2.1.2006", "1/2/2006"} {
|
||||||
dt, err = time.Parse(fmt_, raw)
|
dt, err = time.Parse(fmt_, raw)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
break
|
break
|
||||||
@@ -394,9 +400,19 @@ func parseTransactionRows(rows [][]any) ([]reconcile.Transaction, error) {
|
|||||||
return fmt.Sprint(row[i])
|
return fmt.Sprint(row[i])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// getRaw returns row[i] without stringifying — needed for FormatDate to
|
||||||
|
// dispatch on the underlying numeric type (Sheets returns serial-day
|
||||||
|
// numbers as float64). Stringifying first defeats that dispatch.
|
||||||
|
getRaw := func(row []any, i int) any {
|
||||||
|
if i < 0 || i >= len(row) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return row[i]
|
||||||
|
}
|
||||||
|
|
||||||
var txns []reconcile.Transaction
|
var txns []reconcile.Transaction
|
||||||
for _, row := range rows[1:] {
|
for _, row := range rows[1:] {
|
||||||
dateStr := matching.FormatDate(getVal(row, idxDate))
|
dateStr := matching.FormatDate(getRaw(row, idxDate))
|
||||||
amountRaw := row[idxAmount]
|
amountRaw := row[idxAmount]
|
||||||
if idxAmount < 0 || idxAmount >= len(row) {
|
if idxAmount < 0 || idxAmount >= len(row) {
|
||||||
amountRaw = ""
|
amountRaw = ""
|
||||||
|
|||||||
@@ -114,12 +114,15 @@ func TestLoadJuniors(t *testing.T) {
|
|||||||
|
|
||||||
func TestLoadTransactions(t *testing.T) {
|
func TestLoadTransactions(t *testing.T) {
|
||||||
// Sheets fake keyed by "<spreadsheetID>/<range>" — use the real constant.
|
// Sheets fake keyed by "<spreadsheetID>/<range>" — use the real constant.
|
||||||
|
// Row 1 uses a pre-formatted date string; row 2 uses the numeric Sheets
|
||||||
|
// serial-day form (float64) — the API returns either depending on cell
|
||||||
|
// formatting, and FormatDate must handle both.
|
||||||
paymentsKey := config.PaymentsSheetID + "/A1:Z"
|
paymentsKey := config.PaymentsSheetID + "/A1:Z"
|
||||||
sh := &sheets.Fake{Values: map[string][][]any{
|
sh := &sheets.Fake{Values: map[string][][]any{
|
||||||
paymentsKey: {
|
paymentsKey: {
|
||||||
{"Date", "Amount", "manual fix", "Person", "Purpose", "Inferred Amount", "Sender", "VS", "Message", "Bank ID", "Sync ID"},
|
{"Date", "Amount", "manual fix", "Person", "Purpose", "Inferred Amount", "Sender", "VS", "Message", "Bank ID", "Sync ID"},
|
||||||
{"2026-04-01", 700.0, "", "Alice", "2026-04", "", "Alice Bank", "", "fee", "", "abc"},
|
{"2026-04-01", 700.0, "", "Alice", "2026-04", "", "Alice Bank", "", "fee", "", "abc"},
|
||||||
{"2026-05-01", 500.0, "", "", "", "", "Bob Bank", "", "platba", "", "def"},
|
{46147.0, 500.0, "", "", "", "", "Bob Bank", "", "platba", "", "def"}, // 46147 serial-day = 2026-05-05
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
s := buildSources(t, &attendance.Fake{}, sh)
|
s := buildSources(t, &attendance.Fake{}, sh)
|
||||||
@@ -137,6 +140,12 @@ func TestLoadTransactions(t *testing.T) {
|
|||||||
if txns[0].Amount != 700 {
|
if txns[0].Amount != 700 {
|
||||||
t.Errorf("txn[0].Amount: want 700, got %v", txns[0].Amount)
|
t.Errorf("txn[0].Amount: want 700, got %v", txns[0].Amount)
|
||||||
}
|
}
|
||||||
|
if txns[0].Date != "2026-04-01" {
|
||||||
|
t.Errorf("txn[0].Date: want 2026-04-01, got %q", txns[0].Date)
|
||||||
|
}
|
||||||
|
if txns[1].Date != "2026-05-05" {
|
||||||
|
t.Errorf("txn[1].Date (numeric serial-day): want 2026-05-05, got %q", txns[1].Date)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLoadExceptions(t *testing.T) {
|
func TestLoadExceptions(t *testing.T) {
|
||||||
@@ -165,6 +174,28 @@ func TestLoadExceptions(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestParseDates_SingleDigitDayMonth covers the regression where Go's strict
|
||||||
|
// "02.01.2006" format dropped header cells written without leading zeros
|
||||||
|
// (e.g. "1.6.2026", "23.3.2026"), causing attendance undercounts and missing
|
||||||
|
// months on the /api/juniors response. Czech sheet authors drop the zero
|
||||||
|
// pad freely; Python's strptime tolerates it, so the parsers must match.
|
||||||
|
func TestParseDates_SingleDigitDayMonth(t *testing.T) {
|
||||||
|
// Czech form ("DD.MM.YYYY", with leading zeros optional) is the primary
|
||||||
|
// path. The "M/D/YYYY" fallback mirrors Python's %m/%d/%Y secondary
|
||||||
|
// strptime branch — month-first, day-second.
|
||||||
|
header := []string{"Jméno", "Tier", "", "01.06.2026", "1.6.2026", "23.3.2026", "6.4.2026", "01/02/2026", "1/2/2026"}
|
||||||
|
got := parseDates(header)
|
||||||
|
want := []string{"2026-06", "2026-06", "2026-03", "2026-04", "2026-01", "2026-01"}
|
||||||
|
if len(got) != len(want) {
|
||||||
|
t.Fatalf("parseDates: got %d entries, want %d (%v)", len(got), len(want), got)
|
||||||
|
}
|
||||||
|
for i, e := range got {
|
||||||
|
if e.month != want[i] {
|
||||||
|
t.Errorf("parseDates[%d].month = %q, want %q (raw=%q)", i, e.month, want[i], header[e.col])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TTL smoke test: second call within TTL must not call fetch again.
|
// TTL smoke test: second call within TTL must not call fetch again.
|
||||||
func TestLoadAdults_CacheHit(t *testing.T) {
|
func TestLoadAdults_CacheHit(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
|
|||||||
@@ -236,6 +236,8 @@ def fetch_sheet_data(spreadsheet_id: str, credentials_path: str) -> list[dict]:
|
|||||||
idx_sender = get_col_index("Sender")
|
idx_sender = get_col_index("Sender")
|
||||||
idx_message = get_col_index("Message")
|
idx_message = get_col_index("Message")
|
||||||
idx_bank_id = get_col_index("Bank ID")
|
idx_bank_id = get_col_index("Bank ID")
|
||||||
|
idx_vs = get_col_index("VS")
|
||||||
|
idx_sync_id = get_col_index("Sync ID")
|
||||||
|
|
||||||
required = {"Date": idx_date, "Amount": idx_amount, "Person": idx_person, "Purpose": idx_purpose}
|
required = {"Date": idx_date, "Amount": idx_amount, "Person": idx_person, "Purpose": idx_purpose}
|
||||||
missing = [name for name, idx in required.items() if idx == -1]
|
missing = [name for name, idx in required.items() if idx == -1]
|
||||||
@@ -247,16 +249,33 @@ def fetch_sheet_data(spreadsheet_id: str, credentials_path: str) -> list[dict]:
|
|||||||
def get_val(idx):
|
def get_val(idx):
|
||||||
return row[idx] if idx != -1 and idx < len(row) else ""
|
return row[idx] if idx != -1 and idx < len(row) else ""
|
||||||
|
|
||||||
|
def get_str(idx):
|
||||||
|
v = get_val(idx)
|
||||||
|
if isinstance(v, float) and v.is_integer():
|
||||||
|
return str(int(v))
|
||||||
|
return str(v)
|
||||||
|
|
||||||
|
def get_float(idx):
|
||||||
|
v = get_val(idx)
|
||||||
|
if isinstance(v, (int, float)):
|
||||||
|
return float(v)
|
||||||
|
try:
|
||||||
|
return float(str(v).strip())
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return 0.0
|
||||||
|
|
||||||
tx = {
|
tx = {
|
||||||
"date": format_date(get_val(idx_date)),
|
"date": format_date(get_val(idx_date)),
|
||||||
"amount": get_val(idx_amount),
|
"amount": get_float(idx_amount),
|
||||||
"manual_fix": get_val(idx_manual),
|
"manual_fix": get_val(idx_manual),
|
||||||
"person": get_val(idx_person),
|
"person": get_val(idx_person),
|
||||||
"purpose": get_val(idx_purpose),
|
"purpose": get_val(idx_purpose),
|
||||||
"inferred_amount": get_val(idx_inferred_amount),
|
"inferred_amount": get_val(idx_inferred_amount),
|
||||||
"sender": get_val(idx_sender),
|
"sender": get_val(idx_sender),
|
||||||
"message": get_val(idx_message),
|
"vs": get_str(idx_vs),
|
||||||
|
"message": get_str(idx_message),
|
||||||
"bank_id": get_val(idx_bank_id),
|
"bank_id": get_val(idx_bank_id),
|
||||||
|
"sync_id": get_val(idx_sync_id),
|
||||||
}
|
}
|
||||||
transactions.append(tx)
|
transactions.append(tx)
|
||||||
|
|
||||||
|
|||||||
@@ -310,8 +310,9 @@ def build_juniors_view_model(
|
|||||||
cell_text = "-"
|
cell_text = "-"
|
||||||
amount_to_pay = 0
|
amount_to_pay = 0
|
||||||
|
|
||||||
if expected == "?" or (isinstance(expected, int) and expected > 0):
|
is_unknown = original_expected == "?"
|
||||||
if expected == "?":
|
if is_unknown or (isinstance(expected, int) and expected > 0):
|
||||||
|
if is_unknown:
|
||||||
status = "empty"
|
status = "empty"
|
||||||
cell_text = f"?{count_str}"
|
cell_text = f"?{count_str}"
|
||||||
elif paid >= expected:
|
elif paid >= expected:
|
||||||
@@ -339,7 +340,7 @@ def build_juniors_view_model(
|
|||||||
status = "surplus"
|
status = "surplus"
|
||||||
cell_text = f"PAID {paid}"
|
cell_text = f"PAID {paid}"
|
||||||
|
|
||||||
if (isinstance(expected, int) and expected > 0) or paid > 0:
|
if (not is_unknown and isinstance(expected, int) and expected > 0) or paid > 0:
|
||||||
tooltip = f"Received: {paid}, Expected: {expected}"
|
tooltip = f"Received: {paid}, Expected: {expected}"
|
||||||
else:
|
else:
|
||||||
tooltip = ""
|
tooltip = ""
|
||||||
|
|||||||
@@ -129,6 +129,7 @@ class TestWebApp(unittest.TestCase):
|
|||||||
'date': '2026-01-01', 'amount': 750, 'person': 'Test Member',
|
'date': '2026-01-01', 'amount': 750, 'person': 'Test Member',
|
||||||
'purpose': '2026-01', 'message': 'test payment',
|
'purpose': '2026-01', 'message': 'test payment',
|
||||||
'sender': 'External Bank User', 'inferred_amount': 750,
|
'sender': 'External Bank User', 'inferred_amount': 750,
|
||||||
|
'vs': '', 'sync_id': 'abc123',
|
||||||
}]
|
}]
|
||||||
response = self.client.get('/api/adults')
|
response = self.client.get('/api/adults')
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
@@ -155,6 +156,7 @@ class TestWebApp(unittest.TestCase):
|
|||||||
mock_fetch_sheet.return_value = [{
|
mock_fetch_sheet.return_value = [{
|
||||||
'date': '2026-01-15', 'amount': 500, 'person': 'Junior One',
|
'date': '2026-01-15', 'amount': 500, 'person': 'Junior One',
|
||||||
'purpose': '2026-01', 'message': '', 'sender': 'Parent', 'inferred_amount': 500,
|
'purpose': '2026-01', 'message': '', 'sender': 'Parent', 'inferred_amount': 500,
|
||||||
|
'vs': '', 'sync_id': 'def456',
|
||||||
}]
|
}]
|
||||||
response = self.client.get('/api/juniors')
|
response = self.client.get('/api/juniors')
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
@@ -172,6 +174,7 @@ class TestWebApp(unittest.TestCase):
|
|||||||
mock_fetch_sheet.return_value = [{
|
mock_fetch_sheet.return_value = [{
|
||||||
'date': '2026-01-01', 'amount': 750, 'person': 'Test Member',
|
'date': '2026-01-01', 'amount': 750, 'person': 'Test Member',
|
||||||
'purpose': '2026-01', 'message': 'test', 'sender': 'Someone',
|
'purpose': '2026-01', 'message': 'test', 'sender': 'Someone',
|
||||||
|
'vs': '', 'sync_id': 'ghi789',
|
||||||
}]
|
}]
|
||||||
response = self.client.get('/api/payments')
|
response = self.client.get('/api/payments')
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|||||||
Reference in New Issue
Block a user