From 2432a34198bbb6469ed23cbe884d6e284ca1bd13 Mon Sep 17 00:00:00 2001 From: simon Date: Tue, 26 May 2026 18:27:37 +0200 Subject: [PATCH] Added Printjobs --- go.mod | 1 + go.sum | 2 + internal/itemimage/embed.go | 57 ++++ internal/itemimage/embed_test.go | 78 ++++++ internal/itemimage/prepare.go | 84 ++++++ internal/model/printjob.go | 36 +++ internal/paths/paths.go | 1 + internal/platepdf/composite.go | 29 +- internal/platepdf/platepdf.go | 17 ++ internal/platepdf/platepdf_test.go | 7 +- internal/printjob/assign.go | 63 +++++ internal/printjob/assign_test.go | 56 ++++ internal/printjob/pdf.go | 124 +++++++++ internal/server/admin/static/index.html | 354 +++++++++++++++++++++++- internal/server/api/api.go | 2 + internal/server/api/printjob.go | 255 +++++++++++++++++ internal/store/printjob.go | 97 +++++++ 17 files changed, 1255 insertions(+), 8 deletions(-) create mode 100644 internal/itemimage/embed.go create mode 100644 internal/itemimage/embed_test.go create mode 100644 internal/itemimage/prepare.go create mode 100644 internal/model/printjob.go create mode 100644 internal/printjob/assign.go create mode 100644 internal/printjob/assign_test.go create mode 100644 internal/printjob/pdf.go create mode 100644 internal/server/api/printjob.go create mode 100644 internal/store/printjob.go diff --git a/go.mod b/go.mod index d245946..b4bbf91 100644 --- a/go.mod +++ b/go.mod @@ -10,4 +10,5 @@ require ( require ( github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/spf13/pflag v1.0.9 // indirect + golang.org/x/image v0.41.0 // indirect ) diff --git a/go.sum b/go.sum index fd37884..265dec1 100644 --- a/go.sum +++ b/go.sum @@ -9,4 +9,6 @@ github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiT github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/image v0.41.0 h1:8wS72eGJMJaBxK6okTzd4WaXumUlTVlb753MlsSvTCo= +golang.org/x/image v0.41.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/itemimage/embed.go b/internal/itemimage/embed.go new file mode 100644 index 0000000..374504b --- /dev/null +++ b/internal/itemimage/embed.go @@ -0,0 +1,57 @@ +package itemimage + +import ( + "encoding/base64" + "fmt" + "net/http" + "regexp" + "strings" + + "printer.backend/internal/model" + "printer.backend/internal/svgtemplate" +) + +var maskGroupRE = regexp.MustCompile(`(?s)().*?()`) + +// Embed replaces the green placeholder in an item SVG with the given raster image, +// clipped to the product mask (same area as the green preview rect). +func Embed(itemSVG []byte, spec model.ItemSpec, imageData []byte, contentType string) ([]byte, error) { + if len(imageData) == 0 { + return nil, fmt.Errorf("empty image data") + } + if err := spec.Normalize(); err != nil { + return nil, err + } + + d := svgtemplate.Build( + spec.WidthMM, spec.HeightMM, + spec.BleedMM, spec.MarginMM, spec.PaddingMM, + spec.CornerRadiusMM, + ) + prepared, mime, err := prepareRaster(imageData, d) + if err != nil { + return nil, err + } + if mime == "" { + mime = normalizeMIME(contentType, prepared) + } + href := fmt.Sprintf("data:%s;base64,%s", mime, base64.StdEncoding.EncodeToString(prepared)) + + inner := fmt.Sprintf( + ``, + d.OuterOffsetX, d.OuterOffsetY, d.OuterWidth, d.OuterHeight, href, + ) + + out := maskGroupRE.ReplaceAll(itemSVG, []byte("${1}"+inner+"${2}")) + if string(out) == string(itemSVG) { + return nil, fmt.Errorf("item svg has no printable mask group") + } + return out, nil +} + +func normalizeMIME(contentType string, data []byte) string { + if ct := strings.TrimSpace(strings.Split(contentType, ";")[0]); ct != "" { + return ct + } + return http.DetectContentType(data) +} diff --git a/internal/itemimage/embed_test.go b/internal/itemimage/embed_test.go new file mode 100644 index 0000000..eee7bd4 --- /dev/null +++ b/internal/itemimage/embed_test.go @@ -0,0 +1,78 @@ +package itemimage + +import ( + "bytes" + "image" + "image/color" + "image/png" + "strings" + "testing" + + "printer.backend/internal/model" +) + +const sampleItemSVG = ` + + + + + + + + + +` + +func testPNG(w, h int) []byte { + img := image.NewRGBA(image.Rect(0, 0, w, h)) + for y := 0; y < h; y++ { + for x := 0; x < w; x++ { + img.Set(x, y, color.RGBA{uint8(x % 256), uint8(y % 256), 128, 255}) + } + } + var buf bytes.Buffer + _ = png.Encode(&buf, img) + return buf.Bytes() +} + +func TestEmbedReplacesGreenMask(t *testing.T) { + spec := model.ItemSpec{WidthMM: 80, HeightMM: 80, BleedMM: 2, MarginMM: 5, PaddingMM: 3} + out, err := Embed([]byte(sampleItemSVG), spec, testPNG(1, 1), "image/png") + if err != nil { + t.Fatal(err) + } + s := string(out) + if strings.Contains(s, "#00FF00") || strings.Contains(s, "#00ff00") { + t.Fatal("green placeholder should be replaced") + } + if !strings.Contains(s, ` maxSVG { + t.Fatalf("embedded svg too large: %d bytes", len(out)) + } +} + +func TestEmbedInvalidSVG(t *testing.T) { + spec := model.ItemSpec{WidthMM: 80, HeightMM: 80, BleedMM: 2, MarginMM: 5} + _, err := Embed([]byte(""), spec, testPNG(1, 1), "image/png") + if err == nil { + t.Fatal("expected error") + } +} diff --git a/internal/itemimage/prepare.go b/internal/itemimage/prepare.go new file mode 100644 index 0000000..4d00543 --- /dev/null +++ b/internal/itemimage/prepare.go @@ -0,0 +1,84 @@ +package itemimage + +import ( + "bytes" + "fmt" + "image" + "image/jpeg" + "math" + + "golang.org/x/image/draw" + _ "golang.org/x/image/webp" + + "printer.backend/internal/svgtemplate" +) + +// EmbedDPI matches plate PDF rasterization; embedded pixels need not exceed this resolution. +const EmbedDPI = 300 + +const jpegEmbedQuality = 88 + +// prepareRaster decodes and downscales image data so the resulting SVG stays small enough for rsvg-convert. +func prepareRaster(data []byte, d svgtemplate.Data) ([]byte, string, error) { + img, _, err := image.Decode(bytes.NewReader(data)) + if err != nil { + return nil, "", fmt.Errorf("decode image: %w", err) + } + + maxW := mmToPx(d.OuterWidth) + maxH := mmToPx(d.OuterHeight) + if maxW < 1 { + maxW = 1 + } + if maxH < 1 { + maxH = 1 + } + + covered := scaleCover(img, maxW, maxH) + rgba := image.NewNRGBA(image.Rect(0, 0, maxW, maxH)) + draw.CatmullRom.Scale(rgba, rgba.Bounds(), covered, covered.Bounds(), draw.Over, nil) + + var buf bytes.Buffer + if err := jpeg.Encode(&buf, rgba, &jpeg.Options{Quality: jpegEmbedQuality}); err != nil { + return nil, "", fmt.Errorf("encode jpeg: %w", err) + } + return buf.Bytes(), "image/jpeg", nil +} + +func mmToPx(mm float64) int { + return int(math.Ceil(mm / 25.4 * EmbedDPI)) +} + +// scaleCover returns an image scaled to cover dw×dh (center crop), for preserveAspectRatio slice. +func scaleCover(src image.Image, dw, dh int) image.Image { + sb := src.Bounds() + sw, sh := sb.Dx(), sb.Dy() + if sw <= 0 || sh <= 0 { + return image.NewNRGBA(image.Rect(0, 0, dw, dh)) + } + + scale := math.Max(float64(dw)/float64(sw), float64(dh)/float64(sh)) + nw := int(math.Ceil(float64(sw) * scale)) + nh := int(math.Ceil(float64(sh) * scale)) + + scaled := image.NewNRGBA(image.Rect(0, 0, nw, nh)) + draw.CatmullRom.Scale(scaled, scaled.Bounds(), src, sb, draw.Over, nil) + + x0 := (nw - dw) / 2 + y0 := (nh - dh) / 2 + if x0 < 0 { + x0 = 0 + } + if y0 < 0 { + y0 = 0 + } + if nw < dw { + dw = nw + } + if nh < dh { + dh = nh + } + cropped := image.NewNRGBA(image.Rect(0, 0, dw, dh)) + draw.Draw(cropped, cropped.Bounds(), scaled, image.Point{x0, y0}, draw.Src) + return cropped +} diff --git a/internal/model/printjob.go b/internal/model/printjob.go new file mode 100644 index 0000000..61187c1 --- /dev/null +++ b/internal/model/printjob.go @@ -0,0 +1,36 @@ +package model + +import "time" + +// PrintJob groups a configuration with one or more orders for plate printing. +type PrintJob struct { + ID string `json:"id"` + Name string `json:"name,omitempty"` + ConfigurationID string `json:"configuration_id"` + OrderIDs []string `json:"order_ids"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// PrintJobImageRef identifies one order image assigned to a layout slot. +type PrintJobImageRef struct { + OrderID string `json:"order_id"` + ImageID string `json:"image_id"` +} + +// PrintJobSlotAssignment maps one plate item position to a customer image. +type PrintJobSlotAssignment struct { + Slot int `json:"slot"` + Image PrintJobImageRef `json:"image"` + Position LayoutPosition `json:"position"` +} + +// PrintJobSummary describes image distribution and validation for a print job. +type PrintJobSummary struct { + ImageCount int `json:"image_count"` + SlotCount int `json:"slot_count"` + ImageOverflow bool `json:"image_overflow"` + Warning string `json:"warning,omitempty"` + Assignments []PrintJobSlotAssignment `json:"assignments"` + Preview LayoutPreview `json:"preview"` +} diff --git a/internal/paths/paths.go b/internal/paths/paths.go index 816f31b..5b58807 100644 --- a/internal/paths/paths.go +++ b/internal/paths/paths.go @@ -7,4 +7,5 @@ const ( SVGTemplateDir = "data/svg_template" ConfigurationsDir = "data/configurations" OrdersDir = "data/orders" + PrintJobsDir = "data/print_jobs" ) diff --git a/internal/platepdf/composite.go b/internal/platepdf/composite.go index 5eac0e4..42bddae 100644 --- a/internal/platepdf/composite.go +++ b/internal/platepdf/composite.go @@ -13,6 +13,20 @@ const renderDPI = 300 // BuildCompositeSVG assembles a plate-sized SVG by embedding each item as a PNG // rendered by rsvg-convert (identical to the standalone item file). func BuildCompositeSVG(plate model.Plate, spec model.ItemSpec, itemSVG []byte, preview model.LayoutPreview) ([]byte, error) { + slotSVGs := make([][]byte, len(preview.Positions)) + for i := range preview.Positions { + slotSVGs[i] = itemSVG + } + return BuildCompositeSVGSlots(plate, spec, slotSVGs, preview) +} + +// BuildCompositeSVGSlots places one rendered item PNG per layout position. +// len(slotSVGs) must equal len(preview.Positions). +func BuildCompositeSVGSlots(plate model.Plate, spec model.ItemSpec, slotSVGs [][]byte, preview model.LayoutPreview) ([]byte, error) { + if len(slotSVGs) != len(preview.Positions) { + return nil, fmt.Errorf("slot svg count %d does not match positions %d", len(slotSVGs), len(preview.Positions)) + } + canvasW := preview.CanvasWidthMM canvasH := preview.CanvasHeightMM if canvasW <= 0 || canvasH <= 0 { @@ -20,11 +34,14 @@ func BuildCompositeSVG(plate model.Plate, spec model.ItemSpec, itemSVG []byte, p canvasH = spec.CanvasHeightMM() } - itemPNG, err := svgToPNGViaRsvg(itemSVG, renderDPI) - if err != nil { - return nil, fmt.Errorf("render item: %w", err) + slotPNGs := make([]string, len(slotSVGs)) + for i, svg := range slotSVGs { + itemPNG, err := svgToPNGViaRsvg(svg, renderDPI) + if err != nil { + return nil, fmt.Errorf("render item slot %d: %w", i, err) + } + slotPNGs[i] = base64.StdEncoding.EncodeToString(itemPNG) } - itemB64 := base64.StdEncoding.EncodeToString(itemPNG) var b bytes.Buffer fmt.Fprintf(&b, ` @@ -37,10 +54,10 @@ func BuildCompositeSVG(plate model.Plate, spec model.ItemSpec, itemSVG []byte, p preview.PrintableXMM, preview.PrintableYMM, preview.PrintableWMM, preview.PrintableHMM, ) - for _, pos := range preview.Positions { + for i, pos := range preview.Positions { fmt.Fprintf(&b, ``, - pos.XMM, pos.YMM, canvasW, canvasH, itemB64, + pos.XMM, pos.YMM, canvasW, canvasH, slotPNGs[i], ) } diff --git a/internal/platepdf/platepdf.go b/internal/platepdf/platepdf.go index f4e7f8a..de8f70c 100644 --- a/internal/platepdf/platepdf.go +++ b/internal/platepdf/platepdf.go @@ -30,3 +30,20 @@ func Generate(plate model.Plate, spec model.ItemSpec, itemSVGPath string, previe } return pdf, nil } + +// GenerateWithSlots builds a PDF with a distinct item SVG per layout slot. +// Requires rsvg-convert on PATH. +func GenerateWithSlots(plate model.Plate, spec model.ItemSpec, slotSVGs [][]byte, preview model.LayoutPreview) ([]byte, error) { + if !rsvgAvailable() { + return nil, fmt.Errorf("rsvg-convert not found in PATH (required for PDF export)") + } + composite, err := BuildCompositeSVGSlots(plate, spec, slotSVGs, preview) + if err != nil { + return nil, err + } + pdf, err := svgToPDFViaRsvg(composite, plate.WidthMM, plate.HeightMM) + if err != nil { + return nil, fmt.Errorf("render plate pdf: %w", err) + } + return pdf, nil +} diff --git a/internal/platepdf/platepdf_test.go b/internal/platepdf/platepdf_test.go index c8e9191..7edfbf6 100644 --- a/internal/platepdf/platepdf_test.go +++ b/internal/platepdf/platepdf_test.go @@ -64,7 +64,12 @@ func TestBuildCompositeSVG(t *testing.T) { func TestBuildCompositeSVGInvalid(t *testing.T) { requireRsvg(t) - _, err := BuildCompositeSVG(model.Plate{}, model.ItemSpec{}, []byte("not svg"), model.LayoutPreview{}) + preview := model.LayoutPreview{ + Positions: []model.LayoutPosition{{XMM: 0, YMM: 0}}, + CanvasWidthMM: 10, + CanvasHeightMM: 10, + } + _, err := BuildCompositeSVG(model.Plate{}, model.ItemSpec{WidthMM: 10, HeightMM: 10}, []byte("not svg"), preview) if err == nil { t.Fatal("expected error for invalid svg") } diff --git a/internal/printjob/assign.go b/internal/printjob/assign.go new file mode 100644 index 0000000..d370dd8 --- /dev/null +++ b/internal/printjob/assign.go @@ -0,0 +1,63 @@ +package printjob + +import ( + "fmt" + + "printer.backend/internal/model" +) + +// CollectImageRefs returns all order images in job order (orders, then images within each order). +func CollectImageRefs(orders []model.Order) []model.PrintJobImageRef { + var refs []model.PrintJobImageRef + for _, o := range orders { + for _, img := range o.Images { + refs = append(refs, model.PrintJobImageRef{ + OrderID: o.ID, + ImageID: img.ID, + }) + } + } + return refs +} + +// AssignSlots maps images to layout slots (at most one image per slot). +func AssignSlots(refs []model.PrintJobImageRef, preview model.LayoutPreview) []model.PrintJobSlotAssignment { + limit := len(preview.Positions) + if len(refs) < limit { + limit = len(refs) + } + out := make([]model.PrintJobSlotAssignment, 0, limit) + for i := 0; i < limit; i++ { + out = append(out, model.PrintJobSlotAssignment{ + Slot: i, + Image: refs[i], + Position: preview.Positions[i], + }) + } + return out +} + +// Summary builds validation and slot assignments for a print job. +func Summary(orders []model.Order, preview model.LayoutPreview) model.PrintJobSummary { + refs := CollectImageRefs(orders) + slots := preview.Count + imageCount := len(refs) + overflow := imageCount > slots + + var warning string + if overflow { + warning = fmt.Sprintf( + "%d Bilder für %d Item-Positionen: %d Bilder werden nicht platziert", + imageCount, slots, imageCount-slots, + ) + } + + return model.PrintJobSummary{ + ImageCount: imageCount, + SlotCount: slots, + ImageOverflow: overflow, + Warning: warning, + Assignments: AssignSlots(refs, preview), + Preview: preview, + } +} diff --git a/internal/printjob/assign_test.go b/internal/printjob/assign_test.go new file mode 100644 index 0000000..b2989eb --- /dev/null +++ b/internal/printjob/assign_test.go @@ -0,0 +1,56 @@ +package printjob + +import ( + "strings" + "testing" + + "printer.backend/internal/layout" + "printer.backend/internal/model" +) + +func TestSummaryOverflowWarning(t *testing.T) { + plate := model.Plate{ + WidthMM: 100, HeightMM: 100, + MarginTop: 5, MarginRight: 5, MarginBottom: 5, MarginLeft: 5, + } + spec := model.ItemSpec{WidthMM: 80, HeightMM: 80, BleedMM: 2, MarginMM: 5, PaddingMM: 3} + preview := layout.Pack(plate, spec, 0) + + orders := []model.Order{ + {ID: "o1", Images: []model.OrderImage{{ID: "a"}, {ID: "b"}}}, + {ID: "o2", Images: []model.OrderImage{{ID: "c"}}}, + } + s := Summary(orders, preview) + if preview.Count == 0 { + if s.ImageCount != 3 { + t.Fatalf("expected 3 images, got %d", s.ImageCount) + } + return + } + if preview.Count >= 3 { + if s.ImageOverflow { + t.Fatal("expected no overflow") + } + return + } + if !s.ImageOverflow || s.Warning == "" { + t.Fatalf("expected overflow warning, got overflow=%v warning=%q", s.ImageOverflow, s.Warning) + } + if !strings.Contains(s.Warning, "Bilder") { + t.Fatalf("unexpected warning: %s", s.Warning) + } + if len(s.Assignments) != preview.Count { + t.Fatalf("expected %d assignments, got %d", preview.Count, len(s.Assignments)) + } +} + +func TestCollectImageRefsOrder(t *testing.T) { + orders := []model.Order{ + {ID: "o1", Images: []model.OrderImage{{ID: "a"}, {ID: "b"}}}, + {ID: "o2", Images: []model.OrderImage{{ID: "c"}}}, + } + refs := CollectImageRefs(orders) + if len(refs) != 3 || refs[0].ImageID != "a" || refs[2].OrderID != "o2" { + t.Fatalf("unexpected refs: %+v", refs) + } +} diff --git a/internal/printjob/pdf.go b/internal/printjob/pdf.go new file mode 100644 index 0000000..ccec5a8 --- /dev/null +++ b/internal/printjob/pdf.go @@ -0,0 +1,124 @@ +package printjob + +import ( + "fmt" + "os" + + "printer.backend/internal/itemimage" + "printer.backend/internal/layout" + "printer.backend/internal/model" + "printer.backend/internal/platepdf" + "printer.backend/internal/store" +) + +// RenderPDF builds a plate PDF with customer images placed in each item mask. +func RenderPDF( + job model.PrintJob, + configs *store.ConfigurationStore, + plates *store.PlateStore, + items *store.ItemStore, + orders *store.OrderStore, +) ([]byte, model.PrintJobSummary, error) { + cfg, err := configs.Get(job.ConfigurationID) + if err != nil { + return nil, model.PrintJobSummary{}, fmt.Errorf("configuration: %w", err) + } + + plate, err := findPlate(plates, cfg.PlateID) + if err != nil { + return nil, model.PrintJobSummary{}, err + } + item, err := items.Get(cfg.ItemID) + if err != nil { + return nil, model.PrintJobSummary{}, fmt.Errorf("item: %w", err) + } + itemSVGPath, err := items.SVGPath(cfg.ItemID) + if err != nil { + return nil, model.PrintJobSummary{}, err + } + itemSVG, err := os.ReadFile(itemSVGPath) + if err != nil { + return nil, model.PrintJobSummary{}, fmt.Errorf("read item svg: %w", err) + } + + orderList, err := loadOrders(orders, job.OrderIDs) + if err != nil { + return nil, model.PrintJobSummary{}, err + } + + preview := layout.Pack(plate, item.Spec, cfg.SpacingMM) + preview.PlateID = cfg.PlateID + preview.ItemID = cfg.ItemID + summary := Summary(orderList, preview) + + slotSVGs, err := buildSlotSVGs(itemSVG, item.Spec, preview, summary.Assignments, orders) + if err != nil { + return nil, summary, err + } + + pdf, err := platepdf.GenerateWithSlots(plate, item.Spec, slotSVGs, preview) + if err != nil { + return nil, summary, err + } + return pdf, summary, nil +} + +func buildSlotSVGs( + templateSVG []byte, + spec model.ItemSpec, + preview model.LayoutPreview, + assignments []model.PrintJobSlotAssignment, + orders *store.OrderStore, +) ([][]byte, error) { + slotSVGs := make([][]byte, len(preview.Positions)) + for i := range slotSVGs { + slotSVGs[i] = templateSVG + } + for _, a := range assignments { + if a.Slot < 0 || a.Slot >= len(slotSVGs) { + return nil, fmt.Errorf("slot %d out of range", a.Slot) + } + path, img, err := orders.ImagePath(a.Image.OrderID, a.Image.ImageID) + if err != nil { + return nil, fmt.Errorf("slot %d: %w", a.Slot, err) + } + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("slot %d: read image: %w", a.Slot, err) + } + embedded, err := itemimage.Embed(templateSVG, spec, data, img.ContentType) + if err != nil { + return nil, fmt.Errorf("slot %d: %w", a.Slot, err) + } + slotSVGs[a.Slot] = embedded + } + return slotSVGs, nil +} + +func loadOrders(orders *store.OrderStore, ids []string) ([]model.Order, error) { + if len(ids) == 0 { + return nil, fmt.Errorf("at least one order_id is required") + } + out := make([]model.Order, 0, len(ids)) + for _, id := range ids { + o, err := orders.Get(id) + if err != nil { + return nil, fmt.Errorf("order %s: %w", id, err) + } + out = append(out, o) + } + return out, nil +} + +func findPlate(plates *store.PlateStore, id string) (model.Plate, error) { + list, err := plates.List() + if err != nil { + return model.Plate{}, err + } + for _, p := range list { + if p.ID == id { + return p, nil + } + } + return model.Plate{}, fmt.Errorf("plate not found: %s", id) +} diff --git a/internal/server/admin/static/index.html b/internal/server/admin/static/index.html index af3f8b9..43cce40 100644 --- a/internal/server/admin/static/index.html +++ b/internal/server/admin/static/index.html @@ -197,6 +197,19 @@ grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); } .msg-err { color: var(--err); font-size: 0.85rem; margin-top: 0.75rem; } + .msg-warn { color: #f59e0b; font-size: 0.85rem; margin-top: 0.75rem; } + .print-job-orders { + margin-top: 0.75rem; + max-height: 280px; + overflow-y: auto; + border: 1px solid var(--border); + border-radius: 8px; + padding: 0.5rem 0.75rem; + background: var(--bg); + } + .print-job-orders .form-check { + margin: 0.35rem 0; + } .msg-ok { color: var(--ok); font-size: 0.85rem; margin-top: 0.75rem; } .item-preview-panel { margin-bottom: 1.25rem; @@ -470,6 +483,29 @@ @click="newOrder()">+ New + + + +
+
+

+

Konfiguration und Orders kombinieren; Bilder werden auf die Item-Positionen verteilt.

+
+
+
+
+
+ + +
+
+ + +
+
+ + +

Zuerst Orders anlegen und Bilder hochladen.

+ + +
+ + + +
+

+

+

+
+ + +

+ Keine Layout-Vorschau für diese Konfiguration. +

+

Berechne Zuordnung…

+
+
+
@@ -749,7 +856,7 @@ apiOk: null, section: 'plates', - expanded: { plates: true, items: false, configurations: false, orders: false }, + expanded: { plates: true, items: false, configurations: false, orders: false, printJobs: false }, plates: [], platesLoading: false, @@ -812,6 +919,18 @@ orderImageVersion: 0, orderLightbox: null, + printJobs: [], + printJobsLoading: false, + printJobSaving: false, + printJobPdfGenerating: false, + printJobError: '', + printJobSuccess: '', + printJobEditingId: null, + printJobForm: { name: '', configuration_id: '', order_ids: [] }, + printJobSummary: null, + printJobSummaryLoading: false, + _printJobSummaryTimer: null, + openSection(name) { if (this.section === name) { this.expanded[name] = !this.expanded[name]; @@ -869,6 +988,18 @@ this.cancelOrderEdit(); }, + async selectPrintJob(j) { + this.section = 'printJobs'; + this.expanded.printJobs = true; + await this.editPrintJob(j); + }, + + newPrintJob() { + this.section = 'printJobs'; + this.expanded.printJobs = true; + this.cancelPrintJobEdit(); + }, + sidebarPlateLabel(p) { const n = p.name || 'Platte'; return n + ' · ' + this.fmtSize(p.width_mm, p.height_mm); @@ -889,6 +1020,32 @@ return parts.length ? n + ' · ' + parts.join(' · ') : n; }, + sidebarPrintJobLabel(j) { + const n = j.name || ('Print-Job ' + j.id.slice(0, 8)); + const parts = []; + const cfg = this.configurations.find(c => c.id === j.configuration_id); + if (cfg) parts.push(this.sidebarConfigLabel(cfg)); + const orderCount = (j.order_ids || []).length; + if (orderCount) parts.push(orderCount + ' Order' + (orderCount === 1 ? '' : 's')); + const s = j.summary; + if (s?.image_count != null) parts.push(s.image_count + ' Bilder'); + if (s?.image_overflow) parts.push('!'); + return parts.length ? n + ' · ' + parts.join(' · ') : n; + }, + + isPrintJobOrderSelected(orderId) { + return (this.printJobForm.order_ids || []).includes(orderId); + }, + + togglePrintJobOrder(orderId) { + const ids = [...(this.printJobForm.order_ids || [])]; + const i = ids.indexOf(orderId); + if (i >= 0) ids.splice(i, 1); + else ids.push(orderId); + this.printJobForm.order_ids = ids; + this.refreshPrintJobSummary(); + }, + orderFormFrom(o) { return { name: o.name || '', @@ -951,6 +1108,7 @@ this.loadItems(), this.loadConfigurations(), this.loadOrders(), + this.loadPrintJobs(), ]); } }, @@ -1523,11 +1681,205 @@ } await this.loadOrders(); if (this.orderEditingId === id) this.cancelOrderEdit(); + if (this.section === 'printJobs') this.refreshPrintJobSummary(); } catch (e) { this.orderError = String(e.message || e); } }, + async loadPrintJobs() { + this.printJobsLoading = true; + try { + this.printJobs = await this.apiJSON('GET', '/print-jobs') || []; + } catch (e) { + this.printJobError = String(e.message || e); + } finally { + this.printJobsLoading = false; + } + }, + + defaultPrintJobForm() { + return { name: '', configuration_id: '', order_ids: [] }; + }, + + async editPrintJob(j) { + this.printJobEditingId = j.id; + this.printJobForm = { + name: j.name || '', + configuration_id: j.configuration_id || '', + order_ids: [...(j.order_ids || [])], + }; + this.printJobError = ''; + this.printJobSuccess = ''; + try { + const fresh = await this.apiJSON('GET', '/print-jobs/' + j.id); + this.printJobForm = { + name: fresh.name || '', + configuration_id: fresh.configuration_id || '', + order_ids: [...(fresh.order_ids || [])], + }; + if (fresh.summary) this.printJobSummary = fresh.summary; + } catch (e) { + this.printJobError = String(e.message || e); + } + await this.refreshPrintJobSummary(); + }, + + cancelPrintJobEdit() { + this.printJobEditingId = null; + this.printJobForm = this.defaultPrintJobForm(); + this.printJobSummary = null; + this.printJobError = ''; + this.printJobSuccess = ''; + }, + + async savePrintJob() { + this.printJobSaving = true; + this.printJobError = ''; + this.printJobSuccess = ''; + const body = { + name: this.printJobForm.name, + configuration_id: this.printJobForm.configuration_id, + order_ids: [...(this.printJobForm.order_ids || [])], + }; + try { + let saved; + if (this.printJobEditingId) { + saved = await this.apiJSON('PUT', '/print-jobs/' + this.printJobEditingId, body); + this.printJobSuccess = 'Print-Job gespeichert.'; + } else { + saved = await this.apiJSON('POST', '/print-jobs', body); + this.printJobSuccess = 'Print-Job angelegt.'; + } + const keepId = saved?.id || this.printJobEditingId; + await this.loadPrintJobs(); + if (keepId) { + const j = this.printJobs.find(x => x.id === keepId); + if (j) await this.editPrintJob(j); + else this.cancelPrintJobEdit(); + } else { + this.cancelPrintJobEdit(); + } + } catch (e) { + this.printJobError = String(e.message || e); + } finally { + this.printJobSaving = false; + } + }, + + async deletePrintJob(id) { + if (!confirm('Print-Job wirklich löschen?')) return; + this.printJobError = ''; + try { + const res = await fetch(this.apiUrl('/print-jobs/' + id), { method: 'DELETE' }); + if (!res.ok) { + const text = await res.text(); + throw new Error(text || res.statusText); + } + await this.loadPrintJobs(); + if (this.printJobEditingId === id) this.cancelPrintJobEdit(); + } catch (e) { + this.printJobError = String(e.message || e); + } + }, + + refreshPrintJobSummary() { + clearTimeout(this._printJobSummaryTimer); + this._printJobSummaryTimer = setTimeout(() => this.fetchPrintJobSummary(), 200); + }, + + printJobFormMatchesSaved() { + if (!this.printJobEditingId) return false; + const saved = this.printJobs.find(j => j.id === this.printJobEditingId); + if (!saved) return false; + const a = [...(this.printJobForm.order_ids || [])].sort().join(','); + const b = [...(saved.order_ids || [])].sort().join(','); + return saved.configuration_id === this.printJobForm.configuration_id && a === b; + }, + + async fetchPrintJobSummary() { + if (!this.printJobForm.configuration_id) { + this.printJobSummary = null; + return; + } + this.printJobSummaryLoading = true; + let serverSummary = null; + try { + if (this.printJobEditingId && this.printJobForm.order_ids?.length && this.printJobFormMatchesSaved()) { + serverSummary = await this.apiJSON('GET', '/print-jobs/' + this.printJobEditingId + '/summary'); + } + } catch (_) {} + this.printJobSummary = this.computePrintJobSummaryLocal(serverSummary); + this.printJobSummaryLoading = false; + }, + + computePrintJobSummaryLocal(serverSummary) { + const cfg = this.configurations.find(c => c.id === this.printJobForm.configuration_id); + const preview = cfg?.preview; + if (!preview) return serverSummary || null; + + const selected = this.orders.filter(o => this.printJobForm.order_ids.includes(o.id)); + let imageCount = 0; + for (const o of selected) { + imageCount += (o.images || []).length; + } + const slots = preview.count || 0; + const overflow = imageCount > slots; + let warning = ''; + if (overflow) { + warning = `${imageCount} Bilder für ${slots} Item-Positionen: ${imageCount - slots} Bilder werden nicht platziert`; + } + const base = serverSummary || {}; + return { + ...base, + image_count: imageCount, + slot_count: slots, + image_overflow: overflow, + warning: warning || base.warning, + preview, + assignments: base.assignments || [], + }; + }, + + async downloadPrintJobPDF() { + if (!this.printJobEditingId) return; + this.printJobPdfGenerating = true; + this.printJobError = ''; + try { + const res = await fetch(this.apiUrl('/print-jobs/' + this.printJobEditingId + '/pdf')); + if (!res.ok) { + let msg = res.statusText; + try { + const err = await res.json(); + if (err.error) msg = err.error; + } catch (_) {} + throw new Error(msg); + } + const warn = res.headers.get('X-Print-Job-Warning'); + if (warn) { + this.printJobSummary = { + ...(this.printJobSummary || {}), + image_overflow: true, + warning: warn, + }; + } + const blob = await res.blob(); + const name = (this.printJobForm.name || 'print-job-' + this.printJobEditingId.slice(0, 8)) + .replace(/[^\w\-]+/g, '_') + '.pdf'; + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = name; + a.click(); + URL.revokeObjectURL(url); + this.printJobSuccess = warn ? 'PDF erzeugt (mit Warnung).' : 'PDF erzeugt.'; + } catch (e) { + this.printJobError = String(e.message || e); + } finally { + this.printJobPdfGenerating = false; + } + }, + refreshLayoutPreview() { clearTimeout(this._layoutTimer); this._layoutTimer = setTimeout(() => this.fetchLayoutPreview(), 200); diff --git a/internal/server/api/api.go b/internal/server/api/api.go index 24d7314..93e513f 100644 --- a/internal/server/api/api.go +++ b/internal/server/api/api.go @@ -17,6 +17,7 @@ func NewHandler() http.Handler { items := store.NewItemStore("") configs := store.NewConfigurationStore("") orders := store.NewOrderStore("") + printJobs := store.NewPrintJobStore("") mux := http.NewServeMux() mux.HandleFunc("GET /health", health) @@ -32,6 +33,7 @@ func NewHandler() http.Handler { mux.HandleFunc("GET /items/{id}/svg", serveItemSVG(items)) registerConfigurationRoutes(mux, configs, plates, items) registerOrderRoutes(mux, orders) + registerPrintJobRoutes(mux, printJobs, configs, plates, items, orders) return withCORS(mux) } diff --git a/internal/server/api/printjob.go b/internal/server/api/printjob.go new file mode 100644 index 0000000..655933a --- /dev/null +++ b/internal/server/api/printjob.go @@ -0,0 +1,255 @@ +package api + +import ( + "encoding/json" + "errors" + "net/http" + "time" + + "printer.backend/internal/model" + "printer.backend/internal/printjob" + "printer.backend/internal/store" +) + +func registerPrintJobRoutes( + mux *http.ServeMux, + jobs *store.PrintJobStore, + configs *store.ConfigurationStore, + plates *store.PlateStore, + items *store.ItemStore, + orders *store.OrderStore, +) { + mux.HandleFunc("GET /print-jobs", listPrintJobs(jobs, configs, plates, items, orders)) + mux.HandleFunc("POST /print-jobs", savePrintJob(jobs, configs, orders)) + mux.HandleFunc("GET /print-jobs/{id}", getPrintJob(jobs, configs, plates, items, orders)) + mux.HandleFunc("PUT /print-jobs/{id}", updatePrintJob(jobs, configs, orders)) + mux.HandleFunc("DELETE /print-jobs/{id}", deletePrintJob(jobs)) + mux.HandleFunc("GET /print-jobs/{id}/summary", printJobSummary(jobs, configs, plates, items, orders)) + mux.HandleFunc("GET /print-jobs/{id}/pdf", printJobPDF(jobs, configs, plates, items, orders)) +} + +type printJobResponse struct { + model.PrintJob + Summary *model.PrintJobSummary `json:"summary,omitempty"` +} + +type savePrintJobRequest struct { + Name string `json:"name"` + ConfigurationID string `json:"configuration_id"` + OrderIDs []string `json:"order_ids"` +} + +func listPrintJobs( + jobs *store.PrintJobStore, + configs *store.ConfigurationStore, + plates *store.PlateStore, + items *store.ItemStore, + orders *store.OrderStore, +) http.HandlerFunc { + return func(w http.ResponseWriter, _ *http.Request) { + list, err := jobs.List() + if err != nil { + writeError(w, http.StatusInternalServerError, err) + return + } + if list == nil { + list = []model.PrintJob{} + } + out := make([]printJobResponse, 0, len(list)) + for _, j := range list { + out = append(out, printJobResponseFor(j, configs, plates, items, orders)) + } + writeJSON(w, http.StatusOK, out) + } +} + +func savePrintJob(jobs *store.PrintJobStore, configs *store.ConfigurationStore, orders *store.OrderStore) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req savePrintJobRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + if err := validatePrintJobRequest(req, configs, orders); err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + + j := model.PrintJob{ + Name: req.Name, + ConfigurationID: req.ConfigurationID, + OrderIDs: req.OrderIDs, + CreatedAt: time.Now().UTC(), + } + saved, err := jobs.Save(j) + if err != nil { + writeError(w, http.StatusInternalServerError, err) + return + } + writeJSON(w, http.StatusCreated, saved) + } +} + +func getPrintJob( + jobs *store.PrintJobStore, + configs *store.ConfigurationStore, + plates *store.PlateStore, + items *store.ItemStore, + orders *store.OrderStore, +) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + j, err := jobs.Get(r.PathValue("id")) + if err != nil { + writeError(w, http.StatusNotFound, err) + return + } + writeJSON(w, http.StatusOK, printJobResponseFor(j, configs, plates, items, orders)) + } +} + +func updatePrintJob(jobs *store.PrintJobStore, configs *store.ConfigurationStore, orders *store.OrderStore) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + var req savePrintJobRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + if err := validatePrintJobRequest(req, configs, orders); err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + + j := model.PrintJob{ + Name: req.Name, + ConfigurationID: req.ConfigurationID, + OrderIDs: req.OrderIDs, + } + saved, err := jobs.Update(id, j) + if err != nil { + writeError(w, http.StatusNotFound, err) + return + } + writeJSON(w, http.StatusOK, saved) + } +} + +func deletePrintJob(jobs *store.PrintJobStore) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if err := jobs.Delete(r.PathValue("id")); err != nil { + writeError(w, http.StatusNotFound, err) + return + } + w.WriteHeader(http.StatusNoContent) + } +} + +func printJobSummary( + jobs *store.PrintJobStore, + configs *store.ConfigurationStore, + plates *store.PlateStore, + items *store.ItemStore, + orders *store.OrderStore, +) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + j, err := jobs.Get(r.PathValue("id")) + if err != nil { + writeError(w, http.StatusNotFound, err) + return + } + summary, err := buildPrintJobSummary(j, configs, plates, items, orders) + if err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + writeJSON(w, http.StatusOK, summary) + } +} + +func printJobPDF( + jobs *store.PrintJobStore, + configs *store.ConfigurationStore, + plates *store.PlateStore, + items *store.ItemStore, + orders *store.OrderStore, +) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + j, err := jobs.Get(r.PathValue("id")) + if err != nil { + writeError(w, http.StatusNotFound, err) + return + } + + pdf, summary, err := printjob.RenderPDF(j, configs, plates, items, orders) + if err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + if summary.ImageOverflow { + w.Header().Set("X-Print-Job-Warning", summary.Warning) + } + name := j.Name + if name == "" { + name = "print-job-" + j.ID[:8] + } + servePDF(w, pdf, sanitizeFilename(name)+".pdf") + } +} + +func validatePrintJobRequest(req savePrintJobRequest, configs *store.ConfigurationStore, orders *store.OrderStore) error { + if req.ConfigurationID == "" { + return errors.New("configuration_id is required") + } + if _, err := configs.Get(req.ConfigurationID); err != nil { + return errors.New("configuration not found: " + req.ConfigurationID) + } + if len(req.OrderIDs) == 0 { + return errors.New("at least one order_id is required") + } + for _, id := range req.OrderIDs { + if _, err := orders.Get(id); err != nil { + return errors.New("order not found: " + id) + } + } + return nil +} + +func printJobResponseFor( + j model.PrintJob, + configs *store.ConfigurationStore, + plates *store.PlateStore, + items *store.ItemStore, + orders *store.OrderStore, +) printJobResponse { + resp := printJobResponse{PrintJob: j} + if summary, err := buildPrintJobSummary(j, configs, plates, items, orders); err == nil { + resp.Summary = &summary + } + return resp +} + +func buildPrintJobSummary( + j model.PrintJob, + configs *store.ConfigurationStore, + plates *store.PlateStore, + items *store.ItemStore, + orders *store.OrderStore, +) (model.PrintJobSummary, error) { + cfg, err := configs.Get(j.ConfigurationID) + if err != nil { + return model.PrintJobSummary{}, err + } + preview, err := buildPreview(plates, items, cfg.PlateID, cfg.ItemID, cfg.SpacingMM) + if err != nil { + return model.PrintJobSummary{}, err + } + orderList := make([]model.Order, 0, len(j.OrderIDs)) + for _, id := range j.OrderIDs { + o, err := orders.Get(id) + if err != nil { + return model.PrintJobSummary{}, err + } + orderList = append(orderList, o) + } + return printjob.Summary(orderList, preview), nil +} diff --git a/internal/store/printjob.go b/internal/store/printjob.go new file mode 100644 index 0000000..7cbce76 --- /dev/null +++ b/internal/store/printjob.go @@ -0,0 +1,97 @@ +package store + +import ( + "fmt" + "path/filepath" + "time" + + "github.com/google/uuid" + "printer.backend/internal/model" + "printer.backend/internal/paths" +) + +// PrintJobStore persists print jobs as JSON files. +type PrintJobStore struct { + dir string +} + +// NewPrintJobStore creates a store under dir (default data/print_jobs). +func NewPrintJobStore(dir string) *PrintJobStore { + if dir == "" { + dir = paths.PrintJobsDir + } + return &PrintJobStore{dir: dir} +} + +// List returns all print jobs sorted by creation time (newest first). +func (s *PrintJobStore) List() ([]model.PrintJob, error) { + return listFromDir(s.dir, + func(name string) bool { return filepath.Ext(name) == ".json" }, + "print jobs dir", + func(j model.PrintJob) time.Time { return j.CreatedAt }, + ) +} + +// Get returns a print job by ID. +func (s *PrintJobStore) Get(id string) (model.PrintJob, error) { + list, err := s.List() + if err != nil { + return model.PrintJob{}, err + } + j, err := findByID(list, id, func(j model.PrintJob) string { return j.ID }) + if err != nil { + return model.PrintJob{}, fmt.Errorf("print job not found: %s", id) + } + if j.OrderIDs == nil { + j.OrderIDs = []string{} + } + return j, nil +} + +// Save writes a new print job and assigns an ID when empty. +func (s *PrintJobStore) Save(j model.PrintJob) (model.PrintJob, error) { + if err := ensureDir(s.dir); err != nil { + return model.PrintJob{}, err + } + if j.ID == "" { + j.ID = uuid.NewString() + } + if j.OrderIDs == nil { + j.OrderIDs = []string{} + } + now := stampNew(&j.CreatedAt) + j.CreatedAt = now + j.UpdatedAt = now + + path := filepath.Join(s.dir, j.ID+".json") + if err := writeJSON(path, j); err != nil { + return model.PrintJob{}, fmt.Errorf("write print job: %w", err) + } + return j, nil +} + +// Update replaces an existing print job (preserves id and created_at). +func (s *PrintJobStore) Update(id string, j model.PrintJob) (model.PrintJob, error) { + existing, err := s.Get(id) + if err != nil { + return model.PrintJob{}, err + } + j.ID = existing.ID + j.CreatedAt = existing.CreatedAt + j.UpdatedAt = time.Now().UTC() + if j.OrderIDs == nil { + j.OrderIDs = []string{} + } + + path := filepath.Join(s.dir, id+".json") + if err := writeJSON(path, j); err != nil { + return model.PrintJob{}, fmt.Errorf("write print job: %w", err) + } + return j, nil +} + +// Delete removes a print job by ID. +func (s *PrintJobStore) Delete(id string) error { + path := filepath.Join(s.dir, id+".json") + return removeFile(path, fmt.Sprintf("print job not found: %s", id)) +}