Odoo ERP ported to Go — complete backend + original OWL frontend

Full port of Odoo's ERP system from Python to Go, with the original
Odoo JavaScript frontend (OWL framework) running against the Go server.

Backend (10,691 LoC Go):
- Custom ORM: CRUD, domains→SQL with JOINs, computed fields, sequences
- 93 models across 14 modules (base, account, sale, stock, purchase, hr,
  project, crm, fleet, product, l10n_de, google_address/translate/calendar)
- Auth with bcrypt + session cookies
- Setup wizard (company, SKR03 chart, admin, demo data)
- Double-entry bookkeeping constraint
- Sale→Invoice workflow (confirm SO → generate invoice → post)
- SKR03 chart of accounts (110 accounts) + German taxes (USt/VSt)
- Record rules (multi-company filter)
- Google integrations as opt-in modules (Maps, Translate, Calendar)

Frontend:
- Odoo's original OWL webclient (503 JS modules, 378 XML templates)
- JS transpiled via Odoo's js_transpiler (ES modules → odoo.define)
- SCSS compiled to CSS (675KB) via dart-sass
- XML templates compiled to registerTemplate() JS calls
- Static file serving from Odoo source addons
- Login page, session management, menu navigation
- Contacts list view renders with real data from PostgreSQL

Infrastructure:
- 14MB single binary (CGO_ENABLED=0)
- Docker Compose (Go server + PostgreSQL 16)
- Zero phone-home (no outbound calls to odoo.com)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc
2026-03-31 01:45:09 +02:00
commit 0ed29fe2fd
90 changed files with 12133 additions and 0 deletions

290
pkg/server/setup.go Normal file
View File

