feat(boc): v1.0 - Complete Business Operations Center
- Go backend API with full CRUD for all modules - Rust analytics service with parallel processing - C runtime with POSIX shared memory IPC - PostgreSQL schema with 30+ tables - Redis cache, Kafka event streaming - WebSocket hub, automation engine - PDF generation, Resend email integration - JWT auth, multi-tenant - Docker Compose deployment - Nginx reverse proxy Refs: BOC-001
This commit is contained in:
@@ -0,0 +1,426 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"boc/email"
|
||||
"boc/pdf"
|
||||
)
|
||||
|
||||
type QuoteHandler struct {
|
||||
DB *sql.DB
|
||||
EmailClient *email.Client
|
||||
}
|
||||
|
||||
func NewQuoteHandler(db *sql.DB) *QuoteHandler {
|
||||
return &QuoteHandler{DB: db}
|
||||
}
|
||||
|
||||
func (h *QuoteHandler) SetEmailClient(client *email.Client) {
|
||||
h.EmailClient = client
|
||||
}
|
||||
|
||||
type Quote struct {
|
||||
ID string `json:"id"`
|
||||
CustomerID string `json:"customer_id"`
|
||||
QuoteNumber string `json:"quote_number"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Status string `json:"status"`
|
||||
Amount float64 `json:"amount"`
|
||||
TaxAmount float64 `json:"tax_amount"`
|
||||
Currency string `json:"currency"`
|
||||
ValidUntil *time.Time `json:"valid_until"`
|
||||
AcceptedAt *time.Time `json:"accepted_at"`
|
||||
Notes string `json:"notes"`
|
||||
Terms string `json:"terms"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type QuoteItem struct {
|
||||
ID string `json:"id"`
|
||||
QuoteID string `json:"quote_id"`
|
||||
ProductID *string `json:"product_id"`
|
||||
Description string `json:"description"`
|
||||
Quantity float64 `json:"quantity"`
|
||||
UnitPrice float64 `json:"unit_price"`
|
||||
TaxRate float64 `json:"tax_rate"`
|
||||
Discount float64 `json:"discount"`
|
||||
Total float64 `json:"total"`
|
||||
}
|
||||
|
||||
func (h *QuoteHandler) ListQuotes(w http.ResponseWriter, r *http.Request) {
|
||||
status := r.URL.Query().Get("status")
|
||||
if status == "" {
|
||||
status = "all"
|
||||
}
|
||||
|
||||
var query string
|
||||
var args []interface{}
|
||||
if status == "all" {
|
||||
query = `SELECT id, customer_id, quote_number, title, status, amount, currency, valid_until, created_at FROM boc_quotes ORDER BY created_at DESC LIMIT 100`
|
||||
} else {
|
||||
query = `SELECT id, customer_id, quote_number, title, status, amount, currency, valid_until, created_at FROM boc_quotes WHERE status = $1 ORDER BY created_at DESC LIMIT 100`
|
||||
args = append(args, status)
|
||||
}
|
||||
|
||||
rows, err := h.DB.Query(query, args...)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "database error")
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
quotes := []Quote{}
|
||||
for rows.Next() {
|
||||
var q Quote
|
||||
if err := rows.Scan(&q.ID, &q.CustomerID, &q.QuoteNumber, &q.Title, &q.Status, &q.Amount, &q.Currency, &q.ValidUntil, &q.CreatedAt); err != nil {
|
||||
continue
|
||||
}
|
||||
quotes = append(quotes, q)
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"quotes": quotes,
|
||||
"total": len(quotes),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *QuoteHandler) CreateQuote(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
CustomerID string `json:"customer_id"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
ValidUntil *time.Time `json:"valid_until"`
|
||||
Notes string `json:"notes"`
|
||||
Terms string `json:"terms"`
|
||||
Items []QuoteItem `json:"items"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request")
|
||||
return
|
||||
}
|
||||
|
||||
// Generate quote number
|
||||
quoteNumber := fmt.Sprintf("Q-%d", time.Now().Unix())
|
||||
|
||||
// Calculate totals
|
||||
var totalAmount, totalTax float64
|
||||
for _, item := range req.Items {
|
||||
itemTotal := item.Quantity * item.UnitPrice * (1 - item.Discount/100)
|
||||
itemTax := itemTotal * (item.TaxRate / 100)
|
||||
totalAmount += itemTotal
|
||||
totalTax += itemTax
|
||||
}
|
||||
|
||||
tx, err := h.DB.Begin()
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "transaction error")
|
||||
return
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
var id string
|
||||
err = tx.QueryRow(`
|
||||
INSERT INTO boc_quotes (customer_id, quote_number, title, description, amount, tax_amount, currency, valid_until, notes, terms)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, 'USD', $7, $8, $9)
|
||||
RETURNING id
|
||||
`, req.CustomerID, quoteNumber, req.Title, req.Description, totalAmount, totalTax, req.ValidUntil, req.Notes, req.Terms).Scan(&id)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to create quote")
|
||||
return
|
||||
}
|
||||
|
||||
// Insert items
|
||||
for _, item := range req.Items {
|
||||
itemTotal := item.Quantity * item.UnitPrice * (1 - item.Discount/100)
|
||||
_, err = tx.Exec(`
|
||||
INSERT INTO boc_quote_items (quote_id, product_id, description, quantity, unit_price, tax_rate, discount, total)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
`, id, item.ProductID, item.Description, item.Quantity, item.UnitPrice, item.TaxRate, item.Discount, itemTotal)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to create quote items")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "commit failed")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusCreated, map[string]interface{}{
|
||||
"id": id,
|
||||
"number": quoteNumber,
|
||||
"message": "Quote created",
|
||||
})
|
||||
}
|
||||
|
||||
func (h *QuoteHandler) GetQuote(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
|
||||
var q Quote
|
||||
err := h.DB.QueryRow(`
|
||||
SELECT id, customer_id, quote_number, title, description, status, amount, tax_amount, currency, valid_until, accepted_at, notes, terms, created_at
|
||||
FROM boc_quotes WHERE id = $1
|
||||
`, id).Scan(&q.ID, &q.CustomerID, &q.QuoteNumber, &q.Title, &q.Description, &q.Status, &q.Amount, &q.TaxAmount, &q.Currency, &q.ValidUntil, &q.AcceptedAt, &q.Notes, &q.Terms, &q.CreatedAt)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
writeError(w, http.StatusNotFound, "quote not found")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "database error")
|
||||
return
|
||||
}
|
||||
|
||||
// Get items
|
||||
rows, err := h.DB.Query(`
|
||||
SELECT id, product_id, description, quantity, unit_price, tax_rate, discount, total
|
||||
FROM boc_quote_items WHERE quote_id = $1 ORDER BY sort_order
|
||||
`, id)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "database error")
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
items := []QuoteItem{}
|
||||
for rows.Next() {
|
||||
var i QuoteItem
|
||||
if err := rows.Scan(&i.ID, &i.ProductID, &i.Description, &i.Quantity, &i.UnitPrice, &i.TaxRate, &i.Discount, &i.Total); err != nil {
|
||||
continue
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"quote": q,
|
||||
"items": items,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *QuoteHandler) AcceptQuote(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
|
||||
_, err := h.DB.Exec(`
|
||||
UPDATE boc_quotes SET status = 'accepted', accepted_at = NOW() WHERE id = $1
|
||||
`, id)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to accept quote")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"message": "Quote accepted",
|
||||
})
|
||||
}
|
||||
|
||||
func (h *QuoteHandler) ConvertToOrder(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
|
||||
tx, err := h.DB.Begin()
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "transaction error")
|
||||
return
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
// Get quote details
|
||||
var customerID string
|
||||
var amount, taxAmount float64
|
||||
err = tx.QueryRow(`SELECT customer_id, amount, tax_amount FROM boc_quotes WHERE id = $1`, id).Scan(&customerID, &amount, &taxAmount)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "quote not found")
|
||||
return
|
||||
}
|
||||
|
||||
// Create order
|
||||
orderNumber := fmt.Sprintf("O-%d", time.Now().Unix())
|
||||
var orderID string
|
||||
err = tx.QueryRow(`
|
||||
INSERT INTO boc_orders (customer_id, quote_id, order_number, title, amount, tax_amount, currency, status)
|
||||
SELECT customer_id, id, $2, title, amount, tax_amount, currency, 'confirmed'
|
||||
FROM boc_quotes WHERE id = $1
|
||||
RETURNING id
|
||||
`, id, orderNumber).Scan(&orderID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to create order")
|
||||
return
|
||||
}
|
||||
|
||||
// Copy quote items to order items
|
||||
_, err = tx.Exec(`
|
||||
INSERT INTO boc_order_items (order_id, product_id, description, quantity, unit_price, tax_rate, discount, total)
|
||||
SELECT $1, product_id, description, quantity, unit_price, tax_rate, discount, total
|
||||
FROM boc_quote_items WHERE quote_id = $2
|
||||
`, orderID, id)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to copy items")
|
||||
return
|
||||
}
|
||||
|
||||
// Update quote
|
||||
_, err = tx.Exec(`UPDATE boc_quotes SET status = 'converted', converted_to_order_id = $1 WHERE id = $2`, orderID, id)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to update quote")
|
||||
return
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "commit failed")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusCreated, map[string]interface{}{
|
||||
"order_id": orderID,
|
||||
"number": orderNumber,
|
||||
"message": "Quote converted to order",
|
||||
})
|
||||
}
|
||||
|
||||
// GenerateQuotePDF generates a PDF for a quote
|
||||
func (h *QuoteHandler) GenerateQuotePDF(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
|
||||
var q Quote
|
||||
err := h.DB.QueryRow(`
|
||||
SELECT id, customer_id, quote_number, title, description, status, amount, tax_amount, currency, valid_until, accepted_at, notes, terms, created_at
|
||||
FROM boc_quotes WHERE id = $1
|
||||
`, id).Scan(&q.ID, &q.CustomerID, &q.QuoteNumber, &q.Title, &q.Description, &q.Status, &q.Amount, &q.TaxAmount, &q.Currency, &q.ValidUntil, &q.AcceptedAt, &q.Notes, &q.Terms, &q.CreatedAt)
|
||||
if err == sql.ErrNoRows {
|
||||
writeError(w, http.StatusNotFound, "quote not found")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "database error")
|
||||
return
|
||||
}
|
||||
|
||||
var customerName, customerAddress string
|
||||
h.DB.QueryRow(`SELECT name, address FROM boc_customers WHERE id = $1`, q.CustomerID).Scan(&customerName, &customerAddress)
|
||||
|
||||
validUntil := time.Now().AddDate(0, 0, 30)
|
||||
if q.ValidUntil != nil {
|
||||
validUntil = *q.ValidUntil
|
||||
}
|
||||
|
||||
items := []pdf.QuoteItem{
|
||||
{
|
||||
Description: q.Title,
|
||||
Quantity: 1,
|
||||
Unit: "st",
|
||||
UnitPrice: q.Amount,
|
||||
Total: q.Amount,
|
||||
},
|
||||
}
|
||||
|
||||
data := pdf.QuoteData{
|
||||
QuoteNumber: q.QuoteNumber,
|
||||
QuoteDate: q.CreatedAt,
|
||||
ValidUntil: validUntil,
|
||||
CustomerName: customerName,
|
||||
CustomerAddress: customerAddress,
|
||||
Items: items,
|
||||
Subtotal: q.Amount,
|
||||
VATRate: 0.25,
|
||||
VATAmount: q.TaxAmount,
|
||||
Total: q.Amount + q.TaxAmount,
|
||||
Currency: q.Currency,
|
||||
CompanyName: "Landvex Inc",
|
||||
CompanyAddress: "Houston, TX",
|
||||
Notes: q.Notes,
|
||||
}
|
||||
|
||||
pdfBytes, err := pdf.GenerateQuote(data)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "pdf generation failed")
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/pdf")
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"offert-%s.pdf\"", q.QuoteNumber))
|
||||
w.Write(pdfBytes)
|
||||
}
|
||||
|
||||
// SendQuoteEmail sends a quote via email with PDF attachment
|
||||
func (h *QuoteHandler) SendQuoteEmail(w http.ResponseWriter, r *http.Request) {
|
||||
if h.EmailClient == nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "email not configured")
|
||||
return
|
||||
}
|
||||
|
||||
id := chi.URLParam(r, "id")
|
||||
|
||||
var req struct {
|
||||
To []string `json:"to"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request")
|
||||
return
|
||||
}
|
||||
|
||||
var q Quote
|
||||
err := h.DB.QueryRow(`
|
||||
SELECT id, customer_id, quote_number, title, amount, tax_amount, currency, valid_until, created_at
|
||||
FROM boc_quotes WHERE id = $1
|
||||
`, id).Scan(&q.ID, &q.CustomerID, &q.QuoteNumber, &q.Title, &q.Amount, &q.TaxAmount, &q.Currency, &q.ValidUntil, &q.CreatedAt)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "quote not found")
|
||||
return
|
||||
}
|
||||
|
||||
var customerName, customerAddress string
|
||||
h.DB.QueryRow(`SELECT name, address FROM boc_customers WHERE id = $1`, q.CustomerID).Scan(&customerName, &customerAddress)
|
||||
|
||||
validUntil := time.Now().AddDate(0, 0, 30)
|
||||
if q.ValidUntil != nil {
|
||||
validUntil = *q.ValidUntil
|
||||
}
|
||||
|
||||
data := pdf.QuoteData{
|
||||
QuoteNumber: q.QuoteNumber,
|
||||
QuoteDate: q.CreatedAt,
|
||||
ValidUntil: validUntil,
|
||||
CustomerName: customerName,
|
||||
CustomerAddress: customerAddress,
|
||||
Items: []pdf.QuoteItem{
|
||||
{
|
||||
Description: q.Title,
|
||||
Quantity: 1,
|
||||
Unit: "st",
|
||||
UnitPrice: q.Amount,
|
||||
Total: q.Amount,
|
||||
},
|
||||
},
|
||||
Subtotal: q.Amount,
|
||||
VATRate: 0.25,
|
||||
VATAmount: q.TaxAmount,
|
||||
Total: q.Amount + q.TaxAmount,
|
||||
Currency: q.Currency,
|
||||
CompanyName: "Landvex Inc",
|
||||
CompanyAddress: "Houston, TX",
|
||||
}
|
||||
|
||||
pdfBytes, err := pdf.GenerateQuote(data)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "pdf generation failed")
|
||||
return
|
||||
}
|
||||
|
||||
err = h.EmailClient.SendQuote(req.To, q.QuoteNumber, pdfBytes, "")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, fmt.Sprintf("email failed: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"message": "Quote sent",
|
||||
"to": req.To,
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user