452 lines
16 KiB
Go
452 lines
16 KiB
Go
// 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)
|
||
}
|