@@ -0,0 +1,290 @@
package server
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"odoo-go/pkg/service"
"odoo-go/pkg/tools"
)
// isSetupNeeded checks if the database has been initialized.
func (s *Server) isSetupNeeded() bool {
var count int
err := s.pool.QueryRow(context.Background(),
`SELECT COUNT(*) FROM res_company`).Scan(&count)
return err != nil || count == 0
}
// handleSetup serves the setup wizard.
func (s *Server) handleSetup(w http.ResponseWriter, r *http.Request) {
if !s.isSetupNeeded() {
http.Redirect(w, r, "/web/login", http.StatusFound)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write([]byte(`<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>Odoo — Setup</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: #f0eeee; display: flex; align-items: center; justify-content: center; min-height: 100vh; }
.setup { background: white; padding: 40px; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.1);
width: 100%; max-width: 560px; }
.setup h1 { color: #71639e; margin-bottom: 8px; font-size: 28px; }
.setup .subtitle { color: #666; margin-bottom: 30px; font-size: 14px; }
.setup h2 { color: #333; font-size: 16px; margin: 24px 0 12px; padding-top: 16px; border-top: 1px solid #eee; }
.setup h2:first-of-type { border-top: none; padding-top: 0; }
.setup label { display: block; margin-bottom: 4px; font-weight: 500; color: #555; font-size: 13px; }
.setup input, .setup select { width: 100%; padding: 9px 12px; border: 1px solid #ddd; border-radius: 4px;
font-size: 14px; margin-bottom: 12px; }
.setup input:focus, .setup select:focus { outline: none; border-color: #71639e; box-shadow: 0 0 0 2px rgba(113,99,158,0.2); }
.row { display: flex; gap: 12px; }
.row > div { flex: 1; }
.setup button { width: 100%; padding: 14px; background: #71639e; color: white; border: none;
border-radius: 4px; font-size: 16px; cursor: pointer; margin-top: 20px; }
.setup button:hover { background: #5f5387; }
.setup button:disabled { background: #aaa; cursor: not-allowed; }
.error { color: #dc3545; margin-bottom: 12px; display: none; text-align: center; }
.check { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
.check input { width: auto; margin: 0; }
.check label { margin: 0; }
.progress { display: none; text-align: center; padding: 20px; }
.progress .spinner { font-size: 32px; animation: spin 1s linear infinite; display: inline-block; }
@keyframes spin { to { transform: rotate(360deg); } }
</style>
</head>
<body>
<div class="setup">
<h1>Odoo Setup</h1>
<p class="subtitle">Richten Sie Ihre Datenbank ein</p>
<div id="error" class="error"></div>
<form id="setupForm">
<h2>Unternehmen</h2>
<label for="company_name">Firmenname *</label>
<input type="text" id="company_name" name="company_name" required placeholder="Mustermann GmbH"/>
<div class="row">
<div>
<label for="street">Straße</label>
<input type="text" id="street" name="street" placeholder="Musterstraße 1"/>
</div>
<div>
<label for="zip">PLZ</label>
<input type="text" id="zip" name="zip" placeholder="10115"/>
</div>
</div>
<div class="row">
<div>
<label for="city">Stadt</label>
<input type="text" id="city" name="city" placeholder="Berlin"/>
</div>
<div>
<label for="country">Land</label>
<select id="country" name="country">
<option value="DE" selected>Deutschland</option>
<option value="AT">Österreich</option>
<option value="CH">Schweiz</option>
</select>
</div>
</div>
<label for="email">Email</label>
<input type="email" id="email" name="email" placeholder="info@firma.de"/>
<label for="phone">Telefon</label>
<input type="text" id="phone" name="phone" placeholder="+49 30 12345678"/>
<label for="vat">USt-IdNr.</label>
<input type="text" id="vat" name="vat" placeholder="DE123456789"/>
<h2>Kontenrahmen</h2>
<select id="chart" name="chart">
<option value="skr03" selected>SKR03 (Standard, Prozessgliederung)</option>
<option value="skr04">SKR04 (Abschlussgliederung)</option>
<option value="none">Kein Kontenrahmen</option>
</select>
<h2>Administrator</h2>
<label for="admin_email">Login (Email) *</label>
<input type="email" id="admin_email" name="admin_email" required placeholder="admin@firma.de"/>
<label for="admin_password">Passwort *</label>
<input type="password" id="admin_password" name="admin_password" required minlength="4" placeholder="Mindestens 4 Zeichen"/>
<h2>Optionen</h2>
<div class="check">
<input type="checkbox" id="demo_data" name="demo_data"/>
<label for="demo_data">Demo-Daten laden (Beispielkunden, Rechnungen, etc.)</label>
</div>
<button type="submit" id="submitBtn">Datenbank einrichten</button>
</form>
<div id="progress" class="progress">
<div class="spinner">⟳</div>
<p style="margin-top:12px;color:#666;">Datenbank wird eingerichtet...</p>
</div>
</div>
<script>
document.getElementById('setupForm').addEventListener('submit', function(e) {
e.preventDefault();
var btn = document.getElementById('submitBtn');
var form = document.getElementById('setupForm');
var progress = document.getElementById('progress');
var errorEl = document.getElementById('error');
btn.disabled = true;
errorEl.style.display = 'none';
var data = {
company_name: document.getElementById('company_name').value,
street: document.getElementById('street').value,
zip: document.getElementById('zip').value,
city: document.getElementById('city').value,
country: document.getElementById('country').value,
email: document.getElementById('email').value,
phone: document.getElementById('phone').value,
vat: document.getElementById('vat').value,
chart: document.getElementById('chart').value,
admin_email: document.getElementById('admin_email').value,
admin_password: document.getElementById('admin_password').value,
demo_data: document.getElementById('demo_data').checked
};
form.style.display = 'none';
progress.style.display = 'block';
fetch('/web/setup/install', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(data)
})
.then(function(r) { return r.json(); })
.then(function(result) {
if (result.error) {
form.style.display = 'block';
progress.style.display = 'none';
errorEl.textContent = result.error;
errorEl.style.display = 'block';
btn.disabled = false;
} else {
window.location.href = '/web/login';
}
})
.catch(function(err) {
form.style.display = 'block';
progress.style.display = 'none';
errorEl.textContent = 'Verbindungsfehler: ' + err.message;
errorEl.style.display = 'block';
btn.disabled = false;
});
});
</script>
</body>
</html>`))
}
// SetupParams holds the setup wizard form data.
type SetupParams struct {
CompanyName string `json:"company_name"`
Street string `json:"street"`
Zip string `json:"zip"`
City string `json:"city"`
Country string `json:"country"`
Email string `json:"email"`
Phone string `json:"phone"`
VAT string `json:"vat"`
Chart string `json:"chart"`
AdminEmail string `json:"admin_email"`
AdminPassword string `json:"admin_password"`
DemoData bool `json:"demo_data"`
}
// handleSetupInstall processes the setup wizard form submission.
func (s *Server) handleSetupInstall(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var params SetupParams
if err := json.NewDecoder(r.Body).Decode(&params); err != nil {
writeJSON(w, map[string]string{"error": "Invalid request"})
return
}
if params.CompanyName == "" {
writeJSON(w, map[string]string{"error": "Firmenname ist erforderlich"})
return
}
if params.AdminEmail == "" || params.AdminPassword == "" {
writeJSON(w, map[string]string{"error": "Admin Email und Passwort sind erforderlich"})
return
}
log.Printf("setup: initializing database for %q", params.CompanyName)
// Hash admin password
hashedPw, err := tools.HashPassword(params.AdminPassword)
if err != nil {
writeJSON(w, map[string]string{"error": fmt.Sprintf("Password hash error: %v", err)})
return
}
// Map country code to name
countryName := "Germany"
phoneCode := "49"
switch params.Country {
case "AT":
countryName = "Austria"
phoneCode = "43"
case "CH":
countryName = "Switzerland"
phoneCode = "41"
}
// Run the seed with user-provided data
setupCfg := service.SetupConfig{
CompanyName: params.CompanyName,
Street: params.Street,
Zip: params.Zip,
City: params.City,
CountryCode: params.Country,
CountryName: countryName,
PhoneCode: phoneCode,
Email: params.Email,
Phone: params.Phone,
VAT: params.VAT,
Chart: params.Chart,
AdminLogin: params.AdminEmail,
AdminPassword: hashedPw,
DemoData: params.DemoData,
}
if err := service.SeedWithSetup(context.Background(), s.pool, setupCfg); err != nil {
log.Printf("setup: error: %v", err)
writeJSON(w, map[string]string{"error": fmt.Sprintf("Setup error: %v", err)})
return
}
log.Printf("setup: database initialized successfully for %q", params.CompanyName)
writeJSON(w, map[string]string{"status": "ok"})
}
func writeJSON(w http.ResponseWriter, v interface{}) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(v)
}