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
+}