Added Printjobs
This commit is contained in:
parent
929969d03a
commit
2432a34198
1
go.mod
1
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
|
||||
)
|
||||
|
||||
2
go.sum
2
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=
|
||||
|
||||
57
internal/itemimage/embed.go
Normal file
57
internal/itemimage/embed.go
Normal file
@ -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)(<g clip-path="url\(#item-mask\)">).*?(</g>)`)
|
||||
|
||||
// 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(
|
||||
`<image x="%.4f" y="%.4f" width="%.4f" height="%.4f" preserveAspectRatio="xMidYMid slice" href="%s"/>`,
|
||||
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)
|
||||
}
|
||||
78
internal/itemimage/embed_test.go
Normal file
78
internal/itemimage/embed_test.go
Normal file
@ -0,0 +1,78 @@
|
||||
package itemimage
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/png"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"printer.backend/internal/model"
|
||||
)
|
||||
|
||||
const sampleItemSVG = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-13 -13 106 106">
|
||||
<defs>
|
||||
<clipPath id="item-mask">
|
||||
<rect x="0" y="0" width="80" height="80" rx="5" ry="5" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
<g clip-path="url(#item-mask)">
|
||||
<rect x="-2" y="-2" width="84" height="84" fill="#00FF00" opacity="0.7" />
|
||||
</g>
|
||||
</svg>`
|
||||
|
||||
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, `<image `) || !strings.Contains(s, `preserveAspectRatio="xMidYMid slice"`) {
|
||||
t.Fatalf("expected embedded image, got: %s", s)
|
||||
}
|
||||
if !strings.Contains(s, `x="-2.0000"`) {
|
||||
t.Fatal("expected bleed-aligned image placement")
|
||||
}
|
||||
if !strings.Contains(s, "data:image/jpeg;base64,") {
|
||||
t.Fatal("expected downscaled jpeg data uri")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbedDownscalesLargeImage(t *testing.T) {
|
||||
spec := model.ItemSpec{WidthMM: 80, HeightMM: 80, BleedMM: 2, MarginMM: 5, PaddingMM: 3}
|
||||
huge := testPNG(4000, 3000)
|
||||
out, err := Embed([]byte(sampleItemSVG), spec, huge, "image/png")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const maxSVG = 600_000 // ~600 KB embedded payload is plenty for rsvg
|
||||
if len(out) > 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("<svg></svg>"), spec, testPNG(1, 1), "image/png")
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
}
|
||||
84
internal/itemimage/prepare.go
Normal file
84
internal/itemimage/prepare.go
Normal file
@ -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
|
||||
}
|
||||
36
internal/model/printjob.go
Normal file
36
internal/model/printjob.go
Normal file
@ -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"`
|
||||
}
|
||||
@ -7,4 +7,5 @@ const (
|
||||
SVGTemplateDir = "data/svg_template"
|
||||
ConfigurationsDir = "data/configurations"
|
||||
OrdersDir = "data/orders"
|
||||
PrintJobsDir = "data/print_jobs"
|
||||
)
|
||||
|
||||
@ -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, `<?xml version="1.0" encoding="UTF-8"?>
|
||||
@ -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,
|
||||
`<image x="%.4f" y="%.4f" width="%.4f" height="%.4f" href="data:image/png;base64,%s"/>`,
|
||||
pos.XMM, pos.YMM, canvasW, canvasH, itemB64,
|
||||
pos.XMM, pos.YMM, canvasW, canvasH, slotPNGs[i],
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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")
|
||||
}
|
||||
|
||||
63
internal/printjob/assign.go
Normal file
63
internal/printjob/assign.go
Normal file
@ -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,
|
||||
}
|
||||
}
|
||||
56
internal/printjob/assign_test.go
Normal file
56
internal/printjob/assign_test.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
124
internal/printjob/pdf.go
Normal file
124
internal/printjob/pdf.go
Normal file
@ -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)
|
||||
}
|
||||
@ -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</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="nav-section">
|
||||
<button type="button" class="nav-section-header"
|
||||
:class="{ active: section === 'printJobs' }"
|
||||
@click="openSection('printJobs')">
|
||||
Print-Jobs
|
||||
<span class="nav-chevron" :class="{ open: expanded.printJobs }"></span>
|
||||
</button>
|
||||
<div class="nav-children" x-show="expanded.printJobs">
|
||||
<template x-if="printJobs.length === 0 && !printJobsLoading">
|
||||
<p class="nav-empty">Keine Print-Jobs</p>
|
||||
</template>
|
||||
<template x-for="j in printJobs" :key="j.id">
|
||||
<button type="button" class="nav-item"
|
||||
:class="{ selected: section === 'printJobs' && printJobEditingId === j.id }"
|
||||
@click="selectPrintJob(j)"
|
||||
x-text="sidebarPrintJobLabel(j)"></button>
|
||||
</template>
|
||||
<button type="button" class="nav-item add"
|
||||
:class="{ selected: section === 'printJobs' && !printJobEditingId }"
|
||||
@click="newPrintJob()">+ Neu</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="sidebar-footer">
|
||||
@ -654,6 +690,77 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Print-Jobs -->
|
||||
<div x-show="section === 'printJobs'">
|
||||
<div class="main-header">
|
||||
<h2 x-text="printJobEditingId ? 'Print-Job bearbeiten' : 'Neuer Print-Job'"></h2>
|
||||
<p class="muted">Konfiguration und Orders kombinieren; Bilder werden auf die Item-Positionen verteilt.</p>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<form @submit.prevent="savePrintJob()">
|
||||
<div class="form-grid">
|
||||
<div>
|
||||
<label>Name (optional)</label>
|
||||
<input type="text" x-model="printJobForm.name" placeholder="z.B. Batch Montag">
|
||||
</div>
|
||||
<div>
|
||||
<label>Konfiguration</label>
|
||||
<select x-model="printJobForm.configuration_id" @change="refreshPrintJobSummary()" required>
|
||||
<option value="">— wählen —</option>
|
||||
<template x-for="c in configurations" :key="c.id">
|
||||
<option :value="c.id" x-text="sidebarConfigLabel(c)"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label style="margin-top: 1rem;">Orders</label>
|
||||
<p class="muted" x-show="orders.length === 0">Zuerst Orders anlegen und Bilder hochladen.</p>
|
||||
<div class="print-job-orders" x-show="orders.length > 0">
|
||||
<template x-for="o in orders" :key="o.id">
|
||||
<label class="form-check">
|
||||
<input type="checkbox"
|
||||
:checked="isPrintJobOrderSelected(o.id)"
|
||||
@change="togglePrintJobOrder(o.id)">
|
||||
<span x-text="sidebarOrderLabel(o)"></span>
|
||||
</label>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="btn-row" style="margin-top: 1.25rem;">
|
||||
<button type="submit"
|
||||
:disabled="printJobSaving || !printJobForm.configuration_id || printJobForm.order_ids.length === 0">
|
||||
<span x-text="printJobSaving ? 'Speichere…' : (printJobEditingId ? 'Speichern' : 'Anlegen')"></span>
|
||||
</button>
|
||||
<button type="button" class="secondary" @click="downloadPrintJobPDF()"
|
||||
:disabled="printJobPdfGenerating || !printJobEditingId">
|
||||
<span x-text="printJobPdfGenerating ? 'PDF…' : 'PDF erzeugen'"></span>
|
||||
</button>
|
||||
<button type="button" class="danger" x-show="printJobEditingId" @click="deletePrintJob(printJobEditingId)">Löschen</button>
|
||||
</div>
|
||||
<p class="msg-err" x-show="printJobError" x-text="printJobError"></p>
|
||||
<p class="msg-ok" x-show="printJobSuccess" x-text="printJobSuccess"></p>
|
||||
<p class="msg-warn" x-show="printJobSummary?.image_overflow" x-text="printJobSummary.warning"></p>
|
||||
</form>
|
||||
|
||||
<template x-if="printJobSummary && printJobForm.configuration_id">
|
||||
<div class="layout-preview-wrap" style="margin-top: 1.5rem;">
|
||||
<div x-html="layoutPreviewSvg(printJobSummary.preview)"></div>
|
||||
<div class="layout-stats">
|
||||
<span><strong x-text="printJobSummary.image_count"></strong> Bilder</span>
|
||||
<span><strong x-text="printJobSummary.slot_count"></strong> Item-Positionen</span>
|
||||
<span><strong x-text="printJobSummary.assignments?.length || Math.min(printJobSummary.image_count, printJobSummary.slot_count)"></strong> zugeordnet</span>
|
||||
<span x-show="printJobSummary.image_overflow">Überschuss <strong x-text="printJobSummary.image_count - printJobSummary.slot_count"></strong></span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<p class="muted" x-show="printJobForm.configuration_id && !printJobSummary && !printJobSummaryLoading">
|
||||
Keine Layout-Vorschau für diese Konfiguration.
|
||||
</p>
|
||||
<p class="muted" x-show="printJobSummaryLoading">Berechne Zuordnung…</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Orders -->
|
||||
<div x-show="section === 'orders'">
|
||||
<div class="main-header">
|
||||
@ -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);
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
|
||||
255
internal/server/api/printjob.go
Normal file
255
internal/server/api/printjob.go
Normal file
@ -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
|
||||
}
|
||||
97
internal/store/printjob.go
Normal file
97
internal/store/printjob.go
Normal file
@ -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))
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user