Skip to content

Commit 5e43b30

Browse files
committed
chore: improve csv import inclduing serialized equipment that has content
1 parent 6852db7 commit 5e43b30

27 files changed

Lines changed: 918 additions & 391 deletions

cmd/web/handlers:equipment.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -514,13 +514,17 @@ func (app *application) postEquipmentUpdateUnit(w http.ResponseWriter, r *http.R
514514

515515
if err := app.services.equipment.UpdateUnit(ctx, equipment.UpdateUnit{
516516
ID: unitID,
517+
SerialNumber: form.SerialNumber,
517518
StatusID: form.StatusID,
518519
ManufacturerSerialNumber: form.ManufacturerSerialNumber,
519-
Notes: form.Notes,
520+
Remark: form.Remark,
520521
PurchasePrice: form.PurchasePrice,
521522
PurchasedAt: form.PurchasedAt,
522523
NextInspectionAt: form.NextInspectionAt,
523524
}); err != nil {
525+
if errors.Is(err, database.ErrUniqueConstraint) {
526+
return app.renderEquipmentUnits(w, r, orgID, itemID)
527+
}
524528
return &httperr.Error{Error: err, Message: "Failed to update unit.", Code: http.StatusInternalServerError}
525529
}
526530

cmd/web/handlers:equipment:export.go

Lines changed: 16 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,16 @@ package main
33
import (
44
"encoding/csv"
55
"net/http"
6-
"strconv"
76

7+
"github.com/bit8bytes/gearberg/internal/equipment"
88
"github.com/bit8bytes/gearberg/internal/equipment/imports"
99
"github.com/bit8bytes/gearberg/internal/httperr"
1010
)
1111

1212
// exportColumnCount must equal len(imports.ExpectedHeaders). This line fails to
13-
// compile if the export cw.Write call is updated without updating ExpectedHeaders
14-
// (or vice versa), catching column-count drift at build time.
15-
const exportColumnCount = 18
13+
// compile if RowsForItem is updated without updating ExpectedHeaders (or vice
14+
// versa), catching column-count drift at build time.
15+
const exportColumnCount = 26
1616

1717
var _ = [1]struct{}{}[exportColumnCount-len(imports.ExpectedHeaders)]
1818

@@ -46,26 +46,18 @@ func (app *application) getEquipmentExport(w http.ResponseWriter, r *http.Reques
4646
if item.IsArchived || item.TotalStock == 0 {
4747
continue
4848
}
49-
_ = cw.Write([]string{
50-
item.Name,
51-
item.Type.Label(),
52-
item.UsageType.Label(),
53-
item.CategoryName,
54-
mfrByID[item.ManufacturerID],
55-
item.LocationName,
56-
item.Pricing.RentalPrice.ToDecimal(),
57-
item.Pricing.PurchasePrice.ToDecimal(),
58-
item.Notes,
59-
item.Properties.Weight.ToKG(),
60-
item.Properties.Width.ToCM(),
61-
item.Properties.Height.ToCM(),
62-
item.Properties.Depth.ToCM(),
63-
item.Properties.Voltage.ToV(),
64-
item.Properties.Current.ToA(),
65-
item.Properties.Power.ToW(),
66-
item.Properties.WireGauge.String(),
67-
strconv.FormatInt(item.TotalStock, 10),
68-
})
49+
50+
var units []equipment.Unit
51+
if item.Type == equipment.Serialized {
52+
units, err = app.services.equipment.ListUnits(ctx, item.ID)
53+
if err != nil {
54+
return &httperr.Error{Error: err, Message: "Failed to retrieve units.", Code: http.StatusInternalServerError}
55+
}
56+
}
57+
58+
for _, row := range imports.RowsForItem(item, mfrByID[item.ManufacturerID], units) {
59+
_ = cw.Write(row)
60+
}
6961
}
7062
cw.Flush()
7163
return nil

cmd/web/handlers:equipment:import.go

Lines changed: 83 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
package main
22

33
import (
4+
"fmt"
45
"net/http"
56
"net/url"
7+
"strings"
68

79
"github.com/bit8bytes/gearberg/internal/equipment/imports"
810
"github.com/bit8bytes/gearberg/internal/httperr"
@@ -14,14 +16,91 @@ type equipmentImportData struct {
1416
Error string
1517
}
1618

19+
// importPreviewRow is a display-oriented view of a staged import row.
20+
// Serialized rows with the same name are collapsed into a single entry;
21+
// Stock holds the unit count for serialized items and quantity for bulk.
22+
type importPreviewRow struct {
23+
RowNumber int64
24+
Name string
25+
TypeLabel string
26+
CategoryName string
27+
Stock string
28+
Status string
29+
ErrorMessage string
30+
}
31+
1732
type equipmentImportPreviewData struct {
1833
OrgID string
1934
ImportID string
20-
Rows []imports.Row
35+
Rows []importPreviewRow
2136
CountNew int
2237
CountError int
2338
}
2439

40+
// groupImportRows collapses serialized staging rows that share a name into one
41+
// preview row and returns per-item counts for new and error items.
42+
func groupImportRows(staged []imports.Row) (rows []importPreviewRow, cntNew, cntError int) {
43+
type group struct {
44+
row importPreviewRow
45+
total int
46+
hasErr bool
47+
}
48+
seen := make(map[string]*group)
49+
var order []string
50+
51+
for _, r := range staged {
52+
if !strings.EqualFold(r.TypeLabel, "serialized") {
53+
pr := importPreviewRow{
54+
RowNumber: r.RowNumber,
55+
Name: r.Name,
56+
TypeLabel: r.TypeLabel,
57+
CategoryName: r.CategoryName,
58+
Stock: r.Quantity,
59+
Status: r.Status,
60+
ErrorMessage: r.ErrorMessage,
61+
}
62+
rows = append(rows, pr)
63+
if r.Status == imports.StatusNew {
64+
cntNew++
65+
} else {
66+
cntError++
67+
}
68+
continue
69+
}
70+
71+
key := strings.ToLower(r.Name)
72+
if _, ok := seen[key]; !ok {
73+
seen[key] = &group{row: importPreviewRow{
74+
RowNumber: r.RowNumber,
75+
Name: r.Name,
76+
TypeLabel: r.TypeLabel,
77+
CategoryName: r.CategoryName,
78+
Status: imports.StatusNew,
79+
}}
80+
order = append(order, key)
81+
}
82+
g := seen[key]
83+
g.total++
84+
if r.Status == imports.StatusError && !g.hasErr {
85+
g.hasErr = true
86+
g.row.Status = imports.StatusError
87+
g.row.ErrorMessage = r.ErrorMessage
88+
}
89+
}
90+
91+
for _, key := range order {
92+
g := seen[key]
93+
g.row.Stock = fmt.Sprintf("%d", g.total)
94+
rows = append(rows, g.row)
95+
if g.hasErr {
96+
cntError++
97+
} else {
98+
cntNew++
99+
}
100+
}
101+
return
102+
}
103+
25104
// getEquipmentImport serves the upload form when no ?id= param is present,
26105
// or the staging preview when ?id= is set (after a successful upload).
27106
func (app *application) getEquipmentImport(w http.ResponseWriter, r *http.Request) *httperr.Error {
@@ -77,26 +156,18 @@ func (app *application) postEquipmentImport(w http.ResponseWriter, r *http.Reque
77156
func (app *application) renderImportPreview(w http.ResponseWriter, r *http.Request, orgID, importID string) *httperr.Error {
78157
ctx := r.Context()
79158

80-
rows, err := app.services.equipmentImports.ListStaged(ctx, importID)
159+
staged, err := app.services.equipmentImports.ListStaged(ctx, importID)
81160
if err != nil {
82161
return &httperr.Error{Error: err, Message: "Failed to load import preview.", Code: http.StatusInternalServerError}
83162
}
84163

85-
var cntNew, cntError int
86-
for _, row := range rows {
87-
switch row.Status {
88-
case imports.StatusNew:
89-
cntNew++
90-
case imports.StatusError:
91-
cntError++
92-
}
93-
}
164+
previewRows, cntNew, cntError := groupImportRows(staged)
94165

95166
data := app.html.TemplateData(r)
96167
data.Data = equipmentImportPreviewData{
97168
OrgID: orgID,
98169
ImportID: importID,
99-
Rows: rows,
170+
Rows: previewRows,
100171
CountNew: cntNew,
101172
CountError: cntError,
102173
}

internal/database/migrations/sqlite/20260606133519_equipment_imports.sql

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -13,30 +13,37 @@ CREATE TABLE equipment_imports (
1313
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
1414
-- raw CSV columns; all TEXT to preserve input before validation
1515
name TEXT NOT NULL DEFAULT '',
16-
type_label TEXT NOT NULL DEFAULT '',
17-
tracking_label TEXT NOT NULL DEFAULT '',
18-
usage_type_label TEXT NOT NULL DEFAULT '',
16+
-- Labels are defined by Gearberg and should match the seeded once
17+
type_label TEXT NOT NULL DEFAULT '', -- Bulk/Serialized
18+
tracking_label TEXT NOT NULL DEFAULT '', -- Physical/Virtual
19+
usage_type_label TEXT NOT NULL DEFAULT '', -- Rental/Resale
20+
-- User provided fields:
1921
category_name TEXT NOT NULL DEFAULT '',
2022
manufacturer_name TEXT NOT NULL DEFAULT '',
2123
location_name TEXT NOT NULL DEFAULT '',
24+
purchase_price TEXT NOT NULL DEFAULT '',
2225
rental_price TEXT NOT NULL DEFAULT '',
2326
resale_price TEXT NOT NULL DEFAULT '',
2427
notes TEXT NOT NULL DEFAULT '',
2528
weight_g TEXT NOT NULL DEFAULT '',
2629
width_mm TEXT NOT NULL DEFAULT '',
2730
height_mm TEXT NOT NULL DEFAULT '',
2831
depth_mm TEXT NOT NULL DEFAULT '',
29-
voltage_v TEXT NOT NULL DEFAULT '',
32+
voltage_mv TEXT NOT NULL DEFAULT '',
3033
current_ma TEXT NOT NULL DEFAULT '',
3134
power_mw TEXT NOT NULL DEFAULT '',
3235
wire_gauge_mm2_x100 TEXT NOT NULL DEFAULT '',
3336
quantity TEXT NOT NULL DEFAULT '1',
34-
purchase_price TEXT NOT NULL DEFAULT '',
35-
purchased_at TEXT NOT NULL DEFAULT '',
36-
manufacturer_serial TEXT NOT NULL DEFAULT '',
37+
-- Has content tab for serialized items
38+
has_content TEXT NOT NULL DEFAULT '0',
39+
-- Units for serialized equipment items
40+
unit_serial_number TEXT NOT NULL DEFAULT '',
41+
unit_manufacturer_serial TEXT NOT NULL DEFAULT '',
42+
unit_purchase_price TEXT NOT NULL DEFAULT '',
43+
unit_purchased_at TEXT NOT NULL DEFAULT '',
3744
next_inspection_at TEXT NOT NULL DEFAULT '',
38-
is_active TEXT NOT NULL DEFAULT '1',
39-
remark TEXT NOT NULL DEFAULT ''
45+
unit_is_active TEXT NOT NULL DEFAULT '1',
46+
unit_remark TEXT NOT NULL DEFAULT ''
4047
) STRICT;
4148
-- +goose StatementEnd
4249

internal/database/queries/gen/equipment/equipment.sql.go

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)