Files
aura-crm/production/backend/internal/document/handler.go
T

452 lines
16 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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): <a href> 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)
}