// Package document wires order/client (and, for Phase 3, cartridge-batch) // data into internal/pdfgen and serves the resulting Счёт/Акт PDFs. // Staff-authenticated — same auth-header limitation as photos (see // file.Handler.Get): can't carry a Bearer token, so the frontend // fetches these with JS and downloads/opens the blob. package document import ( "context" "encoding/json" "errors" "fmt" "log" "strconv" "strings" "time" "production/internal/auth" "production/internal/authz" "production/internal/customfields" "production/internal/doctemplates" "production/internal/pdfgen" "production/internal/settings" "github.com/gofiber/fiber/v2" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" ) type Handler struct { db *pgxpool.Pool } func NewHandler(db *pgxpool.Pool) *Handler { return &Handler{db: db} } // loadBusiness reads the service center's own requisites, fetched fresh // per request (not cached at startup) so an edit on the Settings page // applies to the very next PDF generated — see internal/settings. All // fields are optional — the business may not be legally registered yet, // and pdfgen renders clean placeholder blanks for whatever is unset rather // than failing. func (h *Handler) loadBusiness(ctx context.Context) (pdfgen.Business, error) { s, err := settings.Fetch(ctx, h.db) if err != nil { return pdfgen.Business{}, err } return pdfgen.Business{ Name: s.BusinessName, INN: s.BusinessINN, KPP: s.BusinessKPP, Address: s.BusinessAddress, Phone: s.BusinessPhone, BankName: s.BusinessBankName, BankAccount: s.BusinessBankAccount, BIK: s.BusinessBankBIK, CorrAccount: s.BusinessBankCorrAccount, }, nil } // deviceDescription and preferPrice used to live inside pdfgen — moved here // with the pdfgen multi-item refactor (Phase 3), since composing an // order-specific sentence or a cartridge-batch item list is domain logic // pdfgen shouldn't need to know about; it just renders whatever text/items // the caller hands it. func deviceDescription(deviceType, brand, model string) string { parts := make([]string, 0, 3) for _, p := range []string{deviceType, brand, model} { if p = strings.TrimSpace(p); p != "" { parts = append(parts, p) } } if len(parts) == 0 { return "_______________" } return strings.Join(parts, " ") } func preferPrice(finalPrice, priceEstimate string) string { if strings.TrimSpace(finalPrice) != "" { return finalPrice } return priceEstimate } func deref(s *string) string { if s == nil { return "" } return *s } type orderClient struct { id string deviceType, deviceBrand, deviceModel, serialNumber, problem, work string priceEstimate, finalPrice, warrantyUntil string assignedMasterName string customFields map[string]any checklistRaw json.RawMessage createdAt time.Time client pdfgen.Client } func (h *Handler) loadOrder(ctx context.Context, orderID string) (orderClient, error) { var oc orderClient var deviceBrand, deviceModel, serialNumber, workPerformed, priceEstimate, finalPrice, warrantyUntil *string var clientEmail, clientINN, clientKPP, clientCompanyAddress, assignedMasterName *string var customFieldsRaw []byte err := h.db.QueryRow(ctx, ` SELECT o.id, o.device_type, o.device_brand, o.device_model, o.serial_number, o.problem_description, o.work_performed, o.price_estimate::text, o.final_price::text, o.warranty_until::text, o.assigned_master_name, o.custom_fields, o.checklist, o.created_at, cl.type, cl.name, cl.phone, cl.email, cl.inn, cl.kpp, cl.company_address FROM orders o JOIN clients cl ON cl.id = o.client_id WHERE o.id = $1::uuid`, orderID, ).Scan( &oc.id, &oc.deviceType, &deviceBrand, &deviceModel, &serialNumber, &oc.problem, &workPerformed, &priceEstimate, &finalPrice, &warrantyUntil, &assignedMasterName, &customFieldsRaw, &oc.checklistRaw, &oc.createdAt, &oc.client.Type, &oc.client.Name, &oc.client.Phone, &clientEmail, &clientINN, &clientKPP, &clientCompanyAddress, ) if err != nil { return oc, err } oc.deviceBrand = deref(deviceBrand) oc.deviceModel = deref(deviceModel) oc.serialNumber = deref(serialNumber) oc.work = deref(workPerformed) oc.priceEstimate = deref(priceEstimate) oc.finalPrice = deref(finalPrice) oc.warrantyUntil = deref(warrantyUntil) oc.assignedMasterName = deref(assignedMasterName) oc.client.Email = deref(clientEmail) oc.client.INN = deref(clientINN) oc.client.KPP = deref(clientKPP) oc.client.CompanyAddress = deref(clientCompanyAddress) if len(customFieldsRaw) > 0 { if err := json.Unmarshal(customFieldsRaw, &oc.customFields); err != nil { return oc, err } } return oc, nil } func (h *Handler) Invoice(c *fiber.Ctx) error { id := c.Params("id") ctx := context.Background() if err := authz.CheckOrderAccess(ctx, h.db, id, auth.StaffPermissions(c), auth.StaffID(c)); err != nil { return err } oc, err := h.loadOrder(ctx, id) if err != nil { return h.notFoundOr500(c, "order", id, err) } business, err := h.loadBusiness(ctx) if err != nil { return c.Status(500).JSON(fiber.Map{"error": "internal error"}) } blocks, err := doctemplates.Fetch(ctx, h.db, doctemplates.KindInvoice) if err != nil { return c.Status(500).JSON(fiber.Map{"error": "internal error"}) } doc := pdfgen.InvoiceDoc{ ID: oc.id, CreatedAt: oc.createdAt, Items: []pdfgen.LineItem{ {Name: "Ремонт и диагностика: " + deviceDescription(oc.deviceType, oc.deviceBrand, oc.deviceModel), Qty: 1, Price: preferPrice(oc.finalPrice, oc.priceEstimate)}, }, } pdfBytes, err := pdfgen.GenerateInvoice(business, oc.client, doc, blocks) return h.servePDF(c, "invoice", oc.id, pdfBytes, err) } func (h *Handler) Act(c *fiber.Ctx) error { id := c.Params("id") ctx := context.Background() if err := authz.CheckOrderAccess(ctx, h.db, id, auth.StaffPermissions(c), auth.StaffID(c)); err != nil { return err } oc, err := h.loadOrder(ctx, id) if err != nil { return h.notFoundOr500(c, "order", id, err) } business, err := h.loadBusiness(ctx) if err != nil { return c.Status(500).JSON(fiber.Map{"error": "internal error"}) } blocks, err := doctemplates.Fetch(ctx, h.db, doctemplates.KindAct) if err != nil { return c.Status(500).JSON(fiber.Map{"error": "internal error"}) } deviceLine := deviceDescription(oc.deviceType, oc.deviceBrand, oc.deviceModel) if strings.TrimSpace(oc.serialNumber) != "" { deviceLine += fmt.Sprintf(", серийный номер %s", oc.serialNumber) } work := oc.work if strings.TrimSpace(work) == "" { work = "Диагностика и ремонт по заявке: " + oc.problem } total := preferPrice(oc.finalPrice, oc.priceEstimate) items, err := h.loadServiceItems(ctx, id) if err != nil { return c.Status(500).JSON(fiber.Map{"error": "internal error"}) } if len(items) > 0 { total = sumLineItemPrices(items) } doc := pdfgen.ActDoc{ ID: oc.id, CreatedAt: oc.createdAt, IntroLine: fmt.Sprintf("Исполнитель произвёл следующие работы по заявке на ремонт (%s):", deviceLine), WorkSummary: work, Items: items, WarrantyUntil: oc.warrantyUntil, Total: total, } pdfBytes, err := pdfgen.GenerateAct(business, oc.client, doc, blocks) return h.servePDF(c, "act", oc.id, pdfBytes, err) } // Receipt renders "Квитанция о приёме" — issued at drop-off, so unlike // Invoice/Act it has no cost/warranty content, just what was left and // whatever custom fields (internal/customfields, catalog-backed ones // included — see Фаза 19) staff answered while taking it in. func (h *Handler) Receipt(c *fiber.Ctx) error { id := c.Params("id") ctx := context.Background() if err := authz.CheckOrderAccess(ctx, h.db, id, auth.StaffPermissions(c), auth.StaffID(c)); err != nil { return err } oc, err := h.loadOrder(ctx, id) if err != nil { return h.notFoundOr500(c, "order", id, err) } business, err := h.loadBusiness(ctx) if err != nil { return c.Status(500).JSON(fiber.Map{"error": "internal error"}) } blocks, err := doctemplates.Fetch(ctx, h.db, doctemplates.KindReceipt) if err != nil { return c.Status(500).JSON(fiber.Map{"error": "internal error"}) } deviceLine := deviceDescription(oc.deviceType, oc.deviceBrand, oc.deviceModel) if strings.TrimSpace(oc.serialNumber) != "" { deviceLine += fmt.Sprintf(", серийный номер %s", oc.serialNumber) } customLines, err := h.loadCustomFieldLines(ctx, oc.customFields) if err != nil { return c.Status(500).JSON(fiber.Map{"error": "internal error"}) } checklistLines, err := checklistLines(oc.checklistRaw) if err != nil { return c.Status(500).JSON(fiber.Map{"error": "internal error"}) } doc := pdfgen.ReceiptDoc{ ID: oc.id, CreatedAt: oc.createdAt, DeviceLine: deviceLine, Problem: oc.problem, AssignedMaster: oc.assignedMasterName, CustomFields: customLines, ChecklistLines: checklistLines, } pdfBytes, err := pdfgen.GenerateReceipt(business, oc.client, doc, blocks) return h.servePDF(c, "receipt", oc.id, pdfBytes, err) } // loadCustomFieldLines resolves this order's answered custom_fields // (field_key -> raw JSON value) into printable Label/Value pairs, ordered // by field position and skipping anything unanswered — a receipt should // show what was actually recorded, not every question that exists. // ResolveCatalogOptions isn't needed here: a catalog-linked select's // stored value is already the entry's plain name string, same as any // other select — nothing about printing it needs the live option list. func (h *Handler) loadCustomFieldLines(ctx context.Context, values map[string]any) ([]pdfgen.CustomFieldLine, error) { if len(values) == 0 { return nil, nil } defs, err := customfields.Fetch(ctx, h.db, false) if err != nil { return nil, err } lines := make([]pdfgen.CustomFieldLine, 0, len(values)) for _, d := range defs { v, ok := values[d.FieldKey] if !ok { continue } lines = append(lines, pdfgen.CustomFieldLine{Label: d.Label, Value: formatCustomFieldValue(v)}) } return lines, nil } // checklistItemLine is one entry of orders.checklist's opaque JSONB — // mirrors the shape web/src/lib/checklistTemplates.js writes (see // migrations/056_order_inspection_checklist.sql). Unmarshaled loosely // (unknown fields ignored) since this package only ever reads it for // printing, never validates its structure — that's order.validateChecklist's // job on the write path. type checklistPayload struct { Checks []struct { Label string `json:"label"` Status string `json:"status"` Note string `json:"note"` } `json:"checks"` Completeness []struct { Label string `json:"label"` Present bool `json:"present"` } `json:"completeness"` } var checklistStatusRu = map[string]string{"ok": "исправно", "defect": "дефект", "unchecked": "не проверено"} // checklistLines flattens an order's checklist into printable Label/Value // pairs, same role loadCustomFieldLines plays for custom_fields — a // checklist with no answers yet (nil/empty raw, or every item still // 'unchecked' with completeness all false) still prints, since "не // проверено" on every line is itself the record of what was and wasn't // inspected at intake (the whole point of migrations/056's Act-defense // use case), not something worth hiding. func checklistLines(raw json.RawMessage) ([]pdfgen.CustomFieldLine, error) { if len(raw) == 0 { return nil, nil } var cl checklistPayload if err := json.Unmarshal(raw, &cl); err != nil { return nil, err } lines := make([]pdfgen.CustomFieldLine, 0, len(cl.Checks)+len(cl.Completeness)) for _, item := range cl.Checks { value := checklistStatusRu[item.Status] if value == "" { value = checklistStatusRu["unchecked"] } if item.Status == "defect" && strings.TrimSpace(item.Note) != "" { value += " — " + item.Note } lines = append(lines, pdfgen.CustomFieldLine{Label: item.Label, Value: value}) } for _, item := range cl.Completeness { value := "нет" if item.Present { value = "есть" } lines = append(lines, pdfgen.CustomFieldLine{Label: item.Label, Value: value}) } return lines, nil } func formatCustomFieldValue(v any) string { switch x := v.(type) { case bool: if x { return "Да" } return "Нет" case float64: return strconv.FormatFloat(x, 'f', -1, 64) case string: return x default: return "" } } // sumLineItemPrices sums LineItem.Price into a raw decimal string ("4000.00", // no thousand separator) suitable for ActDoc.Total / InvoiceDoc-style // consumers that re-parse it with parseAmount. NOT pdfgen.SumItemPrices, // which returns a display-formatted string (grouped thousands, e.g. // "4 000.00") — feeding that into Total made formatMoney/sumInWordsFromString // choke on the embedded space and silently render "0.00" for any total over // 999.99 (found while building this, see batch.go's Act for the identical // pre-existing bug fixed alongside it). func sumLineItemPrices(items []pdfgen.LineItem) string { sum := 0.0 for _, it := range items { v, _ := strconv.ParseFloat(it.Price, 64) sum += v } return strconv.FormatFloat(sum, 'f', 2, 64) } // loadServiceItems builds the Act's itemized line items from // order_service_items, if the order has any (see migrations/025 — orders // without a single line item fall back to the old work_performed prose, // this returns an empty slice for them). LineItem.Price is the row's // *total* (price × qty), matching the convention every other pdfgen.LineItem // caller in this codebase already follows (they all pass Qty: 1, so Price // there is unit price and total in the same breath) — drawInvoiceTable // prints the same value in both the Цена and Сумма columns, so a qty>1 // service line shows its line total in Цена too rather than a true unit // price. Acceptable: qty>1 service lines are rare (most repairs bill one // diagnostic, one repair — not N of the same service), and getting Итого // mathematically right matters far more than that column's label. func (h *Handler) loadServiceItems(ctx context.Context, orderID string) ([]pdfgen.LineItem, error) { rows, err := h.db.Query(ctx, `SELECT description, price::text, qty FROM order_service_items WHERE order_id = $1::uuid ORDER BY created_at`, orderID) if err != nil { return nil, err } defer rows.Close() items := []pdfgen.LineItem{} for rows.Next() { var desc, price string var qty int if err := rows.Scan(&desc, &price, &qty); err != nil { return nil, err } unitPrice, _ := strconv.ParseFloat(price, 64) items = append(items, pdfgen.LineItem{Name: desc, Qty: qty, Price: strconv.FormatFloat(unitPrice*float64(qty), 'f', 2, 64)}) } return items, rows.Err() } func (h *Handler) notFoundOr500(c *fiber.Ctx, kind, id string, err error) error { if errors.Is(err, pgx.ErrNoRows) { return c.Status(404).JSON(fiber.Map{"error": kind + " not found"}) } log.Printf("document: load %s %s: %v", kind, id, err) return c.Status(500).JSON(fiber.Map{"error": "internal error"}) } // servePDF writes the response. docID is always a value scanned back from // Postgres (never the raw path param) — guaranteed well-formed, so slicing // it for the filename can't panic and can't carry anything a client sent. func (h *Handler) servePDF(c *fiber.Ctx, kind, docID string, pdfBytes []byte, genErr error) error { if genErr != nil { log.Printf("document: generate %s for %s: %v", kind, docID, genErr) return c.Status(500).JSON(fiber.Map{"error": "document generation failed"}) } shortID := docID if len(shortID) > 8 { shortID = shortID[:8] } c.Set(fiber.HeaderContentType, "application/pdf") c.Set(fiber.HeaderContentDisposition, fmt.Sprintf(`inline; filename="%s-%s.pdf"`, kind, shortID)) return c.Send(pdfBytes) }