fix: treat VS as a string on read and write
Go stringified sheet cells with fmt.Sprint, so a numeric VS read with UNFORMATTED_VALUE was rendered as 2.609767878e+09. Text columns of the payments sheet now go through sheets.CellString, which never uses exponent form; bank inference uses the same helper. Both sync implementations now append VS as a text cell so Sheets keeps it a string and leading zeros survive. Existing rows are not rewritten. Python already read VS as a digit string; a regression test pins that. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,33 @@
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// CellString renders a cell read with UNFORMATTED_VALUE as text.
|
||||
// Numeric cells arrive as float64; fmt.Sprint would print large ones in
|
||||
// exponent form ("2.609767878e+09"), which corrupts identifiers such as VS
|
||||
// or Bank ID. Mirrors get_str in legacy/scripts/match_payments.py.
|
||||
func CellString(v any) string {
|
||||
switch x := v.(type) {
|
||||
case nil:
|
||||
return ""
|
||||
case string:
|
||||
return x
|
||||
case float64:
|
||||
return strconv.FormatFloat(x, 'f', -1, 64)
|
||||
}
|
||||
return fmt.Sprint(v)
|
||||
}
|
||||
|
||||
// TextCell marks s to be stored as text when written with USER_ENTERED:
|
||||
// a leading apostrophe stops Sheets from parsing digits as a number (which
|
||||
// would drop leading zeros). The apostrophe is not part of the stored value.
|
||||
// Empty stays empty. Mirrors as_text_cell in legacy/scripts/sync_fio_to_sheets.py.
|
||||
func TextCell(s string) string {
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
return "'" + s
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package sheets
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestTextCell(t *testing.T) {
|
||||
if got := TextCell("0308"); got != "'0308" {
|
||||
t.Errorf(`TextCell("0308"): got %q`, got)
|
||||
}
|
||||
if got := TextCell(""); got != "" {
|
||||
t.Errorf(`TextCell(""): got %q`, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCellString(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in any
|
||||
want string
|
||||
}{
|
||||
{"nil", nil, ""},
|
||||
{"string", "fee", "fee"},
|
||||
{"string with leading zeros", "0308", "0308"},
|
||||
{"small integer float", 123.0, "123"},
|
||||
{"large integer float", 2609767878.0, "2609767878"},
|
||||
{"eleven digit float", 26123456789.0, "26123456789"},
|
||||
{"fractional float", 12.5, "12.5"},
|
||||
{"bool", true, "true"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := CellString(tc.in); got != tc.want {
|
||||
t.Errorf("CellString(%v): want %q, got %q", tc.in, tc.want, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -94,7 +94,7 @@ func InferPayments(
|
||||
if idx < 0 || idx >= len(row) {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(fmt.Sprint(row[idx]))
|
||||
return strings.TrimSpace(sheets.CellString(row[idx]))
|
||||
}
|
||||
|
||||
// Skip rule: any of manual fix / Person / Purpose non-empty → leave alone
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"fuj-management/go/internal/domain/synch"
|
||||
"fuj-management/go/internal/io/fio"
|
||||
"fuj-management/go/internal/io/sheets"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -130,7 +131,7 @@ func SyncToSheets(
|
||||
newRows = append(newRows, []any{
|
||||
tx.Date, tx.Amount,
|
||||
"", "", "", "", // manual fix, Person, Purpose, Inferred Amount
|
||||
tx.Sender, tx.VS, tx.Message, tx.BankID, syncIDs[i],
|
||||
tx.Sender, sheets.TextCell(tx.VS), tx.Message, tx.BankID, syncIDs[i],
|
||||
})
|
||||
}
|
||||
|
||||
@@ -141,7 +142,7 @@ func SyncToSheets(
|
||||
if opts.DryRun {
|
||||
for _, row := range newRows {
|
||||
fmt.Printf("Dry run: would append date=%v amount=%v sender=%v vs=%v message=%v\n",
|
||||
row[0], row[1], row[6], row[7], row[8])
|
||||
row[0], row[1], row[6], strings.TrimPrefix(fmt.Sprint(row[7]), "'"), row[8])
|
||||
}
|
||||
if opts.Sort {
|
||||
fmt.Println("Dry run: would sort by date")
|
||||
|
||||
@@ -38,6 +38,10 @@ func TestSyncToSheets_EmptySheet(t *testing.T) {
|
||||
if syncID, ok := rows[0][10].(string); !ok || len(syncID) != 64 {
|
||||
t.Errorf("expected 64-char hex sync ID, got %v", rows[0][10])
|
||||
}
|
||||
// VS (index 7) is written as text so Sheets keeps it a string.
|
||||
if vs, _ := rows[0][7].(string); vs != "'123" {
|
||||
t.Errorf("expected VS written as text cell '123, got %v", rows[0][7])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncToSheets_Dedup(t *testing.T) {
|
||||
|
||||
@@ -402,7 +402,7 @@ func parseTransactionRows(rows [][]any) ([]reconcile.Transaction, error) {
|
||||
if i < 0 || i >= len(row) {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprint(row[i])
|
||||
return sheets.CellString(row[i])
|
||||
}
|
||||
|
||||
// getRaw returns row[i] without stringifying — needed for FormatDate to
|
||||
@@ -425,7 +425,7 @@ func parseTransactionRows(rows [][]any) ([]reconcile.Transaction, error) {
|
||||
amount := parseFloat(amountRaw)
|
||||
|
||||
var inferredAmount *float64
|
||||
if iv := getVal(row, idxInferred); iv != "" && iv != "<nil>" {
|
||||
if iv := getVal(row, idxInferred); iv != "" {
|
||||
if f := parseFloat(iv); f != 0 {
|
||||
inferredAmount = &f
|
||||
}
|
||||
|
||||
@@ -123,6 +123,8 @@ func TestLoadTransactions(t *testing.T) {
|
||||
{"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"},
|
||||
{46147.0, 500.0, "", "", "", "", "Bob Bank", "", "platba", "", "def"}, // 46147 serial-day = 2026-05-05
|
||||
// Numeric VS / Bank ID cells arrive as float64 and must stay plain digits.
|
||||
{"2026-05-06", 500.0, "", "", "", "", "Carol Bank", 2609767878.0, "platba", 26123456789.0, "ghi"},
|
||||
},
|
||||
}}
|
||||
s := buildSources(t, &attendance.Fake{}, sh)
|
||||
@@ -131,8 +133,8 @@ func TestLoadTransactions(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(txns) != 2 {
|
||||
t.Fatalf("want 2 transactions, got %d", len(txns))
|
||||
if len(txns) != 3 {
|
||||
t.Fatalf("want 3 transactions, got %d", len(txns))
|
||||
}
|
||||
if txns[0].Person != "Alice" {
|
||||
t.Errorf("txn[0].Person: want Alice, got %q", txns[0].Person)
|
||||
@@ -146,6 +148,12 @@ func TestLoadTransactions(t *testing.T) {
|
||||
if txns[1].Date != "2026-05-05" {
|
||||
t.Errorf("txn[1].Date (numeric serial-day): want 2026-05-05, got %q", txns[1].Date)
|
||||
}
|
||||
if txns[2].VS != "2609767878" {
|
||||
t.Errorf("txn[2].VS (numeric cell): want 2609767878, got %q", txns[2].VS)
|
||||
}
|
||||
if txns[2].BankID != "26123456789" {
|
||||
t.Errorf("txn[2].BankID (numeric cell): want 26123456789, got %q", txns[2].BankID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadExceptions(t *testing.T) {
|
||||
|
||||
Reference in New Issue
Block a user