235 lines
8.3 KiB
Go
235 lines
8.3 KiB
Go
package client
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"time"
|
|
"unicode/utf8"
|
|
|
|
"production/internal/auth"
|
|
"production/internal/dbutil"
|
|
"production/internal/smsgw"
|
|
|
|
"github.com/gofiber/fiber/v2"
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
)
|
|
|
|
// Length caps exist mainly so client/order text can't be used to force
|
|
// unbounded PDF rendering in internal/pdfgen/internal/document (MultiCell
|
|
// wraps arbitrarily long input into an arbitrarily long document) — these
|
|
// fields end up on invoices/acts, not just displayed in the UI.
|
|
const (
|
|
maxShortFieldLen = 255
|
|
maxAddressLen = 2000
|
|
)
|
|
|
|
type Handler struct {
|
|
db *pgxpool.Pool
|
|
}
|
|
|
|
func NewHandler(db *pgxpool.Pool) *Handler {
|
|
return &Handler{db: db}
|
|
}
|
|
|
|
type createBody struct {
|
|
Type string `json:"type"`
|
|
Name string `json:"name"`
|
|
Phone string `json:"phone"`
|
|
Email string `json:"email"`
|
|
INN string `json:"inn"`
|
|
KPP string `json:"kpp"`
|
|
CompanyAddress string `json:"company_address"`
|
|
}
|
|
|
|
func (b createBody) validate() string {
|
|
if b.Type != "individual" && b.Type != "company" {
|
|
return "type must be 'individual' or 'company'"
|
|
}
|
|
if b.Name == "" {
|
|
return "name is required"
|
|
}
|
|
if b.Phone == "" {
|
|
return "phone is required"
|
|
}
|
|
if b.Type == "company" && b.INN == "" {
|
|
return "inn is required for company clients"
|
|
}
|
|
for _, f := range []string{b.Name, b.Phone, b.Email, b.INN, b.KPP} {
|
|
if utf8.RuneCountInString(f) > maxShortFieldLen {
|
|
return "one of the fields is too long"
|
|
}
|
|
}
|
|
if utf8.RuneCountInString(b.CompanyAddress) > maxAddressLen {
|
|
return "company_address is too long"
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (h *Handler) Create(c *fiber.Ctx) error {
|
|
var body createBody
|
|
if err := c.BodyParser(&body); err != nil {
|
|
return c.Status(400).JSON(fiber.Map{"error": "invalid request body"})
|
|
}
|
|
if msg := body.validate(); msg != "" {
|
|
return c.Status(400).JSON(fiber.Map{"error": msg})
|
|
}
|
|
|
|
// Best-effort — an unrecognized phone format just leaves
|
|
// phone_normalized NULL (no SMS channel for this client), never blocks
|
|
// client creation.
|
|
normalizedPhone, _ := smsgw.Normalize(body.Phone)
|
|
|
|
var id string
|
|
err := h.db.QueryRow(context.Background(),
|
|
`INSERT INTO clients (type, name, phone, phone_normalized, email, inn, kpp, company_address, created_by_staff_id, created_by_staff_name)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::uuid, $10) RETURNING id`,
|
|
body.Type, body.Name, body.Phone, dbutil.NullIfEmpty(normalizedPhone), dbutil.NullIfEmpty(body.Email), dbutil.NullIfEmpty(body.INN), dbutil.NullIfEmpty(body.KPP), dbutil.NullIfEmpty(body.CompanyAddress),
|
|
auth.StaffID(c), auth.StaffName(c),
|
|
).Scan(&id)
|
|
if err != nil {
|
|
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
|
|
}
|
|
|
|
return c.Status(201).JSON(fiber.Map{"id": id})
|
|
}
|
|
|
|
type clientRow struct {
|
|
ID string `json:"id"`
|
|
Type string `json:"type"`
|
|
Name string `json:"name"`
|
|
Phone string `json:"phone"`
|
|
Email *string `json:"email"`
|
|
INN *string `json:"inn"`
|
|
KPP *string `json:"kpp"`
|
|
CompanyAddress *string `json:"company_address"`
|
|
TGLinked bool `json:"tg_linked"`
|
|
MaxLinked bool `json:"max_linked"`
|
|
VkLinked bool `json:"vk_linked"`
|
|
NotifyTelegram bool `json:"notify_telegram"`
|
|
NotifyMax bool `json:"notify_max"`
|
|
NotifyVk bool `json:"notify_vk"`
|
|
NotifySMS bool `json:"notify_sms"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
}
|
|
|
|
const clientColumns = `id, type, name, phone, email, inn, kpp, company_address,
|
|
(tg_chat_id IS NOT NULL), (max_chat_id IS NOT NULL), (vk_chat_id IS NOT NULL),
|
|
notify_telegram, notify_max, notify_vk, notify_sms, created_at`
|
|
|
|
func scanClient(row pgx.Row, r *clientRow) error {
|
|
return row.Scan(&r.ID, &r.Type, &r.Name, &r.Phone, &r.Email, &r.INN, &r.KPP, &r.CompanyAddress,
|
|
&r.TGLinked, &r.MaxLinked, &r.VkLinked, &r.NotifyTelegram, &r.NotifyMax, &r.NotifyVk, &r.NotifySMS, &r.CreatedAt)
|
|
}
|
|
|
|
// List returns clients, optionally filtered by ?q= matching name or phone (substring).
|
|
func (h *Handler) List(c *fiber.Ctx) error {
|
|
q := c.Query("q")
|
|
clientType := c.Query("type")
|
|
dateFrom := c.Query("date_from")
|
|
dateTo := c.Query("date_to")
|
|
rows, err := h.db.Query(context.Background(),
|
|
`SELECT `+clientColumns+`
|
|
FROM clients
|
|
WHERE ($1 = '' OR name ILIKE '%' || $1 || '%' OR phone ILIKE '%' || $1 || '%')
|
|
AND ($2 = '' OR type = $2)
|
|
AND ($3 = '' OR created_at >= $3::date)
|
|
AND ($4 = '' OR created_at < ($4::date + interval '1 day'))
|
|
ORDER BY created_at DESC LIMIT 200`, q, clientType, dateFrom, dateTo)
|
|
if err != nil {
|
|
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
|
|
}
|
|
defer rows.Close()
|
|
|
|
out := []clientRow{}
|
|
for rows.Next() {
|
|
var r clientRow
|
|
if err := scanClient(rows, &r); err != nil {
|
|
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
|
|
}
|
|
out = append(out, r)
|
|
}
|
|
return c.JSON(out)
|
|
}
|
|
|
|
// Get returns a client with their order history.
|
|
func (h *Handler) Get(c *fiber.Ctx) error {
|
|
id := c.Params("id")
|
|
var r clientRow
|
|
err := scanClient(h.db.QueryRow(context.Background(), `SELECT `+clientColumns+` FROM clients WHERE id = $1::uuid`, id), &r)
|
|
if err != nil {
|
|
if err == pgx.ErrNoRows {
|
|
return c.Status(404).JSON(fiber.Map{"error": "client not found"})
|
|
}
|
|
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
|
|
}
|
|
|
|
// A master only ever sees their own orders and unassigned ones here too
|
|
// — same restriction order.List already applies to its own listing.
|
|
// Without it, a master who knows (or enumerates) a client ID could read
|
|
// tracking_token for a colleague's assigned order out of this endpoint,
|
|
// then use that token against the fully public /api/track/:token to see
|
|
// that order's timeline.
|
|
query := `SELECT id, tracking_token, device_type, status, warranty_until::text, created_at FROM orders WHERE client_id = $1::uuid`
|
|
args := []any{id}
|
|
if !auth.HasPermission(c, "unscoped") {
|
|
query += fmt.Sprintf(" AND (assigned_master_id IS NULL OR assigned_master_id = $%d::uuid)", len(args)+1)
|
|
args = append(args, auth.StaffID(c))
|
|
}
|
|
query += " ORDER BY created_at DESC"
|
|
orderRows, err := h.db.Query(context.Background(), query, args...)
|
|
if err != nil {
|
|
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
|
|
}
|
|
defer orderRows.Close()
|
|
|
|
type orderSummary struct {
|
|
ID string `json:"id"`
|
|
Token string `json:"tracking_token"`
|
|
DeviceType string `json:"device_type"`
|
|
Status string `json:"status"`
|
|
WarrantyUntil *string `json:"warranty_until"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
}
|
|
var orders []orderSummary
|
|
for orderRows.Next() {
|
|
var o orderSummary
|
|
if err := orderRows.Scan(&o.ID, &o.Token, &o.DeviceType, &o.Status, &o.WarrantyUntil, &o.CreatedAt); err != nil {
|
|
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
|
|
}
|
|
orders = append(orders, o)
|
|
}
|
|
|
|
return c.JSON(fiber.Map{"client": r, "orders": orders})
|
|
}
|
|
|
|
// Debt is what this client still owes across every non-cancelled order —
|
|
// same GREATEST/COALESCE-per-order shape as analytics.fetchClientDebtTotal,
|
|
// just scoped to one client_id instead of summed company-wide. Kept as its
|
|
// own lightweight endpoint (not folded into Get's response) so ClientPicker
|
|
// can show it inline wherever a client is selected — OrderModal/
|
|
// TradeInModal — without also paying for Get's order-history join every
|
|
// time. staffAuth only, no extra permission: a master already sees
|
|
// per-order cash history inside that order's own card (see PROJECT.md's
|
|
// role table), so a same-shape aggregate for "this one client" isn't a
|
|
// bigger financial disclosure than what's already visible to that role.
|
|
func (h *Handler) Debt(c *fiber.Ctx) error {
|
|
id := c.Params("id")
|
|
var debt string
|
|
err := h.db.QueryRow(context.Background(),
|
|
`SELECT COALESCE(SUM(GREATEST(COALESCE(o.final_price, o.price_estimate, 0) - COALESCE(paid.amt, 0), 0)), 0)::text
|
|
FROM orders o
|
|
LEFT JOIN (
|
|
SELECT order_id, SUM(amount) AS amt FROM cash_transactions
|
|
WHERE type = 'income' AND order_id IS NOT NULL GROUP BY order_id
|
|
) paid ON paid.order_id = o.id
|
|
WHERE o.client_id = $1::uuid AND o.deleted_at IS NULL
|
|
AND o.status NOT IN (SELECT key FROM order_statuses WHERE system_role = 'cancelled')`,
|
|
id,
|
|
).Scan(&debt)
|
|
if err != nil {
|
|
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
|
|
}
|
|
return c.JSON(fiber.Map{"debt": debt})
|
|
}
|