diff --git a/cmd/web/handlers:equipment:import.go b/cmd/web/handlers:equipment:import.go index a1bcb38..0b643b2 100644 --- a/cmd/web/handlers:equipment:import.go +++ b/cmd/web/handlers:equipment:import.go @@ -15,10 +15,8 @@ package main import ( - "fmt" "net/http" "net/url" - "strings" "github.com/bit8bytes/gearberg/internal/equipmentimports" "github.com/bit8bytes/gearberg/internal/httperr" @@ -30,91 +28,14 @@ type equipmentImportData struct { Error string } -// importPreviewRow is a display-oriented view of a staged import row. -// Serialized rows with the same name are collapsed into a single entry; -// Stock holds the unit count for serialized items and quantity for bulk. -type importPreviewRow struct { - RowNumber int64 - Name string - TypeLabel string - CategoryName string - Stock string - Status string - ErrorMessage string -} - type equipmentImportPreviewData struct { OrgID string ImportID string - Rows []importPreviewRow + Rows []equipmentimports.GroupedRow CountNew int CountError int } -// groupImportRows collapses serialized staging rows that share a name into one -// preview row and returns per-item counts for new and error items. -func groupImportRows(staged []equipmentimports.Row) (rows []importPreviewRow, cntNew, cntError int) { - type group struct { - row importPreviewRow - total int - hasErr bool - } - seen := make(map[string]*group) - var order []string - - for _, r := range staged { - if !strings.EqualFold(r.TypeLabel, "serialized") { - pr := importPreviewRow{ - RowNumber: r.RowNumber, - Name: r.Name, - TypeLabel: r.TypeLabel, - CategoryName: r.CategoryName, - Stock: r.Quantity, - Status: r.Status, - ErrorMessage: r.ErrorMessage, - } - rows = append(rows, pr) - if r.Status == equipmentimports.StatusNew { - cntNew++ - } else { - cntError++ - } - continue - } - - key := strings.ToLower(r.Name) - if _, ok := seen[key]; !ok { - seen[key] = &group{row: importPreviewRow{ - RowNumber: r.RowNumber, - Name: r.Name, - TypeLabel: r.TypeLabel, - CategoryName: r.CategoryName, - Status: equipmentimports.StatusNew, - }} - order = append(order, key) - } - g := seen[key] - g.total++ - if r.Status == equipmentimports.StatusError && !g.hasErr { - g.hasErr = true - g.row.Status = equipmentimports.StatusError - g.row.ErrorMessage = r.ErrorMessage - } - } - - for _, key := range order { - g := seen[key] - g.row.Stock = fmt.Sprintf("%d", g.total) - rows = append(rows, g.row) - if g.hasErr { - cntError++ - } else { - cntNew++ - } - } - return -} - // getEquipmentImport serves the upload form when no ?id= param is present, // or the staging preview when ?id= is set (after a successful upload). func (app *application) getEquipmentImport(w http.ResponseWriter, r *http.Request) *httperr.Error { @@ -171,7 +92,7 @@ func (app *application) renderImportPreview(w http.ResponseWriter, r *http.Reque return httperr.InternalServerError(err) } - previewRows, cntNew, cntError := groupImportRows(staged) + previewRows, cntNew, cntError := equipmentimports.GroupRows(staged) data := app.html.TemplateData(r) data.Data = equipmentImportPreviewData{ diff --git a/cmd/web/handlers:equipment:import_test.go b/cmd/web/handlers:equipment:import_test.go index c80d797..ae4ace7 100644 --- a/cmd/web/handlers:equipment:import_test.go +++ b/cmd/web/handlers:equipment:import_test.go @@ -66,7 +66,7 @@ func assertRoundTrip(t *testing.T, body []byte) { assertSonySerials(t, rows) } -func parseExportCSV(t *testing.T, body []byte) []equipmentimports.RawRow { +func parseExportCSV(t *testing.T, body []byte) []equipmentimports.ProcessedRow { t.Helper() // Strip the UTF-8 BOM the handler prepends so ParseCSV sees clean bytes. if len(body) >= 3 && body[0] == 0xEF && body[1] == 0xBB && body[2] == 0xBF { @@ -79,11 +79,11 @@ func parseExportCSV(t *testing.T, body []byte) []equipmentimports.RawRow { return rows } -func assertExportCounts(t *testing.T, rows []equipmentimports.RawRow) { +func assertExportCounts(t *testing.T, rows []equipmentimports.ProcessedRow) { t.Helper() counts := make(map[string]int) for _, r := range rows { - counts[r.Name]++ + counts[r.Data.Name]++ } if counts["Shure SM58"] != 1 { t.Errorf("Shure SM58: want 1 export row, got %d", counts["Shure SM58"]) @@ -96,21 +96,21 @@ func assertExportCounts(t *testing.T, rows []equipmentimports.RawRow) { } } -func assertShureQuantity(t *testing.T, rows []equipmentimports.RawRow) { +func assertShureQuantity(t *testing.T, rows []equipmentimports.ProcessedRow) { t.Helper() for _, r := range rows { - if r.Name == "Shure SM58" && r.Quantity != "7" { - t.Errorf("Shure SM58: want quantity 7, got %q", r.Quantity) + if r.Data.Name == "Shure SM58" && r.Data.Quantity != "7" { + t.Errorf("Shure SM58: want quantity 7, got %q", r.Data.Quantity) } } } -func assertSonySerials(t *testing.T, rows []equipmentimports.RawRow) { +func assertSonySerials(t *testing.T, rows []equipmentimports.ProcessedRow) { t.Helper() serials := make(map[string]bool) for _, r := range rows { - if r.Name == "Sony A7 IV" { - serials[r.UnitSerialNumber] = true + if r.Data.Name == "Sony A7 IV" { + serials[r.Data.UnitSerialNumber] = true } } for _, want := range []string{"SN-A7IV-001", "SN-A7IV-002"} { diff --git a/internal/equipmentimports/csv.go b/internal/equipmentimports/csv.go index d0e4263..c134769 100644 --- a/internal/equipmentimports/csv.go +++ b/internal/equipmentimports/csv.go @@ -13,61 +13,42 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -// Package equipmentimports provides imports functionality. package equipmentimports import ( - "bufio" - "encoding/csv" + "context" "fmt" "io" "strings" -) - -// ParseCSV reads a CSV (with or without a UTF-8 BOM) and returns the data rows. -// The header row must match ExpectedHeaders exactly. -func ParseCSV(r io.Reader) ([]RawRow, error) { - br := bufio.NewReader(r) - // Strip UTF-8 BOM produced by the export so round-tripped files parse cleanly. - if peek, err := br.Peek(3); err == nil && peek[0] == 0xEF && peek[1] == 0xBB && peek[2] == 0xBF { - _, _ = br.Discard(3) - } - cr := csv.NewReader(br) - cr.TrimLeadingSpace = true - cr.FieldsPerRecord = -1 // allow variable field counts; short rows are padded in readRows - header, err := cr.Read() - if err != nil { - return nil, fmt.Errorf("ParseCSV: read header: %w", err) - } - if err := validateHeader(header); err != nil { - return nil, err - } - return readRows(cr) -} + pkgcsv "github.com/bit8bytes/gearberg/pkg/csv" +) // columnAliases maps legacy column names to their current canonical name. // Old exports that used a different name for a column are accepted transparently. var columnAliases = map[string]string{ // "Has Content" was a boolean column (TRUE/FALSE) replaced by "Equipment Type" - // (Standard/Kit). Values are normalised in readRows. + // (Standard/Kit). Values are normalised in MapRecords. "Has Content": "Equipment Type", } -func validateHeader(header []string) error { - if len(header) != len(ExpectedHeaders) { - return fmt.Errorf("expected %d columns, got %d", len(ExpectedHeaders), len(header)) +// ParseCSV reads a CSV (with or without a UTF-8 BOM) and returns processed rows +// with all values already converted to DB units (cents, grams, millimetres, etc.). +// All columns in ExpectedHeaders must be present; order and extra columns are ignored. +// Every row is initialised to StateValid; ImportID and OrgID are left empty — +// Stage sets them before persisting. +func ParseCSV(r io.Reader) ([]ProcessedRow, error) { + rd := &pkgcsv.Reader{Aliases: columnAliases} + records, err := rd.Read(context.Background(), r) + if err != nil { + return nil, fmt.Errorf("ParseCSV: %w", err) } - for i, h := range header { - canonical := h - if alias, ok := columnAliases[h]; ok { - canonical = alias - } - if canonical != ExpectedHeaders[i] { - return fmt.Errorf("column %d: expected %q, got %q", i+1, ExpectedHeaders[i], h) + for _, name := range ExpectedHeaders { + if _, ok := records[0].Fields[name]; !ok { + return nil, fmt.Errorf("ParseCSV: missing required column %q", name) } } - return nil + return MapRecords(records, "", ""), nil } // normalizeEquipmentTypeLabel maps legacy boolean values from the old "Has Content" @@ -82,55 +63,3 @@ func normalizeEquipmentTypeLabel(v string) string { return v } } - -// readRows reads data rows after the header has been consumed. -// Column positions must match ExpectedHeaders exactly. -func readRows(cr *csv.Reader) ([]RawRow, error) { - var rows []RawRow - for { - record, err := cr.Read() - if err == io.EOF { - break - } - if err != nil { - return nil, fmt.Errorf("readRows: %w", err) - } - if len(record) < len(ExpectedHeaders) { - padded := make([]string, len(ExpectedHeaders)) - copy(padded, record) - record = padded - } - rows = append(rows, RawRow{ - Name: strings.TrimSpace(record[0]), - TypeLabel: strings.TrimSpace(record[1]), - UsageTypeLabel: strings.TrimSpace(record[2]), - CategoryName: strings.TrimSpace(record[3]), - ManufacturerName: strings.TrimSpace(record[4]), - LocationName: strings.TrimSpace(record[5]), - RentalPrice: strings.TrimSpace(record[6]), - ResalePrice: strings.TrimSpace(record[7]), - Notes: strings.TrimSpace(record[8]), - WeightG: strings.TrimSpace(record[9]), - WidthMm: strings.TrimSpace(record[10]), - HeightMm: strings.TrimSpace(record[11]), - DepthMm: strings.TrimSpace(record[12]), - VoltageV: strings.TrimSpace(record[13]), - CurrentA: strings.TrimSpace(record[14]), - PowerW: strings.TrimSpace(record[15]), - WireGaugeMM2X100: strings.TrimSpace(record[16]), - Quantity: strings.TrimSpace(record[17]), - EquipmentTypeLabel: normalizeEquipmentTypeLabel(record[18]), - UnitSerialNumber: strings.TrimSpace(record[19]), - UnitManufacturerSerial: strings.TrimSpace(record[20]), - UnitPurchasePrice: strings.TrimSpace(record[21]), - UnitPurchasedAt: strings.TrimSpace(record[22]), - NextInspectionAt: strings.TrimSpace(record[23]), - UnitIsActive: strings.TrimSpace(record[24]), - UnitRemark: strings.TrimSpace(record[25]), - }) - } - if len(rows) == 0 { - return nil, fmt.Errorf("readRows: no data rows") - } - return rows, nil -} diff --git a/internal/equipmentimports/csv_test.go b/internal/equipmentimports/csv_test.go deleted file mode 100644 index 46578be..0000000 --- a/internal/equipmentimports/csv_test.go +++ /dev/null @@ -1,219 +0,0 @@ -// Copyright (C) 2026 Tobias Gleiter -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published -// by the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . -package equipmentimports_test - -import ( - "bytes" - "strings" - "testing" - - "github.com/bit8bytes/gearberg/internal/equipmentimports" -) - -// TestParseCSV_roundtrip verifies that every column in ExpectedHeaders maps to -// the correct RawRow field. If ExpectedHeaders is reordered or readRows is not -// updated in step, this test breaks before any real data is affected. -func TestParseCSV_roundtrip(t *testing.T) { - want := equipmentimports.RawRow{ - Name: "Shure SM58", - TypeLabel: "Bulk", - UsageTypeLabel: "Rental", - CategoryName: "Audio", - ManufacturerName: "Shure", - LocationName: "Main Warehouse", - RentalPrice: "15.00", - ResalePrice: "99.00", - Notes: "Cardioid dynamic mic", - WeightG: "0.298", - WidthMm: "4.7", - HeightMm: "4.8", - DepthMm: "16.2", - VoltageV: "5", - CurrentA: "2.4", - PowerW: "12", - WireGaugeMM2X100: "150", - Quantity: "7", - EquipmentTypeLabel: "", - UnitSerialNumber: "", - UnitManufacturerSerial: "", - UnitPurchasePrice: "", - UnitPurchasedAt: "", - NextInspectionAt: "", - UnitIsActive: "", - UnitRemark: "", - } - - header := strings.Join(equipmentimports.ExpectedHeaders, ",") - dataRow := strings.Join([]string{ - want.Name, - want.TypeLabel, - want.UsageTypeLabel, - want.CategoryName, - want.ManufacturerName, - want.LocationName, - want.RentalPrice, - want.ResalePrice, - want.Notes, - want.WeightG, - want.WidthMm, - want.HeightMm, - want.DepthMm, - want.VoltageV, - want.CurrentA, - want.PowerW, - want.WireGaugeMM2X100, - want.Quantity, - want.EquipmentTypeLabel, - want.UnitSerialNumber, - want.UnitManufacturerSerial, - want.UnitPurchasePrice, - want.UnitPurchasedAt, - want.NextInspectionAt, - want.UnitIsActive, - want.UnitRemark, - }, ",") - csv := header + "\n" + dataRow + "\n" - - rows, err := equipmentimports.ParseCSV(strings.NewReader(csv)) - if err != nil { - t.Fatalf("ParseCSV: %v", err) - } - if len(rows) != 1 { - t.Fatalf("expected 1 row, got %d", len(rows)) - } - got := rows[0] - - check := func(field, want, got string) { - t.Helper() - if got != want { - t.Errorf("field %s: want %q, got %q", field, want, got) - } - } - check("Name", want.Name, got.Name) - check("TypeLabel", want.TypeLabel, got.TypeLabel) - check("UsageTypeLabel", want.UsageTypeLabel, got.UsageTypeLabel) - check("CategoryName", want.CategoryName, got.CategoryName) - check("ManufacturerName", want.ManufacturerName, got.ManufacturerName) - check("LocationName", want.LocationName, got.LocationName) - check("RentalPrice", want.RentalPrice, got.RentalPrice) - check("ResalePrice", want.ResalePrice, got.ResalePrice) - check("Notes", want.Notes, got.Notes) - check("WeightG", want.WeightG, got.WeightG) - check("WidthMm", want.WidthMm, got.WidthMm) - check("HeightMm", want.HeightMm, got.HeightMm) - check("DepthMm", want.DepthMm, got.DepthMm) - check("VoltageV", want.VoltageV, got.VoltageV) - check("CurrentA", want.CurrentA, got.CurrentA) - check("PowerW", want.PowerW, got.PowerW) - check("WireGaugeMM2X100", want.WireGaugeMM2X100, got.WireGaugeMM2X100) - check("Quantity", want.Quantity, got.Quantity) - check("EquipmentTypeLabel", want.EquipmentTypeLabel, got.EquipmentTypeLabel) - check("UnitSerialNumber", want.UnitSerialNumber, got.UnitSerialNumber) - check("UnitManufacturerSerial", want.UnitManufacturerSerial, got.UnitManufacturerSerial) - check("UnitPurchasePrice", want.UnitPurchasePrice, got.UnitPurchasePrice) - check("UnitPurchasedAt", want.UnitPurchasedAt, got.UnitPurchasedAt) - check("NextInspectionAt", want.NextInspectionAt, got.NextInspectionAt) - check("UnitIsActive", want.UnitIsActive, got.UnitIsActive) - check("UnitRemark", want.UnitRemark, got.UnitRemark) -} - -func TestParseCSV_wrongColumnCount(t *testing.T) { - csv := "Name,Type\nFoo,Bar\n" - _, err := equipmentimports.ParseCSV(strings.NewReader(csv)) - if err == nil { - t.Fatal("expected error for wrong column count, got nil") - } -} - -func TestParseCSV_wrongColumnName(t *testing.T) { - // Replace the first header with something unexpected. - headers := make([]string, len(equipmentimports.ExpectedHeaders)) - copy(headers, equipmentimports.ExpectedHeaders) - headers[0] = "ItemName" // was "Name" - csv := strings.Join(headers, ",") + "\nFoo,Bulk,Rental,Audio,Shure,WH,15,99,,,,,,,,,,1\n" - _, err := equipmentimports.ParseCSV(strings.NewReader(csv)) - if err == nil { - t.Fatal("expected error for wrong column name, got nil") - } -} - -func TestParseCSV_noDataRows(t *testing.T) { - csv := strings.Join(equipmentimports.ExpectedHeaders, ",") + "\n" - _, err := equipmentimports.ParseCSV(strings.NewReader(csv)) - if err == nil { - t.Fatal("expected error for empty data, got nil") - } -} - -// TestParseCSV_templateValid ensures TemplateCSV parses without error, so a -// change to ExpectedHeaders that is not reflected in template.csv breaks the -// build immediately rather than at runtime. -func TestParseCSV_templateValid(t *testing.T) { - rows, err := equipmentimports.ParseCSV(bytes.NewReader(equipmentimports.TemplateCSV)) - if err != nil { - t.Fatalf("TemplateCSV is not valid: %v", err) - } - if len(rows) == 0 { - t.Fatal("TemplateCSV has no data rows") - } -} - -// TestParseCSV_legacyHasContentHeader verifies that old exports using "Has Content" -// instead of "Equipment Type" are accepted and their boolean values normalised. -func TestParseCSV_legacyHasContentHeader(t *testing.T) { - headers := make([]string, len(equipmentimports.ExpectedHeaders)) - copy(headers, equipmentimports.ExpectedHeaders) - headers[18] = "Has Content" // old column name - - cases := []struct { - hasContent string - wantEquipmentType string - }{ - {"TRUE", "Kit"}, - {"FALSE", "Standard"}, - {"1", "Kit"}, - {"0", "Standard"}, - {"", ""}, // blank → left as-is, resolves to Standard via ParseOrDefault - {"Kit", "Kit"}, // already canonical - } - - for _, tc := range cases { - row := strings.Join([]string{ - "Foo", "Bulk", "Rental", "Audio", "Shure", "WH", - "15", "99", "", "", "", "", "", "", "", "", "", "1", - tc.hasContent, "", "", "", "", "", "", "", - }, ",") - csv := strings.Join(headers, ",") + "\n" + row + "\n" - rows, err := equipmentimports.ParseCSV(strings.NewReader(csv)) - if err != nil { - t.Fatalf("hasContent=%q: ParseCSV: %v", tc.hasContent, err) - } - if got := rows[0].EquipmentTypeLabel; got != tc.wantEquipmentType { - t.Errorf("hasContent=%q: EquipmentTypeLabel: want %q, got %q", tc.hasContent, tc.wantEquipmentType, got) - } - } -} - -func TestParseCSV_stripsUTF8BOM(t *testing.T) { - header := strings.Join(equipmentimports.ExpectedHeaders, ",") - csv := "\xEF\xBB\xBF" + header + "\nShure SM58,Bulk,Rental,Audio,Shure,WH,15,99,,,,,,,,,,1,,,,,,,,\n" - rows, err := equipmentimports.ParseCSV(strings.NewReader(csv)) - if err != nil { - t.Fatalf("ParseCSV with BOM: %v", err) - } - if len(rows) != 1 { - t.Fatalf("expected 1 row, got %d", len(rows)) - } -} diff --git a/internal/equipmentimports/importer.go b/internal/equipmentimports/importer.go new file mode 100644 index 0000000..96b18d9 --- /dev/null +++ b/internal/equipmentimports/importer.go @@ -0,0 +1,119 @@ +// Copyright (C) 2026 Tobias Gleiter +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package equipmentimports + +import ( + "context" + "fmt" + "io" + + "github.com/bit8bytes/gearberg/internal/units" + pkgcsv "github.com/bit8bytes/gearberg/pkg/csv" +) + +// dbStr converts a *T (any int64-backed unit type) to its raw integer string, +// or "" if nil. Used to store DB-unit values in Row string fields. +func dbStr[T ~int64](v *T) string { + if v == nil { + return "" + } + return fmt.Sprintf("%d", int64(*v)) +} + +// Inspector reads only the header layer of a file to discover column names for +// the field-mapping UI step, without parsing the full dataset. +type Inspector interface { + InspectHeaders(ctx context.Context, r io.Reader) ([]string, error) +} + +// Reader parses an input source into records. +// Satisfied by *csv.Reader, or a future *json.Reader. +type Reader interface { + Read(ctx context.Context, r io.Reader) ([]pkgcsv.Record, error) +} + +// Step is a single pipeline stage applied to a batch of ProcessedRows. +// Steps are composable and run in order via RunValidation. +// A Step must never return early on a per-row error; instead it mutates +// the row's State to StateInvalid and appends to its Errors slice. +type Step func(ctx context.Context, rows []ProcessedRow) ([]ProcessedRow, error) + +// Writer commits a processed batch to a destination. +// Close must be called after Write to release any held resources. +type Writer interface { + Write(ctx context.Context, rows []Row) error + Close() error +} + +// RunValidation applies steps in order to rows, collecting validation errors +// per row without aborting early. Returns the annotated batch. +func RunValidation(ctx context.Context, rows []ProcessedRow, steps []Step) ([]ProcessedRow, error) { + var err error + for _, step := range steps { + rows, err = step(ctx, rows) + if err != nil { + return nil, err + } + } + return rows, nil +} + +// MapRecords converts pkg/csv records into ProcessedRow values with all physical +// and monetary quantities already expressed in DB units (cents, grams, millimetres, +// millivolts, milliamps, milliwatts). Every row is initialised to StateValid; +// subsequent Steps mark rows invalid when constraints are violated. +// The caller supplies importID and orgID which are set on every row; IDs and +// status fields are left zero so Stage can assign them. +func MapRecords(records []pkgcsv.Record, importID, orgID string) []ProcessedRow { + rows := make([]ProcessedRow, 0, len(records)) + for _, rec := range records { + f := func(name string) string { return rec.Fields[name] } + rows = append(rows, ProcessedRow{ + State: StateValid, + Data: Row{ + ImportID: importID, + OrgID: orgID, + Name: f("Name"), + TypeLabel: f("Type"), + UsageTypeLabel: f("Usage"), + CategoryName: f("Category"), + ManufacturerName: f("Manufacturer"), + LocationName: f("Location"), + RentalPrice: dbStr(units.ParseCents(f("Rental Price"))), + ResalePrice: dbStr(units.ParseCents(f("Resale Price"))), + Notes: f("Notes"), + WeightG: dbStr(units.ParseGrams(f("Weight (kg)"))), + WidthMm: dbStr(units.ParseMillimeters(f("Width (cm)"))), + HeightMm: dbStr(units.ParseMillimeters(f("Height (cm)"))), + DepthMm: dbStr(units.ParseMillimeters(f("Depth (cm)"))), + VoltageMv: dbStr(units.ParseVolts(f("Voltage (V)"))), + CurrentMa: dbStr(units.ParseMilliamps(f("Current (A)"))), + PowerMw: dbStr(units.ParseMilliwatts(f("Power (W)"))), + WireGaugeMM2X100: dbStr(units.ParseWireGauge(f("Wire Gauge (mm² ×100)"))), + Quantity: f("Quantity"), + EquipmentTypeLabel: normalizeEquipmentTypeLabel(f("Equipment Type")), + UnitSerialNumber: f("Unit Serial Number"), + UnitManufacturerSerial: f("Unit Manufacturer Serial"), + UnitPurchasePrice: dbStr(units.ParseCents(f("Unit Purchase Price"))), + UnitPurchasedAt: f("Unit Purchased At"), + NextInspectionAt: f("Next Inspection At"), + UnitIsActive: f("Unit Active"), + UnitRemark: f("Unit Remark"), + }, + }) + } + return rows +} diff --git a/internal/equipmentimports/model.go b/internal/equipmentimports/model.go index b7e3d0c..1c01b51 100644 --- a/internal/equipmentimports/model.go +++ b/internal/equipmentimports/model.go @@ -16,7 +16,11 @@ // Package equipmentimports handles CSV import staging and commit for inventory items. package equipmentimports -import _ "embed" +import ( + _ "embed" + "fmt" + "strings" +) // Status values for a staged import row. const ( @@ -74,36 +78,107 @@ type Row struct { UnitRemark string } -// RawRow holds a parsed CSV data row before validation and staging. -type RawRow struct { - // Equipment fields - Name string - TypeLabel string - UsageTypeLabel string - CategoryName string - ManufacturerName string - LocationName string - RentalPrice string - ResalePrice string - Notes string - WeightG string - WidthMm string - HeightMm string - DepthMm string - VoltageV string - CurrentA string - PowerW string - WireGaugeMM2X100 string - Quantity string - EquipmentTypeLabel string - // Unit fields (serialized items only; blank for bulk) - UnitSerialNumber string - UnitManufacturerSerial string - UnitPurchasePrice string - UnitPurchasedAt string - NextInspectionAt string - UnitIsActive string - UnitRemark string +// GroupedRow is a display-oriented view of staged rows collapsed by equipment name. +// Serialized rows that share a name are folded into one entry; Stock holds the +// unit count for serialized items and the quantity string for bulk. +type GroupedRow struct { + RowNumber int64 + Name string + TypeLabel string + CategoryName string + Stock string + Status string + ErrorMessage string +} + +// GroupRows collapses serialized staging rows that share a name into one GroupedRow +// and returns per-item counts for new and error items. +func GroupRows(staged []Row) (rows []GroupedRow, cntNew, cntError int) { + type group struct { + row GroupedRow + total int + hasErr bool + } + seen := make(map[string]*group) + var order []string + + for _, r := range staged { + if !strings.EqualFold(r.TypeLabel, "serialized") { + rows = append(rows, GroupedRow{ + RowNumber: r.RowNumber, + Name: r.Name, + TypeLabel: r.TypeLabel, + CategoryName: r.CategoryName, + Stock: r.Quantity, + Status: r.Status, + ErrorMessage: r.ErrorMessage, + }) + if r.Status == StatusNew { + cntNew++ + } else { + cntError++ + } + continue + } + + key := strings.ToLower(r.Name) + if _, ok := seen[key]; !ok { + seen[key] = &group{row: GroupedRow{ + RowNumber: r.RowNumber, + Name: r.Name, + TypeLabel: r.TypeLabel, + CategoryName: r.CategoryName, + Status: StatusNew, + }} + order = append(order, key) + } + g := seen[key] + g.total++ + if r.Status == StatusError && !g.hasErr { + g.hasErr = true + g.row.Status = StatusError + g.row.ErrorMessage = r.ErrorMessage + } + } + + for _, key := range order { + g := seen[key] + g.row.Stock = fmt.Sprintf("%d", g.total) + rows = append(rows, g.row) + if g.hasErr { + cntError++ + } else { + cntNew++ + } + } + return +} + +// Mapping links user-defined source column names to canonical Row field names. +// Key: header from the uploaded file; Value: Row field name. +type Mapping map[string]string + +// RowState classifies a ProcessedRow for the UI and the commit gate. +type RowState string + +const ( + StateValid RowState = "valid" + StateInvalid RowState = "invalid" +) + +// ValidationError is a structured, field-level error message surfaced to the UI. +type ValidationError struct { + Line int `json:"line"` + Field string `json:"field"` + Reason string `json:"reason"` +} + +// ProcessedRow wraps a Row with its validation state and collected errors. +// The pipeline operates on []ProcessedRow; Stage converts back to []Row for storage. +type ProcessedRow struct { + State RowState + Errors []ValidationError + Data Row } // TemplateCSV is the pre-filled example CSV file served to users as a download template. diff --git a/internal/equipmentimports/repository.go b/internal/equipmentimports/repository.go index 6632bda..61cc74e 100644 --- a/internal/equipmentimports/repository.go +++ b/internal/equipmentimports/repository.go @@ -35,8 +35,8 @@ func NewRepository(db *sql.DB) *Repository { return &Repository{q: genimports.New(db)} } -// Insert inserts a staged import row. -func (r *Repository) Insert(ctx context.Context, row Row) (*Row, error) { +// Create inserts a staged import row. +func (r *Repository) Create(ctx context.Context, row Row) (*Row, error) { rec, err := r.q.InsertImportRow(ctx, genimports.InsertImportRowParams{ ID: row.ID, ImportID: row.ImportID, @@ -77,17 +77,17 @@ func (r *Repository) Insert(ctx context.Context, row Row) (*Row, error) { UnitRemark: row.UnitRemark, }) if err != nil { - return nil, fmt.Errorf("Insert: %w", err) + return nil, fmt.Errorf("Create: %w", err) } out := fromRecord(rec) return &out, nil } -// GetByID returns a single staged import row. -func (r *Repository) GetByID(ctx context.Context, id string) (*Row, error) { +// Get returns a single staged import row. +func (r *Repository) Get(ctx context.Context, id string) (*Row, error) { rec, err := r.q.GetImportRow(ctx, id) if err != nil { - return nil, fmt.Errorf("GetByID: %w", err) + return nil, fmt.Errorf("Get: %w", err) } out := fromRecord(rec) return &out, nil @@ -112,19 +112,19 @@ func (r *Repository) DeleteByOrgID(ctx context.Context, orgID string) error { return nil } -// DeleteByImportID deletes all staging rows for a specific import. -func (r *Repository) DeleteByImportID(ctx context.Context, importID string) error { +// Delete deletes all staging rows for a specific import. +func (r *Repository) Delete(ctx context.Context, importID string) error { if err := r.q.DeleteImportsByImportID(ctx, importID); err != nil { - return fmt.Errorf("DeleteByImportID: %w", err) + return fmt.Errorf("Delete: %w", err) } return nil } -// ListByImportID returns all staged rows for an import, ordered by row_number. -func (r *Repository) ListByImportID(ctx context.Context, importID string) ([]Row, error) { +// List returns all staged rows for an import, ordered by row_number. +func (r *Repository) List(ctx context.Context, importID string) ([]Row, error) { recs, err := r.q.ListImportRowsByImportID(ctx, importID) if err != nil { - return nil, fmt.Errorf("ListByImportID: %w", err) + return nil, fmt.Errorf("List: %w", err) } rows := make([]Row, len(recs)) for i, rec := range recs { diff --git a/internal/equipmentimports/roundtrip_test.go b/internal/equipmentimports/roundtrip_test.go deleted file mode 100644 index da495bf..0000000 --- a/internal/equipmentimports/roundtrip_test.go +++ /dev/null @@ -1,197 +0,0 @@ -// Copyright (C) 2026 Tobias Gleiter -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published -// by the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . -package equipmentimports_test - -import ( - "bytes" - "encoding/csv" - "strings" - "testing" - - "github.com/bit8bytes/gearberg/internal/equipment" - "github.com/bit8bytes/gearberg/internal/equipment/tracking" - "github.com/bit8bytes/gearberg/internal/equipment/usage" - "github.com/bit8bytes/gearberg/internal/equipmentimports" - "github.com/bit8bytes/gearberg/internal/units" -) - -// buildCSV writes ExpectedHeaders followed by the rows produced by RowsForItem, -// mimicking exactly what the export handler does (minus the BOM and HTTP layer). -func buildCSV(item equipment.Equipment, mfrName string, units []equipment.Unit) string { - var buf bytes.Buffer - cw := csv.NewWriter(&buf) - _ = cw.Write(equipmentimports.ExpectedHeaders) - for _, row := range equipmentimports.RowsForItem(item, mfrName, units) { - _ = cw.Write(row) - } - cw.Flush() - return buf.String() -} - -func TestRoundTrip_bulk(t *testing.T) { - stock := int64(7) - item := equipment.Equipment{ - Name: "Shure SM58", - TrackingType: tracking.Bulk, - UsageType: usage.Rental, - CategoryName: "Audio", - LocationName: "Main Warehouse", - TotalStock: stock, - Notes: "Cardioid dynamic mic", - Pricing: equipment.Pricing{ - RentalPrice: units.ParseCents("15.00"), - PurchasePrice: units.ParseCents("99.00"), - }, - Properties: equipment.Properties{ - Weight: units.ParseGrams("0.298"), - Width: units.ParseMillimeters("4.7"), - Height: units.ParseMillimeters("4.7"), - Depth: units.ParseMillimeters("16.2"), - }, - } - - rows, err := equipmentimports.ParseCSV(strings.NewReader(buildCSV(item, "Shure", nil))) - if err != nil { - t.Fatalf("ParseCSV: %v", err) - } - if len(rows) != 1 { - t.Fatalf("expected 1 row, got %d", len(rows)) - } - got := rows[0] - - check := func(field, want, got string) { - t.Helper() - if got != want { - t.Errorf("%s: want %q, got %q", field, want, got) - } - } - check("Name", "Shure SM58", got.Name) - check("TypeLabel", "Bulk", got.TypeLabel) - check("UsageTypeLabel", "Rental", got.UsageTypeLabel) - check("CategoryName", "Audio", got.CategoryName) - check("ManufacturerName", "Shure", got.ManufacturerName) - check("LocationName", "Main Warehouse", got.LocationName) - check("RentalPrice", "15.00", got.RentalPrice) - check("ResalePrice", "99.00", got.ResalePrice) - check("Notes", "Cardioid dynamic mic", got.Notes) - check("Quantity", "7", got.Quantity) - check("EquipmentTypeLabel", "", got.EquipmentTypeLabel) - check("UnitSerialNumber", "", got.UnitSerialNumber) -} - -func TestRoundTrip_serialized(t *testing.T) { - purchasedAt := int64(1710460800) // 2024-03-15 UTC - inspectAt := int64(1742083200) // 2025-03-16 UTC - - item := equipment.Equipment{ - Name: "Sony A7 IV", - TrackingType: tracking.Serialized, - UsageType: usage.Rental, - CategoryName: "Camera", - LocationName: "Main Warehouse", - Notes: "Full-frame mirrorless camera", - Pricing: equipment.Pricing{ - RentalPrice: units.ParseCents("80.00"), - PurchasePrice: units.ParseCents("2800.00"), - }, - Properties: equipment.Properties{ - Weight: units.ParseGrams("0.659"), - Width: units.ParseMillimeters("13.1"), - Height: units.ParseMillimeters("9.6"), - Depth: units.ParseMillimeters("8.0"), - }, - } - units := []equipment.Unit{ - { - StatusID: 1, - SerialNumber: "SN-A7IV-001", - ManufacturerSerialNumber: "7-000001", - PurchasePrice: units.ParseCents("2800.00"), - PurchasedAt: &purchasedAt, - NextInspectionAt: &inspectAt, - }, - { - StatusID: 1, - SerialNumber: "SN-A7IV-002", - ManufacturerSerialNumber: "7-000002", - PurchasePrice: units.ParseCents("2800.00"), - PurchasedAt: &purchasedAt, - NextInspectionAt: &inspectAt, - Remark: "Minor scratch on top plate", - }, - } - - rows, err := equipmentimports.ParseCSV(strings.NewReader(buildCSV(item, "Sony", units))) - if err != nil { - t.Fatalf("ParseCSV: %v", err) - } - if len(rows) != 2 { - t.Fatalf("expected 2 rows, got %d", len(rows)) - } - - check := func(field, want, got string) { - t.Helper() - if got != want { - t.Errorf("%s: want %q, got %q", field, want, got) - } - } - - for i, got := range rows { - check("Name", "Sony A7 IV", got.Name) - check("TypeLabel", "Serialized", got.TypeLabel) - check("UsageTypeLabel", "Rental", got.UsageTypeLabel) - check("Quantity", "", got.Quantity) - check("EquipmentTypeLabel", "", got.EquipmentTypeLabel) - check("UnitIsActive", "TRUE", got.UnitIsActive) - check("UnitSerialNumber", units[i].SerialNumber, got.UnitSerialNumber) - check("UnitManufacturerSerial", units[i].ManufacturerSerialNumber, got.UnitManufacturerSerial) - check("UnitPurchasePrice", "2800.00", got.UnitPurchasePrice) - check("UnitRemark", units[i].Remark, got.UnitRemark) - } - check("UnitRemark", "Minor scratch on top plate", rows[1].UnitRemark) -} - -func TestRoundTrip_kit(t *testing.T) { - item := equipment.Equipment{ - Name: "Pelican 1510 Case", - Type: equipment.KitType, - TrackingType: tracking.Serialized, - UsageType: usage.Rental, - CategoryName: "Case", - LocationName: "Main Warehouse", - Pricing: equipment.Pricing{ - RentalPrice: units.ParseCents("10.00"), - PurchasePrice: units.ParseCents("120.00"), - }, - } - purchasedAt := int64(1704844800) // 2024-01-10 UTC - units := []equipment.Unit{ - {StatusID: 1, SerialNumber: "PC-1510-001", PurchasePrice: units.ParseCents("120.00"), PurchasedAt: &purchasedAt}, - } - - rows, err := equipmentimports.ParseCSV(strings.NewReader(buildCSV(item, "Pelican", units))) - if err != nil { - t.Fatalf("ParseCSV: %v", err) - } - if len(rows) != 1 { - t.Fatalf("expected 1 row, got %d", len(rows)) - } - if rows[0].EquipmentTypeLabel != "Kit" { - t.Errorf("EquipmentTypeLabel: want %q, got %q", "Kit", rows[0].EquipmentTypeLabel) - } - if rows[0].UnitSerialNumber != "PC-1510-001" { - t.Errorf("UnitSerialNumber: want %q, got %q", "PC-1510-001", rows[0].UnitSerialNumber) - } -} diff --git a/internal/equipmentimports/rows.go b/internal/equipmentimports/rows.go index e575157..3a98f7f 100644 --- a/internal/equipmentimports/rows.go +++ b/internal/equipmentimports/rows.go @@ -24,67 +24,78 @@ import ( "github.com/bit8bytes/gearberg/internal/equipment/tracking" ) +// headerIndex maps each ExpectedHeaders column name to its position. +// Built once at package init so RowsForItem can write by name, not by magic number. +var headerIndex = func() map[string]int { + m := make(map[string]int, len(ExpectedHeaders)) + for i, name := range ExpectedHeaders { + m[name] = i + } + return m +}() + +func newRow(base []string) []string { + row := make([]string, len(ExpectedHeaders)) + copy(row, base) + return row +} + // RowsForItem returns the CSV data rows for one equipment item using the column // order defined by ExpectedHeaders. // Bulk items produce a single row; serialized items produce one row per unit. func RowsForItem(item equipment.Equipment, mfrName string, units []equipment.Unit) [][]string { - equipmentTypeLabel := item.Type.Label() - - base := []string{ - item.Name, - item.TrackingType.Label(), - item.UsageType.Label(), - item.CategoryName, - mfrName, - item.LocationName, - item.Pricing.RentalPrice.String(), - item.Pricing.PurchasePrice.String(), - item.Notes, - item.Properties.Weight.String(), - item.Properties.Width.String(), - item.Properties.Height.String(), - item.Properties.Depth.String(), - item.Properties.Voltage.String(), - item.Properties.Current.String(), - item.Properties.Power.String(), - item.Properties.WireGauge.String(), - } + h := headerIndex + base := make([]string, len(ExpectedHeaders)) + base[h["Name"]] = item.Name + base[h["Type"]] = item.TrackingType.Label() + base[h["Usage"]] = item.UsageType.Label() + base[h["Category"]] = item.CategoryName + base[h["Manufacturer"]] = mfrName + base[h["Location"]] = item.LocationName + base[h["Rental Price"]] = item.Pricing.RentalPrice.String() + base[h["Resale Price"]] = item.Pricing.PurchasePrice.String() + base[h["Notes"]] = item.Notes + base[h["Weight (kg)"]] = item.Properties.Weight.String() + base[h["Width (cm)"]] = item.Properties.Width.String() + base[h["Height (cm)"]] = item.Properties.Height.String() + base[h["Depth (cm)"]] = item.Properties.Depth.String() + base[h["Voltage (V)"]] = item.Properties.Voltage.String() + base[h["Current (A)"]] = item.Properties.Current.String() + base[h["Power (W)"]] = item.Properties.Power.String() + base[h["Wire Gauge (mm² ×100)"]] = item.Properties.WireGauge.String() + base[h["Equipment Type"]] = item.Type.Label() if item.TrackingType != tracking.Serialized { - row := make([]string, len(ExpectedHeaders)) - copy(row, base) - row[17] = strconv.FormatInt(item.TotalStock, 10) - row[18] = equipmentTypeLabel + row := newRow(base) + row[h["Quantity"]] = strconv.FormatInt(item.TotalStock, 10) return [][]string{row} } rows := make([][]string, 0, len(units)) for _, u := range units { - row := make([]string, len(ExpectedHeaders)) - copy(row, base) - row[18] = equipmentTypeLabel - row[19] = u.SerialNumber - row[20] = u.ManufacturerSerialNumber - row[21] = u.PurchasePrice.String() - row[22] = FormatExportDate(u.PurchasedAt) - row[23] = FormatExportDate(u.NextInspectionAt) - row[24] = FormatExportActive(u.IsActive()) - row[25] = u.Remark + row := newRow(base) + row[h["Unit Serial Number"]] = u.SerialNumber + row[h["Unit Manufacturer Serial"]] = u.ManufacturerSerialNumber + row[h["Unit Purchase Price"]] = u.PurchasePrice.String() + row[h["Unit Purchased At"]] = formatExportDate(u.PurchasedAt) + row[h["Next Inspection At"]] = formatExportDate(u.NextInspectionAt) + row[h["Unit Active"]] = formatExportActive(u.IsActive()) + row[h["Unit Remark"]] = u.Remark rows = append(rows, row) } return rows } -// FormatExportDate formats a Unix timestamp pointer as YYYY-MM-DD, or "" if nil. -func FormatExportDate(ts *int64) string { +// formatExportDate formats a Unix timestamp pointer as YYYY-MM-DD, or "" if nil. +func formatExportDate(ts *int64) string { if ts == nil { return "" } return time.Unix(*ts, 0).UTC().Format("2006-01-02") } -// FormatExportActive returns "TRUE" for active, "FALSE" for inactive. -func FormatExportActive(active bool) string { +// formatExportActive returns "TRUE" for active, "FALSE" for inactive. +func formatExportActive(active bool) string { if active { return "TRUE" } diff --git a/internal/equipmentimports/service.go b/internal/equipmentimports/service.go index 8a091d0..8e0a860 100644 --- a/internal/equipmentimports/service.go +++ b/internal/equipmentimports/service.go @@ -21,6 +21,7 @@ import ( "database/sql" "fmt" "math" + "strconv" "strings" "github.com/bit8bytes/gearberg/internal/equipment" @@ -31,18 +32,8 @@ import ( "github.com/bit8bytes/gearberg/internal/units" ) -// CategoryEnsurer resolves or creates categories by name. -type CategoryEnsurer interface { - Upsert(ctx context.Context, orgID, name string) (string, error) -} - -// ManufacturerEnsurer resolves or creates manufacturers by name. -type ManufacturerEnsurer interface { - Upsert(ctx context.Context, orgID, name string) (string, error) -} - -// LocationEnsurer resolves or creates locations by name. -type LocationEnsurer interface { +// Upserter resolves or creates a named entity within an org. +type Upserter interface { Upsert(ctx context.Context, orgID, name string) (string, error) } @@ -51,19 +42,46 @@ type Service struct { repo *Repository db *sql.DB equipment *equipment.Repository - categories CategoryEnsurer - manufacturers ManufacturerEnsurer - locations LocationEnsurer + categories Upserter + manufacturers Upserter + locations Upserter + steps []Step } -// NewService returns a new Service. -func NewService(repo *Repository, db *sql.DB, equip *equipment.Repository, cats CategoryEnsurer, mfrs ManufacturerEnsurer, locs LocationEnsurer) *Service { - return &Service{repo: repo, db: db, equipment: equip, categories: cats, manufacturers: mfrs, locations: locs} +// NewService returns a new Service with the default validation pipeline. +func NewService(repo *Repository, db *sql.DB, equip *equipment.Repository, cats, mfrs, locs Upserter) *Service { + return &Service{ + repo: repo, + db: db, + equipment: equip, + categories: cats, + manufacturers: mfrs, + locations: locs, + steps: []Step{validateStep}, + } +} + +// validateStep marks rows that fail field-level validation as StateInvalid. +func validateStep(_ context.Context, rows []ProcessedRow) ([]ProcessedRow, error) { + for i, pr := range rows { + if pr.State == StateInvalid { + continue + } + if msg := validateRow(pr.Data); msg != "" { + rows[i].State = StateInvalid + rows[i].Errors = append(rows[i].Errors, ValidationError{ + Line: int(pr.Data.RowNumber), + Field: "Name", + Reason: msg, + }) + } + } + return rows, nil } -// Stage deletes any existing staging rows for the org, then validates and stages -// the provided rows. Returns the import_id grouping the new rows. -func (s *Service) Stage(ctx context.Context, orgID string, rawRows []RawRow) (string, error) { +// Stage deletes any existing staging rows for the org, runs the import pipeline, +// and persists the results. Returns the import_id grouping the new rows. +func (s *Service) Stage(ctx context.Context, orgID string, processed []ProcessedRow) (string, error) { if err := s.repo.DeleteByOrgID(ctx, orgID); err != nil { return "", fmt.Errorf("Stage: %w", err) } @@ -78,64 +96,74 @@ func (s *Service) Stage(ctx context.Context, orgID string, rawRows []RawRow) (st } importID := uid.New() - for i, raw := range rawRows { - row := Row{ - ID: uid.New(), - ImportID: importID, - OrgID: orgID, - RowNumber: int64(i + 1), - Name: raw.Name, - TypeLabel: raw.TypeLabel, - UsageTypeLabel: raw.UsageTypeLabel, - CategoryName: raw.CategoryName, - ManufacturerName: raw.ManufacturerName, - LocationName: raw.LocationName, - RentalPrice: raw.RentalPrice, - ResalePrice: raw.ResalePrice, - Notes: raw.Notes, - WeightG: raw.WeightG, - WidthMm: raw.WidthMm, - HeightMm: raw.HeightMm, - DepthMm: raw.DepthMm, - VoltageMv: raw.VoltageV, - CurrentMa: raw.CurrentA, - PowerMw: raw.PowerW, - WireGaugeMM2X100: raw.WireGaugeMM2X100, - Quantity: raw.Quantity, - EquipmentTypeLabel: raw.EquipmentTypeLabel, - UnitSerialNumber: raw.UnitSerialNumber, - UnitManufacturerSerial: raw.UnitManufacturerSerial, - UnitPurchasePrice: raw.UnitPurchasePrice, - UnitPurchasedAt: raw.UnitPurchasedAt, - NextInspectionAt: raw.NextInspectionAt, - UnitIsActive: raw.UnitIsActive, - UnitRemark: raw.UnitRemark, + for i := range processed { + processed[i].Data.ID = uid.New() + processed[i].Data.ImportID = importID + processed[i].Data.OrgID = orgID + processed[i].Data.RowNumber = int64(i + 1) + processed[i].Data.Status = StatusNew + processed[i].Data.Action = ActionCreate + } + + pipeline := make([]Step, len(s.steps)+1) + copy(pipeline, s.steps) + pipeline[len(s.steps)] = conflictStep(existingByName) + processed, err = RunValidation(ctx, processed, pipeline) + if err != nil { + return "", fmt.Errorf("Stage: pipeline: %w", err) + } + + rows := toRows(processed) + for i, row := range rows { + if _, err := s.repo.Create(ctx, row); err != nil { + return "", fmt.Errorf("Stage: row %d: %w", i+1, err) } + } - if errMsg := validateRow(raw); errMsg != "" { - row.Status = StatusError - row.ErrorMessage = errMsg - row.Action = ActionSkip - } else if _, conflict := existingByName[strings.ToLower(raw.Name)]; conflict { + return importID, nil +} + +// toRows converts a validated []ProcessedRow back to []Row for storage, +// mapping StateInvalid→StatusError and collecting the first error message. +func toRows(processed []ProcessedRow) []Row { + rows := make([]Row, len(processed)) + for i, pr := range processed { + row := pr.Data + if pr.State == StateInvalid { row.Status = StatusError - row.ErrorMessage = "A gear item with this name already exists" row.Action = ActionSkip - } else { - row.Status = StatusNew - row.Action = ActionCreate + if len(pr.Errors) > 0 { + row.ErrorMessage = pr.Errors[0].Reason + } } + rows[i] = row + } + return rows +} - if _, err := s.repo.Insert(ctx, row); err != nil { - return "", fmt.Errorf("Stage: row %d: %w", i+1, err) +// conflictStep returns a Step that marks rows whose name already exists in inventory. +func conflictStep(existingByName map[string]string) Step { + return func(_ context.Context, rows []ProcessedRow) ([]ProcessedRow, error) { + for i, pr := range rows { + if pr.State == StateInvalid { + continue + } + if _, conflict := existingByName[strings.ToLower(pr.Data.Name)]; conflict { + rows[i].State = StateInvalid + rows[i].Errors = append(rows[i].Errors, ValidationError{ + Line: int(pr.Data.RowNumber), + Field: "Name", + Reason: "A gear item with this name already exists", + }) + } } + return rows, nil } - - return importID, nil } // ListStaged returns all staged rows for an import. func (s *Service) ListStaged(ctx context.Context, importID string) ([]Row, error) { - rows, err := s.repo.ListByImportID(ctx, importID) + rows, err := s.repo.List(ctx, importID) if err != nil { return nil, fmt.Errorf("ListStaged: %w", err) } @@ -221,7 +249,7 @@ func (s *Service) ensureLocation(ctx context.Context, lk *commitLookups, orgID, // then deletes the staging rows — all within a single transaction so no partial // write is possible. func (s *Service) Commit(ctx context.Context, importID string, orgID string) error { - rows, err := s.repo.ListByImportID(ctx, importID) + rows, err := s.repo.List(ctx, importID) if err != nil { return fmt.Errorf("Commit: %w", err) } @@ -247,7 +275,7 @@ func (s *Service) Commit(ctx context.Context, importID string, orgID string) err // Staging rows are cleaned up after the inventory transaction commits. // A failure here leaves stale rows that DeleteByOrgID will clear on the next import. - if err := s.repo.DeleteByImportID(ctx, importID); err != nil { + if err := s.repo.Delete(ctx, importID); err != nil { return fmt.Errorf("Commit: %w", err) } return nil @@ -295,6 +323,21 @@ func (s *Service) resolveLookups(row Row, lk commitLookups) (catID, mfrID, locID return } +// parseInt64 parses a string as int64, returning 0 for blank or invalid input. +func parseInt64(s string) int64 { + n, _ := strconv.ParseInt(strings.TrimSpace(s), 10, 64) + return n +} + +// ptrOf converts an int64 DB value to a typed pointer; returns nil when v is 0. +func ptrOf[T ~int64](v int64) *T { + if v == 0 { + return nil + } + t := T(v) + return &t +} + func buildBase(row Row, catID, mfrID, locID string) equipment.Base { return equipment.Base{ OrgID: row.OrgID, @@ -306,18 +349,18 @@ func buildBase(row Row, catID, mfrID, locID string) equipment.Base { Notes: row.Notes, EquipmentType: equipment.ParseOrDefault(strings.ToLower(strings.TrimSpace(row.EquipmentTypeLabel))), Pricing: equipment.Pricing{ - PurchasePrice: units.ParseCents(row.ResalePrice), - RentalPrice: units.ParseCents(row.RentalPrice), + PurchasePrice: ptrOf[units.Cents](parseInt64(row.ResalePrice)), + RentalPrice: ptrOf[units.Cents](parseInt64(row.RentalPrice)), }, Properties: equipment.Properties{ - Weight: units.ParseGrams(row.WeightG), - Width: units.ParseMillimeters(row.WidthMm), - Height: units.ParseMillimeters(row.HeightMm), - Depth: units.ParseMillimeters(row.DepthMm), - Power: units.ParseMilliwatts(row.PowerMw), - Current: units.ParseMilliamps(row.CurrentMa), - Voltage: units.ParseVolts(row.VoltageMv), - WireGauge: units.ParseWireGauge(row.WireGaugeMM2X100), + Weight: ptrOf[units.Grams](parseInt64(row.WeightG)), + Width: ptrOf[units.Millimeters](parseInt64(row.WidthMm)), + Height: ptrOf[units.Millimeters](parseInt64(row.HeightMm)), + Depth: ptrOf[units.Millimeters](parseInt64(row.DepthMm)), + Power: ptrOf[units.Milliwatts](parseInt64(row.PowerMw)), + Current: ptrOf[units.Milliamps](parseInt64(row.CurrentMa)), + Voltage: ptrOf[units.Millivolts](parseInt64(row.VoltageMv)), + WireGauge: ptrOf[units.WireGauge](parseInt64(row.WireGaugeMM2X100)), }, } } @@ -348,7 +391,7 @@ func buildUnit(row Row, equipmentID string) equipment.CreateUnit { SerialNumber: sn, ManufacturerSerialNumber: row.UnitManufacturerSerial, Remark: row.UnitRemark, - PurchasePrice: units.ParseCents(row.UnitPurchasePrice), + PurchasePrice: ptrOf[units.Cents](parseInt64(row.UnitPurchasePrice)), PurchasedAt: equipment.ParseDate(row.UnitPurchasedAt), NextInspectionAt: equipment.ParseDate(row.NextInspectionAt), IsActive: isActive, @@ -360,33 +403,33 @@ func (s *Service) commitSerializedGroup(ctx context.Context, tx *sql.Tx, first R catID, mfrID, locID := s.resolveLookups(first, lk) base := buildBase(first, catID, mfrID, locID) itemID := uid.New() - units := make([]equipment.CreateUnit, 0, len(rows)) + unitList := make([]equipment.CreateUnit, 0, len(rows)) for _, row := range rows { - units = append(units, buildUnit(row, itemID)) + unitList = append(unitList, buildUnit(row, itemID)) } if _, err := s.equipment.CreateSerialized(ctx, tx, equipment.CreateSerializedEquipment{ ID: itemID, Base: base, - Units: units, + Units: unitList, }); err != nil { return fmt.Errorf("commitSerializedGroup: %w", err) } return nil } -func validateRow(raw RawRow) string { - if strings.TrimSpace(raw.Name) == "" { +func validateRow(row Row) string { + if strings.TrimSpace(row.Name) == "" { return "Name is required" } - tl := strings.TrimSpace(raw.TypeLabel) + tl := strings.TrimSpace(row.TypeLabel) if !strings.EqualFold(tl, "bulk") && !strings.EqualFold(tl, "serialized") { return fmt.Sprintf("Type must be Bulk or Serialized, got %q", tl) } - ul := strings.TrimSpace(raw.UsageTypeLabel) + ul := strings.TrimSpace(row.UsageTypeLabel) if !strings.EqualFold(ul, "rental") && !strings.EqualFold(ul, "sale") { return fmt.Sprintf("Usage must be Rental or Sale, got %q", ul) } - if strings.TrimSpace(raw.CategoryName) == "" { + if strings.TrimSpace(row.CategoryName) == "" { return "Category is required" } return "" diff --git a/pkg/csv/csv.go b/pkg/csv/csv.go new file mode 100644 index 0000000..57676f6 --- /dev/null +++ b/pkg/csv/csv.go @@ -0,0 +1,136 @@ +// Copyright (C) 2026 Tobias Gleiter +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +// Package csv parses CSV files into generic records. +package csv + +import ( + "bufio" + "context" + "encoding/csv" + "fmt" + "io" + "strings" +) + +// Record is one parsed row: a line number and its field values keyed by column header. +type Record struct { + Line int + Fields map[string]string +} + +// Reader parses a CSV file into records. +// Column order is irrelevant; fields are keyed by their header name. +// Extra columns are ignored; a UTF-8 BOM is stripped automatically. +type Reader struct { + // Aliases maps legacy column names to their canonical header name. + // Useful for accepting old exports that renamed a column. + Aliases map[string]string +} + +// InspectHeaders reads only the first line of r and returns the column names. +// Used by the field-mapping UI step before a full parse is performed. +func (rd *Reader) InspectHeaders(_ context.Context, r io.Reader) ([]string, error) { + cr, err := rd.newCSVReader(r) + if err != nil { + return nil, err + } + rawHeader, err := cr.Read() + if err != nil { + return nil, fmt.Errorf("csv: read header: %w", err) + } + headers := make([]string, len(rawHeader)) + for i, h := range rawHeader { + canonical := h + if rd.Aliases != nil { + if alias, ok := rd.Aliases[h]; ok { + canonical = alias + } + } + headers[i] = canonical + } + return headers, nil +} + +// Read parses r and returns one Record per data row. +// Returns an error if the file has no data rows. +func (rd *Reader) Read(_ context.Context, r io.Reader) ([]Record, error) { + cr, err := rd.newCSVReader(r) + if err != nil { + return nil, err + } + + idx, err := rd.buildIndex(cr) + if err != nil { + return nil, err + } + + return readRecords(cr, idx) +} + +func (rd *Reader) newCSVReader(r io.Reader) (*csv.Reader, error) { + br := bufio.NewReader(r) + if peek, err := br.Peek(3); err == nil && peek[0] == 0xEF && peek[1] == 0xBB && peek[2] == 0xBF { + _, _ = br.Discard(3) + } + cr := csv.NewReader(br) + cr.TrimLeadingSpace = true + cr.FieldsPerRecord = -1 + return cr, nil +} + +func (rd *Reader) buildIndex(cr *csv.Reader) (map[string]int, error) { + rawHeader, err := cr.Read() + if err != nil { + return nil, fmt.Errorf("csv: read header: %w", err) + } + idx := make(map[string]int, len(rawHeader)) + for i, h := range rawHeader { + canonical := h + if rd.Aliases != nil { + if alias, ok := rd.Aliases[h]; ok { + canonical = alias + } + } + idx[canonical] = i + } + return idx, nil +} + +func readRecords(cr *csv.Reader, idx map[string]int) ([]Record, error) { + var records []Record + line := 1 + for { + row, err := cr.Read() + if err == io.EOF { + break + } + if err != nil { + return nil, fmt.Errorf("csv: line %d: %w", line, err) + } + line++ + fields := make(map[string]string, len(idx)) + for name, i := range idx { + if i < len(row) { + fields[name] = strings.TrimSpace(row[i]) + } + } + records = append(records, Record{Line: line, Fields: fields}) + } + if len(records) == 0 { + return nil, fmt.Errorf("csv: no data rows") + } + return records, nil +}