Bring all areas to 60%: modules, reporting, i18n, views, data
Business modules deepened: - sale: tag_ids, invoice/delivery counts with computes - stock: _action_confirm/_action_done on stock.move, quant update stub - purchase: done state added - hr: parent_id, address_home_id, no_of_recruitment - project: user_id, date_start, kanban_state on tasks Reporting framework (0% → 60%): - ir.actions.report model registered - /report/html/<name>/<ids> endpoint serves styled HTML reports - Report-to-model mapping for invoice, sale, stock, purchase i18n framework (0% → 60%): - ir.translation model with src/value/lang/type fields - handleTranslations loads from DB, returns per-module structure - 140 German translations seeded (UI terms across all modules) - res_lang seeded with en_US + de_DE Views/UI improved: - Stored form views: sale.order (with editable O2M lines), account.move (with Post/Cancel buttons), res.partner (with title) - Stored list views: purchase.order, hr.employee, project.project Demo data expanded: - 5 products (templates + variants with codes) - 3 HR departments, 3 employees - 2 projects Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
186
pkg/server/report.go
Normal file
186
pkg/server/report.go
Normal file
@@ -0,0 +1,186 @@
|
||||
// Report endpoint — serves HTML reports.
|
||||
// Mirrors: odoo/addons/web/controllers/report.py ReportController
|
||||
package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"odoo-go/pkg/orm"
|
||||
)
|
||||
|
||||
// handleReport serves HTML reports.
|
||||
// Route: /report/html/<report_name>/<ids>
|
||||
// Mirrors: odoo/addons/web/controllers/report.py report_routes()
|
||||
func (s *Server) handleReport(w http.ResponseWriter, r *http.Request) {
|
||||
// Parse URL: /report/html/account.report_invoice/1,2,3
|
||||
parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/"), "/")
|
||||
// Expected: ["report", "html", "<report_name>", "<ids>"]
|
||||
if len(parts) < 4 {
|
||||
http.Error(w, "Invalid report URL. Expected: /report/html/<report_name>/<ids>", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
reportName := parts[2]
|
||||
idsStr := parts[3]
|
||||
|
||||
// Parse record IDs
|
||||
var ids []int64
|
||||
for _, s := range strings.Split(idsStr, ",") {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
continue
|
||||
}
|
||||
id, err := strconv.ParseInt(s, 10, 64)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("Invalid ID %q: %v", s, err), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
|
||||
if len(ids) == 0 {
|
||||
http.Error(w, "No record IDs provided", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Get session
|
||||
uid := int64(1)
|
||||
companyID := int64(1)
|
||||
if sess := GetSession(r); sess != nil {
|
||||
uid = sess.UID
|
||||
companyID = sess.CompanyID
|
||||
}
|
||||
|
||||
env, err := orm.NewEnvironment(r.Context(), orm.EnvConfig{
|
||||
Pool: s.pool, UID: uid, CompanyID: companyID,
|
||||
})
|
||||
if err != nil {
|
||||
http.Error(w, "Internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer env.Close()
|
||||
|
||||
// Determine model from report name
|
||||
modelName := resolveReportModel(reportName)
|
||||
if modelName == "" {
|
||||
http.Error(w, "Unknown report: "+reportName, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Read records
|
||||
rs := env.Model(modelName).Browse(ids...)
|
||||
records, err := rs.Read(nil)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if err := env.Commit(); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Render HTML report
|
||||
html := renderHTMLReport(reportName, modelName, records)
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Write([]byte(html))
|
||||
}
|
||||
|
||||
// resolveReportModel maps a report name to the ORM model it operates on.
|
||||
// Mirrors: odoo ir.actions.report → model field.
|
||||
func resolveReportModel(reportName string) string {
|
||||
mapping := map[string]string{
|
||||
"account.report_invoice": "account.move",
|
||||
"sale.report_saleorder": "sale.order",
|
||||
"stock.report_picking": "stock.picking",
|
||||
"purchase.report_purchaseorder": "purchase.order",
|
||||
"contacts.report_partner": "res.partner",
|
||||
}
|
||||
return mapping[reportName]
|
||||
}
|
||||
|
||||
// renderHTMLReport generates a basic HTML report for the given records.
|
||||
// This is a minimal implementation — Odoo uses QWeb templates for real reports.
|
||||
func renderHTMLReport(reportName, modelName string, records []orm.Values) string {
|
||||
var b strings.Builder
|
||||
b.WriteString(`<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<title>Report: `)
|
||||
b.WriteString(htmlEscape(reportName))
|
||||
b.WriteString(`</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; margin: 40px; }
|
||||
table { width: 100%; border-collapse: collapse; margin: 20px 0; }
|
||||
th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }
|
||||
th { background: #f5f5f5; font-weight: bold; }
|
||||
h1 { color: #333; }
|
||||
h2 { color: #555; margin-top: 30px; }
|
||||
.header { border-bottom: 2px solid #875A7B; padding-bottom: 10px; margin-bottom: 20px; }
|
||||
.report-info { color: #888; font-size: 0.9em; margin-bottom: 30px; }
|
||||
.record-section { page-break-after: always; margin-bottom: 40px; }
|
||||
.record-section:last-child { page-break-after: avoid; }
|
||||
@media print {
|
||||
body { margin: 20px; }
|
||||
.no-print { display: none; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>`)
|
||||
b.WriteString(htmlEscape(reportName))
|
||||
b.WriteString(`</h1>
|
||||
<div class="report-info">Model: `)
|
||||
b.WriteString(htmlEscape(modelName))
|
||||
b.WriteString(fmt.Sprintf(` | Records: %d`, len(records)))
|
||||
b.WriteString(`</div>
|
||||
</div>
|
||||
<div class="no-print">
|
||||
<button onclick="window.print()">Print</button>
|
||||
</div>
|
||||
`)
|
||||
|
||||
for _, rec := range records {
|
||||
b.WriteString(`<div class="record-section">`)
|
||||
|
||||
// Use "name" or "display_name" as section title if available
|
||||
title := ""
|
||||
if v, ok := rec["display_name"]; ok {
|
||||
title = fmt.Sprintf("%v", v)
|
||||
} else if v, ok := rec["name"]; ok {
|
||||
title = fmt.Sprintf("%v", v)
|
||||
}
|
||||
if title != "" {
|
||||
b.WriteString(fmt.Sprintf("<h2>%s</h2>", htmlEscape(title)))
|
||||
}
|
||||
|
||||
b.WriteString("<table>")
|
||||
b.WriteString("<tr><th>Field</th><th>Value</th></tr>")
|
||||
for key, val := range rec {
|
||||
if key == "id" {
|
||||
continue
|
||||
}
|
||||
valStr := fmt.Sprintf("%v", val)
|
||||
if valStr == "<nil>" {
|
||||
valStr = ""
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("<tr><td>%s</td><td>%s</td></tr>",
|
||||
htmlEscape(key), htmlEscape(valStr)))
|
||||
}
|
||||
b.WriteString("</table>")
|
||||
b.WriteString("</div>")
|
||||
}
|
||||
|
||||
b.WriteString("</body></html>")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// htmlEscape escapes HTML special characters.
|
||||
func htmlEscape(s string) string {
|
||||
return template.HTMLEscapeString(s)
|
||||
}
|
||||
Reference in New Issue
Block a user