feat(04-04): integrate auto-print into intake handler
- Add IntakePrinter interface to intake.go (optional, nil-safe) - Add printer field to IntakeHandler, update NewIntakeHandler signature - Add PrintSkipped bool to IntakeResponse (json: print_skipped) - Auto-print label after NetBox record creation using labels.RenderStandard + printer.ImageToRawBitmap - Printer errors are non-fatal: logged and surfaced via print_skipped=true - Update main.go to pass mockDriver as IntakePrinter - Add 4 new tests covering success, ErrNoDevice, nil printer, and non-fatal error - All 10 intake tests pass (6 existing + 4 new)
This commit is contained in:
parent
4d2d35e277
commit
f9b1b3ff29
3 changed files with 165 additions and 17 deletions
|
|
@ -66,19 +66,6 @@ func main() {
|
|||
log.Printf("WAQ worker started")
|
||||
}
|
||||
|
||||
// Intake handler — waqForHandler may be nil; handler handles nil gracefully
|
||||
intakeHandler := handlers.NewIntakeHandler(
|
||||
orch,
|
||||
nbClient,
|
||||
catalogUpdater,
|
||||
waqForHandler,
|
||||
cfg.NetBoxDefaultDeviceTypeID,
|
||||
cfg.NetBoxDefaultRoleID,
|
||||
cfg.NetBoxDefaultSiteID,
|
||||
cfg.AI.QuickAddEnabled,
|
||||
cfg.AI.QuickAddThreshold,
|
||||
)
|
||||
|
||||
// USB Manager — polls for device connect/disconnect events.
|
||||
// Start with 2-second poll interval (production default).
|
||||
usbManager := usb.NewManager(2 * time.Second)
|
||||
|
|
@ -92,6 +79,20 @@ func main() {
|
|||
log.Printf("WARNING: mock printer connect: %v", err)
|
||||
}
|
||||
|
||||
// Intake handler — waqForHandler and mockDriver may be nil; handler handles nil gracefully.
|
||||
intakeHandler := handlers.NewIntakeHandler(
|
||||
orch,
|
||||
nbClient,
|
||||
catalogUpdater,
|
||||
waqForHandler,
|
||||
cfg.NetBoxDefaultDeviceTypeID,
|
||||
cfg.NetBoxDefaultRoleID,
|
||||
cfg.NetBoxDefaultSiteID,
|
||||
cfg.AI.QuickAddEnabled,
|
||||
cfg.AI.QuickAddThreshold,
|
||||
mockDriver,
|
||||
)
|
||||
|
||||
inventoryHandler := handlers.NewInventoryHandler(nbClient)
|
||||
labelHandler := handlers.NewLabelHandler(nbClient, mockDriver)
|
||||
usbEventsHandler := handlers.NewUSBEventsHandler(usbManager)
|
||||
|
|
|
|||
|
|
@ -13,7 +13,9 @@ import (
|
|||
|
||||
"git.georgsen.dk/hwlab/internal/ai"
|
||||
"git.georgsen.dk/hwlab/internal/inventory"
|
||||
"git.georgsen.dk/hwlab/internal/labels"
|
||||
"git.georgsen.dk/hwlab/internal/netbox"
|
||||
"git.georgsen.dk/hwlab/internal/printer"
|
||||
"git.georgsen.dk/hwlab/internal/queue"
|
||||
)
|
||||
|
||||
|
|
@ -41,13 +43,20 @@ type IntakeWAQ interface {
|
|||
Enqueue(ctx context.Context, op queue.PendingOp) error
|
||||
}
|
||||
|
||||
// IntakePrinter is the optional printer used to auto-print a label after intake.
|
||||
// If nil, label printing is skipped. Printer errors are non-fatal.
|
||||
type IntakePrinter interface {
|
||||
Print(bitmap []byte, width, height int) error
|
||||
}
|
||||
|
||||
// IntakeHandler handles POST /api/intake — multipart photo upload → AI analysis →
|
||||
// HW-ID allocation → NetBox record creation (or WAQ enqueue on NetBox failure).
|
||||
type IntakeHandler struct {
|
||||
orchestrator IntakeOrchestrator
|
||||
netboxClient IntakeNetBoxClient
|
||||
catalogUpdater IntakeCatalogUpdater
|
||||
waq IntakeWAQ // may be nil if DragonFlyDB unavailable
|
||||
waq IntakeWAQ // may be nil if DragonFlyDB unavailable
|
||||
printer IntakePrinter // may be nil; auto-print is optional
|
||||
deviceTypeID int32
|
||||
roleID int32
|
||||
siteID int32
|
||||
|
|
@ -55,7 +64,7 @@ type IntakeHandler struct {
|
|||
quickAddThresh float64
|
||||
}
|
||||
|
||||
// NewIntakeHandler constructs an IntakeHandler. waq may be nil if DragonFlyDB is unavailable.
|
||||
// NewIntakeHandler constructs an IntakeHandler. waq and p may be nil.
|
||||
func NewIntakeHandler(
|
||||
orch IntakeOrchestrator,
|
||||
nb IntakeNetBoxClient,
|
||||
|
|
@ -64,12 +73,14 @@ func NewIntakeHandler(
|
|||
deviceTypeID, roleID, siteID int32,
|
||||
quickAddEnabled bool,
|
||||
quickAddThresh float64,
|
||||
p IntakePrinter, // may be nil — label printing will be skipped
|
||||
) *IntakeHandler {
|
||||
return &IntakeHandler{
|
||||
orchestrator: orch,
|
||||
netboxClient: nb,
|
||||
catalogUpdater: cu,
|
||||
waq: waq,
|
||||
printer: p,
|
||||
deviceTypeID: deviceTypeID,
|
||||
roleID: roleID,
|
||||
siteID: siteID,
|
||||
|
|
@ -90,7 +101,8 @@ type IntakeResponse struct {
|
|||
Confidence float64 `json:"confidence"`
|
||||
CatalogStatus string `json:"catalog_status"`
|
||||
NetBoxID int64 `json:"netbox_id,omitempty"`
|
||||
Queued bool `json:"queued,omitempty"` // true if NetBox was unreachable
|
||||
Queued bool `json:"queued,omitempty"` // true if NetBox was unreachable
|
||||
PrintSkipped bool `json:"print_skipped,omitempty"` // true if label was not printed
|
||||
}
|
||||
|
||||
// ServeHTTP implements http.Handler for the intake endpoint.
|
||||
|
|
@ -239,6 +251,31 @@ func (h *IntakeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||
// Non-fatal
|
||||
}
|
||||
|
||||
// Auto-print label (non-fatal: printer errors skip printing but don't fail intake).
|
||||
// T-04-13: print called once per intake, synchronous, within its own error boundary.
|
||||
printSkipped := true
|
||||
if h.printer != nil {
|
||||
labelData := labels.LabelData{
|
||||
HWID: hwid,
|
||||
Name: strings.TrimSpace(result.Manufacturer + " " + result.Model),
|
||||
SpecLine: result.AINotes, // AINotes is the best single-line summary available
|
||||
}
|
||||
if labelData.Name == "" {
|
||||
labelData.Name = hwid
|
||||
}
|
||||
img, renderErr := labels.RenderStandard(labelData)
|
||||
if renderErr == nil {
|
||||
bitmap, bmpW, bmpH := printer.ImageToRawBitmap(img)
|
||||
if printErr := h.printer.Print(bitmap, bmpW, bmpH); printErr == nil {
|
||||
printSkipped = false
|
||||
} else {
|
||||
log.Printf("intake: auto-print skipped: %v", printErr)
|
||||
}
|
||||
} else {
|
||||
log.Printf("intake: label render failed: %v", renderErr)
|
||||
}
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusCreated, IntakeResponse{
|
||||
HWID: hwid,
|
||||
Model: result.Model,
|
||||
|
|
@ -250,6 +287,7 @@ func (h *IntakeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||
Confidence: result.Confidence,
|
||||
CatalogStatus: string(status),
|
||||
NetBoxID: deviceID,
|
||||
PrintSkipped: printSkipped,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -136,7 +136,19 @@ func defaultNetBox() *mockNetBox {
|
|||
}
|
||||
|
||||
func newHandler(orch handlers.IntakeOrchestrator, nb handlers.IntakeNetBoxClient, cu handlers.IntakeCatalogUpdater, w handlers.IntakeWAQ, quickAdd bool, quickThresh float64) *handlers.IntakeHandler {
|
||||
return handlers.NewIntakeHandler(orch, nb, cu, w, 1, 1, 1, quickAdd, quickThresh)
|
||||
return handlers.NewIntakeHandler(orch, nb, cu, w, 1, 1, 1, quickAdd, quickThresh, nil)
|
||||
}
|
||||
|
||||
// --- Mock printer ---
|
||||
|
||||
type mockPrinter struct {
|
||||
returnErr error
|
||||
called bool
|
||||
}
|
||||
|
||||
func (m *mockPrinter) Print(bitmap []byte, w, h int) error {
|
||||
m.called = true
|
||||
return m.returnErr
|
||||
}
|
||||
|
||||
// --- Tests ---
|
||||
|
|
@ -266,3 +278,100 @@ func TestIntakeHandlerNetBoxDown(t *testing.T) {
|
|||
t.Errorf("expected 1 op enqueued, got %d", len(waq.enqueued))
|
||||
}
|
||||
}
|
||||
|
||||
// TestIntakePrinterSuccess: mock printer returns nil — print_skipped=false, 201.
|
||||
func TestIntakePrinterSuccess(t *testing.T) {
|
||||
orch := defaultOrchResult(0.95)
|
||||
nb := defaultNetBox()
|
||||
p := &mockPrinter{}
|
||||
h := handlers.NewIntakeHandler(orch, nb, &mockCatalogUpdater{}, &mockWAQ{}, 1, 1, 1, false, 0.90, p)
|
||||
|
||||
req := buildMultipartRequest(t, 1)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Errorf("expected 201, got %d: body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var resp handlers.IntakeResponse
|
||||
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if resp.PrintSkipped {
|
||||
t.Error("expected print_skipped=false when printer succeeds")
|
||||
}
|
||||
if !p.called {
|
||||
t.Error("expected printer.Print to be called")
|
||||
}
|
||||
}
|
||||
|
||||
// TestIntakePrinterErrNoDevice: mock printer returns ErrNoDevice — print_skipped=true, still 201.
|
||||
func TestIntakePrinterErrNoDevice(t *testing.T) {
|
||||
orch := defaultOrchResult(0.95)
|
||||
nb := defaultNetBox()
|
||||
p := &mockPrinter{returnErr: fmt.Errorf("printer: no device found matching VID/PID")}
|
||||
h := handlers.NewIntakeHandler(orch, nb, &mockCatalogUpdater{}, &mockWAQ{}, 1, 1, 1, false, 0.90, p)
|
||||
|
||||
req := buildMultipartRequest(t, 1)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Errorf("expected 201, got %d: body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var resp handlers.IntakeResponse
|
||||
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if !resp.PrintSkipped {
|
||||
t.Error("expected print_skipped=true when printer returns error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestIntakeNilPrinter: nil printer — print_skipped=true, no panic, 201.
|
||||
func TestIntakeNilPrinter(t *testing.T) {
|
||||
orch := defaultOrchResult(0.95)
|
||||
nb := defaultNetBox()
|
||||
h := handlers.NewIntakeHandler(orch, nb, &mockCatalogUpdater{}, &mockWAQ{}, 1, 1, 1, false, 0.90, nil)
|
||||
|
||||
req := buildMultipartRequest(t, 1)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Errorf("expected 201, got %d: body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var resp handlers.IntakeResponse
|
||||
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if !resp.PrintSkipped {
|
||||
t.Error("expected print_skipped=true when printer is nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestIntakePrinterErrorNotFatal: printer error does not cause 500 — device was created, 201 returned.
|
||||
func TestIntakePrinterErrorNotFatal(t *testing.T) {
|
||||
orch := defaultOrchResult(0.95)
|
||||
nb := defaultNetBox()
|
||||
p := &mockPrinter{returnErr: fmt.Errorf("some unexpected printer error")}
|
||||
h := handlers.NewIntakeHandler(orch, nb, &mockCatalogUpdater{}, &mockWAQ{}, 1, 1, 1, false, 0.90, p)
|
||||
|
||||
req := buildMultipartRequest(t, 1)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Errorf("expected 201 even with printer error, got %d: body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var resp handlers.IntakeResponse
|
||||
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if !resp.PrintSkipped {
|
||||
t.Error("expected print_skipped=true when printer returns error")
|
||||
}
|
||||
if resp.NetBoxID == 0 {
|
||||
t.Error("expected netbox_id to be set (device was created)")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue