Compare commits
No commits in common. "18.0" and "18.0-MultiModulOpenWorkshop" have entirely different histories.
18.0
...
18.0-Multi
11
.gitignore
vendored
11
.gitignore
vendored
|
|
@ -1,7 +1,3 @@
|
||||||
# Test Logs
|
|
||||||
test_*.log
|
|
||||||
|
|
||||||
|
|
||||||
# ---> Python
|
# ---> Python
|
||||||
# Byte-compiled / optimized / DLL files
|
# Byte-compiled / optimized / DLL files
|
||||||
__pycache__/
|
__pycache__/
|
||||||
|
|
@ -19,7 +15,7 @@ dist/
|
||||||
downloads/
|
downloads/
|
||||||
eggs/
|
eggs/
|
||||||
.eggs/
|
.eggs/
|
||||||
#lib/
|
lib/
|
||||||
lib64/
|
lib64/
|
||||||
parts/
|
parts/
|
||||||
sdist/
|
sdist/
|
||||||
|
|
@ -163,8 +159,3 @@ cython_debug/
|
||||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||||
#.idea/
|
#.idea/
|
||||||
|
|
||||||
# DokuWiki API Test Script (enthält Credentials)
|
|
||||||
test_dokuwiki.py
|
|
||||||
|
|
||||||
# Python Virtual Environment
|
|
||||||
venv/
|
|
||||||
|
|
|
||||||
|
|
@ -1,46 +0,0 @@
|
||||||
# ✅ OpenWorkshop Test Checkliste
|
|
||||||
|
|
||||||
## 🔹 1. Migration
|
|
||||||
- [x] `migrate_existing_partners()` erzeugt zu jedem Partner genau einen `ows.user`.
|
|
||||||
- [x] `migrate_existing_partners()` übernimmt korrekt alte `vvow_*`-Felder.
|
|
||||||
- [x] `migrate_machine_access_from_old_fields()` erstellt korrekte Einträge in `ows.machine.access`.
|
|
||||||
- [x] `migrate_machine_access_from_old_fields()` übernimmt das Änderungsdatum aus `mail.tracking.value`.
|
|
||||||
|
|
||||||
## 🔹 2. Kontakte Backend
|
|
||||||
- [x] Beim Anlegen eines neuen Partners wird automatisch ein `ows.user` angelegt.
|
|
||||||
- [ ] Beim Speichern eines Partners wird überprüft, ob ein `ows.user` existiert, und ggf. angelegt.
|
|
||||||
- [x] Änderungen an Geburtstag, RFID, Haftung in Partner-Formular schreiben korrekt in `ows.user`.
|
|
||||||
- [x] Die Werte aus `ows.user` werden korrekt im Partnerformular angezeigt (via `compute`).
|
|
||||||
- [x] Das HTML-Widget mit Maschinenfreigaben (`machine_access_html`) wird korrekt dargestellt.
|
|
||||||
|
|
||||||
## 🔹 3. POS-Integration
|
|
||||||
- [x] Felder aus `ows.user` (Geburtstag, RFID etc.) erscheinen im POS-Kunden-Popup.
|
|
||||||
- [x] Maschinenfreigaben erscheinen im POS-Layout korrekt gruppiert nach Bereichen.
|
|
||||||
- [x] Farben der Maschinenbereiche werden korrekt aus `color_hex` übernommen.
|
|
||||||
|
|
||||||
## 🔹 4. Maschinenverwaltung
|
|
||||||
- [x] Maschinen-Formular zeigt Nutzungs- und Einweisungsprodukte korrekt an.
|
|
||||||
- [?] Drop-downs in den Produktlisten zeigen nur Produkte der richtigen Kategorie.
|
|
||||||
- [?] Neue Zuordnungen können direkt in den Tree-Ansichten editiert werden.
|
|
||||||
- [?] Filter greifen korrekt (Maschinennutzung / Einweisungen).
|
|
||||||
- [ ] In der Ansicht Wartung - Ausrüstung - Tab Offene Werkstatt (hobbyhimmel) wird unter Nutzungsprodukte und Einweisungsprodukte nur die Auswahl des Produkts angezeigt, eine Zuordnung zu einer Maschine ist automatisch die aktelle ausgewählte Maschine des Formulars.
|
|
||||||
|
|
||||||
## 🔹 5. Menüstruktur
|
|
||||||
- [ ] Menüeinträge "Nutzungsprodukte" und "Einweisungsprodukte" erscheinen unter Wartung > Konfiguration > Zuordnungen.
|
|
||||||
|
|
||||||
|
|
||||||
## 🔹 6. CSV/XML Demo-/Initialdaten
|
|
||||||
- [x] Maschinenbereiche (`ows.machine.area`) sind korrekt aus `data.xml` geladen.
|
|
||||||
- [x] Maschinen und ihre Produkt-Zuordnungen sind vollständig.
|
|
||||||
- [x] Kategorien und Produkte sind korrekt verknüpft (`product.category`, `product.product`).
|
|
||||||
|
|
||||||
## 🔹 7. Systemweite Konsistenz
|
|
||||||
- [x] Es gibt keine doppelten `ows.user`-Einträge.
|
|
||||||
- [bedingt] Kein Partner existiert ohne zugehörigen `ows.user`.
|
|
||||||
- [bedingt] `res.partner.ows_user_id` ist immer gefüllt.
|
|
||||||
|
|
||||||
## 🔹 8. Technische Qualität
|
|
||||||
- [ ] Kein `@api.depends('id')` mehr vorhanden.
|
|
||||||
- [ ] Commit wird in Migrationsfunktionen korrekt gesetzt (`self.env.cr.commit()`).
|
|
||||||
- [ ] Keine toten `vvow_*` Felder mehr im Modell (wenn auf ows.user umgestellt).
|
|
||||||
- [ ] post-init und pre-load Skripte laufen fehlerfrei bei Neuinstallation.
|
|
||||||
|
|
@ -1,323 +0,0 @@
|
||||||
# Projektplan: MQTT-basierte IoT-Events in Odoo 18 Community (Simulation-first)
|
|
||||||
|
|
||||||
Stand: 2026-01-10
|
|
||||||
Ziel: **Odoo-18-Community (self-hosted)** soll **Geräte-Events** (Timer/Maschinenlaufzeit, Waage, Zustandswechsel) über **MQTT** aufnehmen und in Odoo als **saubere, nachvollziehbare Sessions/Events** verarbeiten.
|
|
||||||
Wichtig: **Hardware wird zunächst vollständig simuliert** (Software-Simulatoren), damit die Odoo-Schnittstelle stabil steht, bevor echte Geräte angebunden werden.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Ziele und Nicht-Ziele
|
|
||||||
|
|
||||||
### 1.1 Ziele (MVP)
|
|
||||||
- Einheitliche **Device-Event-Schnittstelle** in Odoo (REST/Webhook) inkl. Authentifizierung
|
|
||||||
- **Event-Log** in Odoo (persistente Rohereignisse + Normalisierung)
|
|
||||||
- **Session-Logik** für Timer/Maschinenlaufzeit (Start/Stop/Heartbeat)
|
|
||||||
- **Simulation** von:
|
|
||||||
- Maschinenzustand (running/idle/fault)
|
|
||||||
- Timer-Events (run_start/run_stop)
|
|
||||||
- Waage (stable_weight/tare)
|
|
||||||
- Reproduzierbare Tests (Unit/Integration) und eindeutige Fehlerdiagnostik (Logging)
|
|
||||||
|
|
||||||
### 1.2 Nicht-Ziele (für Phase 1)
|
|
||||||
- Keine Enterprise-IoT-Box, keine Enterprise-Module
|
|
||||||
- Keine echte Hardware-Treiberentwicklung in Phase 1
|
|
||||||
- Kein POS-Frontend-Live-Widget (optional erst in späterer Phase)
|
|
||||||
- Keine Abrechnungslogik/Preisregeln (kann vorbereitet, aber nicht umgesetzt werden)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Zielarchitektur (Simulation-first)
|
|
||||||
|
|
||||||
### 2.1 Komponenten
|
|
||||||
1. **MQTT Broker** (z. B. Mosquitto in Docker)
|
|
||||||
2. **Device Simulator(s)** (Python/Node)
|
|
||||||
- veröffentlicht MQTT Topics wie echte Geräte
|
|
||||||
3. **Gateway/Bridge (Software)**
|
|
||||||
- abonniert MQTT Topics
|
|
||||||
- validiert/normalisiert Payload
|
|
||||||
- sendet Events via HTTPS an Odoo (Webhook)
|
|
||||||
4. **Odoo Modul** `ows_iot_bridge`
|
|
||||||
- REST Controller `/ows/iot/event`
|
|
||||||
- Modelle für Devices, Events, Sessions
|
|
||||||
- Business-Regeln für Session-Start/Stop/Hysterese (softwareseitig)
|
|
||||||
5. Optional später: **Realtime-Feed** (WebSocket) für POS/Display
|
|
||||||
|
|
||||||
### 2.2 Datenfluss
|
|
||||||
1. Simulator publiziert MQTT → `hobbyhimmel/machines/<machine_id>/state`
|
|
||||||
2. Bridge konsumiert MQTT, mappt auf Event-Format
|
|
||||||
3. Bridge POSTet JSON an Odoo Endpoint
|
|
||||||
4. Odoo speichert Roh-Event + erzeugt ggf. Session-Updates
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Schnittstelle zu Odoo (Kern des Projekts)
|
|
||||||
|
|
||||||
### 3.1 Authentifizierung
|
|
||||||
- Pro Device oder pro Bridge ein **API-Token** (Bearer)
|
|
||||||
- Header: `Authorization: Bearer <token>`
|
|
||||||
- Token in Odoo in `ows.iot.device` oder `ir.config_parameter` hinterlegt
|
|
||||||
- Optional: IP-Allowlist / Rate-Limit (in Reverse Proxy)
|
|
||||||
|
|
||||||
### 3.2 Endpoint (REST/Webhook)
|
|
||||||
- `POST /ows/iot/event`
|
|
||||||
- Content-Type: `application/json`
|
|
||||||
- Antwort:
|
|
||||||
- `200 OK` mit `{ "status": "ok", "event_id": "...", "session_id": "..." }`
|
|
||||||
- Fehler: `400` (Schema), `401/403` (Auth), `409` (Duplikat), `500` (Server)
|
|
||||||
|
|
||||||
### 3.3 Idempotenz / Duplikaterkennung
|
|
||||||
- Jedes Event enthält eine eindeutige `event_uid`
|
|
||||||
- Odoo legt Unique-Constraint auf `event_uid` (pro device)
|
|
||||||
- Wiederholte Events liefern `409 Conflict` oder `200 OK (duplicate=true)` (Designentscheidung)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Ereignis- und Topic-Standard (Versioniert)
|
|
||||||
|
|
||||||
### 4.1 MQTT Topics (v1)
|
|
||||||
- Maschinenzustand:
|
|
||||||
`hobbyhimmel/machines/<machine_id>/state`
|
|
||||||
- Maschinenereignisse:
|
|
||||||
`hobbyhimmel/machines/<machine_id>/event`
|
|
||||||
- Waage:
|
|
||||||
`hobbyhimmel/scales/<scale_id>/event`
|
|
||||||
- Geräte-Status (optional):
|
|
||||||
`hobbyhimmel/devices/<device_id>/status`
|
|
||||||
|
|
||||||
### 4.2 Gemeinsames JSON Event Schema (v1)
|
|
||||||
Pflichtfelder:
|
|
||||||
- `schema_version`: `"v1"`
|
|
||||||
- `event_uid`: UUID/string
|
|
||||||
- `ts`: ISO-8601 UTC (z. B. `"2026-01-10T12:34:56Z"`)
|
|
||||||
- `source`: `"simulator" | "device" | "gateway"`
|
|
||||||
- `device_id`: string
|
|
||||||
- `entity_type`: `"machine" | "scale" | "sensor"`
|
|
||||||
- `entity_id`: string (z. B. machine_id)
|
|
||||||
- `event_type`: string (siehe unten)
|
|
||||||
- `payload`: object
|
|
||||||
- `confidence`: `"high" | "medium" | "low"` (für Sensorfusion)
|
|
||||||
|
|
||||||
### 4.3 Event-Typen (v1)
|
|
||||||
**Maschine/Timer**
|
|
||||||
- `run_start` (Start einer Laufphase)
|
|
||||||
- `run_stop` (Ende einer Laufphase)
|
|
||||||
- `heartbeat` (optional, laufend während running)
|
|
||||||
- `state_changed` (idle/running/fault)
|
|
||||||
- `fault` (Fehler mit Code/Severity)
|
|
||||||
|
|
||||||
**Waage**
|
|
||||||
- `stable_weight` (stabiler Messwert)
|
|
||||||
- `weight` (laufend)
|
|
||||||
- `tare`
|
|
||||||
- `zero`
|
|
||||||
- `error`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Odoo Datenmodell (Vorschlag)
|
|
||||||
|
|
||||||
### 5.1 `ows.iot.device`
|
|
||||||
- `name`
|
|
||||||
- `device_id` (unique)
|
|
||||||
- `token_hash` (oder Token in separater Tabelle)
|
|
||||||
- `device_type` (machine/scale/...)
|
|
||||||
- `active`
|
|
||||||
- `last_seen`
|
|
||||||
- `notes`
|
|
||||||
|
|
||||||
### 5.2 `ows.iot.event`
|
|
||||||
- `event_uid` (unique)
|
|
||||||
- `device_id` (m2o -> device)
|
|
||||||
- `entity_type`, `entity_id`
|
|
||||||
- `event_type`
|
|
||||||
- `timestamp`
|
|
||||||
- `payload_json` (Text/JSON)
|
|
||||||
- `confidence`
|
|
||||||
- `processing_state` (new/processed/error)
|
|
||||||
- `session_id` (m2o optional)
|
|
||||||
|
|
||||||
### 5.3 `ows.machine.session` (Timer-Sessions)
|
|
||||||
- `machine_id` (Char oder m2o auf bestehendes Maschinenmodell)
|
|
||||||
- `start_ts`, `stop_ts`
|
|
||||||
- `duration_s` (computed)
|
|
||||||
- `state` (running/stopped/aborted)
|
|
||||||
- `origin` (sensor/manual/sim)
|
|
||||||
- `confidence_summary`
|
|
||||||
- `event_ids` (o2m)
|
|
||||||
|
|
||||||
> Hinweis: Wenn du bereits `ows.machine` aus deinem open_workshop nutzt, referenziert `machine_id` direkt dieses Modell.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Verarbeitungslogik (Phase 1: minimal, robust)
|
|
||||||
|
|
||||||
### 6.1 Session-Automat (State Machine)
|
|
||||||
- Eingang: Events `run_start`, `run_stop`, optional `heartbeat`
|
|
||||||
- Regeln:
|
|
||||||
- `run_start` erstellt neue Session, wenn keine läuft
|
|
||||||
- `run_start` während laufender Session → nur Log, keine neue Session
|
|
||||||
- `run_stop` schließt laufende Session
|
|
||||||
- Timeout-Regel: wenn `heartbeat` ausbleibt (z. B. 10 min) → Session `aborted`
|
|
||||||
|
|
||||||
### 6.2 Hysterese (Simulation als Stellvertreter für Sensorik)
|
|
||||||
In Simulation definierst du:
|
|
||||||
- Start, wenn „running“ mindestens **2s stabil**
|
|
||||||
- Stop, wenn „idle“ mindestens **15s stabil**
|
|
||||||
Diese Parameter als Odoo Systemparameter, z. B.:
|
|
||||||
- `ows_iot.start_debounce_s`
|
|
||||||
- `ows_iot.stop_debounce_s`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Simulation (Software statt Hardware)
|
|
||||||
|
|
||||||
### 7.1 Device Simulator: Maschine
|
|
||||||
- Konfigurierbar:
|
|
||||||
- Muster: random, fixed schedule, manuell per CLI
|
|
||||||
- Zustände: idle/running/fault
|
|
||||||
- Optional: „power_w“ und „vibration“ als Felder im Payload
|
|
||||||
- Publiziert MQTT in realistischen Intervallen
|
|
||||||
|
|
||||||
### 7.2 Device Simulator: Waage
|
|
||||||
- Modi:
|
|
||||||
- stream weight (mehrfach pro Sekunde)
|
|
||||||
- stable_weight nur auf „stabil“
|
|
||||||
- tare/zero Events per CLI
|
|
||||||
|
|
||||||
### 7.3 Bridge Simulator (MQTT → Odoo)
|
|
||||||
- Abonniert alle relevanten Topics
|
|
||||||
- Validiert Schema v1
|
|
||||||
- POSTet Events an Odoo
|
|
||||||
- Retry-Queue (lokal) bei Odoo-Ausfall
|
|
||||||
- Metriken/Logs:
|
|
||||||
- gesendete Events, Fehlerquoten, Latenz
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. Milestones & Deliverables
|
|
||||||
|
|
||||||
### M0 – Repo/Grundgerüst (0.5–1 Tag)
|
|
||||||
**Deliverables**
|
|
||||||
- Git-Repo Struktur
|
|
||||||
- Docker Compose: mosquitto + simulator + bridge
|
|
||||||
- Odoo Modul Skeleton `ows_iot_bridge`
|
|
||||||
|
|
||||||
### M1 – Odoo Endpoint & Modelle (1–2 Tage)
|
|
||||||
**Deliverables**
|
|
||||||
- `/ows/iot/event` Controller inkl. Token-Auth
|
|
||||||
- Modelle `ows.iot.device`, `ows.iot.event`
|
|
||||||
- Admin UI: Geräte anlegen, Token setzen, letzte Events anzeigen
|
|
||||||
|
|
||||||
### M2 – Session-Engine (1–2 Tage)
|
|
||||||
**Deliverables**
|
|
||||||
- Modell `ows.machine.session`
|
|
||||||
- Event → Session Zuordnung
|
|
||||||
- Timeout/Abbruch-Logik
|
|
||||||
- Parameter für Debounce/Timeout
|
|
||||||
|
|
||||||
### M3 – Simulatoren & End-to-End Test (1–2 Tage)
|
|
||||||
**Deliverables**
|
|
||||||
- Machine Simulator + Scale Simulator
|
|
||||||
- Bridge mit Retry-Queue
|
|
||||||
- End-to-End: Simulator → MQTT → Bridge → Odoo → Session entsteht
|
|
||||||
|
|
||||||
### M4 – Qualität & Betrieb (1 Tag)
|
|
||||||
**Deliverables**
|
|
||||||
- Logging-Konzept (Odoo + Bridge)
|
|
||||||
- Tests (Unit: Controller/Auth, Integration: Session Engine)
|
|
||||||
- Doku: Event-Schema v1, Topics, Beispielpayloads
|
|
||||||
|
|
||||||
### Optional M5 – POS/Anzeige (später)
|
|
||||||
- Realtime Anzeige im POS oder auf Display
|
|
||||||
- Live-Weight in POS
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. Testplan (Simulation-first)
|
|
||||||
|
|
||||||
### 9.1 Unit Tests (Odoo)
|
|
||||||
- Auth: gültiger Token → 200
|
|
||||||
- ungültig/fehlend → 401/403
|
|
||||||
- Schema-Validation → 400
|
|
||||||
- Idempotenz: duplicate `event_uid` → 409 oder duplicate-flag
|
|
||||||
|
|
||||||
### 9.2 Integration Tests
|
|
||||||
- Sequenz: start → heartbeat → stop → Session duration plausibel
|
|
||||||
- stop ohne start → kein Crash, Event loggt Fehlerzustand
|
|
||||||
- Timeout: start → keine heartbeat → Session aborted
|
|
||||||
|
|
||||||
### 9.3 Last-/Stabilitätstest (Simulator)
|
|
||||||
- 20 Maschinen, je 1 Event/s, 1h Lauf
|
|
||||||
- Ziel: Odoo bleibt stabil, Event-Insert performant, Queue läuft nicht über
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. Betriebs- und Sicherheitskonzept
|
|
||||||
|
|
||||||
- Token-Rotation möglich (neues Token, altes deaktivieren)
|
|
||||||
- Reverse Proxy:
|
|
||||||
- HTTPS
|
|
||||||
- Rate limiting auf `/ows/iot/event`
|
|
||||||
- optional Basic WAF Regeln
|
|
||||||
- Payload-Größenlimit (z. B. 16–64 KB)
|
|
||||||
- Bridge persistiert Retry-Queue auf Disk
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 11. Offene Entscheidungen (später, nicht blocker für MVP)
|
|
||||||
|
|
||||||
- Event-Consumer in Odoo vs. Bridge-only Webhook (empfohlen: Webhook)
|
|
||||||
- Genaues Mapping zu bestehenden open_workshop Modellen (`ows.machine`, Nutzer/RFID)
|
|
||||||
- Abrechnung: Preisregeln, Rundungen, POS-Integration
|
|
||||||
- Realtime: Odoo Bus vs. eigener WebSocket Service
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 12. Nächste Schritte (konkret)
|
|
||||||
|
|
||||||
1. `ows_iot_bridge` Modul: Endpoint + Device/Event Modelle implementieren
|
|
||||||
2. Docker Compose: mosquitto + bridge + simulator bereitstellen
|
|
||||||
3. Simulatoren produzieren v1-Events; Bridge sendet an Odoo
|
|
||||||
4. Session-Engine anschließen und End-to-End testen
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Anhang A: Beispiel-Event (Maschine run_start)
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"schema_version": "v1",
|
|
||||||
"event_uid": "c2a7d6f1-7d8d-4a63-9a7f-4e6d7b0d9e2a",
|
|
||||||
"ts": "2026-01-10T12:34:56Z",
|
|
||||||
"source": "simulator",
|
|
||||||
"device_id": "esp32-fraser-01",
|
|
||||||
"entity_type": "machine",
|
|
||||||
"entity_id": "formatkreissaege",
|
|
||||||
"event_type": "run_start",
|
|
||||||
"confidence": "high",
|
|
||||||
"payload": {
|
|
||||||
"power_w": 820,
|
|
||||||
"vibration": 0.73,
|
|
||||||
"reason": "power_threshold"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Anhang B: Beispiel-Event (Waage stable_weight)
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"schema_version": "v1",
|
|
||||||
"event_uid": "b6d0a2c5-9b1f-4a0b-8b19-7f2e1b8f3d11",
|
|
||||||
"ts": "2026-01-10T12:40:12Z",
|
|
||||||
"source": "simulator",
|
|
||||||
"device_id": "scale-sim-01",
|
|
||||||
"entity_type": "scale",
|
|
||||||
"entity_id": "waage-01",
|
|
||||||
"event_type": "stable_weight",
|
|
||||||
"confidence": "high",
|
|
||||||
"payload": {
|
|
||||||
"weight_g": 1532.4,
|
|
||||||
"unit": "g",
|
|
||||||
"stable_ms": 1200
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
@ -68,8 +68,7 @@ open_workshop/
|
||||||
├── open_workshop/ # ✅ Alt-Modul (installable=False, Kompatibilität)
|
├── open_workshop/ # ✅ Alt-Modul (installable=False, Kompatibilität)
|
||||||
├── open_workshop_base/ # ✅ FERTIG – Backend, _inherits maintenance.equipment
|
├── open_workshop_base/ # ✅ FERTIG – Backend, _inherits maintenance.equipment
|
||||||
├── open_workshop_pos/ # ✅ FERTIG – POS-Integration (JS, XML, UI)
|
├── open_workshop_pos/ # ✅ FERTIG – POS-Integration (JS, XML, UI)
|
||||||
├── open_workshop_dokuwiki/ # ⏳ IN ENTWICKLUNG – DokuWiki-Integration
|
└── open_workshop_api/ # ⏳ GEPLANT – REST API für WordPress
|
||||||
└── open_workshop_api/ # 💤 OPTIONAL – REST API (zurückgestellt)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Entfernt:** open_workshop_maintenance (verworfen - Funktionalität direkt in open_workshop_base integriert)
|
**Entfernt:** open_workshop_maintenance (verworfen - Funktionalität direkt in open_workshop_base integriert)
|
||||||
|
|
@ -82,7 +81,7 @@ open_workshop/
|
||||||
|
|
||||||
Enthält:
|
Enthält:
|
||||||
|
|
||||||
- ✅ `ows.machine` mit _inherits zu `maintenance.equipment` (delegiert alle Felder von maintenance.equipment transparent an ows.machine)
|
- ✅ `ows.machine` mit _inherits zu `maintenance.equipment`
|
||||||
- ✅ `ows.machine.area` (Bereiche mit JSONB-Namen)
|
- ✅ `ows.machine.area` (Bereiche mit JSONB-Namen)
|
||||||
- ✅ `ows.machine.product` (Nutzungsprodukte)
|
- ✅ `ows.machine.product` (Nutzungsprodukte)
|
||||||
- ✅ `ows.machine.training` (Einweisungsprodukte)
|
- ✅ `ows.machine.training` (Einweisungsprodukte)
|
||||||
|
|
@ -141,282 +140,87 @@ Vollständig separiertes POS-Modul mit:
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# 5. Modul: open_workshop_dokuwiki ⏳ IN ENTWICKLUNG
|
# 5. Modul: open_workshop_api ⏳ GEPLANT
|
||||||
|
|
||||||
**Status: IN ENTWICKLUNG (Branch: feature/dokuwiki-integration)**
|
**Status: NOCH NICHT IMPLEMENTIERT**
|
||||||
|
|
||||||
Dieses Modul integriert **DokuWiki** als Dokumentationssystem für Equipment.
|
Dieses Modul stellt die **REST API** bereit, über die WordPress öffentlich verfügbare Daten abholt.
|
||||||
|
|
||||||
## 5.1 Ziele
|
## 5.1 Ziele
|
||||||
|
|
||||||
- Automatische Wiki-Seiten für jedes Equipment
|
- Minimaler externer Zugriff (nur API-Endpunkte)
|
||||||
- **Invertierte Include-Architektur**:
|
- JSON-Ausgabe für WordPress
|
||||||
- Odoo generiert geschützte Hauptseite mit Equipment-Daten
|
- Keine Odoo-Website erforderlich
|
||||||
- Hauptseite inkludiert User-Dokumentation via Include Plugin
|
- Odoo selbst bleibt **nicht öffentlich erreichbar**
|
||||||
- User editieren separate `:doku` Unterseite
|
|
||||||
- **Klare Datentrennung**:
|
|
||||||
- Odoo = Master für strukturierte Daten (Hauptseite, read-only für User)
|
|
||||||
- User = Master für Freitext-Dokumentation (`:doku` Unterseite, editierbar)
|
|
||||||
- **Generisches ACL-System**: Eine Regel für alle Equipment
|
|
||||||
- Versionierung durch DokuWiki eingebaut
|
|
||||||
|
|
||||||
## 5.2 Architektur
|
## 5.2 API-Endpunkte
|
||||||
|
|
||||||
```
|
Pflicht:
|
||||||
Odoo (maintenance.equipment)
|
|
||||||
↓
|
|
||||||
XML-RPC Client
|
|
||||||
↓
|
|
||||||
DokuWiki API (wiki.putPage)
|
|
||||||
↓
|
|
||||||
werkstatt:ausruestung:bereich:maschinenname
|
|
||||||
```
|
|
||||||
|
|
||||||
## 5.3 DokuWiki-Seitenstruktur
|
|
||||||
|
|
||||||
**Zwei-Seiten-System pro Equipment:**
|
|
||||||
|
|
||||||
```
|
|
||||||
werkstatt:ausruestung:holzwerkstatt:formatkreissaege ← Hauptseite (Odoo generiert)
|
|
||||||
werkstatt:ausruestung:holzwerkstatt:formatkreissaege:doku ← Doku-Seite (User editieren)
|
|
||||||
werkstatt:ausruestung:lasercutter:epilog_fusion_m2 ← Hauptseite
|
|
||||||
werkstatt:ausruestung:lasercutter:epilog_fusion_m2:doku ← Doku-Seite
|
|
||||||
```
|
|
||||||
|
|
||||||
**Namespace-Regeln:**
|
|
||||||
- **Hauptnamespace**: `werkstatt:ausruestung:`
|
|
||||||
- **Hauptseite**: `werkstatt:ausruestung:bereich:maschinenname`
|
|
||||||
- Bereich: ows_area_id (normalisiert, lowercase)
|
|
||||||
- Maschinenname: Equipment name (normalisiert, ohne Sonderzeichen)
|
|
||||||
- **Komplett Odoo-generiert**, User können nicht editieren
|
|
||||||
|
|
||||||
- **Doku-Seite**: `werkstatt:ausruestung:bereich:maschinenname:doku`
|
|
||||||
- **User editieren hier frei** (Anleitungen, Tipps, Bilder)
|
|
||||||
- Wird via Include in Hauptseite eingebunden
|
|
||||||
- User können weitere Unterseiten anlegen (`:doku:bilder`, `:doku:videos`)
|
|
||||||
|
|
||||||
## 5.4 Hauptseiten-Template (Odoo-generiert)
|
|
||||||
|
|
||||||
Odoo erstellt die komplette Hauptseite inklusive Include für User-Dokumentation:
|
|
||||||
|
|
||||||
```wiki
|
|
||||||
====== Formatkreissäge ======
|
|
||||||
|
|
||||||
===== Grunddaten =====
|
|
||||||
* **Seriennummer:** FKS-2024-001
|
|
||||||
* **Bereich:** [[werkstatt:ausruestung:holzwerkstatt|Holzwerkstatt]]
|
|
||||||
* **Kategorie:** 🔴 Kategorie 3 (Einweisung erforderlich)
|
|
||||||
* **Standort:** Schrank A, Regal 2
|
|
||||||
* **Hersteller:** Bosch
|
|
||||||
|
|
||||||
===== Sicherheit =====
|
|
||||||
* **Einweisungspflichtig:** Ja
|
|
||||||
* **Nutzungsprodukte:**
|
|
||||||
* Holzwerkstatt Nutzung (5€/Tag)
|
|
||||||
* **Einweisungsprodukte:**
|
|
||||||
* Einweisung Formatkreissäge (einmalig 25€)
|
|
||||||
|
|
||||||
===== Wartung =====
|
|
||||||
* **Letzte Wartung:** 01.12.2025
|
|
||||||
* **Nächste Wartung:** 01.06.2026
|
|
||||||
* **Wartungshistorie:** [[odoo:maintenance:request:42|Alle Wartungsaufträge]]
|
|
||||||
|
|
||||||
===== Dokumentation & Anleitungen =====
|
|
||||||
{{page>.:formatkreissaege:doku&noheader}}
|
|
||||||
|
|
||||||
[[.:formatkreissaege:doku|✏️ Dokumentation bearbeiten]]
|
|
||||||
```
|
|
||||||
|
|
||||||
**User-Doku-Seite** (`:doku`, User editieren hier):
|
|
||||||
```wiki
|
|
||||||
==== Bedienung ====
|
|
||||||
1. Hauptschalter einschalten
|
|
||||||
2. Sägeblatt-Drehzahl einstellen
|
|
||||||
3. Anschlag positionieren
|
|
||||||
...
|
|
||||||
|
|
||||||
==== Tipps & Tricks ====
|
|
||||||
- Bei Hartholz: Vorschub langsamer
|
|
||||||
- Sägeblatt nach 50h wechseln
|
|
||||||
|
|
||||||
==== Bilder ====
|
|
||||||
{{.:doku:bild1.jpg?300}}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 5.5 Technische Umsetzung
|
|
||||||
|
|
||||||
**Python-Bibliothek:**
|
|
||||||
- `dokuwiki` (Python XML-RPC Client für DokuWiki)
|
|
||||||
- Installation: `pip install dokuwiki`
|
|
||||||
|
|
||||||
**API-Methoden:**
|
|
||||||
```python
|
|
||||||
# DokuWiki XML-RPC API
|
|
||||||
wiki.putPage(page_id, content, summary, minor) # Seite erstellen/aktualisieren
|
|
||||||
wiki.getPage(page_id) # Seite lesen
|
|
||||||
wiki.getAllPages() # Alle Seiten auflisten
|
|
||||||
```
|
|
||||||
|
|
||||||
**Odoo-Integration:**
|
|
||||||
- Neues Model: `ows.dokuwiki.config` (DokuWiki-URL, Credentials)
|
|
||||||
- Methode auf `maintenance.equipment`: `sync_to_dokuwiki()`
|
|
||||||
- Automated Action: Bei Equipment-Änderung → Wiki aktualisieren
|
|
||||||
- Smart Button: "Wiki öffnen" → Link zur DokuWiki-Seite
|
|
||||||
|
|
||||||
**Sync-Algorithmus (vereinfacht!):**
|
|
||||||
```python
|
|
||||||
def sync_to_dokuwiki(self):
|
|
||||||
"""Generiert Hauptseite mit Include für User-Doku"""
|
|
||||||
# 1. Generiere Hauptseite komplett (Odoo hat volle Kontrolle)
|
|
||||||
content = self._generate_equipment_page()
|
|
||||||
|
|
||||||
# 2. Füge Include für User-Doku hinzu
|
|
||||||
wiki_id = self._get_normalized_name()
|
|
||||||
content += "\n\n===== Dokumentation & Anleitungen =====\n"
|
|
||||||
content += f"{{{{page>.:{{wiki_id}}:doku&noheader}}}}\n\n"
|
|
||||||
content += f"[[.:{{wiki_id}}:doku|✏️ Dokumentation bearbeiten]]\n"
|
|
||||||
|
|
||||||
# 3. Schreibe Hauptseite (überschreibt komplett)
|
|
||||||
main_page = f"werkstatt:ausruestung:{self.ows_area_id.wiki_name}:{wiki_id}"
|
|
||||||
dokuwiki_client.putPage(main_page, content, "Odoo Auto-Sync")
|
|
||||||
|
|
||||||
# 4. Erstelle leere Doku-Seite falls nicht vorhanden
|
|
||||||
doku_page = f"{main_page}:doku"
|
|
||||||
if not dokuwiki_client.getPage(doku_page):
|
|
||||||
initial_doku = "==== Bedienung ====\n\n==== Tipps & Tricks ====\n"
|
|
||||||
dokuwiki_client.putPage(doku_page, initial_doku, "Initial")
|
|
||||||
|
|
||||||
def _generate_equipment_page(self):
|
|
||||||
"""Generiert komplette Hauptseite mit Equipment-Daten"""
|
|
||||||
return f"""====== {self.name} ======
|
|
||||||
|
|
||||||
===== Grunddaten =====
|
|
||||||
* **Seriennummer:** {self.serial_no or 'N/A'}
|
|
||||||
* **Bereich:** [[werkstatt:ausruestung:{self.ows_area_id.wiki_name}|{self.ows_area_id.name}]]
|
|
||||||
* **Kategorie:** {self._get_category_icon()} {self._get_category_label()}
|
|
||||||
* **Standort:** {self.location or 'N/A'}
|
|
||||||
* **Hersteller:** {self.partner_id.name or 'N/A'}
|
|
||||||
|
|
||||||
===== Sicherheit =====
|
|
||||||
* **Einweisungspflichtig:** {self._is_training_required()}
|
|
||||||
* **Nutzungsprodukte:**
|
|
||||||
{self._format_product_list(self.ows_product_ids)}
|
|
||||||
* **Einweisungsprodukte:**
|
|
||||||
{self._format_product_list(self.ows_training_ids)}
|
|
||||||
|
|
||||||
===== Wartung =====
|
|
||||||
* **Letzte Wartung:** {self.last_maintenance_date or 'N/A'}
|
|
||||||
* **Nächste Wartung:** {self.next_action_date or 'N/A'}
|
|
||||||
"""
|
|
||||||
```
|
|
||||||
|
|
||||||
**Vorteile dieser Architektur:**
|
|
||||||
- ✅ Keine Sync-Marker nötig (Hauptseite wird komplett überschrieben)
|
|
||||||
- ✅ User können Odoo-Daten nicht versehentlich löschen
|
|
||||||
- ✅ User-Content ist komplett geschützt (separate Seite)
|
|
||||||
- ✅ Include funktioniert transparent (User sehen alles auf einer Seite)
|
|
||||||
- ✅ Wartungsfreies ACL-System
|
|
||||||
|
|
||||||
## 5.6 ACL-System (Generisch & Wartungsfrei)
|
|
||||||
|
|
||||||
**Phase 1: Nur für Odoo sichtbar (Initial-Setup)**
|
|
||||||
|
|
||||||
```
|
|
||||||
# Komplett gesperrt für alle außer Odoo
|
|
||||||
werkstatt:ausruestung:* @odoo 8 # Voller Zugriff
|
|
||||||
werkstatt:ausruestung:* @ALL 0 # Kein Zugriff
|
|
||||||
```
|
|
||||||
|
|
||||||
**Phase 2: Werkstatt-Betreiber erhalten Zugriff (später)**
|
|
||||||
|
|
||||||
```
|
|
||||||
# User-Doku-Seiten editierbar (spezifischere Regel zuerst!)
|
|
||||||
werkstatt:ausruestung:*:doku @werkstatt 2 # Editieren erlaubt
|
|
||||||
werkstatt:ausruestung:*:doku:* @werkstatt 2 # Auch Unterseiten (Bilder, Videos, etc.)
|
|
||||||
|
|
||||||
# Hauptseiten nur lesen
|
|
||||||
werkstatt:ausruestung:* @werkstatt 1 # Nur Lesezugriff
|
|
||||||
werkstatt:ausruestung:* @ALL 1 # Alle können lesen
|
|
||||||
|
|
||||||
# Odoo hat vollen Zugriff überall
|
|
||||||
werkstatt:ausruestung:* @odoo 8 # Voller Zugriff
|
|
||||||
werkstatt:ausruestung:*:doku @odoo 8 # Auch auf Doku-Seiten (für Initial-Erstellung)
|
|
||||||
**Wie es funktioniert:**
|
|
||||||
|
|
||||||
| Seite | User @werkstatt | User @odoo | Ergebnis |
|
|
||||||
|-------|-----------------|------------|----------|
|
|
||||||
| `werkstatt:ausruestung:holzwerkstatt:formatkreissaege` | Matched: `werkstatt:ausruestung:*` → 1 | Matched: `werkstatt:ausruestung:*` → 8 | User: Lesen, Odoo: Schreiben |
|
|
||||||
| `werkstatt:ausruestung:holzwerkstatt:formatkreissaege:doku` | Matched: `werkstatt:ausruestung:*:doku` → 2 | Matched: `werkstatt:ausruestung:*:doku` → 8 | User: Editieren, Odoo: Voll |
|
|
||||||
- `16` = Admin (alle Rechte)
|
|
||||||
**Vorteile:**
|
|
||||||
- ✅ **Komplett generisch**: Gilt automatisch für alle Equipment
|
|
||||||
- ✅ **Wartungsfrei**: Kein manuelles ACL-Setup pro Equipment
|
|
||||||
- ✅ **Phasenweise Freischaltung**: Erst Odoo-Only, dann schrittweise für Werkstatt
|
|
||||||
- ✅ **Selbsterklärend**: User sehen sofort wo sie editieren können
|
|
||||||
- ✅ **Skalierbar**: Funktioniert auch für 1000+ Equipment-Seiten
|
|
||||||
| `ausruestung:holzwerkstatt:formatkreissaege` | Matched: `ausruestung:*` → 1 | Matched: `ausruestung:*` → 8 | User: Lesen, Odoo: Schreiben |
|
|
||||||
| `ausruestung:holzwerkstatt:formatkreissaege:doku` | Matched: `ausruestung:*:doku` → 2 | Matched: `ausruestung:*:doku` → 8 | User: Editieren, Odoo: Voll |
|
|
||||||
|
|
||||||
**Vorteile:**
|
|
||||||
- ✅ **Komplett generisch**: Gilt automatisch für alle Equipment
|
|
||||||
- ✅ **Wartungsfrei**: Kein manuelles ACL-Setup pro Equipment
|
|
||||||
- ✅ **Selbsterklärend**: User sehen sofort wo sie editieren können
|
|
||||||
- ✅ **Skalierbar**: Funktioniert auch für 1000+ Equipment-Seiten
|
|
||||||
|
|
||||||
**Odoo-Seite:**
|
|
||||||
- Credentials in `ir.config_parameter`:
|
|
||||||
- `dokuwiki.url` (z.B. `https://wiki.hobbyhimmel.de`)
|
|
||||||
- `dokuwiki.user` (z.B. `odoo`)
|
|
||||||
- `dokuwiki.password` (verschlüsselt gespeichert)
|
|
||||||
- XML-RPC über HTTPS
|
|
||||||
- Logging aller Wiki-Operationen in `mail.message`
|
|
||||||
|
|
||||||
## 5.7 Vorteile DokuWiki
|
|
||||||
|
|
||||||
✅ **Leichtgewichtig** – File-based, keine separate Datenbank
|
|
||||||
✅ **Versionierung** – Eingebautes History-System
|
|
||||||
✅ **Wiki-Syntax** – Perfekt für strukturierte Dokumentation
|
|
||||||
✅ **ACL-System** – Granulare Berechtigungen
|
|
||||||
✅ **Suchfunktion** – Volltext-Suche eingebaut
|
|
||||||
✅ **Plugins** – Erweiterbar (Markdown, Galleries, etc.)
|
|
||||||
✅ **Open Source** – Keine Lizenzkosten
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# 6. Modul: open_workshop_api ⏳ GEPLANT
|
|
||||||
|
|
||||||
**Status: ZURÜCKGESTELLT (DokuWiki hat Priorität)**
|
|
||||||
|
|
||||||
REST API für externe Systeme (falls später WordPress/App-Integration gewünscht).
|
|
||||||
|
|
||||||
## 6.1 Potenzielle Endpunkte
|
|
||||||
|
|
||||||
```
|
```
|
||||||
GET /api/v1/machines
|
GET /api/v1/machines
|
||||||
GET /api/v1/machine/<id>
|
GET /api/v1/machine/<id>
|
||||||
GET /api/v1/areas
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Hinweis:** Mit DokuWiki kann die öffentliche Dokumentation direkt über das Wiki erfolgen.
|
Empfohlen:
|
||||||
Eine separate REST API ist optional und wird nur bei Bedarf implementiert.
|
|
||||||
|
|
||||||
## 6.1 Warum DokuWiki statt WordPress?
|
```
|
||||||
|
GET /api/v1/areas
|
||||||
|
GET /api/v1/files/<attachment_id>
|
||||||
|
GET /api/v1/events (später für Kurse)
|
||||||
|
```
|
||||||
|
|
||||||
- **Einfacher**: Keine separate Datenbank, file-based
|
## 5.3 JSON Beispiel
|
||||||
- **Wiki-Syntax**: Perfekt für technische Dokumentation
|
|
||||||
- **Versionierung**: Eingebautes History-System
|
|
||||||
- **ACL**: Feinere Berechtigungskontrolle
|
|
||||||
- **Leichtgewichtig**: Weniger Overhead als WordPress
|
|
||||||
- **Integration**: XML-RPC API für bidirektionale Sync
|
|
||||||
|
|
||||||
## 6.2 Zukunft: WordPress optional
|
```json
|
||||||
|
{
|
||||||
|
"id": 12,
|
||||||
|
"name": "Formatkreissäge",
|
||||||
|
"status": "available",
|
||||||
|
"area": "Holzwerkstatt",
|
||||||
|
"image_url": "https://odoo.example.org/api/v1/file/29",
|
||||||
|
"manual_url": "https://odoo.example.org/api/v1/file/55",
|
||||||
|
"training_required": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
Falls später gewünscht, kann WordPress **zusätzlich** die öffentliche Maschinenliste anzeigen:
|
## 5.4 Sicherheitsmechanismen
|
||||||
- DokuWiki für **Dokumentation** (intern/extern lesbar)
|
|
||||||
- WordPress für **Präsentation** (extern mit Bildern, SEO)
|
|
||||||
- Beide Systeme greifen auf Odoo-Daten zu
|
|
||||||
|
|
||||||
Aktuell hat DokuWiki-Integration **Priorität**.
|
- CORS nur für WordPress-Domain erlauben
|
||||||
|
- Optionaler Token im Header (`Authorization: Bearer <token>`)
|
||||||
|
- Kein Zugriff auf `/web`, `/api/web`, `/pos`
|
||||||
|
- Rate Limiting via Reverse Proxy
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 6. WordPress Integration ⏳ GEPLANT
|
||||||
|
|
||||||
|
**Status: VORBEREITET (API noch nicht implementiert)**
|
||||||
|
|
||||||
|
WordPress bleibt die öffentliche Website.
|
||||||
|
|
||||||
|
## 6.1 Warum?
|
||||||
|
|
||||||
|
- Sehr hohe Performance (CDN, Caching)
|
||||||
|
- SEO-optimiert
|
||||||
|
- Keine Belastung deiner Internetleitung
|
||||||
|
- Keine Sicherheitsrisiken im internen Odoo
|
||||||
|
- Keine Lizenzkosten
|
||||||
|
|
||||||
|
## 6.2 WordPress Plugin (bereitgestellt)
|
||||||
|
|
||||||
|
Das Plugin:
|
||||||
|
|
||||||
|
- Ruft Odoo-API ab
|
||||||
|
- Rendert Maschinenliste via Shortcode
|
||||||
|
- Konsumiert JSON-Daten
|
||||||
|
- Unterstützt Token-Auth
|
||||||
|
|
||||||
|
Beispiel:
|
||||||
|
|
||||||
|
```
|
||||||
|
[openworkshop_machines]
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -425,58 +229,46 @@ Aktuell hat DokuWiki-Integration **Priorität**.
|
||||||
```
|
```
|
||||||
Internet
|
Internet
|
||||||
|
|
|
|
||||||
DokuWiki (Dokumentation)
|
WordPress (Frontend, CDN)
|
||||||
WordPress (optional: Präsentation)
|
|
||||||
|
|
|
|
||||||
XML-RPC / REST API
|
fetch JSON via REST API
|
||||||
|
|
|
|
||||||
----------------------------
|
----------------------------
|
||||||
| Odoo Backend (LAN/VPN)
|
| API Gateway (open_workshop_api)
|
||||||
| open_workshop_dokuwiki
|
| Exposed only: /api/v1/*
|
||||||
| maintenance.equipment
|
|
||||||
----------------------------
|
----------------------------
|
||||||
POS, Benutzer, Einweisungen
|
|
|
||||||
|
Odoo Backend (LAN/VPN)
|
||||||
|
Maschinen, POS, Benutzer, Einweisungen
|
||||||
```
|
```
|
||||||
|
|
||||||
**Fokus:** Equipment-Dokumentation über DokuWiki, WordPress später optional.
|
**Kein direkter Zugriff auf Odoo-Weboberfläche!**
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# 8. Sicherheit
|
# 8. Sicherheit
|
||||||
|
|
||||||
**DokuWiki ACL:**
|
- Reverse Proxy (Traefik/Nginx) muss alle Odoo-Backoffice-URLS sperren
|
||||||
- Namespace `ausruestung:*` lesbar für alle (@ALL = 1)
|
- Nur `/api/v1/*` darf öffentlich sein
|
||||||
- Namespace `ausruestung:*` schreibbar nur für Odoo (@odoo = 8)
|
|
||||||
- Manuelle Edits durch Admins möglich
|
|
||||||
|
|
||||||
**Odoo:**
|
|
||||||
- DokuWiki-Credentials in ir.config_parameter
|
|
||||||
- XML-RPC über HTTPS
|
|
||||||
- Logging aller Wiki-Operationen
|
|
||||||
- Backup-System für Wiki-Dateien
|
|
||||||
|
|
||||||
**Optional (REST API):**
|
|
||||||
- Reverse Proxy sperrt alle Odoo-URLs außer `/api/v1/*`
|
|
||||||
- HTTPS erzwingen
|
- HTTPS erzwingen
|
||||||
- Rate-Limit + Firewall
|
- Rate-Limit + Firewall-Regeln
|
||||||
- Token-Auth
|
- Token-Authentifizierung optional zusätzlich einsetzbar
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# 9. Vorteile dieser Architektur
|
# 9. Vorteile dieser Architektur
|
||||||
|
|
||||||
✔ **DokuWiki für Dokumentation** – Wiki-Syntax perfekt für Anleitungen
|
✔ **WordPress bleibt ultraschnell** (1000 Besucher/Tag problemlos)
|
||||||
✔ **Versionierung eingebaut** – History für alle Änderungen
|
|
||||||
✔ **Odoo bleibt sicher** hinter Firewall
|
✔ **Odoo bleibt sicher** hinter Firewall
|
||||||
✔ **Keine Benutzerkosten** – alle Ehrenamtlichen können intern mitarbeiten
|
✔ **Keine Benutzerkosten** → alle Ehrenamtlichen können intern mitarbeiten
|
||||||
✔ **Modular, wartbar, zukunftssicher**
|
✔ **Modular, wartbar, zukunftssicher**
|
||||||
✔ **Intelligente Sync** – Nur strukturierte Daten werden überschrieben, Freitext bleibt erhalten
|
✔ **API erlaubt feine Steuerung**, welche Daten öffentlich sind
|
||||||
✔ **Öffentlich lesbar** – Anleitungen für alle verfügbar
|
✔ **Keine Last auf DSL-Leitung** (WordPress hostet extern)
|
||||||
✔ **WordPress optional** – Kann später für Marketing/SEO ergänzt werden
|
✔ **Maintenance-Integration** ohne Extra-Modul direkt in Base
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# 10. Implementierungsstand (13.12.2025)
|
# 10. Implementierungsstand (08.12.2025)
|
||||||
|
|
||||||
| Schritt | Status | Details |
|
| Schritt | Status | Details |
|
||||||
|---------|--------|---------|
|
|---------|--------|---------|
|
||||||
|
|
@ -484,55 +276,45 @@ Aktuell hat DokuWiki-Integration **Priorität**.
|
||||||
| 2. open_workshop_pos | ✅ **FERTIG** | POS-Integration komplett |
|
| 2. open_workshop_pos | ✅ **FERTIG** | POS-Integration komplett |
|
||||||
| 3. ~~open_workshop_maintenance~~ | ❌ **VERWORFEN** | Direkt in Base integriert |
|
| 3. ~~open_workshop_maintenance~~ | ❌ **VERWORFEN** | Direkt in Base integriert |
|
||||||
| 4. Maintenance Integration | ✅ **FERTIG** | _inherits Pattern implementiert |
|
| 4. Maintenance Integration | ✅ **FERTIG** | _inherits Pattern implementiert |
|
||||||
| 5. Equipment-View Integration | ✅ **FERTIG** | Related fields, Menu-Migration |
|
| 5. Migration Workflow | ✅ **FERTIG** | SQL + Python, CI/CD integriert |
|
||||||
| 6. Migration Workflow | ✅ **FERTIG** | SQL + Python, CI/CD integriert |
|
| 6. open_workshop_api | ⏳ **GEPLANT** | REST API für WordPress |
|
||||||
| 7. open_workshop_dokuwiki | ⏳ **IN ENTWICKLUNG** | Branch: feature/dokuwiki-integration |
|
| 7. WordPress Plugin | ⏳ **GEPLANT** | Frontend-Integration |
|
||||||
| 8. open_workshop_api | 💤 **OPTIONAL** | Zurückgestellt, bei Bedarf |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# 11. Nächste Schritte
|
# 11. Nächste Schritte
|
||||||
|
|
||||||
1. **open_workshop_dokuwiki** entwickeln
|
1. **open_workshop_api** entwickeln
|
||||||
- DokuWiki XML-RPC Client implementieren
|
- REST Controller implementieren
|
||||||
- Template-System für Equipment-Seiten
|
- JSON-Serializer für machines/areas
|
||||||
- Sync-Mechanismus (Odoo → Wiki)
|
- CORS und Security konfigurieren
|
||||||
- Smart Button "Wiki öffnen"
|
- Token-Auth optional hinzufügen
|
||||||
- ACL-Konfiguration dokumentieren
|
|
||||||
|
|
||||||
2. **DokuWiki aufsetzen**
|
2. **WordPress Plugin** anpassen
|
||||||
- Installation auf Server
|
- API-Endpunkte konfigurieren
|
||||||
- Namespace `ausruestung:` anlegen
|
- Shortcode-Rendering
|
||||||
- ACL konfigurieren (@odoo Gruppe)
|
- Caching implementieren
|
||||||
- XML-RPC API aktivieren
|
|
||||||
|
|
||||||
3. **Testing**
|
3. **Testing & Deployment**
|
||||||
- Equipment-Seiten automatisch generieren
|
- API-Tests schreiben
|
||||||
- Sync bei Änderungen testen
|
- Reverse Proxy konfigurieren
|
||||||
- ACL-Rechte validieren
|
- Performance-Tests
|
||||||
- Performance testen
|
- Go-Live vorbereiten
|
||||||
|
|
||||||
4. **Optional: WordPress Plugin**
|
|
||||||
- Falls öffentliche Präsentation gewünscht
|
|
||||||
- REST API dann implementieren
|
|
||||||
- Caching-Layer hinzufügen
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# 12. Endfazit
|
# 12. Endfazit
|
||||||
|
|
||||||
Diese Architektur vereint das Beste aus beiden Welten:
|
Diese Architektur hat sich bewährt:
|
||||||
|
|
||||||
- ✅ **Technisch korrekt** – _inherits Pattern statt separatem Modul
|
- ✅ **Technisch korrekt** – _inherits Pattern statt separatem Modul
|
||||||
- ✅ **Performant** – Maintenance.equipment als Single Source of Truth
|
- ✅ **Performant** – Maintenance.equipment als Single Source of Truth
|
||||||
- ✅ **Dokumentiert** – DokuWiki für strukturierte Anleitungen
|
- ✅ **Sicher** – API-Layer trennt intern/extern
|
||||||
- ✅ **Versioniert** – History-System eingebaut
|
- ✅ **Kostenoptimiert** – Keine Odoo.sh Lizenzen nötig
|
||||||
- ✅ **Öffentlich zugänglich** – Wiki lesbar für alle
|
|
||||||
- ✅ **Kostenoptimiert** – Keine zusätzlichen Lizenzen
|
|
||||||
- ✅ **Langfristig erweiterbar** – Modularer Aufbau
|
- ✅ **Langfristig erweiterbar** – Modularer Aufbau
|
||||||
- ✅ **Produktiv im Einsatz** – Migration erfolgreich abgeschlossen
|
- ✅ **Produktiv im Einsatz** – Migration erfolgreich abgeschlossen
|
||||||
|
|
||||||
**DokuWiki-Integration ist der nächste logische Schritt** für bessere Equipment-Dokumentation.
|
Die API ist **zentrales Zukunftsmodul** für die WordPress-Integration.
|
||||||
|
|
||||||
**Letztes Update: 13.12.2025**
|
**Letztes Update: 08.12.2025**
|
||||||
|
|
||||||
|
|
|
||||||
415
README.md
415
README.md
|
|
@ -1,415 +0,0 @@
|
||||||
# Open Workshop
|
|
||||||
|
|
||||||
**POS-basiertes Maschinenfreigabe- und Werkstattverwaltungssystem für Odoo**
|
|
||||||
|
|
||||||
Open Workshop ist eine modulare Erweiterung des Odoo POS-Systems zur Verwaltung von einweisungspflichtigen Maschinen und Geräten in Werkstätten, Makerspaces und FabLabs. Das System ermöglicht die Verwaltung von Maschinenfreigaben, Einweisungen und Equipment-Dokumentation.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Hauptfunktionen
|
|
||||||
|
|
||||||
- **Maschinenfreigaben**: Verwaltung von kundenspezifischen Freigaben für einweisungspflichtige Geräte
|
|
||||||
- **Sicherheitskategorien**: Dreistufiges Kategoriesystem (grün/gelb/rot) für unterschiedliche Einweisungspflichten
|
|
||||||
- **POS-Integration**: Verkauf von Einweisungen und Anzeige von Maschinenfreigaben direkt am Point of Sale
|
|
||||||
- **Equipment-Verwaltung**: Vollständige Integration mit dem OCA Maintenance-Modul
|
|
||||||
|
|
||||||
- **Dokumentation**: Automatische Synchronisation von Equipment-Daten mit DokuWiki
|
|
||||||
- **Mitarbeiter-Display**: Dynamische Anzeige von Mitarbeiterdaten im POS Customer Display
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Module
|
|
||||||
|
|
||||||
### open_workshop_base
|
|
||||||
**Kernmodul** - Stellt die Basis-Funktionalität für das gesamte System bereit.
|
|
||||||
|
|
||||||
**Hauptfunktionen:**
|
|
||||||
- Maschinenmodelle mit Sicherheitskategorien (grün/gelb/rot)
|
|
||||||
- Bereiche (z.B. Holz, Metall, FabLab)
|
|
||||||
- Freigabeverwaltung (wer darf welche Maschine nutzen)
|
|
||||||
- Produktverknüpfungen für Einweisungen und Nutzung
|
|
||||||
- Integration mit OCA maintenance.equipment als Single Source of Truth
|
|
||||||
|
|
||||||
**Datenmodelle:**
|
|
||||||
- `ows.user` - Benutzerdaten (RFID, Geburtstag, Sicherheitsunterweisung)
|
|
||||||
- `ows.machine.area` - Maschinenbereiche mit Farbkodierung
|
|
||||||
- `ows.machine` - Maschinen (nutzt _inherits Pattern mit maintenance.equipment)
|
|
||||||
- `ows.machine.access` - Freigaben (Partner ↔ Maschine)
|
|
||||||
- `ows.machine.product` - Nutzungsprodukte für Maschinen
|
|
||||||
- `ows.machine.training` - Einweisungsprodukte für Maschinen
|
|
||||||
|
|
||||||
**Abhängigkeiten:**
|
|
||||||
- OCA `maintenance` (Basis-Equipment-Verwaltung)
|
|
||||||
- OCA `maintenance_equipment_status` (Equipment-Statusverwaltung)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone https://github.com/OCA/maintenance.git
|
|
||||||
cd maintenance
|
|
||||||
git reset --hard 5510275e
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### open_workshop_pos
|
|
||||||
**POS-Integration** - Erweitert den Odoo Point of Sale um Maschinenfreigabe-Funktionalität.
|
|
||||||
|
|
||||||
**Hauptfunktionen:**
|
|
||||||
- **Machine Access List**: Übersichtsanzeige aller verfügbaren Maschinen welche Einweisungspflichtig sind, mit Freigabestatus für den aktuellen Kunden (roter Punkt)
|
|
||||||
- **Customer Sidebar**: Kundenspezifische Maschinenfreigaben am POS
|
|
||||||
- **POS Sidebar**: Maschinenauswahl und Statusanzeige
|
|
||||||
- **Automatische Freigabenvergabe**: Beim Verkauf eines Einweisungsprodukts wird dem Kunden automatisch die Freigabe für das zugehörige Gerät in der Datenbank erteilt
|
|
||||||
|
|
||||||
**Verwendungszweck:**
|
|
||||||
Verkauf von Maschineneinweisungen und Anzeige bestehender Freigaben direkt am Kassensystem. Ermöglicht schnelle Prüfung, welcher Kunde auf welche einweisungspflichtigen Geräte zugreifen darf.
|
|
||||||
|
|
||||||
**Abhängigkeiten:**
|
|
||||||
- `open_workshop_base`
|
|
||||||
- `point_of_sale`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### open_workshop_pos_customer_display
|
|
||||||
**Mitarbeiter-Display** - Zeigt dynamisches Mitarbeiter-Namensschild im POS Customer Display.
|
|
||||||
|
|
||||||
**Hauptfunktionen:**
|
|
||||||
- Anzeige von Mitarbeiterfoto (generiert durch open_workshop_employee_imagegenerator)
|
|
||||||
- Darstellung von Name und Schwerpunkten (job_focus)
|
|
||||||
- Automatische Aktualisierung bei Kassiererwechsel
|
|
||||||
- Responsive Design mit Fallback auf Company-Logo
|
|
||||||
|
|
||||||
**Verwendungszweck:**
|
|
||||||
Darstellung der Mitarbeitenden am Kundendisplay, verbessert die Kundeninteraktion und Transparenz.
|
|
||||||
|
|
||||||
**Abhängigkeiten:**
|
|
||||||
- `point_of_sale`
|
|
||||||
- `hr`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### open_workshop_dokuwiki
|
|
||||||
**DokuWiki-Integration** - Synchronisiert Equipment-Daten aus Odoo mit einem DokuWiki-System.
|
|
||||||
|
|
||||||
**Hauptfunktionen:**
|
|
||||||
- Automatische Erstellung von Wiki-Seiten für Equipment
|
|
||||||
- Übersichtstabelle aller Equipment (DataTables mit Sortierung/Filterung)
|
|
||||||
- Status-Seiten für Include-Plugin (nur von Odoo generiert, read-only)
|
|
||||||
- Smart Button "Wiki öffnen" im Equipment-Formular
|
|
||||||
- Automatische Synchronisation bei Equipment-Änderungen
|
|
||||||
|
|
||||||
**Architektur-Prinzip:**
|
|
||||||
- Odoo generiert **NUR** `odoo-status/` Seiten (maschinengeneriert, read-only)
|
|
||||||
- Benutzer erstellen eigene Dokumentation in `{bereich}/` Namespaces
|
|
||||||
- Einbindung der Odoo-Daten via DokuWiki Include-Plugin
|
|
||||||
|
|
||||||
**Verwendungszweck:**
|
|
||||||
Zentrale Dokumentation aller Geräte mit automatisch aktualisiertem Status aus Odoo, kombiniert mit benutzergenerierten Anleitungen und Wartungshinweisen.
|
|
||||||
|
|
||||||
**Abhängigkeiten:**
|
|
||||||
- `open_workshop_base`
|
|
||||||
- `maintenance`
|
|
||||||
- `maintenance_equipment_status`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 🎨 open_workshop_employee_imagegenerator
|
|
||||||
**Mitarbeiterfoto-Generator** - Upload und Zuschnitt von Mitarbeiterfotos direkt in Odoo.
|
|
||||||
|
|
||||||
**Hauptfunktionen:**
|
|
||||||
- Upload und Zuschnitt von Fotos auf festes Format (369x492 Pixel)
|
|
||||||
- Hinzufügen von Schwerpunktbereichen (job_focus)
|
|
||||||
- Integrierter Cropper.js für professionelle Bildbearbeitung
|
|
||||||
- Fester Crop-Frame (nur Bild bewegt sich, nicht der Rahmen)
|
|
||||||
- Integration im Employee-Formular über Button "Namensschild erstellen"
|
|
||||||
|
|
||||||
**Verwendungszweck:**
|
|
||||||
Erstellung von professionellen Mitarbeiterfotos für das POS Customer Display. Vereinfacht den Workflow durch direkten Upload in Odoo ohne externe Bildbearbeitung.
|
|
||||||
|
|
||||||
**Abhängigkeiten:**
|
|
||||||
- `hr`
|
|
||||||
- `web`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Architektur & Datenfluss
|
|
||||||
|
|
||||||
### Datenmodell-Übersicht
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────────────────────────────────────────────────────┐
|
|
||||||
│ OCA maintenance.equipment │
|
|
||||||
│ (Single Source of Truth für name, serial_no, cost, etc.) │
|
|
||||||
└──────────────────────┬──────────────────────────────────────┘
|
|
||||||
│ _inherits (Delegation Pattern)
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
┌────────────────┐
|
|
||||||
│ ows.machine │ ◄────────┐
|
|
||||||
│ (OWS-Features) │ │
|
|
||||||
└────┬───────────┘ │
|
|
||||||
│ │
|
|
||||||
┌─────────┼──────────────────────┤
|
|
||||||
│ │ │
|
|
||||||
│ ▼ │
|
|
||||||
│ ┌──────────────────┐ │
|
|
||||||
│ │ ows.machine.area │ │
|
|
||||||
│ │ (Bereiche) │ │
|
|
||||||
│ └──────────────────┘ │
|
|
||||||
│ │
|
|
||||||
▼ │
|
|
||||||
┌──────────────────┐ ┌────────┴─────────┐
|
|
||||||
│ ows.machine. │ │ ows.machine. │
|
|
||||||
│ product │ │ training │
|
|
||||||
│ (Nutzung) │ │ (Einweisung) │
|
|
||||||
└──────────────────┘ └──────────────────┘
|
|
||||||
|
|
||||||
┌──────────────────┐ ┌──────────────────┐
|
|
||||||
│ res.partner │ │ ows.user │
|
|
||||||
│ (Kunde) │◄───────────│ (Benutzerdaten) │
|
|
||||||
└────────┬─────────┘ 1:1 └──────────────────┘
|
|
||||||
│
|
|
||||||
│ M:N
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
┌──────────────────┐ ┌──────────────────┐
|
|
||||||
│ ows.machine. │ │ ows.machine │
|
|
||||||
│ access │───────────►│ │
|
|
||||||
│ (Freigaben) │ │ │
|
|
||||||
└──────────────────┘ └──────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
### _inherits Pattern Erklärung
|
|
||||||
|
|
||||||
Das Modul nutzt das Odoo **_inherits Pattern** (Delegation/Prototypische Vererbung) für `ows.machine`:
|
|
||||||
|
|
||||||
**Wie es funktioniert:**
|
|
||||||
- `ows.machine` delegiert Basis-Felder an `maintenance.equipment`
|
|
||||||
- Beim Erstellen einer `ows.machine` wird automatisch ein `maintenance.equipment` erstellt
|
|
||||||
- Felder wie `name`, `serial_no`, `cost`, `location` werden direkt von equipment übernommen
|
|
||||||
- **Keine Datenduplizierung!** Ein Equipment-Datensatz, zwei Modell-Sichten
|
|
||||||
|
|
||||||
**Vorteile:**
|
|
||||||
- `maintenance.equipment` bleibt die **Single Source of Truth** für Stammdaten
|
|
||||||
- Kompatibilität mit allen OCA Maintenance-Modulen
|
|
||||||
- `ows.machine` fügt nur OWS-spezifische Felder hinzu (category, area_id, etc.)
|
|
||||||
- Nahtlose Integration in bestehendes Maintenance-System
|
|
||||||
|
|
||||||
**Beispiel:**
|
|
||||||
```python
|
|
||||||
class OwsMachine(models.Model):
|
|
||||||
_name = 'ows.machine'
|
|
||||||
_inherits = {'maintenance.equipment': 'equipment_id'}
|
|
||||||
|
|
||||||
equipment_id = fields.Many2one('maintenance.equipment', required=True, ondelete='cascade')
|
|
||||||
category = fields.Selection(...) # OWS-spezifisch
|
|
||||||
area_id = fields.Many2one(...) # OWS-spezifisch
|
|
||||||
```
|
|
||||||
|
|
||||||
Beim Zugriff auf `machine.name` wird automatisch `machine.equipment_id.name` zurückgegeben.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔄 Modul-Zusammenhänge
|
|
||||||
|
|
||||||
### Installation & Abhängigkeiten
|
|
||||||
|
|
||||||
```
|
|
||||||
OCA maintenance ─────┐
|
|
||||||
OCA maintenance_ │
|
|
||||||
equipment_status ──┤
|
|
||||||
▼
|
|
||||||
open_workshop_base ◄──────────┐
|
|
||||||
│ │
|
|
||||||
┌───────────┼─────────────┐ │
|
|
||||||
│ │ │ │
|
|
||||||
▼ ▼ ▼ │
|
|
||||||
open_workshop open_workshop open_workshop
|
|
||||||
_pos _dokuwiki _employee_
|
|
||||||
imagegenerator
|
|
||||||
│ │
|
|
||||||
▼ │
|
|
||||||
open_workshop_pos_ │
|
|
||||||
customer_display ◄───────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
**Installation:**
|
|
||||||
|
|
||||||
Die OCA Module `maintenance` und `maintenance_equipment_status` müssen im `addons_path` verfügbar sein. Sie werden automatisch mit installiert, wenn `open_workshop_base` installiert wird (siehe `depends` in der `__manifest__.py`).
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone https://github.com/OCA/maintenance.git
|
|
||||||
cd maintenance
|
|
||||||
git reset --hard 5510275e
|
|
||||||
```
|
|
||||||
|
|
||||||
**Installationsreihenfolge:**
|
|
||||||
1. `open_workshop_base` (Kernmodul - installiert OCA-Abhängigkeiten automatisch)
|
|
||||||
2. Optionale Module je nach Bedarf:
|
|
||||||
- `open_workshop_pos` für POS-Integration
|
|
||||||
- `open_workshop_dokuwiki` für Wiki-Dokumentation
|
|
||||||
- `open_workshop_employee_imagegenerator` für Mitarbeiterfotos
|
|
||||||
- `open_workshop_pos_customer_display` für Customer Display (benötigt imagegenerator)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🛠️ Entwicklungshinweise
|
|
||||||
|
|
||||||
### Wo finde ich was?
|
|
||||||
|
|
||||||
#### open_workshop_base
|
|
||||||
- **Models**: `/models/ows_models.py` - Alle Kern-Datenmodelle
|
|
||||||
- **Views**: `/views/` - Backend-Ansichten (Form, Tree, Kanban)
|
|
||||||
- Erweiterte Views:
|
|
||||||
- `res.partner` (Kontakte): Formular, Listenansicht - Maschinenfreigaben-Tab
|
|
||||||
- `maintenance.equipment`: Formular, Listenansicht, Suchansicht - OWS-Felder
|
|
||||||
- **Security**: `/security/ir.model.access.csv` - Zugriffsrechte
|
|
||||||
- **Data**: `/data/` - Stammdaten (Equipment-Status, Bereiche)
|
|
||||||
|
|
||||||
#### open_workshop_pos
|
|
||||||
- **JavaScript**: `/static/src/js/` - POS Frontend-Logik
|
|
||||||
- `ows_pos_sidebar.js` - POS Maschinensidebar
|
|
||||||
- `ows_pos_customer_sidebar.js` - Kundensidebar
|
|
||||||
- `ows_machine_access_list.js` - Zugriffsliste
|
|
||||||
- **Templates**: `/static/src/xml/` - OWL Templates für POS UI
|
|
||||||
- **Models**: `/models/pos_order.py` - POS Order Extensions
|
|
||||||
|
|
||||||
#### open_workshop_dokuwiki
|
|
||||||
- **Models**: `/models/`
|
|
||||||
- `dokuwiki_client.py` - DokuWiki XML-RPC Client
|
|
||||||
- `maintenance_equipment.py` - Equipment Extensions
|
|
||||||
- **Views**: `/views/`
|
|
||||||
- Erweiterte Views:
|
|
||||||
- `maintenance.equipment`: Smart Button "Wiki öffnen"
|
|
||||||
- `maintenance.equipment.status`: DokuWiki-Synchronisation
|
|
||||||
- **Wizard**: `/wizard/` - Synchronisations-Assistenten
|
|
||||||
|
|
||||||
#### open_workshop_employee_imagegenerator
|
|
||||||
- **JavaScript**: `/static/src/js/employee_image_widget.js` - Cropper Widget
|
|
||||||
- **Templates**: `/static/src/xml/employee_image_widget.xml` - Widget UI
|
|
||||||
- **Views**: `/views/`
|
|
||||||
- Erweiterte Views:
|
|
||||||
- `hr.employee`: Button "Namensschild erstellen" im Formular
|
|
||||||
- **CSS**: `/static/src/css/` - Badge-Styling
|
|
||||||
- **Library**: `/static/lib/cropperjs/` - Cropper.js Integration
|
|
||||||
|
|
||||||
#### open_workshop_pos_customer_display
|
|
||||||
- **JavaScript**: `/static/src/js/customer_display.js` - Display-Logik
|
|
||||||
- **CSS**: `/static/src/css/employee_badge.css` - Badge-Styling
|
|
||||||
|
|
||||||
### Wichtige Methoden & RPC Calls
|
|
||||||
|
|
||||||
#### open_workshop_base
|
|
||||||
- `ows.machine.get_access_list_grouped(partner_id)` - Gruppierte Zugriffsliste für POS
|
|
||||||
- Liefert alle Bereiche mit Maschinen und Zugriffssstatus für einen Partner
|
|
||||||
- Wird vom POS Frontend verwendet
|
|
||||||
|
|
||||||
#### res.partner Extensions
|
|
||||||
- `machine_access_ids` - One2many zu allen Freigaben
|
|
||||||
- `machine_access_html` - Computed HTML-Tabelle für Backend-Ansicht im Res.partner / Kontakt Formular
|
|
||||||
- `ows_user_id` - One2many zu ows.user (Benutzerdaten)
|
|
||||||
|
|
||||||
### Erweiterung des Systems
|
|
||||||
|
|
||||||
**Neue Sicherheitskategorie hinzufügen:**
|
|
||||||
1. In `ows_models.py` → `OwsMachine.category` Selection erweitern
|
|
||||||
2. Icon-Mapping in `_compute_category_icon()` aktualisieren
|
|
||||||
3. CSS in `/static/src/css/category_color.css` ergänzen
|
|
||||||
|
|
||||||
**Neuen Maschinenbereich erstellen:**
|
|
||||||
- Im Backend: **Wartung → Konfiguration → Bereiche**
|
|
||||||
- Oder via Data-File in `/data/` für Standardbereiche
|
|
||||||
|
|
||||||
**POS-UI anpassen:**
|
|
||||||
- OWL Templates in `/static/src/xml/` bearbeiten
|
|
||||||
- JavaScript-Komponenten in `/static/src/js/` erweitern
|
|
||||||
- CSS in `/static/src/css/` anpassen
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📝 Technische Details
|
|
||||||
|
|
||||||
### Backend-Konfiguration
|
|
||||||
|
|
||||||
**Sicherheitskategorien zuweisen:**
|
|
||||||
- Menü: **Wartung → Ausrüstung** → [Equipment auswählen]
|
|
||||||
- Im Formular: Feld "Sicherheitskategorie" (🟢/🟡/🔴)
|
|
||||||
- Die Kategorie ist direkt am `ows.machine` / `maintenance.equipment` gespeichert
|
|
||||||
|
|
||||||
**Einweisungsprodukte zuweisen:**
|
|
||||||
- Menü: **Wartung → Ausrüstung** → [Equipment auswählen]
|
|
||||||
- Im Formular: Tab "Einweisungsprodukte"
|
|
||||||
- Button "Hinzufügen" → Produkt aus `product.product` auswählen
|
|
||||||
- Speichert Verknüpfung in `ows.machine.training` (Many2many über One2many)
|
|
||||||
- Beim Verkauf dieses Produkts am POS wird automatisch die Freigabe erteilt
|
|
||||||
|
|
||||||
**Nutzungsprodukte zuweisen:**
|
|
||||||
- Gleicher Weg wie Einweisungsprodukte
|
|
||||||
- Tab "Nutzungsprodukte" im Equipment-Formular
|
|
||||||
- Speichert Verknüpfung in `ows.machine.product`
|
|
||||||
- Diese Produkte können für Nutzungsgebühren verwendet werden
|
|
||||||
|
|
||||||
**Maschinenfreigaben manuell vergeben:**
|
|
||||||
- Menü: **Kontakte** → [Partner auswählen]
|
|
||||||
- Tab "Maschinenfreigaben" (von open_workshop_base hinzugefügt)
|
|
||||||
- Übersicht aller Bereiche und Maschinen mit aktuellem Freigabestatus
|
|
||||||
- Freigaben werden jedoch hauptsächlich automatisch über POS-Verkäufe vergeben
|
|
||||||
|
|
||||||
**Konfiguration:**
|
|
||||||
- **Bereiche**: Wartung → Konfiguration → Bereiche
|
|
||||||
- **Produkt-Zuordnungen**: Wartung → Konfiguration → Zuordnungen
|
|
||||||
- Nutzungsprodukte (Übersicht aller Zuordnungen)
|
|
||||||
- Einweisungsprodukte (Übersicht aller Zuordnungen)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Sicherheitskategorien
|
|
||||||
|
|
||||||
- **🟢 Grün (Kategorie 1)**: Keine Einweisungspflicht, freier Zugang
|
|
||||||
- **🟡 Gelb (Kategorie 2)**: Empfohlene Einweisung, optionale Freigabe
|
|
||||||
- **🔴 Rot (Kategorie 3)**: Einweisung zwingend erforderlich, POS zeigt nur rote Maschinen
|
|
||||||
|
|
||||||
### Freigabenverwaltung
|
|
||||||
|
|
||||||
Freigaben werden in `ows.machine.access` gespeichert mit:
|
|
||||||
- Partner (Kunde)
|
|
||||||
- Maschine
|
|
||||||
- Datum der Freigabe
|
|
||||||
- Optional: Ablaufdatum
|
|
||||||
- Herkunft (granted_by_pos)
|
|
||||||
|
|
||||||
### DokuWiki Namespace-Struktur
|
|
||||||
|
|
||||||
```
|
|
||||||
{equipment_namespace}:
|
|
||||||
├── overview # Übersichtstabelle (von Odoo)
|
|
||||||
└── odoo-status/ # Maschinengeneriert (read-only)
|
|
||||||
├── maschine_1 # Status-Seite für Include
|
|
||||||
├── maschine_2
|
|
||||||
└── c_template # Template für Status-Seiten
|
|
||||||
|
|
||||||
{bereich}: # Benutzer-Namespaces
|
|
||||||
├── holz/
|
|
||||||
│ ├── maschine_1 # Benutzer-Dokumentation
|
|
||||||
│ └── maschine_2 # (inkludiert odoo-status via {{page>}})
|
|
||||||
└── metall/
|
|
||||||
└── ...
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📄 Lizenz
|
|
||||||
|
|
||||||
AGPL-3 (open_workshop_base)
|
|
||||||
LGPL-3 (weitere Module)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 👤 Autor
|
|
||||||
|
|
||||||
Matthias Lotz / Hobbyhimmel
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔗 Verwandte Projekte
|
|
||||||
|
|
||||||
- [OCA Maintenance](https://github.com/OCA/maintenance) - Basis Equipment-Verwaltung
|
|
||||||
|
|
||||||
|
|
@ -6,7 +6,7 @@ from odoo import models, fields
|
||||||
class MaintenanceEquipment(models.Model):
|
class MaintenanceEquipment(models.Model):
|
||||||
_inherit = 'maintenance.equipment'
|
_inherit = 'maintenance.equipment'
|
||||||
|
|
||||||
qr_code = fields.Binary("QR Code", attachment=True)
|
qr_code = fields.Binary("QR Code")
|
||||||
comp_serial_no = fields.Char("Inventory Serial No", tracking=True)
|
comp_serial_no = fields.Char("Inventory Serial No", tracking=True)
|
||||||
serial_no = fields.Char('Mfg. Serial Number', copy=False)
|
serial_no = fields.Char('Mfg. Serial Number', copy=False)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@
|
||||||
<div class="o_label_data">
|
<div class="o_label_data">
|
||||||
<div class="text-center o_label_right_column">
|
<div class="text-center o_label_right_column">
|
||||||
<t t-if="equipment.qr_code">
|
<t t-if="equipment.qr_code">
|
||||||
<img t-att-src="'data:image/png;base64,' + equipment.qr_code.decode('utf-8')" style="width: 130px;margin-top: -40px;"/>
|
<div t-field="equipment.qr_code" t-options="{'widget': 'image'}" style="width: 130px;margin-top: -40px;"/>
|
||||||
</t>
|
</t>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-left" style="line-height:normal;word-wrap: break-word;">
|
<div class="text-left" style="line-height:normal;word-wrap: break-word;">
|
||||||
|
|
@ -40,7 +40,7 @@
|
||||||
</div>
|
</div>
|
||||||
<div class= "text-center o_label_right_column">
|
<div class= "text-center o_label_right_column">
|
||||||
<t t-if="equipment.qr_code">
|
<t t-if="equipment.qr_code">
|
||||||
<img t-att-src="'data:image/png;base64,' + equipment.qr_code.decode('utf-8')" style="width:95px;padding:0px;margin-top:-10px"/>
|
<div t-field="equipment.qr_code" t-options="{'widget': 'image'}" style="width:95px;padding:0px;margin-top:-10px"/>
|
||||||
</t>
|
</t>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-left o_label_left_column" style="line-height:normal;word-wrap: break-word;">
|
<div class="text-left o_label_left_column" style="line-height:normal;word-wrap: break-word;">
|
||||||
|
|
@ -66,19 +66,19 @@
|
||||||
<td style="width:25%;">Name</td>
|
<td style="width:25%;">Name</td>
|
||||||
<td style="width:5%">:</td>
|
<td style="width:5%">:</td>
|
||||||
<td colspan="2" style="width:70%;word-wrap: break-word;">
|
<td colspan="2" style="width:70%;word-wrap: break-word;">
|
||||||
<span t-field="equipment.name"/>
|
<t t-esc="equipment.name"/>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td style="width:25%">Model</td>
|
<td style="width:25%">Model</td>
|
||||||
<td style="width:5%">:</td>
|
<td style="width:5%">:</td>
|
||||||
<td style="width:35%;word-wrap: break-word;">
|
<td style="width:35%;word-wrap: break-word;">
|
||||||
<span t-field="equipment.model"/>
|
<t t-esc="equipment.model"/>
|
||||||
</td>
|
</td>
|
||||||
<td rowspan="5" style="width:35%">
|
<td rowspan="5" style="width:35%">
|
||||||
<t t-if="equipment.qr_code">
|
<t t-if="equipment.name">
|
||||||
<img t-att-src="'data:image/png;base64,' + equipment.qr_code.decode('utf-8')"
|
<div t-field="equipment.qr_code" t-options="{'widget': 'image'}"
|
||||||
style="width:30mm;height:30mm;"/>
|
style="width:30mm;height:10mm;"/>
|
||||||
</t>
|
</t>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
@ -87,22 +87,23 @@
|
||||||
<td style="width:25%">Mfg Serial</td>
|
<td style="width:25%">Mfg Serial</td>
|
||||||
<td style="width:5%">:</td>
|
<td style="width:5%">:</td>
|
||||||
<td style="width:35%;font-size:10px;word-wrap: break-word;">
|
<td style="width:35%;font-size:10px;word-wrap: break-word;">
|
||||||
<span t-field="equipment.serial_no"/>
|
<t t-esc="equipment.serial_no"/>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td style="width:25%">Serial</td>
|
<td style="width:25%">Serial</td>
|
||||||
<td style="width:5%">:</td>
|
<td style="width:5%">:</td>
|
||||||
<td style="width:35%;word-wrap: break-word;">
|
<td style="width:35%;word-wrap: break-word;">
|
||||||
<span t-field="equipment.comp_serial_no"/>
|
<t t-esc="equipment.comp_serial_no"/>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td style="width:25%">Warranty Date</td>
|
<td style="width:25%">Warranty Date</td>
|
||||||
<td style="width:5%">:</td>
|
<td style="width:5%">:</td>
|
||||||
<td style="width:35%;word-wrap: break-word;">
|
<td style="width:35%;word-wrap: break-word;">
|
||||||
<span t-field="equipment.warranty_date"/>
|
<t t-esc="equipment.warranty_date"/>
|
||||||
</td>
|
</td>
|
||||||
|
<!-- <td style="width:35%;"></td> -->
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td style="width:25%"></td>
|
<td style="width:25%"></td>
|
||||||
|
|
|
||||||
|
|
@ -1,34 +1,84 @@
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import math
|
||||||
|
from io import BytesIO
|
||||||
|
|
||||||
|
import qrcode
|
||||||
from odoo import models
|
from odoo import models
|
||||||
|
|
||||||
|
|
||||||
|
def generate_qr_code(value):
|
||||||
|
qr = qrcode.QRCode(
|
||||||
|
version=1,
|
||||||
|
error_correction=qrcode.constants.ERROR_CORRECT_L,
|
||||||
|
box_size=20,
|
||||||
|
border=4,
|
||||||
|
)
|
||||||
|
qr.add_data(value)
|
||||||
|
qr.make(fit=True)
|
||||||
|
img = qr.make_image()
|
||||||
|
temp = BytesIO()
|
||||||
|
img.save(temp, format="PNG")
|
||||||
|
qr_img = base64.b64encode(temp.getvalue())
|
||||||
|
return qr_img
|
||||||
|
|
||||||
|
|
||||||
|
def _prepare_data(env, data):
|
||||||
|
equipment_label_layout_id = env['equipment.label.layout'].browse(data['equipment_label_layout_id'])
|
||||||
|
equipment_dict = {}
|
||||||
|
equipment_ids = equipment_label_layout_id.equipment_ids
|
||||||
|
for equipment in equipment_ids:
|
||||||
|
if not equipment.name:
|
||||||
|
continue
|
||||||
|
equipment_dict[equipment] = 1
|
||||||
|
combine_equipment_detail = ""
|
||||||
|
|
||||||
|
# Generate Equipment Redirect LInk
|
||||||
|
url = env['ir.config_parameter'].sudo().get_param('web.base.url')
|
||||||
|
menuId = env.ref('maintenance.menu_equipment_form').sudo().id
|
||||||
|
actionId = env.ref('maintenance.hr_equipment_action').sudo().id
|
||||||
|
|
||||||
|
equipment_link = url + '/web#id=' + str(equipment.id) + '&menu_id=' + str(menuId) + '&action=' + str(
|
||||||
|
actionId) + '&model=maintenance.equipment&view_type=form'
|
||||||
|
|
||||||
|
# Prepare main Equipment Detail
|
||||||
|
main_equipment_detail = ""
|
||||||
|
main_equipment_detail = main_equipment_detail.join(
|
||||||
|
"Name: " + str(equipment.name) + "\n" +
|
||||||
|
"Model: " + str(equipment.model) + "\n" +
|
||||||
|
"Mfg serial no: " + str(equipment.serial_no) + "\n"
|
||||||
|
"Warranty Exp. Date: " +str(equipment.warranty_date) + "\n"
|
||||||
|
"Category: " +str(equipment.category_id.name)
|
||||||
|
)
|
||||||
|
# main_equipment_detail = equipment_link + '\n' + '\n' + main_equipment_detail
|
||||||
|
|
||||||
|
# Prepare Child Equipment Detail
|
||||||
|
combine_equipment_detail = main_equipment_detail
|
||||||
|
|
||||||
|
combine_equipment_detail += '\n' + '\n' + equipment_link
|
||||||
|
|
||||||
|
# Generate Qr Code depends on Details
|
||||||
|
qr_image = generate_qr_code(combine_equipment_detail)
|
||||||
|
equipment.write({
|
||||||
|
'qr_code': qr_image
|
||||||
|
})
|
||||||
|
env.cr.commit()
|
||||||
|
page_numbers = (len(equipment_ids) - 1) // (equipment_label_layout_id.rows * equipment_label_layout_id.columns) + 1
|
||||||
|
|
||||||
|
dict_equipment = {
|
||||||
|
'rows': equipment_label_layout_id.rows,
|
||||||
|
'columns': equipment_label_layout_id.columns,
|
||||||
|
'page_numbers': page_numbers,
|
||||||
|
'equipment_data': equipment_dict
|
||||||
|
}
|
||||||
|
return dict_equipment
|
||||||
|
|
||||||
|
|
||||||
class ReportProductTemplateLabel(models.AbstractModel):
|
class ReportProductTemplateLabel(models.AbstractModel):
|
||||||
_name = 'report.aspl_equipment_qrcode_generator.maintenance_quip'
|
_name = 'report.aspl_equipment_qrcode_generator.maintenance_quip'
|
||||||
_description = 'Equipment QR-code Report'
|
_description = 'Equipment QR-code Report'
|
||||||
|
|
||||||
def _get_report_values(self, docids, data=None):
|
def _get_report_values(self, docids, data):
|
||||||
"""
|
return _prepare_data(self.env, data)
|
||||||
QR-Code-Generierung erfolgt im Wizard (equipment_label_layout.py).
|
|
||||||
Dieser Report rendert nur die bereits generierten QR-Codes.
|
|
||||||
"""
|
|
||||||
if not data:
|
|
||||||
return {}
|
|
||||||
|
|
||||||
equipment_label_layout_id = self.env['equipment.label.layout'].browse(data.get('equipment_label_layout_id'))
|
|
||||||
|
|
||||||
equipment_dict = {}
|
|
||||||
for equipment in equipment_label_layout_id.equipment_ids:
|
|
||||||
equipment_dict[equipment] = 1
|
|
||||||
|
|
||||||
page_numbers = (len(equipment_label_layout_id.equipment_ids) - 1) // (
|
|
||||||
equipment_label_layout_id.rows * equipment_label_layout_id.columns
|
|
||||||
) + 1
|
|
||||||
|
|
||||||
return {
|
|
||||||
'rows': equipment_label_layout_id.rows,
|
|
||||||
'columns': equipment_label_layout_id.columns,
|
|
||||||
'page_numbers': page_numbers,
|
|
||||||
'equipment_data': equipment_dict
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -16,16 +16,6 @@ class EquipmentLabelLayout(models.TransientModel):
|
||||||
rows = fields.Integer(compute='_compute_dimensions')
|
rows = fields.Integer(compute='_compute_dimensions')
|
||||||
columns = fields.Integer(compute='_compute_dimensions')
|
columns = fields.Integer(compute='_compute_dimensions')
|
||||||
|
|
||||||
@api.model
|
|
||||||
def default_get(self, fields_list):
|
|
||||||
"""Override to properly set equipment_ids from context"""
|
|
||||||
res = super().default_get(fields_list)
|
|
||||||
if self.env.context.get('active_ids'):
|
|
||||||
res['equipment_ids'] = [(6, 0, self.env.context.get('active_ids', []))]
|
|
||||||
elif self.env.context.get('default_equipment_ids'):
|
|
||||||
res['equipment_ids'] = [(6, 0, self.env.context.get('default_equipment_ids', []))]
|
|
||||||
return res
|
|
||||||
|
|
||||||
@api.depends('print_format')
|
@api.depends('print_format')
|
||||||
def _compute_dimensions(self):
|
def _compute_dimensions(self):
|
||||||
for wizard in self:
|
for wizard in self:
|
||||||
|
|
@ -38,62 +28,9 @@ class EquipmentLabelLayout(models.TransientModel):
|
||||||
|
|
||||||
|
|
||||||
def process_label(self):
|
def process_label(self):
|
||||||
# Generiere QR-Codes für alle Equipment VOR dem Report
|
|
||||||
self._generate_qr_codes()
|
|
||||||
|
|
||||||
xml_id = 'aspl_equipment_qrcode_generator.report_equipment_label'
|
xml_id = 'aspl_equipment_qrcode_generator.report_equipment_label'
|
||||||
data = {
|
data = {
|
||||||
'equipment_label_layout_id': self.id
|
'equipment_label_layout_id':self.id
|
||||||
}
|
}
|
||||||
|
|
||||||
# report_action benötigt die Equipment IDs als docids
|
return self.env.ref(xml_id).report_action(None, data=data)
|
||||||
return self.env.ref(xml_id).report_action(self.equipment_ids.ids, data=data)
|
|
||||||
|
|
||||||
def _generate_qr_codes(self):
|
|
||||||
"""Generiert QR-Codes für alle ausgewählten Equipment"""
|
|
||||||
import base64
|
|
||||||
from io import BytesIO
|
|
||||||
import qrcode
|
|
||||||
|
|
||||||
# Hole die base_url für die Equipment-Links
|
|
||||||
base_url = self.env['ir.config_parameter'].sudo().get_param('web.base.url')
|
|
||||||
menu_id = self.env.ref('maintenance.menu_equipment_form').sudo().id
|
|
||||||
action_id = self.env.ref('maintenance.hr_equipment_action').sudo().id
|
|
||||||
|
|
||||||
for equipment in self.equipment_ids:
|
|
||||||
# Extrahiere Namen aus JSONB falls nötig
|
|
||||||
equipment_name = equipment.name
|
|
||||||
if isinstance(equipment_name, dict):
|
|
||||||
equipment_name = equipment_name.get('de_DE') or equipment_name.get('en_US') or str(equipment_name)
|
|
||||||
|
|
||||||
category_name = equipment.category_id.name if equipment.category_id else ""
|
|
||||||
if isinstance(category_name, dict):
|
|
||||||
category_name = category_name.get('de_DE') or category_name.get('en_US') or str(category_name)
|
|
||||||
|
|
||||||
# Erstelle Equipment-Link
|
|
||||||
equipment_link = f"{base_url}/web#id={equipment.id}&menu_id={menu_id}&action={action_id}&model=maintenance.equipment&view_type=form"
|
|
||||||
|
|
||||||
# Erstelle Equipment-Details für QR-Code
|
|
||||||
qr_data = f"Name: {equipment_name}\n"
|
|
||||||
qr_data += f"Model: {equipment.model or ''}\n"
|
|
||||||
qr_data += f"Mfg serial no: {equipment.serial_no or ''}\n"
|
|
||||||
qr_data += f"Warranty Exp. Date: {equipment.warranty_date or ''}\n"
|
|
||||||
qr_data += f"Category: {category_name}\n\n"
|
|
||||||
qr_data += equipment_link
|
|
||||||
|
|
||||||
# Generiere QR-Code
|
|
||||||
qr = qrcode.QRCode(
|
|
||||||
version=1,
|
|
||||||
error_correction=qrcode.constants.ERROR_CORRECT_L,
|
|
||||||
box_size=20,
|
|
||||||
border=4,
|
|
||||||
)
|
|
||||||
qr.add_data(qr_data)
|
|
||||||
qr.make(fit=True)
|
|
||||||
img = qr.make_image()
|
|
||||||
temp = BytesIO()
|
|
||||||
img.save(temp, format="PNG")
|
|
||||||
qr_image = base64.b64encode(temp.getvalue())
|
|
||||||
|
|
||||||
# Speichere QR-Code im Equipment
|
|
||||||
equipment.write({'qr_code': qr_image})
|
|
||||||
|
|
@ -7,7 +7,6 @@
|
||||||
<field name="arch" type="xml">
|
<field name="arch" type="xml">
|
||||||
<form>
|
<form>
|
||||||
<group>
|
<group>
|
||||||
<field name="equipment_ids" invisible="1"/>
|
|
||||||
<group>
|
<group>
|
||||||
<field name="print_format" widget="radio"/>
|
<field name="print_format" widget="radio"/>
|
||||||
</group>
|
</group>
|
||||||
|
|
|
||||||
300
open_workshop-api-for-wordpress/openworkshop-odoo-api.php
Normal file
300
open_workshop-api-for-wordpress/openworkshop-odoo-api.php
Normal file
|
|
@ -0,0 +1,300 @@
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Plugin Name: OpenWorkshop Odoo API
|
||||||
|
* Plugin URI: https://hobbyhimmel.de/
|
||||||
|
* Description: Bindet Daten aus Odoo (OpenWorkshop) per REST-API in WordPress ein. Stellt u.a. den Shortcode [openworkshop_machines] bereit.
|
||||||
|
* Version: 0.1.0
|
||||||
|
* Author: HobbyHimmel / Matthias Lotz
|
||||||
|
* License: GPLv3
|
||||||
|
* License URI: https://www.gnu.org/licenses/gpl-3.0.html
|
||||||
|
* Text Domain: openworkshop-odoo-api
|
||||||
|
*/
|
||||||
|
|
||||||
|
if ( ! defined( 'ABSPATH' ) ) {
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
class OpenWorkshop_Odoo_API_Plugin {
|
||||||
|
|
||||||
|
const OPTION_GROUP = 'openworkshop_odoo_api_options';
|
||||||
|
const OPTION_NAME = 'openworkshop_odoo_api_settings';
|
||||||
|
|
||||||
|
public function __construct() {
|
||||||
|
add_action( 'admin_menu', array( $this, 'register_settings_page' ) );
|
||||||
|
add_action( 'admin_init', array( $this, 'register_settings' ) );
|
||||||
|
add_shortcode( 'openworkshop_machines', array( $this, 'shortcode_machines' ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register settings page under Settings → OpenWorkshop Odoo API
|
||||||
|
*/
|
||||||
|
public function register_settings_page() {
|
||||||
|
add_options_page(
|
||||||
|
__( 'OpenWorkshop Odoo API', 'openworkshop-odoo-api' ),
|
||||||
|
__( 'OpenWorkshop Odoo API', 'openworkshop-odoo-api' ),
|
||||||
|
'manage_options',
|
||||||
|
'openworkshop-odoo-api',
|
||||||
|
array( $this, 'render_settings_page' )
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register settings (base URL + optional token)
|
||||||
|
*/
|
||||||
|
public function register_settings() {
|
||||||
|
register_setting(
|
||||||
|
self::OPTION_GROUP,
|
||||||
|
self::OPTION_NAME,
|
||||||
|
array(
|
||||||
|
'type' => 'array',
|
||||||
|
'sanitize_callback' => array( $this, 'sanitize_settings' ),
|
||||||
|
'default' => array(
|
||||||
|
'base_url' => '',
|
||||||
|
'api_token' => '',
|
||||||
|
'machines_endpoint' => '/api/v1/machines',
|
||||||
|
),
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
add_settings_section(
|
||||||
|
'openworkshop_odoo_api_main',
|
||||||
|
__( 'Odoo API Einstellungen', 'openworkshop-odoo-api' ),
|
||||||
|
'__return_false',
|
||||||
|
'openworkshop-odoo-api'
|
||||||
|
);
|
||||||
|
|
||||||
|
add_settings_field(
|
||||||
|
'base_url',
|
||||||
|
__( 'Odoo Basis-URL', 'openworkshop-odoo-api' ),
|
||||||
|
array( $this, 'render_field_base_url' ),
|
||||||
|
'openworkshop-odoo-api',
|
||||||
|
'openworkshop_odoo_api_main'
|
||||||
|
);
|
||||||
|
|
||||||
|
add_settings_field(
|
||||||
|
'machines_endpoint',
|
||||||
|
__( 'Endpoint für Maschinenliste', 'openworkshop-odoo-api' ),
|
||||||
|
array( $this, 'render_field_machines_endpoint' ),
|
||||||
|
'openworkshop-odoo-api',
|
||||||
|
'openworkshop_odoo_api_main'
|
||||||
|
);
|
||||||
|
|
||||||
|
add_settings_field(
|
||||||
|
'api_token',
|
||||||
|
__( 'API Token (optional)', 'openworkshop-odoo-api' ),
|
||||||
|
array( $this, 'render_field_api_token' ),
|
||||||
|
'openworkshop-odoo-api',
|
||||||
|
'openworkshop_odoo_api_main'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function sanitize_settings( $input ) {
|
||||||
|
$output = array();
|
||||||
|
|
||||||
|
$output['base_url'] = isset( $input['base_url'] )
|
||||||
|
? esc_url_raw( rtrim( $input['base_url'], '/' ) )
|
||||||
|
: '';
|
||||||
|
|
||||||
|
$output['machines_endpoint'] = isset( $input['machines_endpoint'] )
|
||||||
|
? sanitize_text_field( $input['machines_endpoint'] )
|
||||||
|
: '/api/v1/machines';
|
||||||
|
|
||||||
|
$output['api_token'] = isset( $input['api_token'] )
|
||||||
|
? sanitize_text_field( $input['api_token'] )
|
||||||
|
: '';
|
||||||
|
|
||||||
|
return $output;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function get_settings() {
|
||||||
|
$settings = get_option( self::OPTION_NAME, array() );
|
||||||
|
$defaults = array(
|
||||||
|
'base_url' => '',
|
||||||
|
'api_token' => '',
|
||||||
|
'machines_endpoint' => '/api/v1/machines',
|
||||||
|
);
|
||||||
|
|
||||||
|
return wp_parse_args( $settings, $defaults );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render_settings_page() {
|
||||||
|
if ( ! current_user_can( 'manage_options' ) ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$settings = $this->get_settings();
|
||||||
|
?>
|
||||||
|
<div class="wrap">
|
||||||
|
<h1><?php esc_html_e( 'OpenWorkshop Odoo API Einstellungen', 'openworkshop-odoo-api' ); ?></h1>
|
||||||
|
<form method="post" action="options.php">
|
||||||
|
<?php
|
||||||
|
settings_fields( self::OPTION_GROUP );
|
||||||
|
do_settings_sections( 'openworkshop-odoo-api' );
|
||||||
|
submit_button();
|
||||||
|
?>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<h2><?php esc_html_e( 'Shortcodes', 'openworkshop-odoo-api' ); ?></h2>
|
||||||
|
<p><?php esc_html_e( 'Maschinenliste anzeigen:', 'openworkshop-odoo-api' ); ?></p>
|
||||||
|
<code>[openworkshop_machines]</code>
|
||||||
|
<p><?php esc_html_e( 'Optional können Attribute verwendet werden, um z. B. eine andere Anzahl von Maschinen anzuzeigen.', 'openworkshop-odoo-api' ); ?></p>
|
||||||
|
</div>
|
||||||
|
<?php
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render_field_base_url() {
|
||||||
|
$settings = $this->get_settings();
|
||||||
|
?>
|
||||||
|
<input type="text"
|
||||||
|
name="<?php echo esc_attr( self::OPTION_NAME ); ?>[base_url]"
|
||||||
|
value="<?php echo esc_attr( $settings['base_url'] ); ?>"
|
||||||
|
class="regular-text"
|
||||||
|
placeholder="https://odoo.example.org" />
|
||||||
|
<p class="description">
|
||||||
|
<?php esc_html_e( 'Basis-URL deiner Odoo-Instanz (ohne Slash am Ende). Die API-Route wird daran angehängt.', 'openworkshop-odoo-api' ); ?>
|
||||||
|
</p>
|
||||||
|
<?php
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render_field_machines_endpoint() {
|
||||||
|
$settings = $this->get_settings();
|
||||||
|
?>
|
||||||
|
<input type="text"
|
||||||
|
name="<?php echo esc_attr( self::OPTION_NAME ); ?>[machines_endpoint]"
|
||||||
|
value="<?php echo esc_attr( $settings['machines_endpoint'] ); ?>"
|
||||||
|
class="regular-text"
|
||||||
|
placeholder="/api/v1/machines" />
|
||||||
|
<p class="description">
|
||||||
|
<?php esc_html_e( 'Relativer Pfad zum Maschinen-Endpoint. Standard: /api/v1/machines', 'openworkshop-odoo-api' ); ?>
|
||||||
|
</p>
|
||||||
|
<?php
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render_field_api_token() {
|
||||||
|
$settings = $this->get_settings();
|
||||||
|
?>
|
||||||
|
<input type="text"
|
||||||
|
name="<?php echo esc_attr( self::OPTION_NAME ); ?>[api_token]"
|
||||||
|
value="<?php echo esc_attr( $settings['api_token'] ); ?>"
|
||||||
|
class="regular-text" />
|
||||||
|
<p class="description">
|
||||||
|
<?php esc_html_e( 'Optionaler API-Token, der im Authorization-Header gesendet wird (Bearer <token>).', 'openworkshop-odoo-api' ); ?>
|
||||||
|
</p>
|
||||||
|
<?php
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shortcode: [openworkshop_machines]
|
||||||
|
* Attributes:
|
||||||
|
* limit – Anzahl der Einträge (optional)
|
||||||
|
*/
|
||||||
|
public function shortcode_machines( $atts ) {
|
||||||
|
$atts = shortcode_atts(
|
||||||
|
array(
|
||||||
|
'limit' => 0,
|
||||||
|
),
|
||||||
|
$atts,
|
||||||
|
'openworkshop_machines'
|
||||||
|
);
|
||||||
|
|
||||||
|
$data = $this->fetch_machines();
|
||||||
|
|
||||||
|
if ( is_wp_error( $data ) ) {
|
||||||
|
return '<p>' . esc_html__( 'Fehler beim Laden der Maschinendaten aus Odoo.', 'openworkshop-odoo-api' ) . '</p>';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( ! is_array( $data ) || empty( $data ) ) {
|
||||||
|
return '<p>' . esc_html__( 'Keine Maschinen gefunden.', 'openworkshop-odoo-api' ) . '</p>';
|
||||||
|
}
|
||||||
|
|
||||||
|
$limit = intval( $atts['limit'] );
|
||||||
|
if ( $limit > 0 ) {
|
||||||
|
$data = array_slice( $data, 0, $limit );
|
||||||
|
}
|
||||||
|
|
||||||
|
ob_start();
|
||||||
|
?>
|
||||||
|
<div class="openworkshop-machines">
|
||||||
|
<table class="openworkshop-machines-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th><?php esc_html_e( 'Name', 'openworkshop-odoo-api' ); ?></th>
|
||||||
|
<th><?php esc_html_e( 'Bereich', 'openworkshop-odoo-api' ); ?></th>
|
||||||
|
<th><?php esc_html_e( 'Status', 'openworkshop-odoo-api' ); ?></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<?php foreach ( $data as $machine ) : ?>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<?php echo isset( $machine['name'] ) ? esc_html( $machine['name'] ) : ''; ?>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<?php echo isset( $machine['area'] ) ? esc_html( $machine['area'] ) : ''; ?>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<?php echo isset( $machine['status'] ) ? esc_html( $machine['status'] ) : ''; ?>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<?php
|
||||||
|
return ob_get_clean();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calls the Odoo API and returns decoded JSON or WP_Error.
|
||||||
|
*/
|
||||||
|
protected function fetch_machines() {
|
||||||
|
$settings = $this->get_settings();
|
||||||
|
|
||||||
|
if ( empty( $settings['base_url'] ) ) {
|
||||||
|
return new WP_Error( 'openworkshop_no_base_url', __( 'Keine Odoo Basis-URL konfiguriert.', 'openworkshop-odoo-api' ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
$url = $settings['base_url'] . $settings['machines_endpoint'];
|
||||||
|
|
||||||
|
$args = array(
|
||||||
|
'timeout' => 10,
|
||||||
|
'headers' => array(
|
||||||
|
'Accept' => 'application/json',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if ( ! empty( $settings['api_token'] ) ) {
|
||||||
|
$args['headers']['Authorization'] = 'Bearer ' . $settings['api_token'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$response = wp_remote_get( $url, $args );
|
||||||
|
|
||||||
|
if ( is_wp_error( $response ) ) {
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
$code = wp_remote_retrieve_response_code( $response );
|
||||||
|
$body = wp_remote_retrieve_body( $response );
|
||||||
|
|
||||||
|
if ( $code < 200 || $code >= 300 ) {
|
||||||
|
return new WP_Error( 'openworkshop_bad_status', sprintf( 'HTTP %d', $code ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = json_decode( $body, true );
|
||||||
|
if ( json_last_error() !== JSON_ERROR_NONE ) {
|
||||||
|
return new WP_Error( 'openworkshop_json_error', json_last_error_msg() );
|
||||||
|
}
|
||||||
|
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openworkshop_odoo_api_bootstrap() {
|
||||||
|
static $instance = null;
|
||||||
|
if ( $instance === null ) {
|
||||||
|
$instance = new OpenWorkshop_Odoo_API_Plugin();
|
||||||
|
}
|
||||||
|
return $instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
openworkshop_odoo_api_bootstrap();
|
||||||
30
open_workshop-api-for-wordpress/readme.txt
Normal file
30
open_workshop-api-for-wordpress/readme.txt
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
=== OpenWorkshop Odoo API ===
|
||||||
|
Contributors: hobbyhimmel
|
||||||
|
Tags: odoo, api, openworkshop, integration
|
||||||
|
Requires at least: 5.8
|
||||||
|
Tested up to: 6.6
|
||||||
|
Stable tag: 0.1.0
|
||||||
|
License: GPLv3
|
||||||
|
License URI: https://www.gnu.org/licenses/gpl-3.0.html
|
||||||
|
|
||||||
|
Dieses Plugin bindet Maschinendaten aus einer Odoo/OpenWorkshop-Installation via REST-API in WordPress ein.
|
||||||
|
|
||||||
|
== Beschreibung ==
|
||||||
|
|
||||||
|
Das Plugin stellt u.a. den Shortcode [openworkshop_machines] bereit, der eine einfache Maschinenliste
|
||||||
|
auf Basis eines JSON-Endpunkts in Odoo rendert.
|
||||||
|
|
||||||
|
In den Einstellungen kann die Basis-URL der Odoo-Instanz, der Endpoint (z.B. /api/v1/machines) sowie ein
|
||||||
|
optionaler API-Token hinterlegt werden.
|
||||||
|
|
||||||
|
== Installation ==
|
||||||
|
|
||||||
|
1. ZIP in WordPress unter Plugins → Installieren → Plugin hochladen hochladen.
|
||||||
|
2. Aktivieren.
|
||||||
|
3. Unter Einstellungen → OpenWorkshop Odoo API die Basis-URL und den Endpoint konfigurieren.
|
||||||
|
4. Den Shortcode [openworkshop_machines] in einer Seite oder einem Beitrag einfügen.
|
||||||
|
|
||||||
|
== Changelog ==
|
||||||
|
|
||||||
|
= 0.1.0 =
|
||||||
|
* Erste Version.
|
||||||
11
open_workshop.code-workspace
Normal file
11
open_workshop.code-workspace
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
{
|
||||||
|
"folders": [
|
||||||
|
{
|
||||||
|
"path": "."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "../../../usr/lib/python3/dist-packages/odoo"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"settings": {}
|
||||||
|
}
|
||||||
3
open_workshop/__init__.py
Normal file
3
open_workshop/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
from . import models
|
||||||
|
from . import controllers
|
||||||
|
|
||||||
39
open_workshop/__manifest__.py
Normal file
39
open_workshop/__manifest__.py
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
{
|
||||||
|
'name': 'POS Open Workshop',
|
||||||
|
'license': 'AGPL-3',
|
||||||
|
'version': '18.0.1.0.1',
|
||||||
|
'summary': 'Erstellt Maschinenfreigaben basierend auf POS-Einweisungsprodukten',
|
||||||
|
'depends': ['base', 'account', 'hr','product','sale','contacts','point_of_sale'],
|
||||||
|
'author': 'matthias.lotz',
|
||||||
|
'category': 'Point of Sale',
|
||||||
|
'data': [
|
||||||
|
'security/ir.model.access.csv',
|
||||||
|
'views/machine_product_training_views.xml',
|
||||||
|
'views/menu_views.xml',
|
||||||
|
'views/machine_area_views.xml',
|
||||||
|
'views/machine_views.xml',
|
||||||
|
'views/res_partner_view.xml',
|
||||||
|
'data/data.xml',
|
||||||
|
],
|
||||||
|
'installable': False, # Wird durch open_workshop_base ersetzt
|
||||||
|
'assets': {
|
||||||
|
'web.assets_backend': [
|
||||||
|
'open_workshop/static/src/css/category_color.css',
|
||||||
|
],
|
||||||
|
'point_of_sale._assets_pos': [
|
||||||
|
'open_workshop/static/src/css/pos.css',
|
||||||
|
'open_workshop/static/src/js/ows_machine_access_list.js',
|
||||||
|
'open_workshop/static/src/js/ows_pos_customer_sidebar.js',
|
||||||
|
'open_workshop/static/src/js/ows_pos_sidebar.js',
|
||||||
|
'open_workshop/static/src/js/ows_product_screen_template_patch.js',
|
||||||
|
'open_workshop/static/src/xml/ows_machine_access_list.xml',
|
||||||
|
'open_workshop/static/src/xml/ows_pos_customer_sidebar.xml',
|
||||||
|
'open_workshop/static/src/xml/ows_pos_sidebar.xml',
|
||||||
|
'open_workshop/static/src/xml/ows_product_screen_template_patch.xml',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'description': """
|
||||||
|
Diese App erstellt Maschinenfreigaben basierend auf POS-Einweisungsprodukten.
|
||||||
|
Die App ist für den Einsatz in der Odoo-Version 18.0 konzipiert.
|
||||||
|
""",
|
||||||
|
}
|
||||||
3
open_workshop/controllers/__init__.py
Normal file
3
open_workshop/controllers/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
# Datei: open_workshop/controllers/__init__.py
|
||||||
|
|
||||||
|
from . import pos_access
|
||||||
14
open_workshop/controllers/pos_access.py
Normal file
14
open_workshop/controllers/pos_access.py
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
from odoo import http
|
||||||
|
from odoo.http import request
|
||||||
|
|
||||||
|
class OpenWorkshopPOSController(http.Controller):
|
||||||
|
|
||||||
|
@http.route('/open_workshop/partner_access', type='json', auth='user')
|
||||||
|
def get_partner_machine_access(self, **kwargs):
|
||||||
|
partner_id = kwargs.get('params', {}).get('partner_id')
|
||||||
|
if not partner_id:
|
||||||
|
return {"error": "Missing partner_id"}
|
||||||
|
|
||||||
|
Machine = request.env['ows.machine'].sudo()
|
||||||
|
return Machine.get_access_list_grouped(partner_id)
|
||||||
|
|
||||||
153
open_workshop/data/data.xml
Normal file
153
open_workshop/data/data.xml
Normal file
|
|
@ -0,0 +1,153 @@
|
||||||
|
<odoo>
|
||||||
|
<!-- Bereiche -->
|
||||||
|
<record id="area_fablab" model="ows.machine.area">
|
||||||
|
<field name="name">Fablab</field>
|
||||||
|
<field name="color_hex">#008000</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="area_holz" model="ows.machine.area">
|
||||||
|
<field name="name">Holzbereich</field>
|
||||||
|
<field name="color_hex">#ff0000</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="area_metall" model="ows.machine.area">
|
||||||
|
<field name="name">Metallbereich</field>
|
||||||
|
<field name="color_hex">#0000ff</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="area_elektronik" model="ows.machine.area">
|
||||||
|
<field name="name">Elektronikbereich</field>
|
||||||
|
<field name="color_hex">#ffff00</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Maschinen im Fablab -->
|
||||||
|
<record id="machine_sabako_laser" model="ows.machine">
|
||||||
|
<field name="name">Sabako Laser</field>
|
||||||
|
<field name="code">sabako_laser</field>
|
||||||
|
<field name="area_id" ref="area_fablab"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="machine_prusa" model="ows.machine">
|
||||||
|
<field name="name">Prusa</field>
|
||||||
|
<field name="code">prusa</field>
|
||||||
|
<field name="area_id" ref="area_fablab"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="machine_prusa_mmu" model="ows.machine">
|
||||||
|
<field name="name">Prusa MMU</field>
|
||||||
|
<field name="code">prusa_mmu</field>
|
||||||
|
<field name="area_id" ref="area_fablab"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="machine_3d_delta" model="ows.machine">
|
||||||
|
<field name="name">3D Delta</field>
|
||||||
|
<field name="code">3d_delta</field>
|
||||||
|
<field name="area_id" ref="area_fablab"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="machine_cnc_beamicon" model="ows.machine">
|
||||||
|
<field name="name">CNC Beamicon</field>
|
||||||
|
<field name="code">cnc_beamicon</field>
|
||||||
|
<field name="area_id" ref="area_fablab"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Maschinen im Holzbereich -->
|
||||||
|
<record id="machine_formatkreissaege" model="ows.machine">
|
||||||
|
<field name="name">Formatkreissäge</field>
|
||||||
|
<field name="code">formatkreissaege</field>
|
||||||
|
<field name="area_id" ref="area_holz"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="machine_bandsaege_holz" model="ows.machine">
|
||||||
|
<field name="name">Bandsäge</field>
|
||||||
|
<field name="code">bandsaege_holz</field>
|
||||||
|
<field name="area_id" ref="area_holz"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="machine_abrichte" model="ows.machine">
|
||||||
|
<field name="name">Abricht Dickenhobel</field>
|
||||||
|
<field name="code">dickenhobel</field>
|
||||||
|
<field name="area_id" ref="area_holz"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="machine_drechselbank" model="ows.machine">
|
||||||
|
<field name="name">Drechselbank</field>
|
||||||
|
<field name="code">drechselbank</field>
|
||||||
|
<field name="area_id" ref="area_holz"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="machine_festool_domino" model="ows.machine">
|
||||||
|
<field name="name">Festool Domino Fräse</field>
|
||||||
|
<field name="code">festool_domino</field>
|
||||||
|
<field name="area_id" ref="area_holz"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="machine_maffel_duo" model="ows.machine">
|
||||||
|
<field name="name">Maffel Duo Dübler</field>
|
||||||
|
<field name="code">maffel_duo</field>
|
||||||
|
<field name="area_id" ref="area_holz"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="machine_lamello" model="ows.machine">
|
||||||
|
<field name="name">Lamello Zeta P2</field>
|
||||||
|
<field name="code">lamello_zeta_p2</field>
|
||||||
|
<field name="area_id" ref="area_holz"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Maschinen im Metallbereich -->
|
||||||
|
<record id="machine_kreissaege_metall" model="ows.machine">
|
||||||
|
<field name="name">Kreissäge</field>
|
||||||
|
<field name="code">kreissaege_metall</field>
|
||||||
|
<field name="area_id" ref="area_metall"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="machine_bandsaege_metall" model="ows.machine">
|
||||||
|
<field name="name">Bandsäge</field>
|
||||||
|
<field name="code">bandsaege_metall</field>
|
||||||
|
<field name="area_id" ref="area_metall"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="machine_mig_mag" model="ows.machine">
|
||||||
|
<field name="name">MIG/MAG Schweißgeräte</field>
|
||||||
|
<field name="code">mig_mag</field>
|
||||||
|
<field name="area_id" ref="area_metall"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="machine_wig" model="ows.machine">
|
||||||
|
<field name="name">WIG Schweißgerät</field>
|
||||||
|
<field name="code">wig</field>
|
||||||
|
<field name="area_id" ref="area_metall"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="machine_schweissen" model="ows.machine">
|
||||||
|
<field name="name">Schweißen allgemein</field>
|
||||||
|
<field name="code">schweissen_allgemein</field>
|
||||||
|
<field name="area_id" ref="area_metall"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="machine_drehbank" model="ows.machine">
|
||||||
|
<field name="name">Drehbank</field>
|
||||||
|
<field name="code">drehbank</field>
|
||||||
|
<field name="area_id" ref="area_metall"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="machine_fraese" model="ows.machine">
|
||||||
|
<field name="name">Fräse</field>
|
||||||
|
<field name="code">fraese</field>
|
||||||
|
<field name="area_id" ref="area_metall"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="machine_abkantbank" model="ows.machine">
|
||||||
|
<field name="name">Abkantbank</field>
|
||||||
|
<field name="code">abkantbank</field>
|
||||||
|
<field name="area_id" ref="area_metall"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Maschine im Elektronikbereich -->
|
||||||
|
<record id="machine_loetkolben" model="ows.machine">
|
||||||
|
<field name="name">Lötkolben</field>
|
||||||
|
<field name="code">loetkolben</field>
|
||||||
|
<field name="area_id" ref="area_elektronik"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
</odoo>
|
||||||
5
open_workshop/models/__init__.py
Normal file
5
open_workshop/models/__init__.py
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
from . import ows_models
|
||||||
|
from . import pos_order
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
589
open_workshop/models/ows_models.py
Normal file
589
open_workshop/models/ows_models.py
Normal file
|
|
@ -0,0 +1,589 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# ows_models.py
|
||||||
|
# Part of Odoo Open Workshop
|
||||||
|
|
||||||
|
from odoo import models, fields, api
|
||||||
|
from markupsafe import escape as html_escape
|
||||||
|
import logging
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
_logger.info("✅ ows_models.py geladen")
|
||||||
|
|
||||||
|
class HREmployee(models.Model):
|
||||||
|
_inherit = 'hr.employee'
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def anonymize_for_testsystem(self):
|
||||||
|
"""Benennt Admin-Angestellten um und archiviert alle anderen für das Testsystem."""
|
||||||
|
admin_user = self.env['res.users'].search([('name', '=', 'Administrator')], limit=1)
|
||||||
|
if not admin_user:
|
||||||
|
_logger.error("[OWS] Administrator-Benutzer nicht gefunden!")
|
||||||
|
return
|
||||||
|
|
||||||
|
_logger.info(f"[OWS] Administrator-Benutzer gefunden: {admin_user.name} (ID: {admin_user.id})")
|
||||||
|
|
||||||
|
# Suche auch archivierte Employees
|
||||||
|
admin_employee = self.with_context(active_test=False).search([('user_id', '=', admin_user.id)], limit=1)
|
||||||
|
|
||||||
|
if admin_employee:
|
||||||
|
# Administrator-Employee reaktivieren und umbenennen
|
||||||
|
admin_employee.write({
|
||||||
|
'name': 'TESTSYSTEM',
|
||||||
|
'job_title': 'Testumgebung',
|
||||||
|
'work_email': 'office@hobbyhimmel.de',
|
||||||
|
'work_phone': False,
|
||||||
|
'active': True, # Reaktivieren falls archiviert
|
||||||
|
})
|
||||||
|
_logger.info(f"[OWS] Admin-Angestellter reaktiviert und umbenannt: {admin_employee.name} (ID: {admin_employee.id})")
|
||||||
|
else:
|
||||||
|
_logger.warning("[OWS] Kein Angestellter für Administrator gefunden.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Alle anderen Angestellten archivieren (auch bereits archivierte berücksichtigen)
|
||||||
|
other_employees = self.with_context(active_test=False).search([('id', '!=', admin_employee.id)])
|
||||||
|
other_employees.write({'active': False})
|
||||||
|
_logger.info("[OWS] %d Angestellte archiviert.", len(other_employees))
|
||||||
|
|
||||||
|
|
||||||
|
class ResPartner(models.Model):
|
||||||
|
_inherit = 'res.partner'
|
||||||
|
_logger.info("✅ ows ResPartner geladen")
|
||||||
|
|
||||||
|
ows_user_id = fields.One2many('ows.user', 'partner_id', string="OWS Benutzerdaten")
|
||||||
|
|
||||||
|
# Alte Felder (weiterhin sichtbar für externe Programme)
|
||||||
|
birthday = fields.Date(
|
||||||
|
string="Geburtstag",
|
||||||
|
compute='_compute_ows_user_fields',
|
||||||
|
inverse='_inverse_birthday',
|
||||||
|
store=False
|
||||||
|
)
|
||||||
|
rfid_card = fields.Text(
|
||||||
|
string="RFID Card ID",
|
||||||
|
compute='_compute_ows_user_fields',
|
||||||
|
inverse='_inverse_rfid_card',
|
||||||
|
store=False
|
||||||
|
)
|
||||||
|
security_briefing = fields.Boolean(
|
||||||
|
string="Haftungsausschluss",
|
||||||
|
compute='_compute_ows_user_fields',
|
||||||
|
inverse='_inverse_security_briefing',
|
||||||
|
store=False
|
||||||
|
)
|
||||||
|
security_id = fields.Text(
|
||||||
|
string="Haftungsausschluss ID",
|
||||||
|
compute='_compute_ows_user_fields',
|
||||||
|
inverse='_inverse_security_id',
|
||||||
|
store=False
|
||||||
|
)
|
||||||
|
# Neue direkte vvow_* Felder
|
||||||
|
vvow_birthday = fields.Date(string="Geburtstag (vvow)")
|
||||||
|
vvow_rfid_card = fields.Text(string="RFID Card ID (vvow)")
|
||||||
|
vvow_security_briefing = fields.Boolean(string="Haftungsausschluss (vvow)")
|
||||||
|
vvow_security_id = fields.Text(string="Haftungsausschluss ID (vvow)")
|
||||||
|
|
||||||
|
@api.depends('ows_user_id')
|
||||||
|
def _compute_ows_user_fields(self):
|
||||||
|
for partner in self:
|
||||||
|
user = partner.ows_user_id[0] if partner.ows_user_id else False
|
||||||
|
partner.birthday = user.birthday if user else False
|
||||||
|
partner.rfid_card = user.rfid_card if user else False
|
||||||
|
partner.security_briefing = user.security_briefing if user else False
|
||||||
|
partner.security_id = user.security_id if user else False
|
||||||
|
|
||||||
|
def _inverse_birthday(self):
|
||||||
|
for partner in self:
|
||||||
|
user = partner.ows_user_id[0] if partner.ows_user_id else False
|
||||||
|
if user:
|
||||||
|
user.birthday = partner.birthday
|
||||||
|
|
||||||
|
def _inverse_rfid_card(self):
|
||||||
|
for partner in self:
|
||||||
|
user = partner.ows_user_id[0] if partner.ows_user_id else False
|
||||||
|
if user:
|
||||||
|
user.rfid_card = partner.rfid_card
|
||||||
|
|
||||||
|
def _inverse_security_briefing(self):
|
||||||
|
for partner in self:
|
||||||
|
user = partner.ows_user_id[0] if partner.ows_user_id else False
|
||||||
|
if user:
|
||||||
|
user.security_briefing = partner.security_briefing
|
||||||
|
|
||||||
|
def _inverse_security_id(self):
|
||||||
|
for partner in self:
|
||||||
|
user = partner.ows_user_id[0] if partner.ows_user_id else False
|
||||||
|
if user:
|
||||||
|
user.security_id = partner.security_id
|
||||||
|
|
||||||
|
|
||||||
|
@api.model_create_multi
|
||||||
|
def create(self, vals_list):
|
||||||
|
partners = super().create(vals_list)
|
||||||
|
for vals, partner in zip(vals_list, partners):
|
||||||
|
self.env['ows.user'].create({
|
||||||
|
'partner_id': partner.id,
|
||||||
|
'birthday': vals.get('birthday') or vals.get('vvow_birthday'),
|
||||||
|
'rfid_card': vals.get('rfid_card') or vals.get('vvow_rfid_card'),
|
||||||
|
'security_briefing': vals.get('security_briefing') or vals.get('vvow_security_briefing'),
|
||||||
|
'security_id': vals.get('security_id') or vals.get('vvow_security_id'),
|
||||||
|
})
|
||||||
|
return partners
|
||||||
|
|
||||||
|
def write(self, vals):
|
||||||
|
res = super().write(vals)
|
||||||
|
for partner in self:
|
||||||
|
user = partner.ows_user_id[0] if partner.ows_user_id else False
|
||||||
|
if not user:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Synchronisation alt -> user
|
||||||
|
if 'birthday' in vals:
|
||||||
|
user.birthday = vals['birthday']
|
||||||
|
if 'rfid_card' in vals:
|
||||||
|
user.rfid_card = vals['rfid_card']
|
||||||
|
if 'security_briefing' in vals:
|
||||||
|
user.security_briefing = vals['security_briefing']
|
||||||
|
if 'security_id' in vals:
|
||||||
|
user.security_id = vals['security_id']
|
||||||
|
|
||||||
|
# Synchronisation vvow_* -> user + alt
|
||||||
|
if 'vvow_birthday' in vals:
|
||||||
|
user.birthday = vals['vvow_birthday']
|
||||||
|
partner.birthday = vals['vvow_birthday']
|
||||||
|
if 'vvow_rfid_card' in vals:
|
||||||
|
user.rfid_card = vals['vvow_rfid_card']
|
||||||
|
partner.rfid_card = vals['vvow_rfid_card']
|
||||||
|
if 'vvow_security_briefing' in vals:
|
||||||
|
user.security_briefing = vals['vvow_security_briefing']
|
||||||
|
partner.security_briefing = vals['vvow_security_briefing']
|
||||||
|
if 'vvow_security_id' in vals:
|
||||||
|
user.security_id = vals['vvow_security_id']
|
||||||
|
partner.security_id = vals['vvow_security_id']
|
||||||
|
return res
|
||||||
|
|
||||||
|
machine_access_ids = fields.One2many(
|
||||||
|
'ows.machine.access',
|
||||||
|
'partner_id',
|
||||||
|
string='Maschinenfreigaben'
|
||||||
|
)
|
||||||
|
|
||||||
|
machine_access_html = fields.Html(
|
||||||
|
string="Maschinenfreigabe",
|
||||||
|
compute="_compute_machine_access_html",
|
||||||
|
sanitize=False
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@api.depends('machine_access_ids')
|
||||||
|
def _compute_machine_access_html(self):
|
||||||
|
areas = self.env['ows.machine.area'].search([], order="name")
|
||||||
|
for partner in self:
|
||||||
|
html = ""
|
||||||
|
for area in areas:
|
||||||
|
html += f"""
|
||||||
|
<div class="o_form_sheet">
|
||||||
|
<h3 class="o_form_label">{area.name}</h3>
|
||||||
|
<table class="table table-sm table-bordered o_form_table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Maschine</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Datum</th>
|
||||||
|
<th>Gültig bis</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
"""
|
||||||
|
|
||||||
|
machines = self.env['ows.machine'].search([('area_id', '=', area.id)], order="name")
|
||||||
|
|
||||||
|
for machine in machines:
|
||||||
|
access = self.env['ows.machine.access'].search([
|
||||||
|
('partner_id', '=', partner.id),
|
||||||
|
('machine_id', '=', machine.id),
|
||||||
|
], limit=1)
|
||||||
|
icon = '<span class="text-success fa fa-check"/>' if access else '<span class="text-danger fa fa-times"/>'
|
||||||
|
date_granted = access.date_granted.strftime('%Y-%m-%d') if access and access.date_granted else "-"
|
||||||
|
date_expiry = access.date_expiry.strftime('%Y-%m-%d') if access and access.date_expiry else "-"
|
||||||
|
|
||||||
|
html += f"""
|
||||||
|
<tr>
|
||||||
|
<td>{machine.name}</td>
|
||||||
|
<td>{icon}</td>
|
||||||
|
<td>{date_granted}</td>
|
||||||
|
<td>{date_expiry}</td>
|
||||||
|
</tr>
|
||||||
|
"""
|
||||||
|
|
||||||
|
html += "</tbody></table></div>"
|
||||||
|
|
||||||
|
partner.machine_access_html = html
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def migrate_existing_partners(self):
|
||||||
|
"""
|
||||||
|
Erstellt für alle vorhandenen res.partner einen ows.user,
|
||||||
|
wenn noch keiner existiert, und übernimmt alte vvow_* Felder.
|
||||||
|
Führt am Ende automatisch einen commit durch.
|
||||||
|
|
||||||
|
Verwendung in der odoo shell:
|
||||||
|
env['res.partner'].migrate_existing_partners()
|
||||||
|
env['ows.user'].search_count([])
|
||||||
|
env['ows.user'].search([]).mapped('partner_id.name')
|
||||||
|
"""
|
||||||
|
migrated = 0
|
||||||
|
skipped = 0
|
||||||
|
|
||||||
|
partners = self.env['res.partner'].search([])
|
||||||
|
for partner in partners:
|
||||||
|
if partner.ows_user_id:
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Werte lesen (werden evtl. durch _inherit hinzugefügt)
|
||||||
|
vals = {
|
||||||
|
'partner_id': partner.id,
|
||||||
|
'birthday': getattr(partner, 'vvow_birthday', False),
|
||||||
|
'rfid_card': getattr(partner, 'vvow_rfid_card', False),
|
||||||
|
'security_briefing': getattr(partner, 'vvow_security_briefing', False),
|
||||||
|
'security_id': getattr(partner, 'vvow_security_id', False),
|
||||||
|
}
|
||||||
|
self.env['ows.user'].create(vals)
|
||||||
|
migrated += 1
|
||||||
|
_logger.info(f"Erzeuge ows.user für Partner {partner.id} ({partner.name}) mit Werten: {vals}")
|
||||||
|
|
||||||
|
_logger.info(f"[OWS Migration] ✅ Migriert: {migrated}, ❌ Übersprungen: {skipped}")
|
||||||
|
|
||||||
|
# 🔐 Commit am Ende
|
||||||
|
self.env.cr.commit()
|
||||||
|
|
||||||
|
return {
|
||||||
|
'type': 'ir.actions.client',
|
||||||
|
'tag': 'display_notification',
|
||||||
|
'params': {
|
||||||
|
'title': "Migration abgeschlossen",
|
||||||
|
'message': f"{migrated} Partner migriert, {skipped} übersprungen.",
|
||||||
|
'sticky': False,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def migrate_machine_access_from_old_fields(self):
|
||||||
|
"""
|
||||||
|
Migriert alte vvow_* Boolean-Felder in strukturierte ows.machine.access Einträge.
|
||||||
|
Das Freigabe-Datum wird aus dem Änderungsverlauf (mail.message.tracking_value_ids) extrahiert.
|
||||||
|
"""
|
||||||
|
mapping = [
|
||||||
|
('vvow_holz_sander', 'bandschleifer'),
|
||||||
|
('vvow_holz_felder_bandsaw', 'bandsaege_holz'),
|
||||||
|
('vvow_holz_felder_jointer', 'dickenhobel'),
|
||||||
|
('vvow_holz_felder_mill', 'felder_fraese'),
|
||||||
|
('vvow_holz_felder_tablesaw', 'formatkreissaege'),
|
||||||
|
('vvow_holz_mill', 'tischfraese'),
|
||||||
|
('vvow_holz_lathe', 'drechselbank'),
|
||||||
|
('vvow_holz_domino', 'festool_domino'),
|
||||||
|
('vvow_holz_duoduebler', 'maffel_duo'),
|
||||||
|
('vvow_holz_lamello', 'lamello_zeta_p2'),
|
||||||
|
('vvow_fablab_laser_sabko', 'sabako_laser'),
|
||||||
|
('vvow_fablab_3dprint_delta', '3d_delta'),
|
||||||
|
('vvow_fablab_3dprint_prusa', 'prusa'),
|
||||||
|
('vvow_fablab_3dprint_prusa_mmu', 'prusa_mmu'),
|
||||||
|
('vvow_fablab_cnc_beamicon', 'cnc_beamicon'),
|
||||||
|
('vvow_metall_welding_mig', 'mig_mag'),
|
||||||
|
('vvow_metall_welding_wig', 'wig'),
|
||||||
|
('vvow_metall_welding_misc', 'schweissen_allgemein'),
|
||||||
|
('vvow_metall_lathe', 'drehbank'),
|
||||||
|
('vvow_metall_bandsaw', 'bandsaege_metall'),
|
||||||
|
('vvow_metall_mill_fp2', 'fraese'),
|
||||||
|
('vvow_metall_bende', 'abkantbank'),
|
||||||
|
('vvow_metall_chop_saw', 'kreissaege_metall'),
|
||||||
|
]
|
||||||
|
|
||||||
|
MachineAccess = self.env['ows.machine.access']
|
||||||
|
MailMessage = self.env['mail.message']
|
||||||
|
TrackingValue = self.env['mail.tracking.value']
|
||||||
|
|
||||||
|
count_created = 0
|
||||||
|
|
||||||
|
for partner in self.search([]):
|
||||||
|
for field_name, machine_code in mapping:
|
||||||
|
if not getattr(partner, field_name, False):
|
||||||
|
continue
|
||||||
|
|
||||||
|
machine = self.env['ows.machine'].search([('code', '=', machine_code)], limit=1)
|
||||||
|
if not machine:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Änderungsverlauf durchsuchen: Wann wurde das Feld auf True gesetzt?
|
||||||
|
tracking = TrackingValue.search([
|
||||||
|
('field', '=', field_name),
|
||||||
|
('field_type', '=', 'boolean'),
|
||||||
|
('new_value_integer', '=', 1),
|
||||||
|
('mail_message_id.model', '=', 'res.partner'),
|
||||||
|
('mail_message_id.res_id', '=', partner.id),
|
||||||
|
], order='id ASC', limit=1)
|
||||||
|
|
||||||
|
date_granted = tracking.mail_message_id.date if tracking else fields.Datetime.now()
|
||||||
|
|
||||||
|
if not MachineAccess.search([('partner_id', '=', partner.id), ('machine_id', '=', machine.id)]):
|
||||||
|
MachineAccess.create({
|
||||||
|
'partner_id': partner.id,
|
||||||
|
'machine_id': machine.id,
|
||||||
|
'date_granted': date_granted,
|
||||||
|
})
|
||||||
|
count_created += 1
|
||||||
|
|
||||||
|
_logger.info(f"[OWS Migration] ✅ Maschinenfreigaben erstellt: {count_created}")
|
||||||
|
self.env.cr.commit()
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def archive_partners_without_users(self):
|
||||||
|
"""
|
||||||
|
Archiviert alle Partner (res.partner), die keine Benutzer (res.users) sind.
|
||||||
|
"""
|
||||||
|
Partner = self.env['res.partner']
|
||||||
|
User = self.env['res.users']
|
||||||
|
|
||||||
|
# IDs aller Partner, die ein Benutzerkonto haben
|
||||||
|
user_partner_ids = User.search([]).mapped('partner_id').ids
|
||||||
|
|
||||||
|
# Alle Partner ohne Benutzerkonto
|
||||||
|
partners_to_archive = Partner.search([
|
||||||
|
('id', 'not in', user_partner_ids),
|
||||||
|
('active', '=', True),
|
||||||
|
])
|
||||||
|
|
||||||
|
count = len(partners_to_archive)
|
||||||
|
partners_to_archive.write({'active': False})
|
||||||
|
for p in partners_to_archive:
|
||||||
|
_logger.debug(f"[OWS] Archiviert Partner: {p.name} (ID {p.id})")
|
||||||
|
_logger.info(f"[OWS] Archiviert {count} Partner ohne Benutzerkonto.")
|
||||||
|
self.env.cr.commit()
|
||||||
|
|
||||||
|
|
||||||
|
class OwsUser(models.Model):
|
||||||
|
_name = 'ows.user'
|
||||||
|
_description = 'OWS: Benutzerdaten'
|
||||||
|
_table = 'ows_user'
|
||||||
|
_logger.info("✅ ows_user geladen")
|
||||||
|
|
||||||
|
partner_id = fields.Many2one(
|
||||||
|
'res.partner',
|
||||||
|
required=True,
|
||||||
|
ondelete='cascade',
|
||||||
|
string='Kontakt'
|
||||||
|
)
|
||||||
|
|
||||||
|
birthday = fields.Date('Geburtstag')
|
||||||
|
rfid_card = fields.Text('RFID Card ID')
|
||||||
|
security_briefing = fields.Boolean('Haftungsausschluss', default=False)
|
||||||
|
security_id = fields.Text('Haftungsausschluss ID')
|
||||||
|
|
||||||
|
_sql_constraints = [
|
||||||
|
('partner_unique', 'unique(partner_id)', 'Jeder Partner darf nur einen OWS-Datensatz haben.')
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
AVAILABLE_COLORS = [
|
||||||
|
('#000000', 'schwarz'),
|
||||||
|
('#ff0000', 'Rot'),
|
||||||
|
('#E91E63', 'Pink'),
|
||||||
|
('#9C27B0', 'Lila'),
|
||||||
|
('#3F51B5', 'Indigo'),
|
||||||
|
('#0000ff', 'Blau'),
|
||||||
|
('#008000', 'Grün'),
|
||||||
|
('#ffff00', 'Gelb'),
|
||||||
|
('#FF9800', 'Orange'),
|
||||||
|
('#795548', 'Braun'),
|
||||||
|
('#ffffff', 'Weiss'),
|
||||||
|
]
|
||||||
|
|
||||||
|
class OwsMachineArea(models.Model):
|
||||||
|
_name = 'ows.machine.area'
|
||||||
|
_table = 'ows_machine_area'
|
||||||
|
_description = 'OWS: Maschinenbereich'
|
||||||
|
_order = 'name'
|
||||||
|
|
||||||
|
name = fields.Char(string="Name", required=True, translate=True)
|
||||||
|
|
||||||
|
color_hex = fields.Selection(
|
||||||
|
selection=AVAILABLE_COLORS,
|
||||||
|
string="Farbe (Hex)",
|
||||||
|
required=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
color_hex_value = fields.Char(
|
||||||
|
string="Farbcode",
|
||||||
|
compute='_compute_color_hex_value',
|
||||||
|
store=False
|
||||||
|
)
|
||||||
|
|
||||||
|
color_name = fields.Char(
|
||||||
|
string="Farbname",
|
||||||
|
compute='_compute_color_name',
|
||||||
|
store=False
|
||||||
|
)
|
||||||
|
|
||||||
|
@api.depends('color_hex')
|
||||||
|
def _compute_color_hex_value(self):
|
||||||
|
for rec in self:
|
||||||
|
rec.color_hex_value = rec.color_hex or ''
|
||||||
|
|
||||||
|
@api.depends('color_hex')
|
||||||
|
def _compute_color_name(self):
|
||||||
|
label_dict = dict(AVAILABLE_COLORS)
|
||||||
|
for rec in self:
|
||||||
|
rec.color_name = label_dict.get(rec.color_hex, 'Unbekannt')
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class OwsMachine(models.Model):
|
||||||
|
_name = 'ows.machine'
|
||||||
|
_table = 'ows_machine'
|
||||||
|
_description = 'OWS: Maschine'
|
||||||
|
|
||||||
|
name = fields.Char(required=True, translate=True)
|
||||||
|
code = fields.Char(required=True, help="Eindeutiger Kurzcode, z.B. 'lasercutter'")
|
||||||
|
category = fields.Selection([
|
||||||
|
('green', 'Kategorie 1: grün'),
|
||||||
|
('yellow', 'Kategorie 2: gelb'),
|
||||||
|
('red', 'Kategorie 3: rot'),
|
||||||
|
], string="Sicherheitskategorie", required=True, default='red', help="Sicherheitsrelevante Maschinenkategorie:\n"
|
||||||
|
"- grün: keine Einweisungspflicht\n"
|
||||||
|
"- gelb: empfohlene Einweisung\n"
|
||||||
|
"- rot: Einweisung zwingend erforderlich")
|
||||||
|
|
||||||
|
category_icon = fields.Char(string="Kategorie-Symbol", compute="_compute_category_icon", store=False)
|
||||||
|
|
||||||
|
@api.depends('category')
|
||||||
|
def _compute_category_icon(self):
|
||||||
|
for rec in self:
|
||||||
|
icon_map = {
|
||||||
|
'green': '🟢',
|
||||||
|
'yellow': '🟡',
|
||||||
|
'red': '🔴',
|
||||||
|
}
|
||||||
|
rec.category_icon = icon_map.get(rec.category, '⚪')
|
||||||
|
|
||||||
|
description = fields.Text()
|
||||||
|
active = fields.Boolean(default=True)
|
||||||
|
area_id = fields.Many2one('ows.machine.area', string='Bereich', help="Bereich, in dem die Maschine oder das Gerät steht.")
|
||||||
|
product_ids = fields.One2many('ows.machine.product', 'machine_id', string="Nutzungsprodukte")
|
||||||
|
product_names = fields.Char(string="Liste der Nutzungsprodukte", compute="_compute_product_using_names", store=False,)
|
||||||
|
training_ids = fields.One2many('ows.machine.training', 'machine_id', string="Einweisungsprodukte")
|
||||||
|
training_names = fields.Char(string="Liste der Einweisungsprodukte", compute="_compute_product_training_names", store=False,)
|
||||||
|
storage_location = fields.Char(string="Lagerort", help="Lagerort der Maschine oder des Geräts.")
|
||||||
|
purchase_price = fields.Float(string="Kaufpreis", help="Kaufpreis der Maschine oder des Geräts.")
|
||||||
|
purchase_date = fields.Date(string="Kaufdatum", help="Kaufdatum der Maschine oder des Geräts.")
|
||||||
|
|
||||||
|
@api.depends('product_ids.product_id.name')
|
||||||
|
def _compute_product_using_names(self):
|
||||||
|
for machine in self:
|
||||||
|
names = machine.product_ids.mapped('product_id.name')
|
||||||
|
machine.product_names = ", ".join(names)
|
||||||
|
|
||||||
|
@api.depends('training_ids.training_id.name')
|
||||||
|
def _compute_product_training_names(self):
|
||||||
|
for machine in self:
|
||||||
|
names = machine.training_ids.mapped('training_id.name')
|
||||||
|
machine.training_names = ", ".join(names)
|
||||||
|
|
||||||
|
_sql_constraints = [
|
||||||
|
('code_unique', 'unique(code)', 'Maschinencode muss eindeutig sein.')
|
||||||
|
]
|
||||||
|
|
||||||
|
def name_get(self):
|
||||||
|
return [(rec.id, f"{rec.name} ({rec.code})") for rec in self]
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def get_access_list_grouped(self, partner_id):
|
||||||
|
"""
|
||||||
|
Gibt eine gruppierte Liste von Maschinenzugängen für einen bestimmten Partner zurück. Diese Funktion wird in
|
||||||
|
Odoo POS Frontend verwendet um die Ansicht zu erzeugen auf Welche Maschinen der Partner Zugriff hat.
|
||||||
|
|
||||||
|
Für einen gegebenen Partner (über die partner_id) werden alle Maschinenbereiche (areas) abgefragt.
|
||||||
|
Für jeden Bereich wird geprüft, auf welche Maschinen der Partner Zugriff hat. Das Ergebnis wird
|
||||||
|
als Liste von Bereichen mit jeweils zugehörigen Maschinen und Zugriffsstatus zurückgegeben.
|
||||||
|
|
||||||
|
Zusätzlich werden sicherheitsrelevante Informationen des Partners (wie Sicherheitsunterweisung,
|
||||||
|
Sicherheits-ID, RFID-Karte und Geburtstag) aus dem zugehörigen ows_user ermittelt und mitgeliefert.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
partner_id (int): Die ID des Partners, für den die Zugriffsübersicht erstellt werden soll.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: Ein Dictionary mit folgenden Schlüsseln:
|
||||||
|
- 'access_by_area': Liste von Bereichen mit Maschinen und Zugriffsstatus.
|
||||||
|
- 'security_briefing': Sicherheitsunterweisung des Nutzers (bool oder False).
|
||||||
|
- 'security_id': Sicherheits-ID des Nutzers (str oder '').
|
||||||
|
- 'rfid_card': RFID-Kartennummer des Nutzers (str oder '').
|
||||||
|
- 'birthday': Geburtstag des Nutzers (str oder '').
|
||||||
|
"""
|
||||||
|
partner = self.env['res.partner'].browse(partner_id)
|
||||||
|
areas = self.env['ows.machine.area'].search([], order="name")
|
||||||
|
_logger.info("Access RPC called with partner_id=%s", partner_id)
|
||||||
|
access_by_area = []
|
||||||
|
for area in areas:
|
||||||
|
machines = self.search([('area_id', '=', area.id), ('category', '=', 'red')], order="name")
|
||||||
|
machine_list = []
|
||||||
|
for machine in machines:
|
||||||
|
has_access = bool(self.env['ows.machine.access'].search([
|
||||||
|
('partner_id', '=', partner_id),
|
||||||
|
('machine_id', '=', machine.id),
|
||||||
|
], limit=1))
|
||||||
|
machine_list.append({
|
||||||
|
'name': machine.name,
|
||||||
|
'has_access': has_access,
|
||||||
|
})
|
||||||
|
if machine_list:
|
||||||
|
access_by_area.append({
|
||||||
|
'area': area.name,
|
||||||
|
'color_hex': area.color_hex or '#000000',
|
||||||
|
'machines': machine_list
|
||||||
|
})
|
||||||
|
|
||||||
|
user = partner.ows_user_id[:1]
|
||||||
|
return {
|
||||||
|
'access_by_area': access_by_area,
|
||||||
|
'security_briefing': user.security_briefing if user else False,
|
||||||
|
'security_id': user.security_id if user else '',
|
||||||
|
'rfid_card': user.rfid_card if user else '',
|
||||||
|
'birthday': user.birthday if user else '',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class OwsMachineAccess(models.Model):
|
||||||
|
_name = 'ows.machine.access'
|
||||||
|
_table = 'ows_machine_access'
|
||||||
|
_description = 'OWS: Maschinenfreigabe'
|
||||||
|
_order = 'partner_id, machine_id'
|
||||||
|
|
||||||
|
partner_id = fields.Many2one('res.partner', required=True, ondelete='cascade')
|
||||||
|
machine_id = fields.Many2one('ows.machine', required=True)
|
||||||
|
date_granted = fields.Date(default=fields.Date.today)
|
||||||
|
date_expiry = fields.Date(string="Ablaufdatum")
|
||||||
|
granted_by_pos = fields.Boolean(default=True)
|
||||||
|
|
||||||
|
_sql_constraints = [
|
||||||
|
('partner_machine_unique', 'unique(partner_id, machine_id)', 'Der Kunde hat diese Freigabe bereits.')
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class OwsMachineProduct(models.Model):
|
||||||
|
_name = 'ows.machine.product'
|
||||||
|
_table = 'ows_machine_product'
|
||||||
|
_description = 'OWS: Zuordnung Produkt der Nutzung zu der Maschine'
|
||||||
|
|
||||||
|
product_id = fields.Many2one('product.product', required=True, domain=[('available_in_pos', '=', True)], ondelete='cascade')
|
||||||
|
machine_id = fields.Many2one('ows.machine', required=True, ondelete='cascade')
|
||||||
|
|
||||||
|
class OwsMachineTraining(models.Model):
|
||||||
|
_name = 'ows.machine.training'
|
||||||
|
_table = 'ows_machine_training'
|
||||||
|
_description = 'OWS: Zuordnung Produkt der Einweisung zu der Maschine'
|
||||||
|
|
||||||
|
training_id = fields.Many2one('product.product', required=True, domain=[('available_in_pos', '=', True)], ondelete='cascade')
|
||||||
|
machine_id = fields.Many2one('ows.machine', required=True, ondelete='cascade')
|
||||||
8
open_workshop/security/ir.model.access.csv
Normal file
8
open_workshop/security/ir.model.access.csv
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||||
|
access_ows_machine_access_user,ows.machine.access,model_ows_machine_access,base.group_user,1,1,1,1
|
||||||
|
access_ows_machine_user,ows.machine,model_ows_machine,base.group_user,1,1,1,1
|
||||||
|
access_ows_machine_product_user,ows.machine.product,model_ows_machine_product,base.group_user,1,1,1,1
|
||||||
|
access_ows_machine_training_user,access_ows_machine_training_user,model_ows_machine_training,base.group_user,1,1,1,1
|
||||||
|
access_ows_machine_area,ows.machine.area,model_ows_machine_area,base.group_user,1,1,1,1
|
||||||
|
access_ows_user,ows.user,model_ows_user,base.group_user,1,1,1,1
|
||||||
|
access_ows_machine_training,ows.machine.training,model_ows_machine_training,base.group_user,1,1,1,1
|
||||||
|
9
open_workshop/static/src/css/category_color.css
Normal file
9
open_workshop/static/src/css/category_color.css
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
.category-color-circle {
|
||||||
|
display: inline-block;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border-radius: 50%;
|
||||||
|
margin-left: 8px;
|
||||||
|
vertical-align: middle;
|
||||||
|
border: 1px solid #444;
|
||||||
|
}
|
||||||
57
open_workshop/static/src/css/pos.css
Normal file
57
open_workshop/static/src/css/pos.css
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
.custompane {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ows-sidebar {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
width: 220px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.order-entry:hover { cursor: pointer; }
|
||||||
|
.order-entry.selected { background-color: #007bff; color: white; }
|
||||||
|
|
||||||
|
.ows-customer-list {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
overflow-y: auto;
|
||||||
|
min-height: 0; /* notwendig für Scrollbar */
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-details-grid {
|
||||||
|
flex-shrink: 0;
|
||||||
|
max-height: 60%;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pos *::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
height:8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-line {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.5em;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
padding: 0.2em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-date {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-name {
|
||||||
|
flex-shrink: 1;
|
||||||
|
text-align: right;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
72
open_workshop/static/src/js/ows_machine_access_list.js
Normal file
72
open_workshop/static/src/js/ows_machine_access_list.js
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
// @odoo-module ows_machine_access_list.js
|
||||||
|
|
||||||
|
import { Component, useState } from "@odoo/owl";
|
||||||
|
import { useBus } from "@web/core/utils/hooks";
|
||||||
|
import { usePos } from "@point_of_sale/app/store/pos_hook";
|
||||||
|
import { rpc } from "@web/core/network/rpc";
|
||||||
|
import { registry } from "@web/core/registry";
|
||||||
|
|
||||||
|
export class OwsMachineAccessList extends Component {
|
||||||
|
static template = 'open_workshop.OwsMachineAccessList';
|
||||||
|
|
||||||
|
setup() {
|
||||||
|
this.pos = usePos();
|
||||||
|
|
||||||
|
this.state = useState({
|
||||||
|
client: null,
|
||||||
|
grouped_accesses: [],
|
||||||
|
security_briefing: false,
|
||||||
|
security_id: '',
|
||||||
|
rfid_card: '',
|
||||||
|
birthday: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
// 🔁 Reagiere auf Partnerwechsel über den Odoo-Bus
|
||||||
|
useBus(this.env.bus, 'partner-changed', () => {
|
||||||
|
this.updateAccessList();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 🔃 Beim Mounten initiale Daten laden
|
||||||
|
this.updateAccessList();
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateAccessList() {
|
||||||
|
const order = this.pos.get_order();
|
||||||
|
const partner = order?.get_partner?.();
|
||||||
|
this.state.client = partner || null;
|
||||||
|
if (!partner) {
|
||||||
|
this.state.grouped_accesses = [];
|
||||||
|
this.state.security_briefing = false;
|
||||||
|
this.state.security_id = '';
|
||||||
|
this.state.rfid_card = '';
|
||||||
|
this.state.birthday = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await rpc("/open_workshop/partner_access", {
|
||||||
|
params: { partner_id: partner.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
this.state.grouped_accesses = data.access_by_area || [];
|
||||||
|
this.state.security_briefing = data.security_briefing;
|
||||||
|
this.state.security_id = data.security_id;
|
||||||
|
this.state.rfid_card = data.rfid_card;
|
||||||
|
this.state.birthday = data.birthday;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Fehler beim Laden der Einweisungen:", error);
|
||||||
|
this.state.grouped_accesses = [];
|
||||||
|
this.state.security_briefing = false;
|
||||||
|
this.state.security_id = '';
|
||||||
|
this.state.rfid_card = '';
|
||||||
|
this.state.birthday = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
registry.category("templates").add("open_workshop.OwsMachineAccessList", OwsMachineAccessList);
|
||||||
|
|
||||||
54
open_workshop/static/src/js/ows_pos_customer_sidebar.js
Normal file
54
open_workshop/static/src/js/ows_pos_customer_sidebar.js
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
// @odoo-module ows_pos_customer_sidebar.js
|
||||||
|
|
||||||
|
import { Component } from "@odoo/owl";
|
||||||
|
import { useService } from "@web/core/utils/hooks";
|
||||||
|
import { usePos } from "@point_of_sale/app/store/pos_hook";
|
||||||
|
import { _t } from "@web/core/l10n/translation";
|
||||||
|
import { ask } from "@web/core/confirmation_dialog/confirmation_dialog";
|
||||||
|
|
||||||
|
export class OwsPosCustomerSidebar extends Component {
|
||||||
|
static template = "open_workshop.OwsPosCustomerSidebar";
|
||||||
|
|
||||||
|
setup() {
|
||||||
|
this.pos = usePos(); // ✅ Holt dir Zugriff auf den zentralen POS-Store
|
||||||
|
this.dialog = useService("dialog");
|
||||||
|
}
|
||||||
|
|
||||||
|
addOrder() {
|
||||||
|
this.pos.add_new_order(); // ✅ Neue Order wird aktive Order
|
||||||
|
this.pos.showScreen("ProductScreen");
|
||||||
|
this.pos.selectPartner();
|
||||||
|
this.env.bus.trigger('partner-changed'); // ✅ Event manuell auslösen
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeCurrentOrder() {
|
||||||
|
this.pos.onDeleteOrder(this.pos.get_order())
|
||||||
|
}
|
||||||
|
|
||||||
|
openTicketScreen() {
|
||||||
|
this.pos.showScreen("TicketScreen");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🔧 FIXED: Zugriff auf Order-Liste korrigiert
|
||||||
|
getFilteredOrderList() {
|
||||||
|
return this.pos.get_open_orders();
|
||||||
|
}
|
||||||
|
|
||||||
|
getDate(order) {
|
||||||
|
const date = new Date(order.date_order);
|
||||||
|
const dd = String(date.getDate()).padStart(2, '0');
|
||||||
|
const mm = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
|
const hh = String(date.getHours()).padStart(2, '0');
|
||||||
|
const mi = String(date.getMinutes()).padStart(2, '0');
|
||||||
|
return `${dd}.${mm}. ${hh}:${mi}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
getPartner(order) {
|
||||||
|
return order.get_partner()?.name || "Kein Kunde";
|
||||||
|
}
|
||||||
|
|
||||||
|
selectOrder(order) {
|
||||||
|
this.pos.set_order(order);
|
||||||
|
this.env.bus.trigger('partner-changed');
|
||||||
|
}
|
||||||
|
}
|
||||||
11
open_workshop/static/src/js/ows_pos_sidebar.js
Normal file
11
open_workshop/static/src/js/ows_pos_sidebar.js
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
// ows_pos_sidebar.js
|
||||||
|
// @odoo-module
|
||||||
|
|
||||||
|
import { Component } from "@odoo/owl";
|
||||||
|
import { OwsPosCustomerSidebar } from "./ows_pos_customer_sidebar";
|
||||||
|
import { OwsMachineAccessList } from "./ows_machine_access_list";
|
||||||
|
|
||||||
|
export class OwsPosSidebar extends Component {
|
||||||
|
static template = "open_workshop.OwsPosSidebar";
|
||||||
|
static components = { OwsPosCustomerSidebar, OwsMachineAccessList };
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
// product_screen_template_patch.js
|
||||||
|
// @odoo-module
|
||||||
|
|
||||||
|
import { registry } from "@web/core/registry";
|
||||||
|
import { ProductScreen } from "@point_of_sale/app/screens/product_screen/product_screen";
|
||||||
|
import { OwsPosSidebar } from "./ows_pos_sidebar";
|
||||||
|
|
||||||
|
class OwsProductScreen extends ProductScreen {
|
||||||
|
static components = Object.assign({}, ProductScreen.components, {
|
||||||
|
OwsPosSidebar,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
registry.category("pos_screens").remove("ProductScreen");
|
||||||
|
registry.category("pos_screens").add("ProductScreen", OwsProductScreen);
|
||||||
76
open_workshop/static/src/xml/ows_machine_access_list.xml
Normal file
76
open_workshop/static/src/xml/ows_machine_access_list.xml
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
<t t-name="open_workshop.OwsMachineAccessList">
|
||||||
|
<div class="client-details-grid p-2 small">
|
||||||
|
|
||||||
|
<!-- ✅ Sicherheitsbereich -->
|
||||||
|
<t t-if="state.client">
|
||||||
|
<div class="client-details-header">
|
||||||
|
<ul>
|
||||||
|
<li><span class="client-details-label">Einweisungen</span></li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="client-details-area border" t-att-style="'border: solid 3px #ffffff; margin: 5px;'">
|
||||||
|
<ul>
|
||||||
|
<li class="client-detail">
|
||||||
|
<span class="detail client-details-vvow_briefing">✅</span>
|
||||||
|
<span class="briefinglabel">Werkstatt</span>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li class="client-detail">
|
||||||
|
<t t-if="!state.security_briefing">
|
||||||
|
<span class="detail client-details-vvow_briefing_error">❌</span>
|
||||||
|
</t>
|
||||||
|
<t t-if="state.security_briefing">
|
||||||
|
<span class="detail client-details-vvow_briefing">✅</span>
|
||||||
|
</t>
|
||||||
|
<span class="briefinglabel">Haftungsausschluss</span>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<t t-if="!state.security_briefing">
|
||||||
|
<li class="client-detail">
|
||||||
|
<ul class="subpoints">
|
||||||
|
<span class="detail client-details-vvow_sec_briefing_error">‼️Bitte Prüfen‼️</span>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
</t>
|
||||||
|
|
||||||
|
<t t-if="state.security_briefing">
|
||||||
|
<ul class="subpoints">
|
||||||
|
<li class="client-detail">
|
||||||
|
<span class="label">Id:</span>
|
||||||
|
<span class="detail client-details-vvow_security_id">
|
||||||
|
<t t-esc="state.security_id || 'N/A'" />
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
<li class="client-detail">
|
||||||
|
<span class="label">Geburtstag:</span>
|
||||||
|
<span class="detail client-details-vvow_security_id">
|
||||||
|
<t t-esc="state.birthday || 'N/A'" />
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</t>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
|
||||||
|
<!-- ✅ Maschinenliste: immer sichtbar, gefiltert -->
|
||||||
|
<t t-foreach="state.grouped_accesses" t-as="area" t-key="area.area">
|
||||||
|
<t t-if="area.machines.length > 0">
|
||||||
|
<div class="client-details-area" t-att-style="'border: solid 3px ' + area.color_hex + '; margin: 5px;'">
|
||||||
|
<ul>
|
||||||
|
<t t-foreach="area.machines" t-as="machine" t-key="machine.name">
|
||||||
|
<li class="client-detail">
|
||||||
|
<span t-attf-class="detail {{ machine.has_access ? 'client-details-vvow_briefing' : 'client-details-vvow_briefing_error' }}">
|
||||||
|
<t t-esc="machine.has_access ? '✅' : '❌'" />
|
||||||
|
</span>
|
||||||
|
<span class="briefinglabel"><t t-esc="machine.name"/></span>
|
||||||
|
</li>
|
||||||
|
</t>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
</t>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
27
open_workshop/static/src/xml/ows_pos_customer_sidebar.xml
Normal file
27
open_workshop/static/src/xml/ows_pos_customer_sidebar.xml
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<templates id="template" xml:space="preserve">
|
||||||
|
<t t-name="open_workshop.OwsPosCustomerSidebar" owl="1">
|
||||||
|
<div class="ows-sidebar p-2 bg-light border-end h-100">
|
||||||
|
<div class="ows-sidebar-header mb-2 d-flex justify-content-between align-items-center">
|
||||||
|
<button class="btn btn-secondary" t-on-click="openTicketScreen">Orders</button>
|
||||||
|
<div>
|
||||||
|
<button class="btn btn-sm btn-success me-1" t-on-click="addOrder">+</button>
|
||||||
|
<button class="btn btn-sm btn-danger" t-on-click="removeCurrentOrder">–</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="ows-customer-list overflow-auto">
|
||||||
|
<t t-foreach="getFilteredOrderList()" t-as="order" t-key="order.uid">
|
||||||
|
<!--div class="order-entry p-1 rounded mb-1 border"
|
||||||
|
t-att-class="order === pos.get_order() ? 'bg-primary text-white' : 'bg-white'"
|
||||||
|
t-on-click="() => selectOrder(order)"-->
|
||||||
|
<div t-att-class="'order-entry' + (order === pos.get_order() ? ' selected' : '')" t-on-click="() => this.selectOrder(order)">
|
||||||
|
<div class="sidebar-line">
|
||||||
|
<span class="sidebar-date"><t t-esc="getDate(order)"/></span>
|
||||||
|
<span class="sidebar-name" t-att-title="getPartner(order)"><t t-esc="getPartner(order)"/></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
</templates>
|
||||||
9
open_workshop/static/src/xml/ows_pos_sidebar.xml
Normal file
9
open_workshop/static/src/xml/ows_pos_sidebar.xml
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<templates id="template" xml:space="preserve">
|
||||||
|
<t t-name="open_workshop.OwsPosSidebar" owl="1">
|
||||||
|
<div class="custompane d-flex flex-column border-end bg-200">
|
||||||
|
<OwsPosCustomerSidebar />
|
||||||
|
<OwsMachineAccessList />
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
</templates>
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<templates id="template" xml:space="preserve">
|
||||||
|
<t t-name="point_of_sale.ProductScreen" t-inherit="point_of_sale.ProductScreen" t-inherit-mode="extension">
|
||||||
|
<xpath expr="//div[contains(@class, 'leftpane')]" position="before">
|
||||||
|
<OwsPosSidebar />
|
||||||
|
</xpath>
|
||||||
|
</t>
|
||||||
|
</templates>
|
||||||
8
open_workshop/views/assets.xml
Normal file
8
open_workshop/views/assets.xml
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
<odoo>
|
||||||
|
<template id="assets_open_workshop" inherit_id="point_of_sale._assets_pos">
|
||||||
|
<xpath expr="." position="inside">
|
||||||
|
<script type="text/javascript" src="/open_workshop/static/src/js/machine_access_sidebar.js"/>
|
||||||
|
<link rel="stylesheet" type="text/css" href="/open_workshop/static/src/css/pos.css"/>
|
||||||
|
</xpath>
|
||||||
|
</template>
|
||||||
|
</odoo>
|
||||||
40
open_workshop/views/machine_area_views.xml
Normal file
40
open_workshop/views/machine_area_views.xml
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
<!-- machine_area_views.xml -->
|
||||||
|
<odoo>
|
||||||
|
<!-- Action zum Anzeigen der Bereiche -->
|
||||||
|
<record id="action_machine_area_list" model="ir.actions.act_window">
|
||||||
|
<field name="name">Maschinenbereiche</field>
|
||||||
|
<field name="res_model">ows.machine.area</field>
|
||||||
|
<field name="view_mode">list,form</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Menüpunkt unter Maschinen > Konfiguration -->
|
||||||
|
<menuitem id="menu_machine_area" name="Bereiche" parent="menu_machine_config" action="open_workshop.action_machine_area_list" sequence="30"/>
|
||||||
|
|
||||||
|
<!-- Listenansicht -->
|
||||||
|
<record id="view_machine_area_tree" model="ir.ui.view">
|
||||||
|
<field name="name">ows.machine.area.tree</field>
|
||||||
|
<field name="model">ows.machine.area</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<list>
|
||||||
|
<field name="name"/>
|
||||||
|
<field name="color_hex_value" string="Farbe (Hex)"/>
|
||||||
|
<field name="color_name" string="Farbname"/>
|
||||||
|
</list>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Formularansicht -->
|
||||||
|
<record id="view_machine_area_form" model="ir.ui.view">
|
||||||
|
<field name="name">ows.machine.area.form</field>
|
||||||
|
<field name="model">ows.machine.area</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<form string="Maschinenbereich">
|
||||||
|
<group>
|
||||||
|
<field name="name"/>
|
||||||
|
<field name="color_hex"/>
|
||||||
|
<field name="color_name" readonly="1"/>
|
||||||
|
</group>
|
||||||
|
</form>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
</odoo>
|
||||||
49
open_workshop/views/machine_product_training_views.xml
Normal file
49
open_workshop/views/machine_product_training_views.xml
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
<odoo>
|
||||||
|
<!-- Tree View: Nutzungsprodukte -->
|
||||||
|
<record id="view_machine_product_tree" model="ir.ui.view">
|
||||||
|
<field name="name">ows.machine.product.tree</field>
|
||||||
|
<field name="model">ows.machine.product</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<list editable="bottom">
|
||||||
|
<field name="machine_id"/>
|
||||||
|
<field name="product_id" domain="[('categ_id.name', '=', 'Maschinennutzung')]"/>
|
||||||
|
</list>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Tree View: Einweisungsprodukte -->
|
||||||
|
<record id="view_machine_training_tree" model="ir.ui.view">
|
||||||
|
<field name="name">ows.machine.training.tree</field>
|
||||||
|
<field name="model">ows.machine.training</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<list editable="bottom">
|
||||||
|
<field name="machine_id"/>
|
||||||
|
<field name="training_id" domain="[('categ_id.name', 'in', ['Einweisungen', 'Kurse'])]"/>
|
||||||
|
</list>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Action: Nutzungsprodukte -->
|
||||||
|
<record id="action_machine_product" model="ir.actions.act_window">
|
||||||
|
<field name="name">Maschinen-Nutzungsprodukte</field>
|
||||||
|
<field name="res_model">ows.machine.product</field>
|
||||||
|
<field name="view_mode">list</field>
|
||||||
|
<field name="view_id" ref="view_machine_product_tree"/>
|
||||||
|
<field name="help" type="html">
|
||||||
|
<p>Verwalte die Zuordnung von Maschinen zu Nutzungsprodukten.</p>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Action: Einweisungsprodukte -->
|
||||||
|
<record id="action_machine_training" model="ir.actions.act_window">
|
||||||
|
<field name="name">Maschinen-Einweisungsprodukte</field>
|
||||||
|
<field name="res_model">ows.machine.training</field>
|
||||||
|
<field name="view_mode">list</field>
|
||||||
|
<field name="view_id" ref="view_machine_training_tree"/>
|
||||||
|
<field name="help" type="html">
|
||||||
|
<p>Verwalte die Zuordnung von Maschinen zu Einweisungsprodukten.</p>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
|
||||||
|
</odoo>
|
||||||
68
open_workshop/views/machine_views.xml
Normal file
68
open_workshop/views/machine_views.xml
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
<!-- machine_views.xml -->
|
||||||
|
<odoo>
|
||||||
|
<!-- Maschinen Listenansicht -->
|
||||||
|
<record id="view_machine_tree" model="ir.ui.view">
|
||||||
|
<field name="name">ows.machine.tree</field>
|
||||||
|
<field name="model">ows.machine</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<list>
|
||||||
|
<field name="category_icon" string="⚙" readonly="1"/>
|
||||||
|
<field name="name"/>
|
||||||
|
<field name="category"/>
|
||||||
|
<field name="code"/>
|
||||||
|
<field name="area_id" widget="many2one_color"/>
|
||||||
|
<field name="product_names"/>
|
||||||
|
<field name="training_names"/>
|
||||||
|
<field name="storage_location"/>
|
||||||
|
<field name="purchase_price"/>
|
||||||
|
<field name="purchase_date"/>
|
||||||
|
<field name="active"/>
|
||||||
|
|
||||||
|
</list>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Maschinen Formularansicht -->
|
||||||
|
<record id="view_machine_form" model="ir.ui.view">
|
||||||
|
<field name="name">ows.machine.form</field>
|
||||||
|
<field name="model">ows.machine</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<form string="Maschine">
|
||||||
|
<sheet>
|
||||||
|
<group>
|
||||||
|
<field name="category_icon" string="⚙" readonly="1"/>
|
||||||
|
<field name="name"/>
|
||||||
|
<field name="category"/>
|
||||||
|
<field name="code"/>
|
||||||
|
<field name="area_id"/>
|
||||||
|
</group>
|
||||||
|
<group>
|
||||||
|
<field name="description"/>
|
||||||
|
<field name="storage_location"/>
|
||||||
|
<field name="purchase_price"/>
|
||||||
|
<field name="purchase_date"/>
|
||||||
|
<field name="active"/>
|
||||||
|
</group>
|
||||||
|
|
||||||
|
<!-- Notebook für Produkte und Einweisungen -->
|
||||||
|
<notebook>
|
||||||
|
<page string="Nutzungsprodukte">
|
||||||
|
<field name="product_ids" context="{'default_machine_id': id}">
|
||||||
|
<list editable="bottom">
|
||||||
|
<field name="product_id" domain="[('categ_id.name', '=', 'Maschinennutzung')]" />
|
||||||
|
</list>
|
||||||
|
</field>
|
||||||
|
</page>
|
||||||
|
<page string="Einweisungsprodukte">
|
||||||
|
<field name="training_ids" context="{'default_machine_id': id}">
|
||||||
|
<list editable="bottom">
|
||||||
|
<field name="training_id" domain="[('categ_id.name', 'in', ['Einweisungen', 'Kurse'])]" />
|
||||||
|
</list>
|
||||||
|
</field>
|
||||||
|
</page>
|
||||||
|
</notebook>
|
||||||
|
</sheet>
|
||||||
|
</form>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
</odoo>
|
||||||
81
open_workshop/views/menu_views.xml
Normal file
81
open_workshop/views/menu_views.xml
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
<!-- menu_views.xml -->
|
||||||
|
<odoo>
|
||||||
|
<!-- Maschinenliste -->
|
||||||
|
<record id="action_machine_list" model="ir.actions.act_window">
|
||||||
|
<field name="name">Maschinen</field>
|
||||||
|
<field name="res_model">ows.machine</field>
|
||||||
|
<field name="view_mode">list,form</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Trainingsprodukt-Liste -->
|
||||||
|
<record id="action_training_product_list" model="ir.actions.act_window">
|
||||||
|
<field name="name">Einweisungs-Produkte</field>
|
||||||
|
<field name="res_model">ows.machine.product</field>
|
||||||
|
<field name="view_mode">list,form</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Menüstruktur -->
|
||||||
|
<!-- Oberstes Menü -->
|
||||||
|
<menuitem id="menu_machine_root"
|
||||||
|
name="Maschinen"
|
||||||
|
sequence="10"/>
|
||||||
|
|
||||||
|
<!-- Konfigurationsebene -->
|
||||||
|
<menuitem id="menu_machine_config"
|
||||||
|
name="Konfiguration"
|
||||||
|
parent="menu_machine_root"
|
||||||
|
sequence="10"/>
|
||||||
|
|
||||||
|
<!-- Menüpunkt: Maschinenliste (klickbar) -->
|
||||||
|
<menuitem id="menu_machine_list_action"
|
||||||
|
name="Alle Maschinen"
|
||||||
|
parent="menu_machine_config"
|
||||||
|
action="open_workshop.action_machine_list"
|
||||||
|
sequence="10"/>
|
||||||
|
|
||||||
|
<!-- Menücontainer: Zuordnungen -->
|
||||||
|
<menuitem id="menu_machine_list"
|
||||||
|
name="Zuordnungen"
|
||||||
|
parent="menu_machine_config"
|
||||||
|
sequence="20"/>
|
||||||
|
|
||||||
|
<!-- Untermenü: Nutzungsprodukte -->
|
||||||
|
<menuitem id="menu_machine_product"
|
||||||
|
name="Nutzungsprodukte"
|
||||||
|
parent="menu_machine_list"
|
||||||
|
action="action_machine_product"
|
||||||
|
sequence="10"/>
|
||||||
|
|
||||||
|
<!-- Untermenü: Einweisungsprodukte -->
|
||||||
|
<menuitem id="menu_machine_training"
|
||||||
|
name="Einweisungsprodukte"
|
||||||
|
parent="menu_machine_list"
|
||||||
|
action="action_machine_training"
|
||||||
|
sequence="20"/>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- List & Form Views für training.product -->
|
||||||
|
<record id="view_training_product_tree" model="ir.ui.view">
|
||||||
|
<field name="name">ows.machine.product.tree</field>
|
||||||
|
<field name="model">ows.machine.product</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<list>
|
||||||
|
<field name="product_id"/>
|
||||||
|
<field name="machine_id"/>
|
||||||
|
</list>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="view_training_product_form" model="ir.ui.view">
|
||||||
|
<field name="name">ows.machine.product.form</field>
|
||||||
|
<field name="model">ows.machine.product</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<form string="Einweisungs-Produkt">
|
||||||
|
<group>
|
||||||
|
<field name="product_id"/>
|
||||||
|
<field name="machine_id"/>
|
||||||
|
</group>
|
||||||
|
</form>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
</odoo>
|
||||||
127
open_workshop/views/res_partner_view.xml
Normal file
127
open_workshop/views/res_partner_view.xml
Normal file
|
|
@ -0,0 +1,127 @@
|
||||||
|
<odoo>
|
||||||
|
<!-- Entfernt die Partner-Warnung (Duplicate Bank Accounts) in res.partner
|
||||||
|
<record id="patch_res_partner_duplicate_warning" model="ir.ui.view">
|
||||||
|
<field name="name">res.partner.remove.duplicate.bank.warning</field>
|
||||||
|
<field name="model">res.partner</field>
|
||||||
|
<field name="inherit_id" ref="account.view_partner_property_form"/>
|
||||||
|
<field name="priority" eval="99"/>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<xpath expr="//div[@name='warning_tax' and @class='alert alert-warning oe_edit_only']" position="replace"/>
|
||||||
|
</field>
|
||||||
|
</record>-->
|
||||||
|
|
||||||
|
<!-- Entfernt die Bankkonto-Warnung in res.partner.bank
|
||||||
|
<record id="patch_res_partner_bank_duplicate_warning" model="ir.ui.view">
|
||||||
|
<field name="name">res.partner.bank.remove.duplicate.warning</field>
|
||||||
|
<field name="model">res.partner.bank</field>
|
||||||
|
<field name="inherit_id" ref="account.view_partner_bank_form_inherit_account"/>
|
||||||
|
<field name="priority" eval="99"/>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<xpath expr="//div[@class='alert alert-warning']" position="replace"/>
|
||||||
|
</field>
|
||||||
|
</record> -->
|
||||||
|
|
||||||
|
<!-- Zentrale View für alle drei Tabs in garantierter Reihenfolge -->
|
||||||
|
|
||||||
|
<record id="view_partner_form_inherit_open_workshop_tabs" model="ir.ui.view">
|
||||||
|
<field name="name">res.partner.form.ows.tabs</field>
|
||||||
|
<field name="model">res.partner</field>
|
||||||
|
<field name="inherit_id" ref="base.view_partner_form"/>
|
||||||
|
<field name="priority" eval="20"/>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<xpath expr="//notebook" position="inside">
|
||||||
|
<page name="ows_machine_access" string="Offene Werkstatt (Hobbyhimmel)">
|
||||||
|
<!-- EINWEISUNG: Zwei Felder nebeneinander -->
|
||||||
|
<group name="container_row_2" string="Sicherheitseinweisung" col="2">
|
||||||
|
<field name="security_briefing"/>
|
||||||
|
<field name="security_id"/>
|
||||||
|
</group>
|
||||||
|
|
||||||
|
<!-- MASCHINENFREIGABEN: Volle Breite -->
|
||||||
|
<group string="Maschinenfreigaben" col="2">
|
||||||
|
<field name="machine_access_ids" colspan="2" context="{'default_partner_id': id}" nolabel="1">
|
||||||
|
<list>
|
||||||
|
<field name="machine_id"/>
|
||||||
|
<field name="date_granted"/>
|
||||||
|
<field name="date_expiry"/>
|
||||||
|
<field name="granted_by_pos"/>
|
||||||
|
</list>
|
||||||
|
</field>
|
||||||
|
</group>
|
||||||
|
|
||||||
|
<!-- ÜBERSICHT: Volle Breite -->
|
||||||
|
<group string="Maschinenfreigaben Übersicht" >
|
||||||
|
<field name="machine_access_html" colspan="2" readonly="1" widget="html" nolabel="1"/>
|
||||||
|
</group>
|
||||||
|
</page>
|
||||||
|
</xpath>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Geburtstag direkt nach der USt-ID -->
|
||||||
|
<record id="view_partner_form_inherit_ows_birthday" model="ir.ui.view">
|
||||||
|
<field name="name">res.partner.form.ows.birthday</field>
|
||||||
|
<field name="model">res.partner</field>
|
||||||
|
<field name="inherit_id" ref="base.view_partner_form"/>
|
||||||
|
<field name="priority" eval="15"/>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<xpath expr="//field[@name='vat']" position="after">
|
||||||
|
<field name="birthday"/>
|
||||||
|
</xpath>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- List View Anpassung -->
|
||||||
|
<record id="ows_userList_inherit" model="ir.ui.view">
|
||||||
|
<field name="name">res.partner.ows.tree</field>
|
||||||
|
<field name="model">res.partner</field>
|
||||||
|
<field name="inherit_id" ref="base.view_partner_tree"/>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<xpath expr="//field[@name='vat']" position="after">
|
||||||
|
<field name="create_date" optional="show"/>
|
||||||
|
<field name="security_briefing" optional="show"/>
|
||||||
|
<field name="security_id" optional="show"/>
|
||||||
|
<field name="rfid_card" optional="show"/>
|
||||||
|
<field name="category_id" widget="many2many_tags"/>
|
||||||
|
</xpath>
|
||||||
|
<xpath expr="//field[@name='vat']" position="replace">
|
||||||
|
<field name="vat" invisible="1"/>
|
||||||
|
</xpath>
|
||||||
|
<xpath expr="//field[@name='email']" position="replace">
|
||||||
|
<field name="email" invisible="1"/>
|
||||||
|
</xpath>
|
||||||
|
<xpath expr="//field[@name='phone']" position="replace">
|
||||||
|
<field name="phone" invisible="1"/>
|
||||||
|
</xpath>
|
||||||
|
<xpath expr="//field[@name='state_id']" position="replace">
|
||||||
|
<field name="state_id" invisible="1"/>
|
||||||
|
</xpath>
|
||||||
|
<xpath expr="//field[@name='country_id']" position="replace">
|
||||||
|
<field name="country_id" invisible="1"/>
|
||||||
|
</xpath>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Standardwerte setzen (company_type = person) -->
|
||||||
|
<record id="view_partner_form_inherit" model="ir.ui.view">
|
||||||
|
<field name="name">res.partner.form.inherit.default_person</field>
|
||||||
|
<field name="model">res.partner</field>
|
||||||
|
<field name="inherit_id" ref="base.view_partner_form"/>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<field name="company_type" position="attributes">
|
||||||
|
<attribute name="default">person</attribute>
|
||||||
|
</field>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Optional: Kontakte-Action, falls gebraucht -->
|
||||||
|
<record id="contacts.action_contacts" model="ir.actions.act_window">
|
||||||
|
<field name="view_mode">list,kanban,form,activity</field>
|
||||||
|
</record>
|
||||||
|
<record id="contacts.action_contacts_view_kanban" model="ir.actions.act_window.view">
|
||||||
|
<field name="sequence" eval="1"/>
|
||||||
|
</record>
|
||||||
|
<record id="contacts.action_contacts_view_tree" model="ir.actions.act_window.view">
|
||||||
|
<field name="sequence" eval="0"/>
|
||||||
|
</record>
|
||||||
|
</odoo>
|
||||||
44
open_workshop_base/Checkliste.md
Normal file
44
open_workshop_base/Checkliste.md
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
# ✅ OpenWorkshop Test Checkliste
|
||||||
|
|
||||||
|
## 🔹 1. Migration
|
||||||
|
- [ ] `migrate_existing_partners()` erzeugt zu jedem Partner genau einen `ows.user`.
|
||||||
|
- [ ] `migrate_existing_partners()` übernimmt korrekt alte `vvow_*`-Felder.
|
||||||
|
- [ ] `migrate_machine_access_from_old_fields()` erstellt korrekte Einträge in `ows.machine.access`.
|
||||||
|
- [ ] `migrate_machine_access_from_old_fields()` übernimmt das Änderungsdatum aus `mail.tracking.value`.
|
||||||
|
|
||||||
|
## 🔹 2. Kontakte Backend
|
||||||
|
- [ ] Beim Anlegen eines neuen Partners wird automatisch ein `ows.user` angelegt.
|
||||||
|
- [ ] Änderungen an Geburtstag, RFID, Haftung in Partner-Formular schreiben korrekt in `ows.user`.
|
||||||
|
- [ ] Die Werte aus `ows.user` werden korrekt im Partnerformular angezeigt (via `compute`).
|
||||||
|
- [ ] Das HTML-Widget mit Maschinenfreigaben (`machine_access_html`) wird korrekt dargestellt.
|
||||||
|
|
||||||
|
## 🔹 3. POS-Integration
|
||||||
|
- [ ] Felder aus `ows.user` (Geburtstag, RFID etc.) erscheinen im POS-Kunden-Popup.
|
||||||
|
- [ ] Maschinenfreigaben erscheinen im POS-Layout korrekt gruppiert nach Bereichen.
|
||||||
|
- [ ] Farben der Maschinenbereiche werden korrekt aus `color_hex` übernommen.
|
||||||
|
|
||||||
|
## 🔹 4. Maschinenverwaltung
|
||||||
|
- [ ] Maschinen-Formular zeigt Nutzungs- und Einweisungsprodukte korrekt an.
|
||||||
|
- [ ] Drop-downs in den Produktlisten zeigen nur Produkte der richtigen Kategorie.
|
||||||
|
- [ ] Neue Zuordnungen können direkt in den Tree-Ansichten editiert werden.
|
||||||
|
- [ ] Filter greifen korrekt (Maschinennutzung / Einweisungen).
|
||||||
|
|
||||||
|
## 🔹 5. Menüstruktur
|
||||||
|
- [ ] Menüeinträge "Nutzungsprodukte" und "Einweisungsprodukte" erscheinen unter Konfiguration > Maschinen.
|
||||||
|
- [ ] Klick auf "Alle Maschinen" öffnet die erwartete Listenansicht.
|
||||||
|
|
||||||
|
## 🔹 6. CSV/XML Demo-/Initialdaten
|
||||||
|
- [ ] Maschinenbereiche (`ows.machine.area`) sind korrekt aus `data.xml` geladen.
|
||||||
|
- [ ] Maschinen und ihre Produkt-Zuordnungen sind vollständig.
|
||||||
|
- [ ] Kategorien und Produkte sind korrekt verknüpft (`product.category`, `product.product`).
|
||||||
|
|
||||||
|
## 🔹 7. Systemweite Konsistenz
|
||||||
|
- [ ] Es gibt keine doppelten `ows.user`-Einträge.
|
||||||
|
- [ ] Kein Partner existiert ohne zugehörigen `ows.user`.
|
||||||
|
- [ ] `res.partner.ows_user_id` ist immer gefüllt.
|
||||||
|
|
||||||
|
## 🔹 8. Technische Qualität
|
||||||
|
- [ ] Kein `@api.depends('id')` mehr vorhanden.
|
||||||
|
- [ ] Commit wird in Migrationsfunktionen korrekt gesetzt (`self.env.cr.commit()`).
|
||||||
|
- [ ] Keine toten `vvow_*` Felder mehr im Modell (wenn auf ows.user umgestellt).
|
||||||
|
- [ ] post-init und pre-load Skripte laufen fehlerfrei bei Neuinstallation.
|
||||||
|
|
@ -1,21 +1,20 @@
|
||||||
{
|
{
|
||||||
'name': 'Open Workshop Base',
|
'name': 'Open Workshop Base',
|
||||||
'license': 'AGPL-3',
|
'license': 'AGPL-3',
|
||||||
'version': '18.0.1.0.5', # Migration läuft bei 18.0.1.0.4
|
'version': '18.0.1.0.4', # Migration läuft bei 18.0.1.0.4
|
||||||
'summary': 'Kern-Modul für Maschinenfreigaben - vereinfachte Equipment-Integration',
|
'summary': 'Kern-Modul für Maschinenfreigaben - vereinfachte Equipment-Integration',
|
||||||
'depends': ['base', 'account', 'hr', 'product', 'sale', 'contacts', 'maintenance', 'maintenance_equipment_status'],
|
'depends': ['base', 'account', 'hr', 'product', 'sale', 'contacts', 'maintenance'],
|
||||||
'author': 'matthias.lotz',
|
'author': 'matthias.lotz',
|
||||||
'category': 'Manufacturing',
|
'category': 'Manufacturing',
|
||||||
|
|
||||||
'data': [
|
'data': [
|
||||||
'security/ir.model.access.csv',
|
'security/ir.model.access.csv',
|
||||||
'data/maintenance_equipment_status_data.xml',
|
|
||||||
'views/machine_product_training_views.xml',
|
'views/machine_product_training_views.xml',
|
||||||
|
'views/menu_views.xml',
|
||||||
'views/machine_area_views.xml',
|
'views/machine_area_views.xml',
|
||||||
'views/machine_views.xml',
|
'views/machine_views.xml',
|
||||||
'views/maintenance_equipment_views.xml',
|
'views/maintenance_equipment_views.xml',
|
||||||
'views/res_partner_view.xml',
|
'views/res_partner_view.xml',
|
||||||
'views/menu_views.xml',
|
|
||||||
'data/data.xml',
|
'data/data.xml',
|
||||||
],
|
],
|
||||||
'installable': True,
|
'installable': True,
|
||||||
|
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<odoo noupdate="1">
|
|
||||||
<!-- Equipment Status für Open Workshop -->
|
|
||||||
|
|
||||||
<record id="equipment_status_inbetrieb" model="maintenance.equipment.status">
|
|
||||||
<field name="name">In Betrieb</field>
|
|
||||||
<field name="sequence" eval="10"/>
|
|
||||||
</record>
|
|
||||||
|
|
||||||
<record id="equipment_status_defekt" model="maintenance.equipment.status">
|
|
||||||
<field name="name">Defekt</field>
|
|
||||||
<field name="sequence" eval="20"/>
|
|
||||||
</record>
|
|
||||||
|
|
||||||
<record id="equipment_status_wartung" model="maintenance.equipment.status">
|
|
||||||
<field name="name">Wartung</field>
|
|
||||||
<field name="sequence" eval="30"/>
|
|
||||||
</record>
|
|
||||||
|
|
||||||
<record id="equipment_status_ausgemustert" model="maintenance.equipment.status">
|
|
||||||
<field name="name">Ausgemustert</field>
|
|
||||||
<field name="sequence" eval="40"/>
|
|
||||||
</record>
|
|
||||||
|
|
||||||
</odoo>
|
|
||||||
|
|
@ -99,55 +99,7 @@ def migrate(cr, version):
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
_logger.error(f"Failed to migrate machine {m_id}: {e}")
|
_logger.error(f"Failed to migrate machine {m_id}: {e}")
|
||||||
|
|
||||||
# 3. Synchronisiere related fields von ows.machine → maintenance.equipment
|
# 3. Validierung
|
||||||
# Nötig weil related fields mit store=True nicht automatisch bei SQL-INSERT synchronisiert werden
|
|
||||||
_logger.info("Synchronizing related fields (area_id, category) to maintenance.equipment...")
|
|
||||||
|
|
||||||
cr.execute("""
|
|
||||||
UPDATE maintenance_equipment me
|
|
||||||
SET ows_area_id = om.area_id
|
|
||||||
FROM ows_machine om
|
|
||||||
WHERE om.equipment_id = me.id
|
|
||||||
AND om.area_id IS NOT NULL
|
|
||||||
""")
|
|
||||||
synced_areas = cr.rowcount
|
|
||||||
_logger.info(f"✅ Synchronized {synced_areas} area_id values")
|
|
||||||
|
|
||||||
cr.execute("""
|
|
||||||
UPDATE maintenance_equipment me
|
|
||||||
SET ows_category = om.category
|
|
||||||
FROM ows_machine om
|
|
||||||
WHERE om.equipment_id = me.id
|
|
||||||
AND om.category IS NOT NULL
|
|
||||||
""")
|
|
||||||
synced_categories = cr.rowcount
|
|
||||||
_logger.info(f"✅ Synchronized {synced_categories} category values")
|
|
||||||
|
|
||||||
# 4. Setze Default-Status "In Betrieb" für migrierte Equipment ohne Status
|
|
||||||
# (Status-Records werden automatisch über XML-Datei erstellt)
|
|
||||||
_logger.info("Setting default status for equipment without status...")
|
|
||||||
|
|
||||||
# Prüfe ob Status "In Betrieb" existiert
|
|
||||||
cr.execute("SELECT id FROM maintenance_equipment_status WHERE name = 'In Betrieb' LIMIT 1")
|
|
||||||
status_result = cr.fetchone()
|
|
||||||
|
|
||||||
if status_result:
|
|
||||||
status_id = status_result[0]
|
|
||||||
cr.execute("""
|
|
||||||
UPDATE maintenance_equipment me
|
|
||||||
SET status_id = %s
|
|
||||||
WHERE me.status_id IS NULL
|
|
||||||
AND EXISTS (
|
|
||||||
SELECT 1 FROM ows_machine om
|
|
||||||
WHERE om.equipment_id = me.id
|
|
||||||
)
|
|
||||||
""", (status_id,))
|
|
||||||
status_set = cr.rowcount
|
|
||||||
_logger.info(f"✅ Set default status for {status_set} equipment records")
|
|
||||||
else:
|
|
||||||
_logger.warning("⚠️ Status 'In Betrieb' not found - skipping status assignment")
|
|
||||||
|
|
||||||
# 5. Validierung
|
|
||||||
cr.execute("SELECT COUNT(*) FROM ows_machine WHERE equipment_id IS NULL")
|
cr.execute("SELECT COUNT(*) FROM ows_machine WHERE equipment_id IS NULL")
|
||||||
remaining = cr.fetchone()[0]
|
remaining = cr.fetchone()[0]
|
||||||
|
|
||||||
|
|
@ -157,4 +109,3 @@ def migrate(cr, version):
|
||||||
_logger.info(f"✅ Successfully migrated {migrated_count} machines to equipment")
|
_logger.info(f"✅ Successfully migrated {migrated_count} machines to equipment")
|
||||||
|
|
||||||
_logger.info("Post-migration completed")
|
_logger.info("Post-migration completed")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
from . import ows_models
|
from . import ows_models
|
||||||
|
from . import pos_order
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -194,7 +194,7 @@ class ResPartner(models.Model):
|
||||||
<tbody>
|
<tbody>
|
||||||
"""
|
"""
|
||||||
|
|
||||||
machines = self.env['ows.machine'].search([('area_id', '=', area.id), ('category', '=', 'red')], order="name")
|
machines = self.env['ows.machine'].search([('area_id', '=', area.id)], order="name")
|
||||||
|
|
||||||
for machine in machines:
|
for machine in machines:
|
||||||
access = self.env['ows.machine.access'].search([
|
access = self.env['ows.machine.access'].search([
|
||||||
|
|
@ -400,128 +400,6 @@ AVAILABLE_COLORS = [
|
||||||
('#ffffff', 'Weiss'),
|
('#ffffff', 'Weiss'),
|
||||||
]
|
]
|
||||||
|
|
||||||
class MaintenanceEquipment(models.Model):
|
|
||||||
"""Erweitere maintenance.equipment mit OWS-Feldern für direkte Bearbeitung"""
|
|
||||||
_inherit = 'maintenance.equipment'
|
|
||||||
|
|
||||||
# Inverse Relation zu ows.machine
|
|
||||||
ows_machine_id = fields.One2many('ows.machine', 'equipment_id', string='OWS Maschine', readonly=True)
|
|
||||||
|
|
||||||
# OWS-Felder direkt auf Equipment (via related für einfache Bearbeitung)
|
|
||||||
# store=True für Performance beim Filtern/Suchen
|
|
||||||
ows_category = fields.Selection(
|
|
||||||
related='ows_machine_id.category',
|
|
||||||
string='Sicherheitskategorie',
|
|
||||||
readonly=False,
|
|
||||||
store=True
|
|
||||||
)
|
|
||||||
ows_category_icon = fields.Char(
|
|
||||||
related='ows_machine_id.category_icon',
|
|
||||||
string='⚙'
|
|
||||||
)
|
|
||||||
ows_area_id = fields.Many2one(
|
|
||||||
'ows.machine.area',
|
|
||||||
related='ows_machine_id.area_id',
|
|
||||||
string='Bereich',
|
|
||||||
readonly=False,
|
|
||||||
store=True
|
|
||||||
)
|
|
||||||
ows_product_ids = fields.One2many(
|
|
||||||
'ows.machine.product',
|
|
||||||
related='ows_machine_id.product_ids',
|
|
||||||
string='Nutzungsprodukte',
|
|
||||||
readonly=False
|
|
||||||
)
|
|
||||||
ows_training_ids = fields.One2many(
|
|
||||||
'ows.machine.training',
|
|
||||||
related='ows_machine_id.training_ids',
|
|
||||||
string='Einweisungsprodukte',
|
|
||||||
readonly=False
|
|
||||||
)
|
|
||||||
ows_usage_product_ids = fields.Many2many(
|
|
||||||
'product.product',
|
|
||||||
compute='_compute_usage_products',
|
|
||||||
inverse='_inverse_usage_products',
|
|
||||||
string='Nutzungsprodukte',
|
|
||||||
store=False
|
|
||||||
)
|
|
||||||
ows_training_product_ids = fields.Many2many(
|
|
||||||
'product.product',
|
|
||||||
compute='_compute_training_products',
|
|
||||||
inverse='_inverse_training_products',
|
|
||||||
string='Einweisungsprodukte',
|
|
||||||
store=False
|
|
||||||
)
|
|
||||||
|
|
||||||
@api.depends('ows_product_ids.product_id')
|
|
||||||
def _compute_usage_products(self):
|
|
||||||
for record in self:
|
|
||||||
record.ows_usage_product_ids = record.ows_product_ids.mapped('product_id')
|
|
||||||
|
|
||||||
def _inverse_usage_products(self):
|
|
||||||
for record in self:
|
|
||||||
if record.ows_machine_id:
|
|
||||||
# Entferne alle bisherigen
|
|
||||||
record.ows_machine_id.product_ids.unlink()
|
|
||||||
# Füge neue hinzu
|
|
||||||
for product in record.ows_usage_product_ids:
|
|
||||||
self.env['ows.machine.product'].create({
|
|
||||||
'machine_id': record.ows_machine_id.id,
|
|
||||||
'product_id': product.id
|
|
||||||
})
|
|
||||||
|
|
||||||
@api.depends('ows_training_ids.training_id')
|
|
||||||
def _compute_training_products(self):
|
|
||||||
for record in self:
|
|
||||||
record.ows_training_product_ids = record.ows_training_ids.mapped('training_id')
|
|
||||||
|
|
||||||
def _inverse_training_products(self):
|
|
||||||
for record in self:
|
|
||||||
if record.ows_machine_id:
|
|
||||||
# Entferne alle bisherigen
|
|
||||||
record.ows_machine_id.training_ids.unlink()
|
|
||||||
# Füge neue hinzu
|
|
||||||
for product in record.ows_training_product_ids:
|
|
||||||
self.env['ows.machine.training'].create({
|
|
||||||
'machine_id': record.ows_machine_id.id,
|
|
||||||
'training_id': product.id
|
|
||||||
})
|
|
||||||
|
|
||||||
@api.model_create_multi
|
|
||||||
def create(self, vals_list):
|
|
||||||
"""Erstelle automatisch ows.machine für jedes neue Equipment (außer wenn vom ows.machine.create aufgerufen)"""
|
|
||||||
records = super(MaintenanceEquipment, self).create(vals_list)
|
|
||||||
records._create_missing_ows_machines()
|
|
||||||
return records
|
|
||||||
|
|
||||||
def _create_missing_ows_machines(self):
|
|
||||||
"""Erstelle fehlende ows.machine Datensätze für Equipment ohne ows.machine"""
|
|
||||||
if self.env.context.get('skip_ows_machine_creation'):
|
|
||||||
return
|
|
||||||
|
|
||||||
for record in self:
|
|
||||||
if not record.ows_machine_id:
|
|
||||||
self.env['ows.machine'].with_context(skip_ows_machine_creation=True).create({
|
|
||||||
'equipment_id': record.id,
|
|
||||||
'category': record.ows_category or 'red',
|
|
||||||
'area_id': record.ows_area_id.id if record.ows_area_id else False,
|
|
||||||
})
|
|
||||||
|
|
||||||
@api.model
|
|
||||||
def load(self, fields, data):
|
|
||||||
"""Override load() um sicherzustellen, dass ows.machine nach dem Import erstellt wird"""
|
|
||||||
result = super(MaintenanceEquipment, self).load(fields, data)
|
|
||||||
|
|
||||||
# Nach erfolgreichem Import: Erstelle fehlende ows.machine Einträge
|
|
||||||
if result.get('ids'):
|
|
||||||
equipment_records = self.browse(result['ids'])
|
|
||||||
equipment_records._create_missing_ows_machines()
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class OwsMachineArea(models.Model):
|
class OwsMachineArea(models.Model):
|
||||||
_name = 'ows.machine.area'
|
_name = 'ows.machine.area'
|
||||||
_table = 'ows_machine_area'
|
_table = 'ows_machine_area'
|
||||||
|
|
@ -635,33 +513,43 @@ class OwsMachine(models.Model):
|
||||||
|
|
||||||
# Keine eigenen SQL Constraints - Equipment hat bereits unique constraint für serial_no
|
# Keine eigenen SQL Constraints - Equipment hat bereits unique constraint für serial_no
|
||||||
|
|
||||||
@api.model_create_multi
|
@api.model
|
||||||
def create(self, vals_list):
|
def create(self, vals):
|
||||||
"""
|
"""
|
||||||
Beim Erstellen einer ows.machine:
|
Beim Erstellen einer ows.machine:
|
||||||
1. Equipment nur erstellen wenn equipment_id NICHT angegeben wurde
|
1. Equipment IMMER automatisch erstellen
|
||||||
2. Area → Location synchronisieren
|
2. Area → Location synchronisieren
|
||||||
3. serial_no und name vom User übernehmen
|
3. serial_no und name vom User übernehmen
|
||||||
"""
|
"""
|
||||||
# Equipment nur erstellen wenn noch nicht vorhanden
|
# Equipment IMMER automatisch erstellen
|
||||||
for vals in vals_list:
|
|
||||||
# Wenn equipment_id bereits gesetzt ist (z.B. bei Import), KEIN neues Equipment erstellen!
|
|
||||||
if 'equipment_id' not in vals:
|
|
||||||
equipment_vals = {
|
equipment_vals = {
|
||||||
'name': vals.get('name', 'Neue Maschine'),
|
'name': vals.get('name', 'Neue Maschine'),
|
||||||
'serial_no': vals.get('serial_no', False),
|
'serial_no': vals.get('serial_no', False),
|
||||||
}
|
}
|
||||||
|
|
||||||
equipment = self.env['maintenance.equipment'].with_context(skip_ows_machine_creation=True).create(equipment_vals)
|
# Area → Location Mapping für Equipment
|
||||||
|
if 'area_id' in vals and vals['area_id']:
|
||||||
|
area = self.env['ows.machine.area'].browse(vals['area_id'])
|
||||||
|
if area and area.name:
|
||||||
|
equipment_vals['location'] = area.name
|
||||||
|
|
||||||
|
equipment = self.env['maintenance.equipment'].create(equipment_vals)
|
||||||
vals['equipment_id'] = equipment.id
|
vals['equipment_id'] = equipment.id
|
||||||
|
|
||||||
return super(OwsMachine, self).create(vals_list)
|
return super(OwsMachine, self).create(vals)
|
||||||
|
|
||||||
def write(self, vals):
|
def write(self, vals):
|
||||||
"""
|
"""
|
||||||
Bei Updates:
|
Bei Updates:
|
||||||
1. Name/Serial_no → Equipment synchronisieren
|
1. Area → Location synchronisieren
|
||||||
|
2. Name/Serial_no → Equipment synchronisieren
|
||||||
"""
|
"""
|
||||||
|
# Area → Location Mapping
|
||||||
|
if 'area_id' in vals and vals['area_id']:
|
||||||
|
area = self.env['ows.machine.area'].browse(vals['area_id'])
|
||||||
|
if area and area.name:
|
||||||
|
vals['location'] = area.name
|
||||||
|
|
||||||
# Name/Serial_no an Equipment weiterleiten
|
# Name/Serial_no an Equipment weiterleiten
|
||||||
equipment_vals = {}
|
equipment_vals = {}
|
||||||
if 'name' in vals:
|
if 'name' in vals:
|
||||||
|
|
@ -724,7 +612,7 @@ class OwsMachine(models.Model):
|
||||||
_logger.info("Access RPC called with partner_id=%s", partner_id)
|
_logger.info("Access RPC called with partner_id=%s", partner_id)
|
||||||
access_by_area = []
|
access_by_area = []
|
||||||
for area in areas:
|
for area in areas:
|
||||||
machines = self.search([('area_id', '=', area.id), ('category', '=', 'red'), ('equipment_id.status_id.name', '!=', 'Ausgemustert')], order="name")
|
machines = self.search([('area_id', '=', area.id), ('category', '=', 'red')], order="name")
|
||||||
machine_list = []
|
machine_list = []
|
||||||
for machine in machines:
|
for machine in machines:
|
||||||
has_access = bool(self.env['ows.machine.access'].search([
|
has_access = bool(self.env['ows.machine.access'].search([
|
||||||
|
|
@ -732,7 +620,6 @@ class OwsMachine(models.Model):
|
||||||
('machine_id', '=', machine.id),
|
('machine_id', '=', machine.id),
|
||||||
], limit=1))
|
], limit=1))
|
||||||
machine_list.append({
|
machine_list.append({
|
||||||
'id': machine.id,
|
|
||||||
'name': machine.name,
|
'name': machine.name,
|
||||||
'has_access': has_access,
|
'has_access': has_access,
|
||||||
})
|
})
|
||||||
|
|
@ -779,14 +666,6 @@ class OwsMachineProduct(models.Model):
|
||||||
product_id = fields.Many2one('product.product', required=True, domain=[('available_in_pos', '=', True)], ondelete='cascade')
|
product_id = fields.Many2one('product.product', required=True, domain=[('available_in_pos', '=', True)], ondelete='cascade')
|
||||||
machine_id = fields.Many2one('ows.machine', required=True, ondelete='cascade')
|
machine_id = fields.Many2one('ows.machine', required=True, ondelete='cascade')
|
||||||
|
|
||||||
def name_get(self):
|
|
||||||
"""Zeigt den Produktnamen statt ows.machine.product,ID"""
|
|
||||||
result = []
|
|
||||||
for record in self:
|
|
||||||
name = record.product_id.name if record.product_id else 'Unbenannt'
|
|
||||||
result.append((record.id, name))
|
|
||||||
return result
|
|
||||||
|
|
||||||
class OwsMachineTraining(models.Model):
|
class OwsMachineTraining(models.Model):
|
||||||
_name = 'ows.machine.training'
|
_name = 'ows.machine.training'
|
||||||
_table = 'ows_machine_training'
|
_table = 'ows_machine_training'
|
||||||
|
|
@ -794,11 +673,3 @@ class OwsMachineTraining(models.Model):
|
||||||
|
|
||||||
training_id = fields.Many2one('product.product', required=True, domain=[('available_in_pos', '=', True)], ondelete='cascade')
|
training_id = fields.Many2one('product.product', required=True, domain=[('available_in_pos', '=', True)], ondelete='cascade')
|
||||||
machine_id = fields.Many2one('ows.machine', required=True, ondelete='cascade')
|
machine_id = fields.Many2one('ows.machine', required=True, ondelete='cascade')
|
||||||
|
|
||||||
def name_get(self):
|
|
||||||
"""Zeigt den Produktnamen statt ows.machine.training,ID"""
|
|
||||||
result = []
|
|
||||||
for record in self:
|
|
||||||
name = record.training_id.name if record.training_id else 'Unbenannt'
|
|
||||||
result.append((record.id, name))
|
|
||||||
return result
|
|
||||||
|
|
|
||||||
50
open_workshop_base/models/pos_order.py
Normal file
50
open_workshop_base/models/pos_order.py
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
from odoo import models, fields, api
|
||||||
|
from collections import defaultdict
|
||||||
|
|
||||||
|
#import debugpy
|
||||||
|
import logging
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
_logger.info("✅ pos_order.py geladen Test 2")
|
||||||
|
|
||||||
|
#debugpy.listen(("0.0.0.0", 5678))
|
||||||
|
print("✅ debugpy wartet auf Verbindung (Port 5678) ...")
|
||||||
|
# Optional: Starte erst, wenn VS Code verbunden ist
|
||||||
|
#debugpy.wait_for_client()
|
||||||
|
|
||||||
|
class PosOrder(models.Model):
|
||||||
|
_inherit = 'pos.order'
|
||||||
|
|
||||||
|
def _process_order(self, order, existing_order):
|
||||||
|
_logger.info("🚨 DEBUG: _process_order wurde aufgerufen mit order: %s", order.get('name', 'unbekannt'))
|
||||||
|
pos_order_id = super(PosOrder, self)._process_order(order, existing_order)
|
||||||
|
pos_order = self.browse(pos_order_id)
|
||||||
|
|
||||||
|
training_products = self.env['ows.machine.training'].search([])
|
||||||
|
product_map = defaultdict(list)
|
||||||
|
for tp in training_products:
|
||||||
|
product_map[tp.training_id.product_tmpl_id.id].append(tp.machine_id.id)
|
||||||
|
|
||||||
|
partner = pos_order.partner_id
|
||||||
|
if not partner:
|
||||||
|
_logger.info("🟡 POS-Bestellung ohne Partner – keine Freigabe möglich")
|
||||||
|
return pos_order_id
|
||||||
|
|
||||||
|
for line in pos_order.lines:
|
||||||
|
product_tmpl_id = line.product_id.product_tmpl_id.id
|
||||||
|
machine_ids = product_map.get(product_tmpl_id, [])
|
||||||
|
_logger.info("🔍 Prüfe Produkt %s → Maschinen IDs: %s", line.product_id.display_name, machine_ids)
|
||||||
|
for machine_id in machine_ids:
|
||||||
|
already_exists = self.env['ows.machine.access'].search([
|
||||||
|
('partner_id', '=', partner.id),
|
||||||
|
('machine_id', '=', machine_id)
|
||||||
|
], limit=1)
|
||||||
|
if not already_exists:
|
||||||
|
self.env['ows.machine.access'].create({
|
||||||
|
'partner_id': partner.id,
|
||||||
|
'machine_id': machine_id,
|
||||||
|
'granted_by_pos': True
|
||||||
|
})
|
||||||
|
_logger.info("✅ Maschinenfreigabe erstellt: %s für %s", machine_id, partner.name)
|
||||||
|
|
||||||
|
return pos_order_id
|
||||||
|
|
@ -7,6 +7,9 @@
|
||||||
<field name="view_mode">list,form</field>
|
<field name="view_mode">list,form</field>
|
||||||
</record>
|
</record>
|
||||||
|
|
||||||
|
<!-- Menüpunkt unter Maschinen > Konfiguration -->
|
||||||
|
<menuitem id="menu_machine_area" name="Bereiche" parent="menu_machine_config" action="open_workshop_base.action_machine_area_list" sequence="30"/>
|
||||||
|
|
||||||
<!-- Listenansicht -->
|
<!-- Listenansicht -->
|
||||||
<record id="view_machine_area_tree" model="ir.ui.view">
|
<record id="view_machine_area_tree" model="ir.ui.view">
|
||||||
<field name="name">ows.machine.area.tree</field>
|
<field name="name">ows.machine.area.tree</field>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<odoo>
|
<odoo>
|
||||||
<!-- Tree View: Nutzungsprodukte (normale Ansicht) -->
|
<!-- Tree View: Nutzungsprodukte -->
|
||||||
<record id="view_machine_product_tree" model="ir.ui.view">
|
<record id="view_machine_product_tree" model="ir.ui.view">
|
||||||
<field name="name">ows.machine.product.tree</field>
|
<field name="name">ows.machine.product.tree</field>
|
||||||
<field name="model">ows.machine.product</field>
|
<field name="model">ows.machine.product</field>
|
||||||
|
|
@ -11,19 +11,7 @@
|
||||||
</field>
|
</field>
|
||||||
</record>
|
</record>
|
||||||
|
|
||||||
<!-- Tree View: Nutzungsprodukte (vereinfacht für Equipment) -->
|
<!-- Tree View: Einweisungsprodukte -->
|
||||||
<record id="view_ows_machine_product_tree_simple" model="ir.ui.view">
|
|
||||||
<field name="name">ows.machine.product.tree.simple</field>
|
|
||||||
<field name="model">ows.machine.product</field>
|
|
||||||
<field name="arch" type="xml">
|
|
||||||
<list editable="bottom">
|
|
||||||
<field name="machine_id" column_invisible="1"/>
|
|
||||||
<field name="product_id" domain="[('categ_id.name', '=', 'Maschinennutzung')]"/>
|
|
||||||
</list>
|
|
||||||
</field>
|
|
||||||
</record>
|
|
||||||
|
|
||||||
<!-- Tree View: Einweisungsprodukte (normale Ansicht) -->
|
|
||||||
<record id="view_machine_training_tree" model="ir.ui.view">
|
<record id="view_machine_training_tree" model="ir.ui.view">
|
||||||
<field name="name">ows.machine.training.tree</field>
|
<field name="name">ows.machine.training.tree</field>
|
||||||
<field name="model">ows.machine.training</field>
|
<field name="model">ows.machine.training</field>
|
||||||
|
|
@ -35,18 +23,6 @@
|
||||||
</field>
|
</field>
|
||||||
</record>
|
</record>
|
||||||
|
|
||||||
<!-- Tree View: Einweisungsprodukte (vereinfacht für Equipment) -->
|
|
||||||
<record id="view_ows_machine_training_tree_simple" model="ir.ui.view">
|
|
||||||
<field name="name">ows.machine.training.tree.simple</field>
|
|
||||||
<field name="model">ows.machine.training</field>
|
|
||||||
<field name="arch" type="xml">
|
|
||||||
<list editable="bottom">
|
|
||||||
<field name="machine_id" column_invisible="1"/>
|
|
||||||
<field name="training_id" domain="[('categ_id.name', 'in', ['Einweisungen', 'Kurse'])]"/>
|
|
||||||
</list>
|
|
||||||
</field>
|
|
||||||
</record>
|
|
||||||
|
|
||||||
<!-- Action: Nutzungsprodukte -->
|
<!-- Action: Nutzungsprodukte -->
|
||||||
<record id="action_machine_product" model="ir.actions.act_window">
|
<record id="action_machine_product" model="ir.actions.act_window">
|
||||||
<field name="name">Maschinen-Nutzungsprodukte</field>
|
<field name="name">Maschinen-Nutzungsprodukte</field>
|
||||||
|
|
|
||||||
|
|
@ -6,27 +6,20 @@
|
||||||
<field name="model">ows.machine</field>
|
<field name="model">ows.machine</field>
|
||||||
<field name="arch" type="xml">
|
<field name="arch" type="xml">
|
||||||
<search string="Ausrüstung suchen">
|
<search string="Ausrüstung suchen">
|
||||||
<!-- OWS Felder -->
|
|
||||||
<field name="name" string="Name"/>
|
<field name="name" string="Name"/>
|
||||||
<field name="serial_no" string="Code"/>
|
<field name="serial_no" string="Code"/>
|
||||||
<field name="area_id" string="Bereich"/>
|
<field name="area_id" string="Bereich"/>
|
||||||
<!-- maintenance.equipment Felder (via _inherits) -->
|
|
||||||
<field name="model" string="Modell"/>
|
|
||||||
<field name="partner_id" string="Hersteller"/>
|
|
||||||
<field name="location" string="Standort"/>
|
|
||||||
<field name="company_id" string="Unternehmen"/>
|
|
||||||
<separator/>
|
<separator/>
|
||||||
<filter string="Kategorie grün" name="filter_green" domain="[('category', '=', 'green')]"/>
|
<filter string="Kategorie 1 (grün)" name="filter_green" domain="[('category', '=', 'green')]"/>
|
||||||
<filter string="Kategorie gelb" name="filter_yellow" domain="[('category', '=', 'yellow')]"/>
|
<filter string="Kategorie 2 (gelb)" name="filter_yellow" domain="[('category', '=', 'yellow')]"/>
|
||||||
<filter string="Kategorie rot" name="filter_red" domain="[('category', '=', 'red')]"/>
|
<filter string="Kategorie 3 (rot)" name="filter_red" domain="[('category', '=', 'red')]"/>
|
||||||
<separator/>
|
<separator/>
|
||||||
<filter string="Aktiv" name="filter_active" domain="[('active', '=', True)]"/>
|
<filter string="Aktiv" name="filter_active" domain="[('active', '=', True)]"/>
|
||||||
<filter string="Archiviert" name="filter_inactive" domain="[('active', '=', False)]"/>
|
<filter string="Archiviert" name="filter_inactive" domain="[('active', '=', False)]"/>
|
||||||
<separator/>
|
<separator/>
|
||||||
<group expand="1" string="Gruppieren nach">
|
<group expand="0" string="Gruppieren nach">
|
||||||
<filter string="Bereich" name="group_area" context="{'group_by': 'area_id'}"/>
|
<filter string="Bereich" name="group_area" context="{'group_by': 'area_id'}"/>
|
||||||
<filter string="Kategorie" name="group_category" context="{'group_by': 'category'}"/>
|
<filter string="Kategorie" name="group_category" context="{'group_by': 'category'}"/>
|
||||||
|
|
||||||
</group>
|
</group>
|
||||||
</search>
|
</search>
|
||||||
</field>
|
</field>
|
||||||
|
|
@ -40,7 +33,7 @@
|
||||||
<list sample="1" multi_edit="1">
|
<list sample="1" multi_edit="1">
|
||||||
<field name="category_icon" string="⚙" readonly="1"/>
|
<field name="category_icon" string="⚙" readonly="1"/>
|
||||||
<field name="name" optional="show"/>
|
<field name="name" optional="show"/>
|
||||||
<!--field name="serial_no" string="Code" optional="show"/-->
|
<field name="serial_no" string="Code" optional="show"/>
|
||||||
<field name="category" optional="show"/>
|
<field name="category" optional="show"/>
|
||||||
<field name="area_id" widget="many2one" optional="show"/>
|
<field name="area_id" widget="many2one" optional="show"/>
|
||||||
<field name="location" optional="hide"/>
|
<field name="location" optional="hide"/>
|
||||||
|
|
|
||||||
|
|
@ -1,80 +1,15 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<odoo>
|
<odoo>
|
||||||
<!-- Erweitere Equipment Form mit OWS-Feldern -->
|
<!-- Inherit maintenance.equipment form view to make location readonly -->
|
||||||
<record id="view_equipment_form_ows_extension" model="ir.ui.view">
|
<record id="view_equipment_form_location_readonly" model="ir.ui.view">
|
||||||
<field name="name">maintenance.equipment.form.ows</field>
|
<field name="name">maintenance.equipment.form.location.readonly</field>
|
||||||
<field name="model">maintenance.equipment</field>
|
<field name="model">maintenance.equipment</field>
|
||||||
<field name="inherit_id" ref="maintenance.hr_equipment_view_form"/>
|
<field name="inherit_id" ref="maintenance.hr_equipment_view_form"/>
|
||||||
<field name="arch" type="xml">
|
<field name="arch" type="xml">
|
||||||
<!-- Füge OWS-Felder in die Details-Gruppe ein -->
|
<xpath expr="//field[@name='location']" position="attributes">
|
||||||
<xpath expr="//field[@name='location']" position="after">
|
<attribute name="readonly">1</attribute>
|
||||||
<field name="ows_area_id" string="Bereich"/>
|
<attribute name="help">Wird automatisch aus dem Bereich (ows.machine) synchronisiert. Änderungen nur über Bereich möglich.</attribute>
|
||||||
<field name="ows_category" string="Sicherheitskategorie"/>
|
|
||||||
</xpath>
|
|
||||||
|
|
||||||
<!-- Füge Open Workshop Notebook-Page hinzu -->
|
|
||||||
<xpath expr="//notebook" position="inside">
|
|
||||||
<page string="Offene Werkstatt (Hobbyhimmel)" name="open_workshop" invisible="not ows_machine_id">
|
|
||||||
<group>
|
|
||||||
<group string="Sicherheit">
|
|
||||||
<field name="ows_machine_id" invisible="1"/>
|
|
||||||
<field name="ows_category_icon" readonly="1"/>
|
|
||||||
<field name="ows_category"/>
|
|
||||||
<field name="ows_area_id"/>
|
|
||||||
</group>
|
|
||||||
</group>
|
|
||||||
<group string="Nutzungsprodukte">
|
|
||||||
<field name="ows_usage_product_ids" nolabel="1" widget="many2many_tags" options="{'no_create': True}" domain="[('categ_id.name', '=', 'Maschinennutzung')]"/>
|
|
||||||
</group>
|
|
||||||
<group string="Einweisungsprodukte">
|
|
||||||
<field name="ows_training_product_ids" nolabel="1" widget="many2many_tags" options="{'no_create': True}" domain="[('categ_id.name', 'in', ['Einweisungen', 'Kurse'])]"/>
|
|
||||||
</group>
|
|
||||||
</page>
|
|
||||||
</xpath>
|
|
||||||
</field>
|
|
||||||
</record>
|
|
||||||
|
|
||||||
<!-- Erweitere Equipment List-View mit OWS-Spalten -->
|
|
||||||
<record id="view_equipment_tree_ows_extension" model="ir.ui.view">
|
|
||||||
<field name="name">maintenance.equipment.tree.ows</field>
|
|
||||||
<field name="model">maintenance.equipment</field>
|
|
||||||
<field name="inherit_id" ref="maintenance.hr_equipment_view_tree"/>
|
|
||||||
<field name="arch" type="xml">
|
|
||||||
<xpath expr="//field[@name='name']" position="after">
|
|
||||||
<field name="ows_category_icon" string="⚙" optional="show"/>
|
|
||||||
<field name="ows_area_id" optional="show"/>
|
|
||||||
</xpath>
|
|
||||||
</field>
|
|
||||||
</record>
|
|
||||||
|
|
||||||
<!-- Erweitere Equipment Search-View mit OWS-Filtern -->
|
|
||||||
<record id="view_equipment_search_ows_extension" model="ir.ui.view">
|
|
||||||
<field name="name">maintenance.equipment.search.ows</field>
|
|
||||||
<field name="model">maintenance.equipment</field>
|
|
||||||
<field name="inherit_id" ref="maintenance.hr_equipment_view_search"/>
|
|
||||||
<field name="arch" type="xml">
|
|
||||||
<!-- Füge OWS-Felder zur Suche hinzu -->
|
|
||||||
<xpath expr="//field[@name='name']" position="after">
|
|
||||||
<field name="ows_area_id" string="Bereich"/>
|
|
||||||
<field name="ows_category" string="Kategorie"/>
|
|
||||||
</xpath>
|
|
||||||
|
|
||||||
<!-- Füge OWS-Filter hinzu -->
|
|
||||||
<xpath expr="//filter[@name='inactive']" position="after">
|
|
||||||
<separator/>
|
|
||||||
<filter string="Kategorie 1 (grün)" name="filter_green" domain="[('ows_category', '=', 'green')]"/>
|
|
||||||
<filter string="Kategorie 2 (gelb)" name="filter_yellow" domain="[('ows_category', '=', 'yellow')]"/>
|
|
||||||
<filter string="Kategorie 3 (rot)" name="filter_red" domain="[('ows_category', '=', 'red')]"/>
|
|
||||||
</xpath>
|
|
||||||
|
|
||||||
<!-- Füge OWS-Gruppierungen hinzu (erstelle group wenn nicht vorhanden) -->
|
|
||||||
<xpath expr="//search" position="inside">
|
|
||||||
<group expand="0" string="Gruppieren nach">
|
|
||||||
<filter string="Bereich" name="group_by_area" context="{'group_by': 'ows_area_id'}"/>
|
|
||||||
<filter string="Kategorie" name="group_by_category" context="{'group_by': 'ows_category'}"/>
|
|
||||||
</group>
|
|
||||||
</xpath>
|
</xpath>
|
||||||
</field>
|
</field>
|
||||||
</record>
|
</record>
|
||||||
</odoo>
|
</odoo>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,10 @@
|
||||||
<!-- menu_views.xml -->
|
<!-- menu_views.xml -->
|
||||||
<odoo>
|
<odoo>
|
||||||
<!-- Equipment-Liste mit OWS-Filter (ersetzt Standard Equipment Action) -->
|
<!-- Maschinenliste -->
|
||||||
<record id="action_equipment_ows" model="ir.actions.act_window">
|
<record id="action_machine_list" model="ir.actions.act_window">
|
||||||
<field name="name">Ausrüstung</field>
|
<field name="name">Ausrüstung</field>
|
||||||
<field name="res_model">maintenance.equipment</field>
|
<field name="res_model">ows.machine</field>
|
||||||
<field name="view_mode">list,form</field>
|
<field name="view_mode">list,form</field>
|
||||||
<field name="domain">[('ows_machine_id', '!=', False)]</field>
|
|
||||||
<field name="context">{
|
|
||||||
'default_ows_category': 'red',
|
|
||||||
'search_default_group_by_area': 1
|
|
||||||
}</field>
|
|
||||||
</record>
|
</record>
|
||||||
|
|
||||||
<!-- Trainingsprodukt-Liste -->
|
<!-- Trainingsprodukt-Liste -->
|
||||||
|
|
@ -19,32 +14,42 @@
|
||||||
<field name="view_mode">list,form</field>
|
<field name="view_mode">list,form</field>
|
||||||
</record>
|
</record>
|
||||||
|
|
||||||
<!-- Integration in Maintenance Menü -->
|
<!-- Menüstruktur -->
|
||||||
|
<!-- Oberstes Menü -->
|
||||||
|
<menuitem id="menu_machine_root"
|
||||||
|
name="Ausrüstung"
|
||||||
|
sequence="10"/>
|
||||||
|
|
||||||
<!-- Bereiche unter Maintenance → Configuration -->
|
<!-- Konfigurationsebene -->
|
||||||
<menuitem id="menu_maintenance_ows_areas"
|
<menuitem id="menu_machine_config"
|
||||||
name="Bereiche"
|
name="Konfiguration"
|
||||||
parent="maintenance.menu_maintenance_config"
|
parent="menu_machine_root"
|
||||||
action="action_machine_area_list"
|
sequence="10"/>
|
||||||
sequence="50"/>
|
|
||||||
|
|
||||||
<!-- Zuordnungen Container unter Maintenance → Configuration -->
|
<!-- Menüpunkt: Maschinenliste (klickbar) -->
|
||||||
<menuitem id="menu_maintenance_ows_assignments"
|
<menuitem id="menu_machine_list_action"
|
||||||
|
name="Alle Ausrüstung"
|
||||||
|
parent="menu_machine_config"
|
||||||
|
action="open_workshop_base.action_machine_list"
|
||||||
|
sequence="10"/>
|
||||||
|
|
||||||
|
<!-- Menücontainer: Zuordnungen -->
|
||||||
|
<menuitem id="menu_machine_list"
|
||||||
name="Zuordnungen"
|
name="Zuordnungen"
|
||||||
parent="maintenance.menu_maintenance_config"
|
parent="menu_machine_config"
|
||||||
sequence="51"/>
|
sequence="20"/>
|
||||||
|
|
||||||
<!-- Nutzungsprodukte unter Zuordnungen -->
|
<!-- Untermenü: Nutzungsprodukte -->
|
||||||
<menuitem id="menu_maintenance_machine_product"
|
<menuitem id="menu_machine_product"
|
||||||
name="Nutzungsprodukte"
|
name="Nutzungsprodukte"
|
||||||
parent="menu_maintenance_ows_assignments"
|
parent="menu_machine_list"
|
||||||
action="action_machine_product"
|
action="action_machine_product"
|
||||||
sequence="10"/>
|
sequence="10"/>
|
||||||
|
|
||||||
<!-- Einweisungsprodukte unter Zuordnungen -->
|
<!-- Untermenü: Einweisungsprodukte -->
|
||||||
<menuitem id="menu_maintenance_machine_training"
|
<menuitem id="menu_machine_training"
|
||||||
name="Einweisungsprodukte"
|
name="Einweisungsprodukte"
|
||||||
parent="menu_maintenance_ows_assignments"
|
parent="menu_machine_list"
|
||||||
action="action_machine_training"
|
action="action_machine_training"
|
||||||
sequence="20"/>
|
sequence="20"/>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,456 +0,0 @@
|
||||||
# Equipment Übersichtstabelle - Setup
|
|
||||||
|
|
||||||
## Voraussetzungen
|
|
||||||
|
|
||||||
Das **datatables Plugin** muss in DokuWiki installiert sein:
|
|
||||||
```bash
|
|
||||||
# Als DokuWiki Admin: Administration → Erweiterungsverwaltung
|
|
||||||
# Suche: "datatables"
|
|
||||||
# Installiere: "DataTables Plugin" von Matthias Schulte
|
|
||||||
```
|
|
||||||
|
|
||||||
Das **include Plugin** muss in DokuWiki installiert sein (für Einbindung von Odoo-Status-Seiten):
|
|
||||||
```bash
|
|
||||||
# Als DokuWiki Admin: Administration → Erweiterungsverwaltung
|
|
||||||
# Suche: "include"
|
|
||||||
# Installiere: "Include Plugin" - sollte standardmäßig vorhanden sein
|
|
||||||
```
|
|
||||||
|
|
||||||
## Setup-Schritte
|
|
||||||
|
|
||||||
### 1. Odoo Konfiguration
|
|
||||||
|
|
||||||
Die Übersichtstabelle wird vollständig aus Odoo heraus konfiguriert - **kein DokuWiki Template nötig!**
|
|
||||||
|
|
||||||
Gehe zu: **Einstellungen → Technisch → Parameter → Systemparameter**
|
|
||||||
|
|
||||||
Standardwerte (automatisch gesetzt beim Modul-Install):
|
|
||||||
|
|
||||||
```python
|
|
||||||
# Basis-Namespace für Equipment-Dokumentation (konfigurierbar)
|
|
||||||
dokuwiki.equipment_namespace = werkstatt:ausstattung
|
|
||||||
|
|
||||||
# Seiten-ID wo die Übersicht erstellt wird (konfigurierbar)
|
|
||||||
dokuwiki.overview_page_id = werkstatt:ausstattung:uebersicht
|
|
||||||
|
|
||||||
# Titel der Übersichtsseite
|
|
||||||
dokuwiki.overview_title = Geräte & Maschinen - Übersicht
|
|
||||||
|
|
||||||
# Spaltenüberschriften (Pipe-separiert)
|
|
||||||
dokuwiki.overview_columns = Status|Sicherheits-Kategorie|Hersteller|Bemerkung|Typ|Bild|Standort|Dokumentation
|
|
||||||
|
|
||||||
# Spaltendaten mit Platzhaltern (Pipe-separiert)
|
|
||||||
dokuwiki.overview_column_data = {status_smiley}|{ows_machine_id.category_icon}|{partner_id}|{note}|{model}|{image}|{ows_machine_id.location}|{wiki_doku_link}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Erste Synchronisation
|
|
||||||
|
|
||||||
1. In Odoo: **Wartung → Konfiguration → Wiki-Synchronisation**
|
|
||||||
2. Wähle: **"Übersichtstabelle aktualisieren"**
|
|
||||||
3. Klicke: **"Synchronisieren"**
|
|
||||||
|
|
||||||
Die Übersichtsseite wird erstellt unter: `werkstatt:ausstattung:uebersicht` (konfigurierbar über Systemparameter)
|
|
||||||
|
|
||||||
## Features
|
|
||||||
|
|
||||||
### DataTables Funktionen
|
|
||||||
- ✅ Sortieren nach allen Spalten (Klick auf Header)
|
|
||||||
- ✅ Filter/Suche über alle Felder
|
|
||||||
- ✅ Pagination bei vielen Einträgen
|
|
||||||
- ✅ Responsive Design
|
|
||||||
|
|
||||||
### Flexible Spalten
|
|
||||||
- ✅ Spalten in Odoo konfigurierbar (kein DokuWiki Template)
|
|
||||||
- ✅ Alle Platzhalter verfügbar (siehe unten)
|
|
||||||
- ✅ Spalten hinzufügen/entfernen ohne Code-Änderung
|
|
||||||
- ✅ Änderungen sofort beim nächsten Sync wirksam
|
|
||||||
|
|
||||||
### Standard-Spalten
|
|
||||||
| Spalte | Platzhalter | Ausgabe |
|
|
||||||
|--------|-------------|---------|
|
|
||||||
| Status | `{status_smiley}` | 😊 / 😐 / ☹️ |
|
|
||||||
| Sicherheits-Kategorie | `{ows_machine_id.category_icon}` | 🟢 / 🟡 / 🔴 |
|
|
||||||
| Hersteller | `{partner_id}` | BOSCH, Festool, ... |
|
|
||||||
| Bemerkung | `{note}` | Notizen |
|
|
||||||
| Typ | `{model}` | Modellbezeichnung |
|
|
||||||
| Bild | `{image}` | Thumbnail 100px |
|
|
||||||
| Standort | `{ows_machine_id.location}` | Regal A3, ... |
|
|
||||||
| Dokumentation | `{wiki_doku_link}` | Link zur Detail-Seite |
|
|
||||||
|
|
||||||
## Spalten anpassen
|
|
||||||
|
|
||||||
### Beispiel 1: Seriennummer hinzufügen
|
|
||||||
|
|
||||||
```python
|
|
||||||
# Spaltenüberschriften erweitern
|
|
||||||
dokuwiki.overview_columns = Status|Typ|Seriennummer|Hersteller|Standort|Dokumentation
|
|
||||||
|
|
||||||
# Spaltendaten erweitern
|
|
||||||
dokuwiki.overview_column_data = {status_smiley}|{model}|{serial_no}|{partner_id}|{ows_machine_id.location}|{wiki_doku_link}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Beispiel 2: Minimale Ansicht
|
|
||||||
|
|
||||||
```python
|
|
||||||
dokuwiki.overview_columns = Name|Status|Kategorie|Dokumentation
|
|
||||||
dokuwiki.overview_column_data = {name}|{status_smiley}|{ows_machine_id.category_icon}|{wiki_doku_link}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Beispiel 3: Detaillierte Ansicht mit Kosten
|
|
||||||
|
|
||||||
```python
|
|
||||||
dokuwiki.overview_columns = Name|Hersteller|Modell|Seriennummer|Kosten|Garantie|Standort|Kategorie
|
|
||||||
dokuwiki.overview_column_data = {name}|{partner_id}|{model}|{serial_no}|{cost}|{warranty_date}|{location}|{ows_machine_id.category_icon}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Beispiel 4: Mit Tags
|
|
||||||
|
|
||||||
```python
|
|
||||||
dokuwiki.overview_columns = Status|Name|Typ|Tags|Standort|Dokumentation
|
|
||||||
dokuwiki.overview_column_data = {status_smiley}|{name}|{model}|{tags_list}|{location}|{wiki_doku_link}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Wichtig:** Anzahl der Pipes muss übereinstimmen!
|
|
||||||
- 3 Pipes = 4 Spalten
|
|
||||||
- Spalten und Daten müssen gleiche Anzahl haben
|
|
||||||
|
|
||||||
## Performance
|
|
||||||
|
|
||||||
- ⚡ Schneller Sync: Keine DB-Iteration über alle Einträge
|
|
||||||
- ⚡ Generiert nur eine Seite (statt 156 einzelne)
|
|
||||||
- ⚡ DokuWiki cached die Seite automatisch
|
|
||||||
- ⚡ Spalten-Änderung ohne Code-Deploy
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### DataTables funktioniert nicht
|
|
||||||
→ Installiere DataTables Plugin in DokuWiki
|
|
||||||
|
|
||||||
### Bilder werden nicht angezeigt
|
|
||||||
→ Prüfe ob Equipment `image_1920` Feld gesetzt hat
|
|
||||||
→ Bilder müssen zuerst via "Alle Equipment" Sync hochgeladen werden
|
|
||||||
|
|
||||||
### Equipment fehlen in Tabelle
|
|
||||||
→ Nur Equipment mit gesetztem `ows_area_id` werden angezeigt
|
|
||||||
→ Prüfe Filter in `action_sync_overview_table()`
|
|
||||||
|
|
||||||
### Spalten werden nicht korrekt dargestellt
|
|
||||||
→ Prüfe ob Anzahl Pipes in `overview_columns` und `overview_column_data` übereinstimmt
|
|
||||||
→ Beispiel: 3 Pipes = 4 Spalten
|
|
||||||
|
|
||||||
### Platzhalter wird nicht ersetzt (z.B. `{status}` bleibt stehen)
|
|
||||||
→ Prüfe Schreibweise (case-sensitive!)
|
|
||||||
→ Siehe Liste der verfügbaren Platzhalter unten
|
|
||||||
→ Bei `ows.machine` Feldern: `{ows_machine_id.feldname}` verwenden
|
|
||||||
|
|
||||||
## Workflow
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────────┐
|
|
||||||
│ Odoo Systempar. │
|
|
||||||
│ - Spalten │
|
|
||||||
│ - Platzhalter │
|
|
||||||
└────────┬────────┘
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
┌─────────────────────────┐
|
|
||||||
│ Odoo Equipment │
|
|
||||||
│ (156 Einträge) │
|
|
||||||
└────────┬────────────────┘
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
┌─────────────────────────┐
|
|
||||||
│ _generate_overview_ │
|
|
||||||
│ table_row() │ ← Pro Equipment eine Zeile
|
|
||||||
│ (Platzhalter ersetzen) │ mit Platzhalter-Engine
|
|
||||||
└────────┬────────────────┘
|
|
||||||
│ 156 Zeilen
|
|
||||||
▼
|
|
||||||
┌─────────────────────────┐
|
|
||||||
│ Seite zusammenbauen │
|
|
||||||
│ - Titel │
|
|
||||||
│ - DataTables Header │
|
|
||||||
│ - Zeilen │
|
|
||||||
│ - Footer │
|
|
||||||
└────────┬────────────────┘
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
┌─────────────────────────┐
|
|
||||||
│ Seite speichern │
|
|
||||||
│ werkstatt:ausstattung: │
|
|
||||||
│ uebersicht │
|
|
||||||
└─────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
## Automatische Updates
|
|
||||||
|
|
||||||
Die Übersichtstabelle kann optional automatisch bei Equipment-Änderungen aktualisiert werden.
|
|
||||||
|
|
||||||
### Manueller Sync (empfohlen)
|
|
||||||
|
|
||||||
**Manueller Sync empfohlen für:**
|
|
||||||
- Nach Massen-Importen
|
|
||||||
- Einmal täglich/wöchentlich
|
|
||||||
- Bei strukturellen Änderungen (neue Bereiche, etc.)
|
|
||||||
|
|
||||||
**Vorteil:** Performanter, keine zusätzliche Last bei jeder Equipment-Änderung
|
|
||||||
|
|
||||||
### Automatischer Sync bei Equipment-Änderungen
|
|
||||||
|
|
||||||
**Was passiert beim Speichern eines Equipment?**
|
|
||||||
|
|
||||||
1. **Equipment-Detailseite wird immer aktualisiert** wenn:
|
|
||||||
- Eines der überwachten Felder geändert wurde (siehe unten)
|
|
||||||
- `wiki_auto_sync` für das Equipment aktiviert ist (Standard: `True`)
|
|
||||||
- Equipment bereits synchronisiert wurde (`wiki_synced = True`)
|
|
||||||
|
|
||||||
2. **Übersichtstabelle wird zusätzlich aktualisiert** wenn:
|
|
||||||
- Alle Bedingungen von (1) erfüllt sind UND
|
|
||||||
- Systemparameter `dokuwiki.auto_update_overview_table = True` gesetzt ist
|
|
||||||
|
|
||||||
**Überwachte Felder (lösen Sync aus):**
|
|
||||||
- `name`, `serial_no`, `ows_area_id`, `category_id`, `status_id`
|
|
||||||
- `model`, `partner_id`, `location`, `note`, `image_1920`, `tag_ids`
|
|
||||||
|
|
||||||
**Aktivierung Übersichtstabellen-Sync:**
|
|
||||||
```python
|
|
||||||
# In Odoo: Einstellungen → Technisch → Parameter → Systemparameter
|
|
||||||
dokuwiki.auto_update_overview_table = True
|
|
||||||
```
|
|
||||||
|
|
||||||
**Achtung:**
|
|
||||||
- Bei vielen gleichzeitigen Equipment-Änderungen kann dies Performance-Probleme verursachen!
|
|
||||||
- Die komplette Übersichtstabelle wird bei jeder Änderung neu generiert (ca. 156 Zeilen)
|
|
||||||
|
|
||||||
### Cronjob (Alternative)
|
|
||||||
```python
|
|
||||||
# In Odoo: Einstellungen → Technisch → Automatisierung → Geplante Aktionen
|
|
||||||
|
|
||||||
Name: Wiki Übersichtstabelle Update
|
|
||||||
Modell: maintenance.equipment
|
|
||||||
Aktion: action_sync_overview_table()
|
|
||||||
Intervall: Täglich um 02:00 Uhr
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Verfügbare Platzhalter
|
|
||||||
|
|
||||||
Alle Platzhalter aus dem Detail-Template sind verfügbar!
|
|
||||||
|
|
||||||
## Basis-Felder (maintenance.equipment)
|
|
||||||
|
|
||||||
- `{name}` - Equipment-Name
|
|
||||||
- `{serial_no}` - Seriennummer
|
|
||||||
- `{model}` - Modell
|
|
||||||
- `{category}` - Kategoriename
|
|
||||||
- `{status}` - Status (aus status_id)
|
|
||||||
- `{status_smiley}` - Status als Smiley (aus status_id.smiley Feld, z.B. 😊 oder ☹️)
|
|
||||||
- `{location}` - Standort
|
|
||||||
- `{ows_area}` - Bereichsname
|
|
||||||
- `{assign_date}` - Zuweisungsdatum (formatiert)
|
|
||||||
- `{cost}` - Kosten
|
|
||||||
- `{warranty_date}` - Garantiedatum (formatiert)
|
|
||||||
- `{note}` - Notizen
|
|
||||||
- `{partner_id}` - Lieferant (Name)
|
|
||||||
- `{partner_ref}` - Lieferanten-Referenz
|
|
||||||
|
|
||||||
## Tags (falls maintenance_equipment_tags installiert)
|
|
||||||
|
|
||||||
- `{tags}` - Komma-separierte Liste aller Tags (z.B. "Holz, CNC, Einweisung erforderlich")
|
|
||||||
- `{tags_list}` - DokuWiki Bullet-Liste mit Zeilenumbrüchen (ideal für Tabellenzellen)
|
|
||||||
- `{tags_count}` - Anzahl der zugewiesenen Tags
|
|
||||||
|
|
||||||
## Spezial-Felder
|
|
||||||
|
|
||||||
- `{view_type}` - "Bereich" oder "Einsatzzweck"
|
|
||||||
- `{view_name}` - Name des Bereichs/Einsatzzwecks
|
|
||||||
- `{wiki_doku_page}` - ID der zentralen Doku-Seite
|
|
||||||
- `{wiki_doku_link}` - Fertiger Link zur zentralen Doku-Seite
|
|
||||||
- `{odoo_link}` - Fertiger Link zur Odoo Equipment-Seite
|
|
||||||
- `{odoo_url}` - URL zur Odoo Equipment-Seite (ohne Link-Markup)
|
|
||||||
- `{sync_datetime}` - Aktuelles Datum/Zeit
|
|
||||||
- `{image}` - Equipment-Bild (100px Breite) - Format: `{{:media_id?100}}`
|
|
||||||
- `{image_large}` - Equipment-Bild (Originalgröße) - Format: `{{:media_id}}`
|
|
||||||
- `{image_id}` - Media-ID des Bildes (z.B. werkstatt:ausruestung:media:sabako-laser.jpg)
|
|
||||||
|
|
||||||
## ows.machine Felder (falls verknüpft)
|
|
||||||
|
|
||||||
- `{ows_machine_id.name}` - Name
|
|
||||||
- `{ows_machine_id.model}` - Modell
|
|
||||||
- `{ows_machine_id.serial_no}` - Seriennummer
|
|
||||||
- `{ows_machine_id.location}` - Standort
|
|
||||||
- `{ows_machine_id.note}` - Notizen
|
|
||||||
- `{ows_machine_id.category}` - Sicherheitskategorie (red/yellow/green)
|
|
||||||
- `{ows_machine_id.category_icon}` - Kategorie-Icon als Emoji (🔴/🟡/🟢)
|
|
||||||
|
|
||||||
## Tipps für Platzhalter
|
|
||||||
|
|
||||||
- **Case-sensitive**: `{Name}` funktioniert nicht, nur `{name}`
|
|
||||||
- **Pipe als Trenner**: Zwischen Spalten `|` verwenden
|
|
||||||
- **Leere Werte**: Werden automatisch durch `-` ersetzt
|
|
||||||
- **Links**: `{wiki_doku_link}` und `{odoo_link}` enthalten bereits DokuWiki Link-Syntax
|
|
||||||
- **Bilder**: `{image}` ist bereits formatiert mit `{{:...?100}}`
|
|
||||||
- **ows.machine**: Immer mit Präfix `ows_machine_id.` (z.B. `{ows_machine_id.location}`)
|
|
||||||
|
|
||||||
## Beispiel-Konfigurationen
|
|
||||||
|
|
||||||
### Minimalistische Ansicht
|
|
||||||
```
|
|
||||||
Spalten: Name|Status|Kategorie
|
|
||||||
Daten: {name}|{status_smiley}|{ows_machine_id.category_icon}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Standard-Ansicht (Default)
|
|
||||||
```
|
|
||||||
Spalten: Status|Sicherheits-Kategorie|Hersteller|Bemerkung|Typ|Bild|Standort|Dokumentation
|
|
||||||
Daten: {status_smiley}|{ows_machine_id.category_icon}|{partner_id}|{note}|{model}|{image}|{ows_machine_id.location}|{wiki_doku_link}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Vollständige Ansicht
|
|
||||||
```
|
|
||||||
Spalten: Name|Status|Kategorie|Hersteller|Modell|S/N|Kosten|Garantie|Standort|Bereich|Bild|Doku
|
|
||||||
Daten: {name}|{status_smiley}|{ows_machine_id.category_icon}|{partner_id}|{model}|{serial_no}|{cost}|{warranty_date}|{location}|{ows_area}|{image}|{wiki_doku_link}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Mit Tags
|
|
||||||
```
|
|
||||||
Spalten: Name|Status|Tags|Hersteller|Standort|Doku
|
|
||||||
Daten: {name}|{status_smiley}|{tags_list}|{partner_id}|{location}|{wiki_doku_link}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Best Practices: DokuWiki Seitenstruktur
|
|
||||||
|
|
||||||
## Erweiterbare Dokumentation ohne Umbenennung
|
|
||||||
|
|
||||||
### Problem
|
|
||||||
Benutzer möchten zu Equipment-Seiten eigene Unterseiten hinzufügen, ohne die von Odoo generierten Seiten umbenennen zu müssen.
|
|
||||||
|
|
||||||
### Lösung: Parallele Seite + Namespace
|
|
||||||
|
|
||||||
DokuWiki erlaubt **gleichzeitig** eine Seite und einen gleichnamigen Namespace:
|
|
||||||
- Datei: `equipment.txt` (von Odoo generiert)
|
|
||||||
- Verzeichnis: `equipment/` (für Benutzer-Unterseiten)
|
|
||||||
|
|
||||||
**Die Seite `equipment.txt` hat Vorrang und bleibt die Haupt-Equipment-Seite!**
|
|
||||||
|
|
||||||
### Empfohlene Struktur
|
|
||||||
|
|
||||||
```
|
|
||||||
werkstatt/
|
|
||||||
└── ausstattung/
|
|
||||||
├── uebersicht # VON ODOO GENERIERT: Übersichtstabelle
|
|
||||||
├── odoo-status/ # VON ODOO GENERIERT: Nur-Lese Seiten (nur odoo.odoo schreibt)
|
|
||||||
│ ├── c_template.txt # Template für Equipment-Status-Seiten
|
|
||||||
│ ├── analog-oscilloscope-hm303-6.txt # Status-Seite für include-Plugin
|
|
||||||
│ ├── cnc-fraese-xyz.txt # Status-Seite für include-Plugin
|
|
||||||
│ └── ...
|
|
||||||
└── {Bereich Name}/ # VON BENUTZER ERSTELLT: Namespace wird von Odoo festgelegt
|
|
||||||
├── analog-oscilloscope-hm303-6.txt # Benutzer erstellt diese Seite manuell (Klick auf Link in Übersicht)
|
|
||||||
├── analog-oscilloscope-hm303-6/ # Benutzer-Unterseiten (optional)
|
|
||||||
│ ├── kalibrierung.txt
|
|
||||||
│ ├── messungen.txt
|
|
||||||
│ └── bilder/
|
|
||||||
│ └── oszillogramme.txt
|
|
||||||
├── cnc-fraese-xyz.txt # Benutzer erstellt diese Seite manuell (Klick auf Link in Übersicht)
|
|
||||||
└── cnc-fraese-xyz/ # Benutzer-Unterseiten (optional)
|
|
||||||
├── programme.txt
|
|
||||||
└── werkzeuge.txt
|
|
||||||
```
|
|
||||||
|
|
||||||
**Wichtig:**
|
|
||||||
- **Odoo erstellt NUR:** `{overview_page_id}` (Übersichtstabelle) und `odoo-status/*.txt` (Status-Seiten)
|
|
||||||
- **Odoo erstellt NIEMALS:** Seiten in `{Bereich Name}/` - diese werden ausschließlich von Benutzern erstellt
|
|
||||||
- **Workflow:** Übersichtstabelle verlinkt auf `{Bereich Name}/equipment-name.txt` → Link ist rot (Seite existiert nicht) → Benutzer klickt darauf → DokuWiki bietet "Seite erstellen" an → Benutzer fügt `{{page>odoo-status:equipment-name}}` ein
|
|
||||||
- **Berechtigungen:** `odoo-status/` Namespace hat spezielle Berechtigungen (nur odoo.odoo kann schreiben)
|
|
||||||
|
|
||||||
### Wichtige Regeln
|
|
||||||
|
|
||||||
#### ✅ DO: Unterverzeichnis OHNE start.txt
|
|
||||||
|
|
||||||
```
|
|
||||||
equipment/
|
|
||||||
├── subpage1.txt
|
|
||||||
├── subpage2.txt
|
|
||||||
└── weitere/
|
|
||||||
└── details.txt
|
|
||||||
```
|
|
||||||
|
|
||||||
**Vorteile:**
|
|
||||||
- Breadcrumb zeigt: `Home » Werkstatt » Ausrüstung » Equipment » Subpage1`
|
|
||||||
- Keine Duplikation oder Verwirrung
|
|
||||||
- Equipment-Hauptseite bleibt `equipment.txt`
|
|
||||||
|
|
||||||
#### ❌ DON'T: Unterverzeichnis MIT start.txt
|
|
||||||
|
|
||||||
```
|
|
||||||
equipment/
|
|
||||||
├── start.txt # ❌ NICHT erstellen!
|
|
||||||
├── subpage1.txt
|
|
||||||
└── subpage2.txt
|
|
||||||
```
|
|
||||||
|
|
||||||
**Probleme:**
|
|
||||||
- Breadcrumb wird verwirrend: `Equipment` könnte auf zwei Seiten verweisen
|
|
||||||
- `equipment` und `equipment:start` zeigen unterschiedliche Inhalte
|
|
||||||
- Benutzer wissen nicht, welche Seite die "richtige" ist
|
|
||||||
|
|
||||||
### Include-Plugin für Odoo-Status einbinden
|
|
||||||
|
|
||||||
**Wichtig:** Diese Seite wird NICHT von Odoo erstellt! Der Benutzer erstellt sie manuell nach Klick auf den Link in der Übersichtstabelle.
|
|
||||||
|
|
||||||
In der Benutzer-Dokumentationsseite wird der Odoo-generierte Status eingebunden:
|
|
||||||
|
|
||||||
```dokuwiki
|
|
||||||
===== Analog Oscilloscope HM303-6 =====
|
|
||||||
|
|
||||||
==== Odoo Status ====
|
|
||||||
|
|
||||||
{{page>werkstatt:ausstattung:odoo-status:analog-oscilloscope-hm303-6}}
|
|
||||||
|
|
||||||
==== Eigene Dokumentation ====
|
|
||||||
|
|
||||||
Hier kann der Benutzer seine eigenen Inhalte hinzufügen...
|
|
||||||
|
|
||||||
==== Weitere Dokumentation ====
|
|
||||||
|
|
||||||
<catlist .:analog-oscilloscope-hm303-6 -noNSInBold -sortByTitle>
|
|
||||||
```
|
|
||||||
|
|
||||||
**Syntax-Erklärung:**
|
|
||||||
- `{{page>...}}` → Bindet Odoo-Status-Seite ein (nur lesbar für Benutzer)
|
|
||||||
- `.:namespace` → Relativer Namespace für catlist (Punkt + Doppelpunkt!)
|
|
||||||
- `-noNSInBold` → Namespace-Präfix nicht fett darstellen
|
|
||||||
- `-sortByTitle` → Alphabetisch sortieren
|
|
||||||
- `-exclude:{ns1 ns2}` → Optional: Namespaces ausschließen
|
|
||||||
|
|
||||||
**Ergebnis:**
|
|
||||||
- Odoo-Status wird direkt in Benutzer-Seite eingebettet
|
|
||||||
- Benutzer sehen aktuelle Odoo-Daten ohne direkt auf odoo-status zuzugreifen
|
|
||||||
- Catlist zeigt alle Benutzer-Unterseiten im `equipment/` Verzeichnis
|
|
||||||
- Automatisch aktualisiert wenn Benutzer neue Seiten erstellen
|
|
||||||
- Kein manuelles Pflegen von Links nötig
|
|
||||||
|
|
||||||
### Workflow
|
|
||||||
|
|
||||||
1. **Odoo synchronisiert** → Erstellt/aktualisiert `{overview_page_id}` (Übersichtstabelle) und `odoo-status/equipment.txt` (Status-Seiten)
|
|
||||||
2. **Benutzer sieht Übersichtstabelle** → Equipment sind verlinkt, aber Links sind rot (Seiten existieren nicht)
|
|
||||||
3. **Benutzer klickt roten Link** → DokuWiki zeigt "Diese Seite existiert noch nicht - erstellen?"
|
|
||||||
4. **Benutzer erstellt Seite** → Fügt `{{page>werkstatt:ausstattung:odoo-status:equipment-name}}` ein und eigene Inhalte
|
|
||||||
5. **Benutzer erstellt optional Unterseiten** → Erstellt Verzeichnis `equipment/` für weitere Dokumentation
|
|
||||||
6. **Catlist zeigt automatisch Unterseiten** → Wenn in Hauptseite eingebunden
|
|
||||||
7. **Keine Konflikte** → Odoo schreibt NIEMALS in `{Bereich Name}/`, nur in `start` und `odoo-status/`
|
|
||||||
|
|
||||||
### Vorteile dieser Struktur
|
|
||||||
|
|
||||||
- ✅ **Klare Trennung:** `odoo-status/` (nur Odoo schreibt) vs. `{Bereich Name}/` (nur Benutzer schreiben)
|
|
||||||
- ✅ **Berechtigungsschutz:** Benutzer können Odoo-Daten nicht versehentlich überschreiben
|
|
||||||
- ✅ **Keine Überschreibgefahr:** Odoo erstellt niemals Seiten in Benutzer-Namespaces
|
|
||||||
- ✅ **Einfaches Einbinden:** Ein `{{page>...}}` in Benutzer-Seite, fertig
|
|
||||||
- ✅ **Keine Namenskollisionen:** Odoo und Benutzer haben komplett getrennte Namespaces
|
|
||||||
- ✅ **Benutzer entscheiden:** Nur wer dokumentieren will, erstellt eine Seite (über Link in Übersichtstabelle)
|
|
||||||
- ✅ **Flexibel erweiterbar:** Benutzer können beliebig viele Unterseiten erstellen
|
|
||||||
- ✅ **Automatische Updates:** Odoo-Status wird in alle einbindenden Seiten propagiert
|
|
||||||
- ✅ **Konfigurierbarer Namespace:** Basis-Pfad über Systemparameter `dokuwiki.equipment_namespace` anpassbar
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
from . import models
|
|
||||||
from . import wizard
|
|
||||||
from .hooks import post_init_hook, pre_init_hook
|
|
||||||
|
|
@ -1,68 +0,0 @@
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
{
|
|
||||||
'name': 'Open Workshop - DokuWiki Integration',
|
|
||||||
'version': '18.0.2.1.0',
|
|
||||||
'category': 'Maintenance',
|
|
||||||
'summary': 'Synchronisiert Equipment-Daten mit DokuWiki für Dokumentation',
|
|
||||||
'description': """
|
|
||||||
Open Workshop DokuWiki Integration
|
|
||||||
===================================
|
|
||||||
|
|
||||||
Dieses Modul synchronisiert Equipment-Daten aus Odoo mit einem DokuWiki System.
|
|
||||||
|
|
||||||
Features:
|
|
||||||
---------
|
|
||||||
* Automatische Erstellung von Wiki-Seiten für Equipment
|
|
||||||
* Übersichtstabelle aller Equipment (DataTables mit Sortierung/Filterung)
|
|
||||||
* Status-Seiten für Include-Plugin (nur von Odoo generiert, nur lesbar)
|
|
||||||
* Benutzer erstellen eigene Dokumentationsseiten und binden Odoo-Status ein
|
|
||||||
* Smart Button "Wiki öffnen" im Equipment-Formular
|
|
||||||
* Automatische Synchronisation bei Equipment-Änderungen (optional)
|
|
||||||
|
|
||||||
Architektur:
|
|
||||||
------------
|
|
||||||
* Odoo generiert NUR: {overview_page_id} (Übersichtstabelle) und odoo-status/ Seiten
|
|
||||||
* Odoo generiert NIEMALS: Benutzer-Dokumentationsseiten in {bereich}/ Namespaces
|
|
||||||
* Namespace-Struktur (konfigurierbar über dokuwiki.equipment_namespace):
|
|
||||||
- {equipment_namespace}:{overview_page_id} - Übersichtstabelle (von Odoo)
|
|
||||||
- {equipment_namespace}:odoo-status:{equipment_id} - Status-Seiten (von Odoo, nur lesbar)
|
|
||||||
- {equipment_namespace}:odoo-status:c_template - Template für Status-Seiten
|
|
||||||
- {equipment_namespace}:{bereich}:{equipment_id} - Benutzer-Dokumentation (von Benutzern erstellt!)
|
|
||||||
|
|
||||||
Workflow:
|
|
||||||
---------
|
|
||||||
1. Odoo synchronisiert Equipment → erstellt odoo-status/ Seiten
|
|
||||||
2. Übersichtstabelle verlinkt auf {bereich}/{equipment_id} → Link ist rot (existiert nicht)
|
|
||||||
3. Benutzer klickt roten Link → DokuWiki: "Seite erstellen?"
|
|
||||||
4. Benutzer erstellt Seite und fügt {{page>odoo-status:equipment_id}} ein
|
|
||||||
|
|
||||||
ACL-Empfehlung:
|
|
||||||
---------------
|
|
||||||
* odoo-status/ Namespace: Nur odoo.odoo kann schreiben, alle anderen nur lesen
|
|
||||||
* {bereich}/ Namespaces: Benutzer haben volle Schreibrechte
|
|
||||||
""",
|
|
||||||
'author': 'Hobbyhimmel',
|
|
||||||
'website': 'https://hobbyhimmel.de',
|
|
||||||
'license': 'LGPL-3',
|
|
||||||
'depends': [
|
|
||||||
'open_workshop_base',
|
|
||||||
'maintenance',
|
|
||||||
'maintenance_equipment_status',
|
|
||||||
],
|
|
||||||
'external_dependencies': {
|
|
||||||
'python': ['dokuwiki'],
|
|
||||||
},
|
|
||||||
'data': [
|
|
||||||
'security/ir.model.access.csv',
|
|
||||||
'data/ir_config_parameter.xml',
|
|
||||||
'data/maintenance_equipment_status_data.xml',
|
|
||||||
'views/maintenance_equipment_views.xml',
|
|
||||||
'views/maintenance_equipment_status_views.xml',
|
|
||||||
'wizard/equipment_wiki_sync_wizard_views.xml',
|
|
||||||
],
|
|
||||||
'post_init_hook': 'post_init_hook',
|
|
||||||
'pre_init_hook': 'pre_init_hook',
|
|
||||||
'installable': True,
|
|
||||||
'application': False,
|
|
||||||
'auto_install': False,
|
|
||||||
}
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<odoo>
|
|
||||||
<!--
|
|
||||||
DokuWiki System Parameters werden über post_init_hook initialisiert
|
|
||||||
Dadurch werden sie bei Install und Update korrekt gehandhabt
|
|
||||||
und User-Änderungen bleiben erhalten
|
|
||||||
-->
|
|
||||||
</odoo>
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<odoo noupdate="1">
|
|
||||||
<!-- DokuWiki Smiley-Werte für Status aus open_workshop_base -->
|
|
||||||
|
|
||||||
<record id="open_workshop_base.equipment_status_inbetrieb" model="maintenance.equipment.status">
|
|
||||||
<field name="smiley">😊</field>
|
|
||||||
</record>
|
|
||||||
|
|
||||||
<record id="open_workshop_base.equipment_status_defekt" model="maintenance.equipment.status">
|
|
||||||
<field name="smiley">☹️</field>
|
|
||||||
</record>
|
|
||||||
|
|
||||||
<record id="open_workshop_base.equipment_status_wartung" model="maintenance.equipment.status">
|
|
||||||
<field name="smiley">🛠️</field>
|
|
||||||
</record>
|
|
||||||
|
|
||||||
<record id="open_workshop_base.equipment_status_ausgemustert" model="maintenance.equipment.status">
|
|
||||||
<field name="smiley">❌</field>
|
|
||||||
</record>
|
|
||||||
|
|
||||||
</odoo>
|
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
|
|
||||||
def pre_init_hook(env):
|
|
||||||
"""
|
|
||||||
Hook der VOR der Modul-Installation/Update ausgeführt wird.
|
|
||||||
"""
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def post_init_hook(env):
|
|
||||||
"""
|
|
||||||
Hook der nach der Modul-Installation/Update ausgeführt wird.
|
|
||||||
Initialisiert die DokuWiki-Parameter falls sie nicht existieren.
|
|
||||||
"""
|
|
||||||
env['res.config.settings']._init_dokuwiki_parameters()
|
|
||||||
|
|
@ -1,101 +0,0 @@
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
import logging
|
|
||||||
from psycopg2 import sql
|
|
||||||
|
|
||||||
_logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
def migrate(cr, version):
|
|
||||||
"""
|
|
||||||
Fügt die neuen Wiki-ID-Felder zur maintenance_equipment Tabelle hinzu.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
cr: Database cursor
|
|
||||||
version: Aktuelle Version vor dem Upgrade (sollte 18.0.2.0.0 sein)
|
|
||||||
"""
|
|
||||||
_logger.info("=" * 80)
|
|
||||||
_logger.info("PRE-MIGRATION SCRIPT: open_workshop_dokuwiki 18.0.2.1.0")
|
|
||||||
_logger.info("Aktuelle Version: %s", version)
|
|
||||||
_logger.info("=" * 80)
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Prüfe ob die Tabelle maintenance_equipment existiert
|
|
||||||
cr.execute("""
|
|
||||||
SELECT EXISTS (
|
|
||||||
SELECT FROM information_schema.tables
|
|
||||||
WHERE table_schema = 'public'
|
|
||||||
AND table_name = 'maintenance_equipment'
|
|
||||||
)
|
|
||||||
""")
|
|
||||||
table_exists = cr.fetchone()[0]
|
|
||||||
|
|
||||||
if not table_exists:
|
|
||||||
_logger.warning("Tabelle maintenance_equipment existiert nicht - überspringe Migration")
|
|
||||||
return
|
|
||||||
|
|
||||||
_logger.info("Tabelle maintenance_equipment gefunden")
|
|
||||||
|
|
||||||
# Prüfe ob die Felder bereits existieren
|
|
||||||
cr.execute("""
|
|
||||||
SELECT column_name
|
|
||||||
FROM information_schema.columns
|
|
||||||
WHERE table_schema = 'public'
|
|
||||||
AND table_name = 'maintenance_equipment'
|
|
||||||
AND column_name IN ('wiki_status_id', 'wiki_doku_id')
|
|
||||||
""")
|
|
||||||
existing_fields = [row[0] for row in cr.fetchall()]
|
|
||||||
_logger.info("Bereits existierende Felder: %s", existing_fields)
|
|
||||||
|
|
||||||
# Füge wiki_status_id hinzu falls nicht vorhanden
|
|
||||||
if 'wiki_status_id' not in existing_fields:
|
|
||||||
_logger.info("Füge Spalte wiki_status_id hinzu...")
|
|
||||||
cr.execute("""
|
|
||||||
ALTER TABLE maintenance_equipment
|
|
||||||
ADD COLUMN wiki_status_id VARCHAR
|
|
||||||
""")
|
|
||||||
cr.commit() # Wichtig: Commit nach jeder Änderung
|
|
||||||
_logger.info("✓ Spalte wiki_status_id erfolgreich hinzugefügt")
|
|
||||||
else:
|
|
||||||
_logger.info("→ Spalte wiki_status_id existiert bereits, überspringe")
|
|
||||||
|
|
||||||
# Füge wiki_doku_id hinzu falls nicht vorhanden
|
|
||||||
if 'wiki_doku_id' not in existing_fields:
|
|
||||||
_logger.info("Füge Spalte wiki_doku_id hinzu...")
|
|
||||||
cr.execute("""
|
|
||||||
ALTER TABLE maintenance_equipment
|
|
||||||
ADD COLUMN wiki_doku_id VARCHAR
|
|
||||||
""")
|
|
||||||
cr.commit() # Wichtig: Commit nach jeder Änderung
|
|
||||||
_logger.info("✓ Spalte wiki_doku_id erfolgreich hinzugefügt")
|
|
||||||
else:
|
|
||||||
_logger.info("→ Spalte wiki_doku_id existiert bereits, überspringe")
|
|
||||||
|
|
||||||
# Verifiziere dass beide Felder jetzt existieren
|
|
||||||
cr.execute("""
|
|
||||||
SELECT column_name
|
|
||||||
FROM information_schema.columns
|
|
||||||
WHERE table_schema = 'public'
|
|
||||||
AND table_name = 'maintenance_equipment'
|
|
||||||
AND column_name IN ('wiki_status_id', 'wiki_doku_id')
|
|
||||||
ORDER BY column_name
|
|
||||||
""")
|
|
||||||
final_fields = [row[0] for row in cr.fetchall()]
|
|
||||||
_logger.info("Felder nach Migration: %s", final_fields)
|
|
||||||
|
|
||||||
if len(final_fields) == 2:
|
|
||||||
_logger.info("=" * 80)
|
|
||||||
_logger.info("✓ PRE-MIGRATION ERFOLGREICH ABGESCHLOSSEN")
|
|
||||||
_logger.info("=" * 80)
|
|
||||||
else:
|
|
||||||
_logger.error("!" * 80)
|
|
||||||
_logger.error("FEHLER: Nicht alle Felder wurden hinzugefügt!")
|
|
||||||
_logger.error("Erwartet: ['wiki_doku_id', 'wiki_status_id']")
|
|
||||||
_logger.error("Gefunden: %s", final_fields)
|
|
||||||
_logger.error("!" * 80)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
_logger.error("!" * 80)
|
|
||||||
_logger.error("FEHLER IM PRE-MIGRATION SCRIPT!")
|
|
||||||
_logger.error("Exception: %s", str(e))
|
|
||||||
_logger.error("!" * 80)
|
|
||||||
raise
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
from . import dokuwiki_client
|
|
||||||
from . import maintenance_equipment
|
|
||||||
from . import maintenance_equipment_status
|
|
||||||
from . import res_config_settings
|
|
||||||
|
|
@ -1,392 +0,0 @@
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
import logging
|
|
||||||
from dokuwiki import DokuWiki, DokuWikiError
|
|
||||||
from odoo import api, models
|
|
||||||
from odoo.exceptions import UserError
|
|
||||||
|
|
||||||
_logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
# Connection Cache (pro Request wiederverwendet)
|
|
||||||
_connection_cache = {}
|
|
||||||
|
|
||||||
|
|
||||||
class DokuWikiClient(models.AbstractModel):
|
|
||||||
"""
|
|
||||||
Wrapper für DokuWiki XML-RPC API Client.
|
|
||||||
Managed die Verbindung zum DokuWiki und stellt Methoden für Seitenoperationen bereit.
|
|
||||||
|
|
||||||
Performance-Optimierung: Verbindungen werden gecached und wiederverwendet
|
|
||||||
um den Overhead von XML-RPC Verbindungsaufbauten zu minimieren.
|
|
||||||
"""
|
|
||||||
_name = 'dokuwiki.client'
|
|
||||||
_description = 'DokuWiki API Client'
|
|
||||||
|
|
||||||
@api.model
|
|
||||||
def _get_cache_key(self):
|
|
||||||
"""
|
|
||||||
Generiert einen Cache-Key basierend auf den Verbindungsparametern.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
str: Cache-Key (URL + User)
|
|
||||||
"""
|
|
||||||
IrConfigParameter = self.env['ir.config_parameter'].sudo()
|
|
||||||
wiki_url = IrConfigParameter.get_param('dokuwiki.url', '')
|
|
||||||
wiki_user = IrConfigParameter.get_param('dokuwiki.user', '')
|
|
||||||
return f"{wiki_url}:{wiki_user}"
|
|
||||||
|
|
||||||
@api.model
|
|
||||||
def _get_wiki_connection(self, use_cache=True):
|
|
||||||
"""
|
|
||||||
Erstellt eine DokuWiki-Verbindung mit Credentials aus System-Parametern.
|
|
||||||
Verbindungen werden gecached und wiederverwendet für bessere Performance.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
use_cache (bool): Wenn True, wird eine gecachte Verbindung wiederverwendet
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
DokuWiki: Verbundenes DokuWiki-Client-Objekt
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
UserError: Wenn Konfiguration fehlt oder Verbindung fehlschlägt
|
|
||||||
"""
|
|
||||||
IrConfigParameter = self.env['ir.config_parameter'].sudo()
|
|
||||||
|
|
||||||
# Credentials aus System-Parametern holen
|
|
||||||
wiki_url = IrConfigParameter.get_param('dokuwiki.url')
|
|
||||||
wiki_user = IrConfigParameter.get_param('dokuwiki.user')
|
|
||||||
wiki_password = IrConfigParameter.get_param('dokuwiki.password')
|
|
||||||
|
|
||||||
if not all([wiki_url, wiki_user, wiki_password]):
|
|
||||||
raise UserError(
|
|
||||||
"DokuWiki-Konfiguration unvollständig. "
|
|
||||||
"Bitte unter Einstellungen → Technisch → Parameter → Systemparameter konfigurieren:\n"
|
|
||||||
"- dokuwiki.url\n"
|
|
||||||
"- dokuwiki.user\n"
|
|
||||||
"- dokuwiki.password"
|
|
||||||
)
|
|
||||||
|
|
||||||
# URL validieren/normalisieren
|
|
||||||
if not wiki_url.startswith(('http://', 'https://')):
|
|
||||||
raise UserError(f"DokuWiki URL muss mit http:// oder https:// beginnen: {wiki_url}")
|
|
||||||
|
|
||||||
# Cache-Key generieren
|
|
||||||
cache_key = self._get_cache_key()
|
|
||||||
|
|
||||||
# Gecachte Verbindung wiederverwenden wenn möglich
|
|
||||||
if use_cache and cache_key in _connection_cache:
|
|
||||||
try:
|
|
||||||
# Teste ob Verbindung noch lebt
|
|
||||||
cached_wiki = _connection_cache[cache_key]
|
|
||||||
_ = cached_wiki.version # Test-Call
|
|
||||||
_logger.debug(f"Wiederverwendung gecachter DokuWiki-Verbindung: {wiki_url}")
|
|
||||||
return cached_wiki
|
|
||||||
except Exception as e:
|
|
||||||
_logger.warning(f"Gecachte Verbindung ungültig, erstelle neue: {e}")
|
|
||||||
del _connection_cache[cache_key]
|
|
||||||
|
|
||||||
# Neue Verbindung erstellen
|
|
||||||
try:
|
|
||||||
_logger.info(f"Neue DokuWiki-Verbindung: {wiki_url} (User: {wiki_user})")
|
|
||||||
wiki = DokuWiki(wiki_url, wiki_user, wiki_password)
|
|
||||||
# Test-Verbindung
|
|
||||||
version = wiki.version
|
|
||||||
_logger.info(f"DokuWiki-Verbindung erfolgreich: Version {version}")
|
|
||||||
|
|
||||||
# In Cache speichern
|
|
||||||
if use_cache:
|
|
||||||
_connection_cache[cache_key] = wiki
|
|
||||||
_logger.debug(f"Verbindung im Cache gespeichert: {cache_key}")
|
|
||||||
|
|
||||||
return wiki
|
|
||||||
except DokuWikiError as e:
|
|
||||||
error_details = f"URL: {wiki_url}, User: {wiki_user}, Fehler: {e}"
|
|
||||||
_logger.error(f"DokuWiki-Verbindung fehlgeschlagen: {error_details}")
|
|
||||||
raise UserError(
|
|
||||||
f"Verbindung zu DokuWiki fehlgeschlagen.\n\n"
|
|
||||||
f"Details:\n"
|
|
||||||
f"- URL: {wiki_url}\n"
|
|
||||||
f"- User: {wiki_user}\n"
|
|
||||||
f"- Fehler: {e}\n\n"
|
|
||||||
f"Hinweise:\n"
|
|
||||||
f"- Prüfen Sie ob die URL korrekt ist (sollte auf /lib/exe/xmlrpc.php zeigen)\n"
|
|
||||||
f"- Stellen Sie sicher, dass XML-RPC in DokuWiki aktiviert ist\n"
|
|
||||||
f"- Prüfen Sie die Benutzer-Zugangsdaten"
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
_logger.error(f"Unerwarteter Fehler bei DokuWiki-Verbindung: {e}", exc_info=True)
|
|
||||||
raise UserError(f"Unerwarteter Fehler: {e}")
|
|
||||||
|
|
||||||
@api.model
|
|
||||||
def clear_connection_cache(self):
|
|
||||||
"""
|
|
||||||
Löscht den Verbindungs-Cache (z.B. nach Konfigurationsänderungen).
|
|
||||||
"""
|
|
||||||
global _connection_cache
|
|
||||||
_connection_cache.clear()
|
|
||||||
_logger.info("DokuWiki Verbindungs-Cache geleert")
|
|
||||||
|
|
||||||
@api.model
|
|
||||||
def create_page(self, page_id, content, summary="Erstellt von Odoo"):
|
|
||||||
"""
|
|
||||||
Erstellt oder aktualisiert eine DokuWiki-Seite.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
page_id (str): DokuWiki Page-ID (z.B. "werkstatt:ausruestung:holzwerkstatt:maschine1")
|
|
||||||
content (str): Wiki-Markup-Inhalt der Seite
|
|
||||||
summary (str): Änderungskommentar für die Wiki-History
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
bool: True bei Erfolg
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
UserError: Bei API-Fehlern
|
|
||||||
"""
|
|
||||||
wiki = self._get_wiki_connection()
|
|
||||||
|
|
||||||
try:
|
|
||||||
_logger.info(f"Erstelle/Aktualisiere Wiki-Seite: {page_id} ({len(content)} Zeichen)")
|
|
||||||
wiki.pages.set(page_id, content, summary=summary)
|
|
||||||
_logger.info(f"Wiki-Seite erfolgreich erstellt/aktualisiert: {page_id}")
|
|
||||||
return True
|
|
||||||
except DokuWikiError as e:
|
|
||||||
_logger.error(f"DokuWiki API Fehler bei Seite {page_id}: {e}", exc_info=True)
|
|
||||||
raise UserError(
|
|
||||||
f"Fehler beim Erstellen der Wiki-Seite '{page_id}':\n\n"
|
|
||||||
f"{e}\n\n"
|
|
||||||
f"Mögliche Ursachen:\n"
|
|
||||||
f"- XML-RPC nicht aktiviert in DokuWiki\n"
|
|
||||||
f"- Keine Schreibrechte für den Namespace\n"
|
|
||||||
f"- Ungültige Zeichen in der Page-ID\n"
|
|
||||||
f"- DokuWiki gibt Fehler/Warnings aus (prüfen Sie die DokuWiki-Logs)"
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
_logger.error(f"Unerwarteter Fehler beim Erstellen der Seite {page_id}: {e}", exc_info=True)
|
|
||||||
raise UserError(f"Unerwarteter Fehler: {e}")
|
|
||||||
|
|
||||||
@api.model
|
|
||||||
def get_page(self, page_id):
|
|
||||||
"""
|
|
||||||
Liest den Inhalt einer DokuWiki-Seite.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
page_id (str): DokuWiki Page-ID
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
str: Wiki-Markup-Inhalt der Seite (leer wenn Seite nicht existiert)
|
|
||||||
"""
|
|
||||||
wiki = self._get_wiki_connection()
|
|
||||||
|
|
||||||
try:
|
|
||||||
content = wiki.pages.get(page_id)
|
|
||||||
return content or ""
|
|
||||||
except DokuWikiError as e:
|
|
||||||
_logger.warning(f"Fehler beim Lesen der Wiki-Seite {page_id}: {e}")
|
|
||||||
return ""
|
|
||||||
|
|
||||||
@api.model
|
|
||||||
def page_exists(self, page_id):
|
|
||||||
"""
|
|
||||||
Prüft ob eine DokuWiki-Seite existiert.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
page_id (str): DokuWiki Page-ID
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
bool: True wenn Seite existiert
|
|
||||||
"""
|
|
||||||
content = self.get_page(page_id)
|
|
||||||
return bool(content.strip())
|
|
||||||
|
|
||||||
@api.model
|
|
||||||
def delete_page(self, page_id, summary="Gelöscht von Odoo"):
|
|
||||||
"""
|
|
||||||
Löscht eine DokuWiki-Seite (setzt Inhalt auf leer).
|
|
||||||
|
|
||||||
Args:
|
|
||||||
page_id (str): DokuWiki Page-ID
|
|
||||||
summary (str): Änderungskommentar für die Wiki-History
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
bool: True bei Erfolg
|
|
||||||
"""
|
|
||||||
wiki = self._get_wiki_connection()
|
|
||||||
|
|
||||||
try:
|
|
||||||
wiki.pages.set(page_id, "", summary=summary)
|
|
||||||
_logger.info(f"Wiki-Seite gelöscht: {page_id}")
|
|
||||||
return True
|
|
||||||
except DokuWikiError as e:
|
|
||||||
_logger.error(f"Fehler beim Löschen der Wiki-Seite {page_id}: {e}")
|
|
||||||
raise UserError(f"Fehler beim Löschen der Wiki-Seite: {e}")
|
|
||||||
|
|
||||||
@api.model
|
|
||||||
def list_pages(self, namespace):
|
|
||||||
"""
|
|
||||||
Listet alle Seiten in einem DokuWiki-Namespace auf.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
namespace (str): DokuWiki-Namespace (z.B. "werkstatt:ausruestung")
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
list: Liste von Dictionaries mit Page-Informationen
|
|
||||||
"""
|
|
||||||
wiki = self._get_wiki_connection()
|
|
||||||
|
|
||||||
try:
|
|
||||||
pages = wiki.pages.list(namespace)
|
|
||||||
return pages
|
|
||||||
except DokuWikiError as e:
|
|
||||||
_logger.error(f"Fehler beim Auflisten des Namespace {namespace}: {e}")
|
|
||||||
return []
|
|
||||||
|
|
||||||
@api.model
|
|
||||||
def get_wiki_url(self, page_id):
|
|
||||||
"""
|
|
||||||
Generiert die vollständige URL zu einer DokuWiki-Seite.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
page_id (str): DokuWiki Page-ID
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
str: Vollständige URL zur Wiki-Seite
|
|
||||||
"""
|
|
||||||
IrConfigParameter = self.env['ir.config_parameter'].sudo()
|
|
||||||
wiki_url = IrConfigParameter.get_param('dokuwiki.url', '')
|
|
||||||
|
|
||||||
if not wiki_url:
|
|
||||||
return ""
|
|
||||||
|
|
||||||
# Entferne trailing slash falls vorhanden
|
|
||||||
wiki_url = wiki_url.rstrip('/')
|
|
||||||
|
|
||||||
return f"{wiki_url}/doku.php?id={page_id}"
|
|
||||||
|
|
||||||
@api.model
|
|
||||||
def upload_media(self, media_id, file_content, overwrite=True):
|
|
||||||
"""
|
|
||||||
Lädt eine Mediendatei (Bild) ins DokuWiki hoch.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
media_id (str): DokuWiki Media-ID (z.B. "werkstatt:ausruestung:equipment_3.jpg")
|
|
||||||
file_content (bytes): Binärer Dateiinhalt
|
|
||||||
overwrite (bool): Existierende Datei überschreiben
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
bool: True bei Erfolg
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
UserError: Bei Fehler beim Upload
|
|
||||||
"""
|
|
||||||
wiki = self._get_wiki_connection()
|
|
||||||
|
|
||||||
import tempfile
|
|
||||||
import os
|
|
||||||
import base64
|
|
||||||
import imghdr
|
|
||||||
|
|
||||||
tmp_path = None
|
|
||||||
|
|
||||||
# Unterstütze mehrere Eingabetypen:
|
|
||||||
# - ir.attachment record (Recordset)
|
|
||||||
# - attachment id (int)
|
|
||||||
# - rohe Bytes
|
|
||||||
# - base64-codierter String
|
|
||||||
try:
|
|
||||||
content_bytes = None
|
|
||||||
ext = ''
|
|
||||||
|
|
||||||
# ir.attachment Record
|
|
||||||
if hasattr(file_content, '_name') and getattr(file_content, '_name') == 'ir.attachment':
|
|
||||||
att = file_content.sudo()
|
|
||||||
if not att.datas:
|
|
||||||
raise UserError(f"Attachment {att.id} enthält keine Daten")
|
|
||||||
content_bytes = base64.b64decode(att.datas)
|
|
||||||
fname = att.datas_fname or ''
|
|
||||||
ext = os.path.splitext(fname)[1]
|
|
||||||
|
|
||||||
# attachment id
|
|
||||||
elif isinstance(file_content, int):
|
|
||||||
att = self.env['ir.attachment'].sudo().browse(file_content)
|
|
||||||
if not att.exists() or not att.datas:
|
|
||||||
raise UserError(f"Attachment {file_content} nicht gefunden oder leer")
|
|
||||||
content_bytes = base64.b64decode(att.datas)
|
|
||||||
fname = att.datas_fname or ''
|
|
||||||
ext = os.path.splitext(fname)[1]
|
|
||||||
|
|
||||||
# base64 string
|
|
||||||
elif isinstance(file_content, str):
|
|
||||||
try:
|
|
||||||
content_bytes = base64.b64decode(file_content)
|
|
||||||
except Exception:
|
|
||||||
# Fallback: treat as empty
|
|
||||||
raise UserError("Übergebener String konnte nicht als base64 decodiert werden")
|
|
||||||
ext = os.path.splitext(media_id)[1]
|
|
||||||
|
|
||||||
# rohe bytes
|
|
||||||
else:
|
|
||||||
# assume bytes-like
|
|
||||||
content_bytes = file_content
|
|
||||||
# try extension from media_id
|
|
||||||
ext = os.path.splitext(media_id)[1]
|
|
||||||
|
|
||||||
# Wenn noch keine Extension, versuche Bildtyp zu erkennen
|
|
||||||
if not ext:
|
|
||||||
try:
|
|
||||||
img_type = imghdr.what(None, content_bytes)
|
|
||||||
if img_type:
|
|
||||||
ext = f'.{img_type}'
|
|
||||||
except Exception:
|
|
||||||
ext = ''
|
|
||||||
|
|
||||||
if not ext:
|
|
||||||
ext = '.bin'
|
|
||||||
|
|
||||||
# Sicherstellen, dass ext mit Punkt beginnt
|
|
||||||
if not ext.startswith('.'):
|
|
||||||
ext = f'.{ext}'
|
|
||||||
|
|
||||||
# Temporäre Datei erstellen (dokuwiki.py erwartet Datei-Pfad)
|
|
||||||
with tempfile.NamedTemporaryFile(delete=False, suffix=ext) as tmp_file:
|
|
||||||
tmp_file.write(content_bytes)
|
|
||||||
tmp_path = tmp_file.name
|
|
||||||
|
|
||||||
try:
|
|
||||||
wiki.medias.add(media_id, tmp_path, overwrite=overwrite)
|
|
||||||
_logger.info(f"Mediendatei hochgeladen: {media_id} (tmp={tmp_path})")
|
|
||||||
return True
|
|
||||||
except DokuWikiError as e:
|
|
||||||
error_msg = f"Fehler beim Hochladen der Mediendatei {media_id}: {e}"
|
|
||||||
_logger.error(error_msg)
|
|
||||||
raise UserError(error_msg)
|
|
||||||
finally:
|
|
||||||
# Temporäre Datei löschen
|
|
||||||
if tmp_path and os.path.exists(tmp_path):
|
|
||||||
try:
|
|
||||||
os.unlink(tmp_path)
|
|
||||||
except Exception:
|
|
||||||
_logger.warning(f"Konnte temporäre Datei nicht löschen: {tmp_path}")
|
|
||||||
|
|
||||||
@api.model
|
|
||||||
def get_media_url(self, media_id):
|
|
||||||
"""
|
|
||||||
Generiert die vollständige URL zu einer DokuWiki-Mediendatei.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
media_id (str): DokuWiki Media-ID
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
str: Vollständige URL zur Mediendatei
|
|
||||||
"""
|
|
||||||
IrConfigParameter = self.env['ir.config_parameter'].sudo()
|
|
||||||
wiki_url = IrConfigParameter.get_param('dokuwiki.url', '')
|
|
||||||
|
|
||||||
if not wiki_url:
|
|
||||||
return ""
|
|
||||||
|
|
||||||
# Entferne trailing slash falls vorhanden
|
|
||||||
wiki_url = wiki_url.rstrip('/')
|
|
||||||
|
|
||||||
# Ersetze : durch / für Media-Pfad
|
|
||||||
media_path = media_id.replace(':', '/')
|
|
||||||
|
|
||||||
return f"{wiki_url}/lib/exe/fetch.php?media={media_id}"
|
|
||||||
|
|
@ -1,974 +0,0 @@
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
import logging
|
|
||||||
import re
|
|
||||||
from datetime import datetime
|
|
||||||
from odoo import api, fields, models, _
|
|
||||||
from odoo.exceptions import UserError
|
|
||||||
|
|
||||||
_logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
# Cache für Wiki-Templates (wird beim Server-Neustart geleert)
|
|
||||||
_template_cache = {}
|
|
||||||
|
|
||||||
class MaintenanceEquipment(models.Model):
|
|
||||||
"""
|
|
||||||
Erweitert das Maintenance Equipment Modell um DokuWiki-Integration.
|
|
||||||
Diese Klasse ermöglicht die Synchronisation von Werkstatt-Equipment-Daten mit einem
|
|
||||||
DokuWiki-System. Jedes Equipment erhält eine zentrale Dokumentationsseite sowie
|
|
||||||
verschiedene Ansichtsseiten (nach Bereich, später auch nach Einsatzzweck).
|
|
||||||
Hauptfunktionen:
|
|
||||||
- Automatische Generierung von Wiki-Seiten aus Equipment-Daten
|
|
||||||
- Template-basierte Wiki-Seiten-Erstellung (verwendet c_template aus DokuWiki)
|
|
||||||
- Bild-Upload und Einbindung in Wiki-Seiten
|
|
||||||
- Zentrale Dokumentationsseite pro Equipment (manuell erweiterbar)
|
|
||||||
- Bereichs-spezifische Übersichtsseiten (automatisch aktualisiert)
|
|
||||||
- Automatische Übersichtstabelle aller Equipment (DataTable mit Sortierung/Filterung)
|
|
||||||
- Automatische Synchronisation bei Feldänderungen (optional)
|
|
||||||
Wiki-Seitenstruktur:
|
|
||||||
- {equipment_namespace}:{overview_page_id} - Übersichtstabelle (von Odoo generiert)
|
|
||||||
- {equipment_namespace}:odoo-status:{wiki_doku_id} - Status-Seite (von Odoo generiert, nur lesbar)
|
|
||||||
- {equipment_namespace}:odoo-status:c_template - Template für Status-Seiten
|
|
||||||
- {equipment_namespace}:{bereich}:{wiki_doku_id} - Benutzer-Dokumentation (NICHT von Odoo erstellt!)
|
|
||||||
Neue Felder:
|
|
||||||
- image_1920: Bild des Equipment (wird automatisch ins Wiki hochgeladen)
|
|
||||||
- wiki_doku_id: Eindeutige ID für Wiki-Dokumentation (aus Equipment-Name generiert)
|
|
||||||
- wiki_page_url: Berechnete URL zur Wiki-Seite
|
|
||||||
- wiki_synced: Status der Synchronisation
|
|
||||||
- wiki_last_sync: Zeitstempel der letzten Synchronisation
|
|
||||||
- wiki_auto_sync: Schalter für automatische Synchronisation bei Änderungen
|
|
||||||
Template-System:
|
|
||||||
Die Wiki-Seiten werden aus einem Template ({equipment_namespace}:odoo-status:c_template)
|
|
||||||
generiert, das folgende Platzhalter unterstützt:
|
|
||||||
- {feldname} - Direkte Equipment-Felder (z.B. {name}, {serial_no})
|
|
||||||
- {ows_machine_id.feldname} - Maschinenfelder (z.B. {ows_machine_id.power})
|
|
||||||
- {wiki_status_page} - ID der Odoo-Status-Seite
|
|
||||||
- {wiki_status_link} - DokuWiki-Link zur Odoo-Status-Seite
|
|
||||||
- {image} - Bild-Einbindung (wird automatisch hochgeladen)
|
|
||||||
- {tags} - Komma-separierte Tag-Liste
|
|
||||||
- {sync_datetime} - Zeitstempel der Synchronisation
|
|
||||||
Übersichtstabelle:
|
|
||||||
Generiert eine zentrale Übersichtstabelle aller Equipment mit konfigurierbaren
|
|
||||||
Spalten. Die Tabelle verwendet das DokuWiki DataTable-Plugin für interaktive
|
|
||||||
Sortierung und Filterung. Konfiguration über Systemparameter:
|
|
||||||
- dokuwiki.overview_page_id - Seiten-ID der Übersicht
|
|
||||||
- dokuwiki.overview_title - Titel der Seite
|
|
||||||
- dokuwiki.overview_columns - Spaltenüberschriften (pipe-separiert)
|
|
||||||
- dokuwiki.overview_column_data - Spaltendaten mit Platzhaltern
|
|
||||||
Verwendung:
|
|
||||||
1. Equipment anlegen/bearbeiten in Odoo
|
|
||||||
2. Bereich (ows_area_id) setzen (erforderlich für Wiki-Sync)
|
|
||||||
3. Optional: Bild hochladen (wird automatisch ins Wiki übertragen)
|
|
||||||
4. "Zum Wiki synchronisieren" klicken (oder automatisch bei Änderungen)
|
|
||||||
5. Wiki-Seiten werden erstellt/aktualisiert
|
|
||||||
6. "Wiki-Seite öffnen" für direkten Browser-Zugriff
|
|
||||||
Notes:
|
|
||||||
- Zentrale Dokumentationsseite wird nur beim ersten Sync erstellt
|
|
||||||
- Bereichsansichten werden bei jedem Sync aktualisiert
|
|
||||||
- Bilder werden als {equipment_namespace}:{bereich}:{wiki_doku_id}.jpg gespeichert (parallele Struktur zu Pages)
|
|
||||||
- Wiki-Namen werden normalisiert (Umlaute, Sonderzeichen, Kleinschreibung)
|
|
||||||
- Bei fehlendem Template wird Fallback-Inhalt verwendet
|
|
||||||
- Übersichtstabelle kann manuell oder automatisch aktualisiert werden
|
|
||||||
"""
|
|
||||||
_inherit = 'maintenance.equipment'
|
|
||||||
|
|
||||||
# Bild-Feld
|
|
||||||
image_1920 = fields.Image(
|
|
||||||
string='Bild',
|
|
||||||
max_width=1920,
|
|
||||||
max_height=1920,
|
|
||||||
help='Equipment-Bild (wird beim Wiki-Sync hochgeladen)'
|
|
||||||
)
|
|
||||||
|
|
||||||
# Wiki-Felder
|
|
||||||
wiki_status_id = fields.Char(
|
|
||||||
string='Wiki Status-ID',
|
|
||||||
readonly=True,
|
|
||||||
help='Eindeutige ID für die Odoo-Status-Seite (automatisch generiert aus Name + Seriennummer, z.B. "formatkreissaege-eq001")'
|
|
||||||
)
|
|
||||||
wiki_doku_id = fields.Char(
|
|
||||||
string='Wiki Dokumentations-ID',
|
|
||||||
readonly=False,
|
|
||||||
help='ID für die Benutzer-Dokumentationsseite (editierbar, initial aus Equipment-Namen generiert, z.B. "formatkreissaege"). Mehrere Equipment können die gleiche ID nutzen.'
|
|
||||||
)
|
|
||||||
wiki_page_url = fields.Char(
|
|
||||||
string='Wiki-Seiten URL',
|
|
||||||
compute='_compute_wiki_page_url',
|
|
||||||
help='URL zur Wiki-Seite im Browser'
|
|
||||||
)
|
|
||||||
wiki_synced = fields.Boolean(
|
|
||||||
string='Wiki synchronisiert',
|
|
||||||
default=False,
|
|
||||||
help='Gibt an, ob die Wiki-Seite aktuell ist'
|
|
||||||
)
|
|
||||||
wiki_last_sync = fields.Datetime(
|
|
||||||
string='Letzte Wiki-Synchronisation',
|
|
||||||
readonly=True,
|
|
||||||
help='Zeitpunkt der letzten erfolgreichen Wiki-Synchronisation'
|
|
||||||
)
|
|
||||||
wiki_auto_sync = fields.Boolean(
|
|
||||||
string='Automatische Wiki-Synchronisation',
|
|
||||||
default=True,
|
|
||||||
help='Bei Änderungen automatisch zum Wiki synchronisieren'
|
|
||||||
)
|
|
||||||
wiki_doku_link = fields.Html(
|
|
||||||
string='Wiki Doku',
|
|
||||||
compute='_compute_wiki_doku_link',
|
|
||||||
sanitize=False,
|
|
||||||
help='Klickbarer Link zur Wiki-Dokumentationsseite'
|
|
||||||
)
|
|
||||||
wiki_status_link = fields.Html(
|
|
||||||
string='Wiki Status',
|
|
||||||
compute='_compute_wiki_status_link',
|
|
||||||
sanitize=False,
|
|
||||||
help='Klickbarer Link zur Wiki-Status-Seite (Odoo-Daten)'
|
|
||||||
)
|
|
||||||
|
|
||||||
@api.depends('wiki_doku_id', 'wiki_page_url')
|
|
||||||
def _compute_wiki_doku_link(self):
|
|
||||||
"""
|
|
||||||
Generiert einen HTML-Link mit wiki_doku_id als Text und wiki_page_url als Ziel.
|
|
||||||
"""
|
|
||||||
for record in self:
|
|
||||||
if record.wiki_doku_id and record.wiki_page_url:
|
|
||||||
record.wiki_doku_link = f'<a href="{record.wiki_page_url}" target="_blank" style="color: #007bff;">{record.wiki_doku_id}</a>'
|
|
||||||
else:
|
|
||||||
record.wiki_doku_link = ''
|
|
||||||
|
|
||||||
@api.depends('wiki_status_id', 'name', 'comp_serial_no', 'ows_area_id')
|
|
||||||
def _compute_wiki_status_link(self):
|
|
||||||
"""
|
|
||||||
Generiert einen HTML-Link mit wiki_status_id als Text zur Odoo-Status-Seite.
|
|
||||||
"""
|
|
||||||
dokuwiki_client = self.env['dokuwiki.client']
|
|
||||||
for record in self:
|
|
||||||
# wiki_status_id dynamisch berechnen falls noch nicht gesetzt
|
|
||||||
wiki_status_id = record.wiki_status_id or record._get_wiki_status_id()
|
|
||||||
if wiki_status_id and record.ows_area_id:
|
|
||||||
status_page_id = record._get_wiki_status_page_id()
|
|
||||||
if status_page_id:
|
|
||||||
status_url = dokuwiki_client.get_wiki_url(status_page_id)
|
|
||||||
record.wiki_status_link = f'<a href="{status_url}" target="_blank" style="color: #007bff;">{wiki_status_id}</a>'
|
|
||||||
else:
|
|
||||||
record.wiki_status_link = ''
|
|
||||||
else:
|
|
||||||
record.wiki_status_link = ''
|
|
||||||
|
|
||||||
@api.depends('ows_area_id')
|
|
||||||
def _compute_wiki_page_url(self):
|
|
||||||
"""
|
|
||||||
Berechnet die URL zur Haupt-Wiki-Seite (nach Bereich).
|
|
||||||
"""
|
|
||||||
dokuwiki_client = self.env['dokuwiki.client']
|
|
||||||
|
|
||||||
for record in self:
|
|
||||||
wiki_doku_id = record._get_wiki_doku_id()
|
|
||||||
if wiki_doku_id and record.ows_area_id:
|
|
||||||
page_id = record._get_wiki_page_id_by_area()
|
|
||||||
record.wiki_page_url = dokuwiki_client.get_wiki_url(page_id)
|
|
||||||
else:
|
|
||||||
record.wiki_page_url = False
|
|
||||||
|
|
||||||
def _get_wiki_status_id(self):
|
|
||||||
"""
|
|
||||||
Generiert die Wiki-Status-ID aus Equipment-Namen und comp_serial_no.
|
|
||||||
Format: normalisierter_name-comp_serial_no (z.B. "formatkreissaege-eq001")
|
|
||||||
|
|
||||||
Diese ID ist eindeutig und wird für die Odoo-Status-Seite und Media-Dateien verwendet.
|
|
||||||
Die comp_serial_no wird automatisch generiert falls noch nicht vorhanden.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
str: Wiki-Status-ID oder False wenn kein Name vorhanden
|
|
||||||
"""
|
|
||||||
self.ensure_one()
|
|
||||||
|
|
||||||
# Verwende wiki_status_id falls bereits gesetzt
|
|
||||||
if self.wiki_status_id:
|
|
||||||
return self.wiki_status_id
|
|
||||||
|
|
||||||
# Name ist erforderlich
|
|
||||||
if not self.name:
|
|
||||||
return False
|
|
||||||
|
|
||||||
# comp_serial_no automatisch generieren falls nicht vorhanden
|
|
||||||
if not self.comp_serial_no:
|
|
||||||
self.generate_serial_no()
|
|
||||||
# Refresh nach generate_serial_no
|
|
||||||
self.refresh()
|
|
||||||
|
|
||||||
# wiki_status_id aus Name + comp_serial_no generieren
|
|
||||||
normalized_name = self._normalize_wiki_name(self.name)
|
|
||||||
normalized_serial = self._normalize_wiki_name(self.comp_serial_no)
|
|
||||||
return f"{normalized_name}-{normalized_serial}"
|
|
||||||
|
|
||||||
def _get_wiki_doku_id(self):
|
|
||||||
"""
|
|
||||||
Generiert die Wiki-Dokumentations-ID aus Equipment-Namen (ohne Seriennummer).
|
|
||||||
Format: normalisierter_name (z.B. "formatkreissaege")
|
|
||||||
|
|
||||||
Diese ID ist editierbar und wird für die Benutzer-Dokumentationsseite verwendet.
|
|
||||||
Mehrere Equipment können die gleiche wiki_doku_id haben (z.B. alle Akkuschrauber).
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
str: Wiki-Doku-ID oder False wenn kein Name vorhanden
|
|
||||||
"""
|
|
||||||
self.ensure_one()
|
|
||||||
|
|
||||||
# Verwende wiki_doku_id falls bereits gesetzt
|
|
||||||
if self.wiki_doku_id:
|
|
||||||
return self.wiki_doku_id
|
|
||||||
|
|
||||||
# Name ist erforderlich
|
|
||||||
if not self.name:
|
|
||||||
return False
|
|
||||||
|
|
||||||
# wiki_doku_id nur aus Name generieren (Duplikate sind erwünscht)
|
|
||||||
normalized_name = self._normalize_wiki_name(self.name)
|
|
||||||
return normalized_name
|
|
||||||
|
|
||||||
def _get_wiki_page_id_by_area(self):
|
|
||||||
"""
|
|
||||||
Generiert die Wiki-Page-ID für die Benutzer-Dokumentationsseite (nach Bereich).
|
|
||||||
Format: {equipment_namespace}:{area_name}:{wiki_doku_id}
|
|
||||||
|
|
||||||
WICHTIG: Diese Seite wird NICHT von Odoo erstellt! Sie wird nur in der Übersichtstabelle
|
|
||||||
verlinkt. Benutzer erstellen sie manuell durch Klick auf den Link.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
str: Page-ID für Benutzer-Dokumentation
|
|
||||||
"""
|
|
||||||
self.ensure_one()
|
|
||||||
|
|
||||||
if not self.ows_area_id:
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Equipment-Namespace aus Systemparameter laden
|
|
||||||
IrConfigParameter = self.env['ir.config_parameter'].sudo()
|
|
||||||
equipment_namespace = IrConfigParameter.get_param(
|
|
||||||
'dokuwiki.equipment_namespace',
|
|
||||||
default='werkstatt:ausstattung'
|
|
||||||
)
|
|
||||||
|
|
||||||
# Area-Name normalisieren (Umlaute, Leerzeichen, Sonderzeichen)
|
|
||||||
area_name = self._normalize_wiki_name(self.ows_area_id.name)
|
|
||||||
|
|
||||||
wiki_doku_id = self._get_wiki_doku_id()
|
|
||||||
return f"{equipment_namespace}:{area_name}:{wiki_doku_id}"
|
|
||||||
|
|
||||||
def _get_wiki_status_page_id(self):
|
|
||||||
"""
|
|
||||||
Generiert die Wiki-Page-ID für die Odoo-Status-Seite (nur lesbar für Benutzer).
|
|
||||||
Format: {equipment_namespace}:odoo-status:{wiki_status_id}
|
|
||||||
Diese Seite wird von Odoo generiert und kann mit dem include-Plugin eingebunden werden.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
str: Page-ID der Odoo-Status-Seite
|
|
||||||
"""
|
|
||||||
self.ensure_one()
|
|
||||||
|
|
||||||
# Namespace aus Systemparameter laden (verwendet jetzt central_documentation_namespace)
|
|
||||||
IrConfigParameter = self.env['ir.config_parameter'].sudo()
|
|
||||||
status_namespace = IrConfigParameter.get_param(
|
|
||||||
'dokuwiki.central_documentation_namespace',
|
|
||||||
default='werkstatt:ausstattung:odoo-status'
|
|
||||||
)
|
|
||||||
|
|
||||||
wiki_status_id = self._get_wiki_status_id()
|
|
||||||
return f"{status_namespace}:{wiki_status_id}"
|
|
||||||
|
|
||||||
def _normalize_wiki_name(self, name):
|
|
||||||
"""
|
|
||||||
Normalisiert einen Namen für DokuWiki-Page-IDs.
|
|
||||||
- Umlaute ersetzen
|
|
||||||
- Leerzeichen und Sonderzeichen durch Bindestriche ersetzen
|
|
||||||
- Kleinbuchstaben
|
|
||||||
|
|
||||||
Args:
|
|
||||||
name (str): Original-Name
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
str: Normalisierter Name
|
|
||||||
"""
|
|
||||||
if not name:
|
|
||||||
return "unnamed"
|
|
||||||
|
|
||||||
# Umlaute ersetzen
|
|
||||||
replacements = {
|
|
||||||
'ä': 'ae', 'ö': 'oe', 'ü': 'ue',
|
|
||||||
'Ä': 'ae', 'Ö': 'oe', 'Ü': 'ue',
|
|
||||||
'ß': 'ss'
|
|
||||||
}
|
|
||||||
for old, new in replacements.items():
|
|
||||||
name = name.replace(old, new)
|
|
||||||
|
|
||||||
# Nur alphanumerische Zeichen und Bindestriche
|
|
||||||
name = re.sub(r'[^a-zA-Z0-9\-_]', '-', name)
|
|
||||||
|
|
||||||
# Mehrfache Bindestriche durch einen ersetzen
|
|
||||||
name = re.sub(r'-+', '-', name)
|
|
||||||
|
|
||||||
# Führende/Trailing Bindestriche entfernen
|
|
||||||
name = name.strip('-')
|
|
||||||
|
|
||||||
# Kleinbuchstaben
|
|
||||||
name = name.lower()
|
|
||||||
|
|
||||||
return name or "unnamed"
|
|
||||||
|
|
||||||
def _render_template_from_wiki(self, view_type='area'):
|
|
||||||
"""
|
|
||||||
Lädt c_template aus DokuWiki und ersetzt Platzhalter mit Odoo-Feldwerten.
|
|
||||||
Template-Pfad wird dynamisch aus dem Systemparameter 'dokuwiki.central_documentation_namespace' generiert.
|
|
||||||
Standard: werkstatt:ausstattung:odoo-status:c_template
|
|
||||||
|
|
||||||
Platzhalter-Format:
|
|
||||||
- {feldname} für maintenance.equipment Felder, z.B. {name}, {serial_no}
|
|
||||||
- {ows_machine_id.feldname} für ows.machine Felder, z.B. {ows_machine_id.power}
|
|
||||||
- {wiki_status_page} für die Odoo-Status-Seite ID
|
|
||||||
- {wiki_status_link} für Link zur Odoo-Status-Seite
|
|
||||||
- {ows_area} für Bereichsname
|
|
||||||
- {category} für Kategoriename
|
|
||||||
- {sync_datetime} für aktuelles Datum/Zeit
|
|
||||||
|
|
||||||
Args:
|
|
||||||
view_type (str): 'area' oder 'purpose'
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
str: Gerenderter Wiki-Markup-Inhalt
|
|
||||||
"""
|
|
||||||
self.ensure_one()
|
|
||||||
global _template_cache
|
|
||||||
dokuwiki_client = self.env['dokuwiki.client']
|
|
||||||
|
|
||||||
# Template-Pfad aus Systemparameter generieren
|
|
||||||
IrConfigParameter = self.env['ir.config_parameter'].sudo()
|
|
||||||
status_namespace = IrConfigParameter.get_param(
|
|
||||||
'dokuwiki.central_documentation_namespace',
|
|
||||||
default='werkstatt:ausstattung:odoo-status'
|
|
||||||
)
|
|
||||||
template_page_id = f"{status_namespace}:c_template"
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Template aus Cache oder Wiki laden
|
|
||||||
if template_page_id not in _template_cache:
|
|
||||||
template_content = dokuwiki_client.get_page(template_page_id)
|
|
||||||
if not template_content:
|
|
||||||
_logger.warning(f"Template {template_page_id} nicht gefunden, verwende Fallback")
|
|
||||||
return self._generate_wiki_main_page_content_fallback(view_type)
|
|
||||||
|
|
||||||
_template_cache[template_page_id] = template_content
|
|
||||||
_logger.info(f"Template geladen und gecacht: {template_page_id} ({len(template_content)} Zeichen)")
|
|
||||||
else:
|
|
||||||
_logger.debug(f"Template aus Cache verwendet: {template_page_id}")
|
|
||||||
|
|
||||||
template_content = _template_cache[template_page_id]
|
|
||||||
|
|
||||||
# Werte-Dictionary vorbereiten
|
|
||||||
values = self._prepare_template_values(view_type)
|
|
||||||
|
|
||||||
# Debug-Logging für partner_ref
|
|
||||||
if 'partner_ref' in values:
|
|
||||||
_logger.info(f"partner_ref Wert für {self.name}: '{values['partner_ref']}'")
|
|
||||||
|
|
||||||
# Platzhalter ersetzen
|
|
||||||
rendered_content = template_content
|
|
||||||
for key, value in values.items():
|
|
||||||
placeholder = '{' + key + '}'
|
|
||||||
rendered_content = rendered_content.replace(placeholder, str(value or ''))
|
|
||||||
|
|
||||||
return rendered_content
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
_logger.warning(f"Fehler beim Laden des Templates {template_page_id}: {e}")
|
|
||||||
return self._generate_wiki_main_page_content_fallback(view_type)
|
|
||||||
|
|
||||||
def _prepare_template_values(self, view_type='area'):
|
|
||||||
"""
|
|
||||||
Bereitet Dictionary mit allen verfügbaren Feldwerten für Template-Rendering vor.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
view_type (str): 'area' oder 'purpose'
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
dict: Dictionary mit Feldnamen als Keys und Werten
|
|
||||||
"""
|
|
||||||
self.ensure_one()
|
|
||||||
|
|
||||||
# Odoo-Status-Seite
|
|
||||||
status_page_id = self._get_wiki_status_page_id()
|
|
||||||
|
|
||||||
# Odoo Base URL
|
|
||||||
base_url = self.env['ir.config_parameter'].sudo().get_param('web.base.url', 'http://localhost:8069')
|
|
||||||
odoo_equipment_url = f"{base_url}/web#id={self.id}&model=maintenance.equipment&view_type=form"
|
|
||||||
|
|
||||||
# Wiki Page IDs
|
|
||||||
wiki_page_id = self._get_wiki_page_id_by_area() if self.ows_area_id else ''
|
|
||||||
|
|
||||||
# Basis-Werte für maintenance.equipment
|
|
||||||
values = {
|
|
||||||
# Spezielle Werte
|
|
||||||
'wiki_status_page': status_page_id,
|
|
||||||
'wiki_status_link': f"[[{status_page_id}|📊 Odoo Status]]",
|
|
||||||
'wiki_page_id': wiki_page_id, # Page-ID der Hauptseite (nach Bereich)
|
|
||||||
'wiki_page_url': self.wiki_page_url or '', # Externe URL zur Hauptseite
|
|
||||||
'odoo_link': f"[[{odoo_equipment_url}|🔗 In Odoo öffnen]]",
|
|
||||||
'odoo_url': odoo_equipment_url,
|
|
||||||
'sync_datetime': datetime.now().strftime('%d.%m.%Y %H:%M'),
|
|
||||||
|
|
||||||
# Standard Equipment-Felder
|
|
||||||
'name': self.name or '',
|
|
||||||
'serial_no': self.serial_no or '',
|
|
||||||
'model': self.model or '',
|
|
||||||
'ows_area': self.ows_area_id.name if self.ows_area_id else '',
|
|
||||||
'category': self.category_id.name if self.category_id else '',
|
|
||||||
'status': self.status_id.name if self.status_id else '',
|
|
||||||
'status_smiley': self.status_id.smiley if self.status_id and self.status_id.smiley else '',
|
|
||||||
'location': self.location or '',
|
|
||||||
'assign_date': self.assign_date.strftime('%d.%m.%Y') if self.assign_date else '',
|
|
||||||
'cost': str(self.cost) if self.cost else '',
|
|
||||||
'warranty_date': self.warranty_date.strftime('%d.%m.%Y') if self.warranty_date else '',
|
|
||||||
'color': str(self.color) if self.color else '',
|
|
||||||
'note': self.note or '',
|
|
||||||
'partner_id': self.partner_id.name if self.partner_id else '',
|
|
||||||
'partner_ref': self.partner_ref or '',
|
|
||||||
}
|
|
||||||
|
|
||||||
# Tags hinzufügen (falls vorhanden)
|
|
||||||
if hasattr(self, 'tag_ids') and self.tag_ids:
|
|
||||||
# Komma-separierte Liste für Template
|
|
||||||
values['tags'] = ', '.join(self.tag_ids.mapped('name'))
|
|
||||||
# DokuWiki Bullet-Liste für Tabellenzellen
|
|
||||||
values['tags_list'] = ' '.join(self.tag_ids.mapped('name')) if self.tag_ids else ''
|
|
||||||
# Anzahl der Tags
|
|
||||||
values['tags_count'] = str(len(self.tag_ids))
|
|
||||||
else:
|
|
||||||
values['tags'] = ''
|
|
||||||
values['tags_list'] = ''
|
|
||||||
values['tags_count'] = '0'
|
|
||||||
|
|
||||||
# ows.machine Felder hinzufügen (falls verknüpft)
|
|
||||||
if self.ows_machine_id:
|
|
||||||
machine = self.ows_machine_id
|
|
||||||
# Nur existierende Felder hinzufügen
|
|
||||||
ows_machine_fields = {}
|
|
||||||
|
|
||||||
# Standard-Felder von ows.machine
|
|
||||||
if hasattr(machine, 'name') and machine.name:
|
|
||||||
ows_machine_fields['ows_machine_id.name'] = machine.name
|
|
||||||
if hasattr(machine, 'model') and machine.model:
|
|
||||||
ows_machine_fields['ows_machine_id.model'] = machine.model
|
|
||||||
if hasattr(machine, 'serial_no') and machine.serial_no:
|
|
||||||
ows_machine_fields['ows_machine_id.serial_no'] = machine.serial_no
|
|
||||||
if hasattr(machine, 'location') and machine.location:
|
|
||||||
ows_machine_fields['ows_machine_id.location'] = machine.location
|
|
||||||
if hasattr(machine, 'note'):
|
|
||||||
ows_machine_fields['ows_machine_id.note'] = machine.note or ''
|
|
||||||
if hasattr(machine, 'ows_category') and machine.ows_category:
|
|
||||||
ows_machine_fields['ows_machine_id.category'] = machine.ows_category
|
|
||||||
if hasattr(machine, 'ows_category_icon') and machine.ows_category_icon:
|
|
||||||
ows_machine_fields['ows_machine_id.category_icon'] = machine.ows_category_icon
|
|
||||||
|
|
||||||
values.update(ows_machine_fields)
|
|
||||||
|
|
||||||
# Bild-Upload und Referenz (falls vorhanden)
|
|
||||||
if self.image_1920:
|
|
||||||
wiki_status_id = self._get_wiki_status_id()
|
|
||||||
# Media-ID mit gleicher Namespace-Struktur wie Pages (DokuWiki Best Practice)
|
|
||||||
IrConfigParameter = self.env['ir.config_parameter'].sudo()
|
|
||||||
equipment_namespace = IrConfigParameter.get_param(
|
|
||||||
'dokuwiki.equipment_namespace',
|
|
||||||
default='werkstatt:ausstattung'
|
|
||||||
)
|
|
||||||
# Area-Name normalisieren (wie bei Pages)
|
|
||||||
area_name = self._normalize_wiki_name(self.ows_area_id.name) if self.ows_area_id else 'unbekannt'
|
|
||||||
# Bild ins Wiki hochladen (erkenne Extension aus Bytes)
|
|
||||||
try:
|
|
||||||
import base64
|
|
||||||
import imghdr
|
|
||||||
|
|
||||||
# image_1920 ist base64-kodiert, in bytes umwandeln für XML-RPC
|
|
||||||
image_bytes = base64.b64decode(self.image_1920)
|
|
||||||
|
|
||||||
# Typ/Extension erkennen (jpeg -> .jpg)
|
|
||||||
img_type = None
|
|
||||||
try:
|
|
||||||
img_type = imghdr.what(None, image_bytes)
|
|
||||||
except Exception:
|
|
||||||
img_type = None
|
|
||||||
|
|
||||||
if img_type == 'jpeg':
|
|
||||||
ext = '.jpg'
|
|
||||||
elif img_type:
|
|
||||||
ext = f'.{img_type}'
|
|
||||||
elif image_bytes.startswith(b'%PDF'):
|
|
||||||
ext = '.pdf'
|
|
||||||
else:
|
|
||||||
ext = '.bin'
|
|
||||||
|
|
||||||
media_id = f"{equipment_namespace}:{area_name}:{wiki_status_id}{ext}"
|
|
||||||
|
|
||||||
dokuwiki_client = self.env['dokuwiki.client']
|
|
||||||
dokuwiki_client.upload_media(media_id, image_bytes, overwrite=True)
|
|
||||||
|
|
||||||
# DokuWiki Image-Syntax: {{namespace:file.jpg?300}}
|
|
||||||
values['image'] = f"{{{{:{media_id}?100}}}}"
|
|
||||||
values['image_large'] = f"{{{{:{media_id}}}}}"
|
|
||||||
values['image_id'] = media_id
|
|
||||||
_logger.info(f"Bild hochgeladen: {media_id}")
|
|
||||||
except Exception as e:
|
|
||||||
_logger.error(f"Fehler beim Bild-Upload: {e}", exc_info=True)
|
|
||||||
values['image'] = ''
|
|
||||||
values['image_large'] = ''
|
|
||||||
values['image_id'] = ''
|
|
||||||
else:
|
|
||||||
values['image'] = ''
|
|
||||||
values['image_large'] = ''
|
|
||||||
values['image_id'] = ''
|
|
||||||
|
|
||||||
# View-Typ spezifische Werte
|
|
||||||
if view_type == 'area':
|
|
||||||
values['view_type'] = 'Bereich'
|
|
||||||
values['view_name'] = self.ows_area_id.name if self.ows_area_id else 'Unbekannt'
|
|
||||||
else:
|
|
||||||
values['view_type'] = 'Einsatzzweck'
|
|
||||||
values['view_name'] = 'TODO: Einsatzzweck'
|
|
||||||
|
|
||||||
return values
|
|
||||||
|
|
||||||
def _generate_wiki_main_page_content_fallback(self, view_type='area'):
|
|
||||||
"""
|
|
||||||
Fallback-Methode: Generiert hart-codierten Wiki-Markup-Inhalt,
|
|
||||||
wenn c_template.txt nicht verfügbar ist.
|
|
||||||
Dieser Content wird in odoo-status/ gespeichert und ist nur lesbar.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
view_type (str): 'area' oder 'purpose'
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
str: Wiki-Markup-Inhalt für odoo-status Seite
|
|
||||||
"""
|
|
||||||
self.ensure_one()
|
|
||||||
|
|
||||||
# Header je nach View-Typ
|
|
||||||
if view_type == 'area':
|
|
||||||
view_name = self.ows_area_id.name if self.ows_area_id else "Unbekannt"
|
|
||||||
view_label = "Bereich"
|
|
||||||
else:
|
|
||||||
# Später für Einsatzzweck
|
|
||||||
view_name = "TODO: Einsatzzweck"
|
|
||||||
view_label = "Einsatzzweck"
|
|
||||||
|
|
||||||
content = f"""====== {self.name} - Odoo Status ======
|
|
||||||
|
|
||||||
**{view_label}:** {view_name}
|
|
||||||
**Kategorie:** {self.category_id.name if self.category_id else 'Keine'}
|
|
||||||
**Seriennummer:** {self.serial_no or 'Keine'}
|
|
||||||
**Modell:** {self.model or 'Keine'}
|
|
||||||
**Status:** {self.status_id.name if self.status_id else 'Unbekannt'} {self.status_id.smiley if self.status_id and self.status_id.smiley else ''}
|
|
||||||
|
|
||||||
===== Technische Daten =====
|
|
||||||
|
|
||||||
**Hersteller:** {self.partner_id.name if self.partner_id else 'Unbekannt'}
|
|
||||||
**Standort:** {self.location or 'Nicht angegeben'}
|
|
||||||
**Kosten:** {self.cost if self.cost else 'Nicht angegeben'}
|
|
||||||
**Garantie bis:** {self.warranty_date.strftime('%d.%m.%Y') if self.warranty_date else 'Keine'}
|
|
||||||
|
|
||||||
===== Notizen =====
|
|
||||||
|
|
||||||
{self.note or 'Keine Notizen'}
|
|
||||||
|
|
||||||
----
|
|
||||||
//Diese Seite wird automatisch von Odoo generiert und ist nur lesbar.//
|
|
||||||
//Letzte Synchronisation: {datetime.now().strftime('%d.%m.%Y %H:%M')} //
|
|
||||||
"""
|
|
||||||
return content
|
|
||||||
|
|
||||||
def _generate_wiki_main_page_content(self, view_type='area'):
|
|
||||||
"""
|
|
||||||
Generiert den Wiki-Markup-Inhalt für die Odoo-Status-Seite.
|
|
||||||
Verwendet c_template.txt aus DokuWiki falls verfügbar, sonst Fallback.
|
|
||||||
Diese Seite wird in odoo-status/ gespeichert und ist nur lesbar.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
view_type (str): 'area' oder 'purpose'
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
str: Wiki-Markup-Inhalt für odoo-status Seite
|
|
||||||
"""
|
|
||||||
return self._render_template_from_wiki(view_type)
|
|
||||||
|
|
||||||
def _generate_wiki_doku_page_content(self):
|
|
||||||
"""
|
|
||||||
DEPRECATED: Diese Methode wird nicht mehr verwendet.
|
|
||||||
Odoo erstellt keine Benutzer-Dokumentationsseiten mehr.
|
|
||||||
Benutzer erstellen diese Seiten manuell durch Klick auf den Link in der Übersichtstabelle.
|
|
||||||
"""
|
|
||||||
_logger.warning("_generate_wiki_doku_page_content() wurde aufgerufen - diese Methode ist deprecated!")
|
|
||||||
return ""
|
|
||||||
|
|
||||||
def sync_to_dokuwiki(self):
|
|
||||||
"""
|
|
||||||
Synchronisiert Equipment-Daten zum DokuWiki.
|
|
||||||
|
|
||||||
WICHTIG: Erstellt NUR die Odoo-Status-Seite in odoo-status/ Namespace!
|
|
||||||
Benutzer-Dokumentationsseiten werden NICHT erstellt - diese erstellen Benutzer
|
|
||||||
manuell durch Klick auf den Link in der Übersichtstabelle.
|
|
||||||
|
|
||||||
Erstellt/aktualisiert:
|
|
||||||
- wiki_doku_id beim ersten Sync (falls noch nicht gesetzt)
|
|
||||||
- Odoo-Status-Seite (odoo-status:wiki_doku_id) - wird immer aktualisiert
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
bool: True bei Erfolg
|
|
||||||
"""
|
|
||||||
self.ensure_one()
|
|
||||||
|
|
||||||
if not self.ows_area_id:
|
|
||||||
raise UserError("Bereich muss gesetzt sein für Wiki-Synchronisation!")
|
|
||||||
|
|
||||||
if not self.name:
|
|
||||||
raise UserError("Equipment-Name muss gesetzt sein für Wiki-Synchronisation!")
|
|
||||||
|
|
||||||
# wiki_status_id beim ersten Sync setzen (aus Equipment-Namen und comp_serial_no generieren)
|
|
||||||
if not self.wiki_status_id:
|
|
||||||
generated_status_id = self._get_wiki_status_id()
|
|
||||||
if not generated_status_id:
|
|
||||||
raise UserError("wiki_status_id konnte nicht generiert werden!")
|
|
||||||
self.write({'wiki_status_id': generated_status_id})
|
|
||||||
_logger.info(f"Wiki-Status-ID für {self.name} gesetzt: {generated_status_id}")
|
|
||||||
|
|
||||||
# wiki_doku_id beim ersten Sync setzen (aus Equipment-Namen generieren, editierbar)
|
|
||||||
if not self.wiki_doku_id:
|
|
||||||
generated_doku_id = self._get_wiki_doku_id()
|
|
||||||
if not generated_doku_id:
|
|
||||||
generated_doku_id = self._normalize_wiki_name(self.name)
|
|
||||||
self.write({'wiki_doku_id': generated_doku_id})
|
|
||||||
_logger.info(f"Wiki-Doku-ID für {self.name} gesetzt: {generated_doku_id}")
|
|
||||||
|
|
||||||
dokuwiki_client = self.env['dokuwiki.client']
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Odoo-Status-Seite erstellen/aktualisieren (immer beim Sync)
|
|
||||||
status_page_id = self._get_wiki_status_page_id()
|
|
||||||
status_content = self._generate_wiki_main_page_content(view_type='area')
|
|
||||||
|
|
||||||
dokuwiki_client.create_page(
|
|
||||||
status_page_id,
|
|
||||||
status_content,
|
|
||||||
f"Synchronisiert von Odoo: {self.name}"
|
|
||||||
)
|
|
||||||
_logger.info(f"Odoo-Status-Seite für {self.name} aktualisiert: {status_page_id}")
|
|
||||||
|
|
||||||
# WICHTIG: Benutzer-Dokumentationsseiten werden NICHT erstellt!
|
|
||||||
# Diese werden nur in der Übersichtstabelle verlinkt und von Benutzern
|
|
||||||
# manuell erstellt durch Klick auf den roten Link.
|
|
||||||
|
|
||||||
# Sync-Status aktualisieren
|
|
||||||
self.write({
|
|
||||||
'wiki_synced': True,
|
|
||||||
'wiki_last_sync': fields.Datetime.now(),
|
|
||||||
})
|
|
||||||
|
|
||||||
_logger.info(f"Equipment {self.name} erfolgreich zu DokuWiki synchronisiert")
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
_logger.error(f"Fehler bei Wiki-Synchronisation für {self.name}: {e}")
|
|
||||||
self.write({'wiki_synced': False})
|
|
||||||
raise UserError(f"Wiki-Synchronisation fehlgeschlagen: {e}")
|
|
||||||
|
|
||||||
def action_sync_to_dokuwiki(self):
|
|
||||||
"""
|
|
||||||
Button-Action für manuelle Wiki-Synchronisation.
|
|
||||||
"""
|
|
||||||
for record in self:
|
|
||||||
record.sync_to_dokuwiki()
|
|
||||||
|
|
||||||
return {
|
|
||||||
'type': 'ir.actions.client',
|
|
||||||
'tag': 'display_notification',
|
|
||||||
'params': {
|
|
||||||
'title': _('Wiki-Synchronisation'),
|
|
||||||
'message': _('Equipment wurde erfolgreich zum Wiki synchronisiert.'),
|
|
||||||
'type': 'success',
|
|
||||||
'sticky': False,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
def action_open_wiki(self):
|
|
||||||
"""
|
|
||||||
Button-Action zum Öffnen der Wiki-Seite im Browser.
|
|
||||||
"""
|
|
||||||
self.ensure_one()
|
|
||||||
|
|
||||||
if not self.wiki_page_url:
|
|
||||||
raise UserError("Wiki-URL nicht verfügbar. Bitte erst zum Wiki synchronisieren.")
|
|
||||||
|
|
||||||
return {
|
|
||||||
'type': 'ir.actions.act_url',
|
|
||||||
'url': self.wiki_page_url,
|
|
||||||
'target': 'new',
|
|
||||||
}
|
|
||||||
|
|
||||||
def write(self, vals):
|
|
||||||
"""
|
|
||||||
Override write um automatische Synchronisation zu triggern.
|
|
||||||
"""
|
|
||||||
result = super().write(vals)
|
|
||||||
|
|
||||||
# Felder die eine Synchronisation auslösen
|
|
||||||
sync_fields = {'name', 'serial_no', 'ows_area_id', 'category_id', 'status_id',
|
|
||||||
'model', 'partner_id', 'partner_ref', 'location', 'note', 'image_1920', 'tag_ids'}
|
|
||||||
|
|
||||||
# Flag ob Übersichtstabelle aktualisiert werden soll
|
|
||||||
should_update_overview = False
|
|
||||||
|
|
||||||
if sync_fields & set(vals.keys()):
|
|
||||||
# Nur synchronisieren wenn auto_sync aktiviert und bereits synced
|
|
||||||
for record in self:
|
|
||||||
if record.wiki_auto_sync and record.wiki_synced:
|
|
||||||
try:
|
|
||||||
record.sync_to_dokuwiki()
|
|
||||||
should_update_overview = True
|
|
||||||
except Exception as e:
|
|
||||||
_logger.warning(f"Automatische Wiki-Sync fehlgeschlagen: {e}")
|
|
||||||
# Nicht abbrechen, nur loggen
|
|
||||||
else:
|
|
||||||
# Sync-Status zurücksetzen
|
|
||||||
record.write({'wiki_synced': False})
|
|
||||||
|
|
||||||
# Optional: Übersichtstabelle aktualisieren
|
|
||||||
if should_update_overview:
|
|
||||||
IrConfigParameter = self.env['ir.config_parameter'].sudo()
|
|
||||||
auto_update_overview = IrConfigParameter.get_param(
|
|
||||||
'dokuwiki.auto_update_overview_table',
|
|
||||||
default='False'
|
|
||||||
)
|
|
||||||
|
|
||||||
if auto_update_overview.lower() == 'true':
|
|
||||||
try:
|
|
||||||
self.env['maintenance.equipment'].action_sync_overview_table()
|
|
||||||
_logger.info("Übersichtstabelle automatisch aktualisiert")
|
|
||||||
except Exception as e:
|
|
||||||
_logger.warning(f"Automatische Übersichtstabellen-Sync fehlgeschlagen: {e}")
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
# ==========================================
|
|
||||||
# Übersichtstabelle (DataTable)
|
|
||||||
# ==========================================
|
|
||||||
|
|
||||||
def _generate_overview_table_row(self, column_data_template):
|
|
||||||
"""
|
|
||||||
Generiert eine DokuWiki DataTable Zeile für dieses Equipment mit konfigurierbaren Spalten.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
column_data_template (str): Template mit Platzhaltern, z.B. "{status_smiley}|{partner_id}|{model}"
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
str: Wiki-Markup für eine Tabellenzeile
|
|
||||||
"""
|
|
||||||
self.ensure_one()
|
|
||||||
|
|
||||||
# Template-Werte vorbereiten (nutzt bestehende Platzhalter-Engine)
|
|
||||||
values = self._prepare_template_values(view_type='area')
|
|
||||||
|
|
||||||
# Platzhalter im Template ersetzen
|
|
||||||
rendered_data = column_data_template
|
|
||||||
for key, value in values.items():
|
|
||||||
placeholder = '{' + key + '}'
|
|
||||||
rendered_data = rendered_data.replace(placeholder, str(value or '-'))
|
|
||||||
|
|
||||||
# Pipe-separierte Werte in DokuWiki-Tabellenzeile umwandeln
|
|
||||||
columns = rendered_data.split('|')
|
|
||||||
row = '| ' + ' | '.join(columns) + ' |'
|
|
||||||
|
|
||||||
return row
|
|
||||||
|
|
||||||
@api.model
|
|
||||||
def action_sync_overview_table(self):
|
|
||||||
"""
|
|
||||||
Aktualisiert die Übersichtstabelle in DokuWiki mit allen Equipment-Einträgen.
|
|
||||||
Verwendet konfigurierbare Spalten aus Systemparametern (kein Template mehr nötig).
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
dict: Ergebnis-Dictionary mit 'total', 'success', 'error_messages'
|
|
||||||
"""
|
|
||||||
IrConfigParameter = self.env['ir.config_parameter'].sudo()
|
|
||||||
dokuwiki_client = self.env['dokuwiki.client']
|
|
||||||
|
|
||||||
# Config Parameter (Defaults werden via _register_hook automatisch gesetzt)
|
|
||||||
overview_page_id = IrConfigParameter.get_param('dokuwiki.overview_page_id')
|
|
||||||
overview_title = IrConfigParameter.get_param('dokuwiki.overview_title')
|
|
||||||
overview_columns = IrConfigParameter.get_param('dokuwiki.overview_columns')
|
|
||||||
overview_column_data = IrConfigParameter.get_param('dokuwiki.overview_column_data')
|
|
||||||
|
|
||||||
# Validierung
|
|
||||||
if not all([overview_page_id, overview_title, overview_columns, overview_column_data]):
|
|
||||||
raise UserError("DokuWiki Übersichtstabellen-Parameter nicht konfiguriert! Bitte Modul neu installieren.")
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Alle Equipment mit ows_area_id laden (sortiert nach Bereich, dann Name)
|
|
||||||
equipment_records = self.search([
|
|
||||||
('ows_area_id', '!=', False)
|
|
||||||
], order='ows_area_id, name')
|
|
||||||
|
|
||||||
if not equipment_records:
|
|
||||||
raise UserError("Keine Equipment-Einträge mit Bereich gefunden!")
|
|
||||||
|
|
||||||
# Tabellenzeilen generieren
|
|
||||||
table_rows = []
|
|
||||||
for equipment in equipment_records:
|
|
||||||
try:
|
|
||||||
row = equipment._generate_overview_table_row(overview_column_data)
|
|
||||||
table_rows.append(row)
|
|
||||||
except Exception as e:
|
|
||||||
_logger.warning(f"Fehler beim Generieren der Zeile für {equipment.name}: {e}")
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Rows zusammenfügen
|
|
||||||
table_rows_markup = '\n'.join(table_rows)
|
|
||||||
|
|
||||||
# Spalten-Header generieren (aus overview_columns)
|
|
||||||
column_headers = overview_columns.split('|')
|
|
||||||
header_row = '^ ' + ' ^ '.join(column_headers) + ' ^'
|
|
||||||
|
|
||||||
# Sync-Zeitstempel
|
|
||||||
sync_datetime = datetime.now().strftime('%d.%m.%Y %H:%M:%S')
|
|
||||||
|
|
||||||
# Komplette Seite zusammenbauen (ohne Template)
|
|
||||||
overview_content = f"""====== {overview_title} ======
|
|
||||||
|
|
||||||
Diese Seite wird automatisch aktualisiert.
|
|
||||||
|
|
||||||
**Letztes Update:** {sync_datetime}
|
|
||||||
|
|
||||||
<datatable Equipment-Übersicht>
|
|
||||||
{header_row}
|
|
||||||
{table_rows_markup}
|
|
||||||
</datatable>
|
|
||||||
|
|
||||||
----
|
|
||||||
|
|
||||||
**Hinweis:** Diese Tabelle ist interaktiv:
|
|
||||||
* Klicke auf die Spaltenköpfe zum Sortieren
|
|
||||||
* Nutze das Suchfeld zum Filtern
|
|
||||||
* Klicke auf die Links in der Spalte "Name" für Details
|
|
||||||
|
|
||||||
**Status:** 🙂 Maschine/Gerät in gutem Zustand, 🙁 - Maschine/Gerät ist Defekt, ❌ Maschine/Gerät wurde ausgemustert
|
|
||||||
|
|
||||||
**Sicherheit:** 🟢 **keine** Einweisungspflicht, 🟡 keine **explizite** Einweisungspflicht, 🔴 explizite Einweisungspflicht
|
|
||||||
|
|
||||||
(Für Einweisungstermine bitte den Thekendienst befragen oder trage dich [[https://hobbyhimmel.de/so-gehts/einweisungen/|hier]] im Kalender für einen Termin ein)
|
|
||||||
|
|
||||||
|
|
||||||
"""
|
|
||||||
|
|
||||||
# Seite in DokuWiki speichern
|
|
||||||
dokuwiki_client.create_page(
|
|
||||||
overview_page_id,
|
|
||||||
overview_content,
|
|
||||||
f"Automatisches Update der Übersichtstabelle ({len(table_rows)} Equipment)"
|
|
||||||
)
|
|
||||||
|
|
||||||
_logger.info(f"✓ Übersichtstabelle aktualisiert: {len(table_rows)} Equipment in {overview_page_id}")
|
|
||||||
|
|
||||||
return {
|
|
||||||
'total': len(equipment_records),
|
|
||||||
'success': len(table_rows),
|
|
||||||
'error_messages': '',
|
|
||||||
'overview_url': dokuwiki_client.get_wiki_url(overview_page_id),
|
|
||||||
}
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
error_msg = f"Fehler beim Aktualisieren der Übersichtstabelle: {str(e)}"
|
|
||||||
_logger.error(error_msg)
|
|
||||||
return {
|
|
||||||
'total': 0,
|
|
||||||
'success': 0,
|
|
||||||
'error_messages': error_msg,
|
|
||||||
'overview_url': '',
|
|
||||||
}
|
|
||||||
|
|
||||||
@api.model
|
|
||||||
def action_clear_template_cache(self):
|
|
||||||
"""
|
|
||||||
Leert den Template-Cache und erzwingt ein Neuladen aller Templates aus DokuWiki.
|
|
||||||
Nützlich nach Template-Änderungen im Wiki.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
dict: Notification mit Anzahl geleerte Cache-Einträge
|
|
||||||
"""
|
|
||||||
global _template_cache
|
|
||||||
|
|
||||||
count = len(_template_cache)
|
|
||||||
cache_keys = list(_template_cache.keys())
|
|
||||||
_template_cache.clear()
|
|
||||||
|
|
||||||
_logger.info(f"Template-Cache geleert: {count} Einträge ({', '.join(cache_keys)})")
|
|
||||||
|
|
||||||
return {
|
|
||||||
'type': 'ir.actions.client',
|
|
||||||
'tag': 'display_notification',
|
|
||||||
'params': {
|
|
||||||
'title': _('Template-Cache geleert'),
|
|
||||||
'message': _(f'{count} Template(s) aus dem Cache entfernt. Nächster Sync lädt Templates neu.'),
|
|
||||||
'type': 'success',
|
|
||||||
'sticky': False,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@api.model
|
|
||||||
def action_reset_wiki_sync_status(self):
|
|
||||||
"""
|
|
||||||
Setzt den Wiki-Synchronisationsstatus für alle Equipment zurück.
|
|
||||||
Nützlich nach Namespace-Änderungen oder wenn alle Equipment neu synchronisiert werden sollen.
|
|
||||||
|
|
||||||
Setzt zurück:
|
|
||||||
- wiki_synced auf False
|
|
||||||
- wiki_last_sync auf NULL
|
|
||||||
|
|
||||||
Optional (auskommentiert):
|
|
||||||
- wiki_doku_id auf NULL (nur wenn IDs komplett neu generiert werden sollen)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
dict: Notification mit Anzahl zurückgesetzter Equipment
|
|
||||||
"""
|
|
||||||
equipment_records = self.search([
|
|
||||||
('wiki_synced', '=', True)
|
|
||||||
])
|
|
||||||
|
|
||||||
count = len(equipment_records)
|
|
||||||
|
|
||||||
if count == 0:
|
|
||||||
return {
|
|
||||||
'type': 'ir.actions.client',
|
|
||||||
'tag': 'display_notification',
|
|
||||||
'params': {
|
|
||||||
'title': _('Wiki-Sync Reset'),
|
|
||||||
'message': _('Keine synchronisierten Equipment gefunden.'),
|
|
||||||
'type': 'warning',
|
|
||||||
'sticky': False,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# Reset durchführen
|
|
||||||
equipment_records.write({
|
|
||||||
'wiki_synced': False,
|
|
||||||
'wiki_last_sync': False,
|
|
||||||
'wiki_status_id': False, # Status-ID neu generieren
|
|
||||||
# wiki_doku_id wird NICHT zurückgesetzt - ist manuell editierbar!
|
|
||||||
})
|
|
||||||
|
|
||||||
_logger.info(f"Wiki-Sync-Status für {count} Equipment zurückgesetzt")
|
|
||||||
|
|
||||||
return {
|
|
||||||
'type': 'ir.actions.client',
|
|
||||||
'tag': 'display_notification',
|
|
||||||
'params': {
|
|
||||||
'title': _('Wiki-Sync Reset'),
|
|
||||||
'message': _(f'{count} Equipment wurden zurückgesetzt und müssen neu synchronisiert werden.'),
|
|
||||||
'type': 'success',
|
|
||||||
'sticky': False,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,27 +0,0 @@
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
from odoo import fields, models
|
|
||||||
|
|
||||||
|
|
||||||
class MaintenanceEquipmentStatus(models.Model):
|
|
||||||
"""
|
|
||||||
Extension of the maintenance.equipment.status model to add DokuWiki smiley support.
|
|
||||||
|
|
||||||
This class extends the standard Odoo maintenance equipment status model to include
|
|
||||||
a smiley field that can be used for DokuWiki integration. The smiley field allows
|
|
||||||
administrators to associate DokuWiki emoticon syntax with each maintenance equipment
|
|
||||||
status, enabling visual representation of equipment status in DokuWiki documentation.
|
|
||||||
|
|
||||||
The smiley field accepts DokuWiki emoticon syntax (e.g., ':-)', ':-(', ':-D', etc.)
|
|
||||||
which can be used to display appropriate emoticons when exporting or displaying
|
|
||||||
equipment status information in DokuWiki format.
|
|
||||||
|
|
||||||
This extension is particularly useful for workshops or maintenance departments that
|
|
||||||
use DokuWiki as their documentation platform and want to have a visual representation
|
|
||||||
of equipment status alongside textual information.
|
|
||||||
"""
|
|
||||||
_inherit = 'maintenance.equipment.status'
|
|
||||||
|
|
||||||
smiley = fields.Char(
|
|
||||||
string='Smiley',
|
|
||||||
help='DokuWiki Smiley für diesen Status (z.B. :-) oder :-()'
|
|
||||||
)
|
|
||||||
|
|
@ -1,54 +0,0 @@
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
import logging
|
|
||||||
from odoo import api, models
|
|
||||||
|
|
||||||
_logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class ResConfigSettings(models.TransientModel):
|
|
||||||
_inherit = 'res.config.settings'
|
|
||||||
|
|
||||||
@api.model
|
|
||||||
def _register_hook(self):
|
|
||||||
"""
|
|
||||||
Hook der bei jedem Modul-Load (auch bei Update) ausgeführt wird.
|
|
||||||
Initialisiert fehlende DokuWiki-Parameter.
|
|
||||||
"""
|
|
||||||
super()._register_hook()
|
|
||||||
self._init_dokuwiki_parameters()
|
|
||||||
|
|
||||||
@api.model
|
|
||||||
def _init_dokuwiki_parameters(self):
|
|
||||||
"""
|
|
||||||
Initialisiert DokuWiki-Parameter beim Modul-Install/-Update falls sie nicht existieren.
|
|
||||||
Wird automatisch beim Laden des Moduls aufgerufen.
|
|
||||||
"""
|
|
||||||
IrConfigParameter = self.env['ir.config_parameter'].sudo()
|
|
||||||
|
|
||||||
# Default-Parameter
|
|
||||||
defaults = {
|
|
||||||
'dokuwiki.url': 'https://wiki.hobbyhimmel.de',
|
|
||||||
'dokuwiki.user': 'odoo.odoo',
|
|
||||||
'dokuwiki.password': 'CHANGE_ME',
|
|
||||||
'dokuwiki.equipment_namespace': 'werkstatt:ausstattung', # NEU: Basis-Namespace für Equipment
|
|
||||||
'dokuwiki.central_documentation_namespace': 'werkstatt:ausstattung:odoo-status', # NEU: Namespace für Odoo-generierte Status-Seiten
|
|
||||||
'dokuwiki.overview_page_id': 'werkstatt:ausstattung:uebersicht', # Seiten-ID für Übersichtstabelle
|
|
||||||
'dokuwiki.overview_title': 'Geräte & Maschinen - Übersicht',
|
|
||||||
'dokuwiki.overview_columns': 'Name|Zustand|Sicherheit|Bereich|Standort|Bild',
|
|
||||||
'dokuwiki.overview_column_data': '[[{wiki_page_id}|{name}]]|{status_smiley}|{ows_machine_id.category_icon}|{ows_area}|{location}|{image}',
|
|
||||||
'dokuwiki.auto_update_overview_table': 'False', # Automatische Übersichtstabellen-Aktualisierung bei Equipment-Änderungen
|
|
||||||
}
|
|
||||||
|
|
||||||
# Nur fehlende Parameter anlegen
|
|
||||||
count_created = 0
|
|
||||||
count_existing = 0
|
|
||||||
for key, value in defaults.items():
|
|
||||||
if not IrConfigParameter.get_param(key):
|
|
||||||
IrConfigParameter.set_param(key, value)
|
|
||||||
count_created += 1
|
|
||||||
_logger.info(f"DokuWiki parameter '{key}' initialized with default value")
|
|
||||||
else:
|
|
||||||
count_existing += 1
|
|
||||||
|
|
||||||
if count_created > 0:
|
|
||||||
_logger.info(f"DokuWiki parameters: {count_created} created, {count_existing} already existing")
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
|
||||||
access_equipment_wiki_sync_wizard,equipment.wiki.sync.wizard,model_equipment_wiki_sync_wizard,base.group_user,1,1,1,1
|
|
||||||
|
|
|
@ -1,24 +0,0 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<odoo>
|
|
||||||
<record id="maintenance_equipment_status_form_view" model="ir.ui.view">
|
|
||||||
<field name="name">maintenance.equipment.status.form.dokuwiki</field>
|
|
||||||
<field name="model">maintenance.equipment.status</field>
|
|
||||||
<field name="inherit_id" ref="maintenance_equipment_status.maintenance_equipment_status_view_form"/>
|
|
||||||
<field name="arch" type="xml">
|
|
||||||
<field name="name" position="after">
|
|
||||||
<field name="smiley" placeholder="z.B. :-) oder :-("/>
|
|
||||||
</field>
|
|
||||||
</field>
|
|
||||||
</record>
|
|
||||||
|
|
||||||
<record id="maintenance_equipment_status_tree_view" model="ir.ui.view">
|
|
||||||
<field name="name">maintenance.equipment.status.tree.dokuwiki</field>
|
|
||||||
<field name="model">maintenance.equipment.status</field>
|
|
||||||
<field name="inherit_id" ref="maintenance_equipment_status.maintenance_equipment_status_view_tree"/>
|
|
||||||
<field name="arch" type="xml">
|
|
||||||
<field name="name" position="after">
|
|
||||||
<field name="smiley" optional="show"/>
|
|
||||||
</field>
|
|
||||||
</field>
|
|
||||||
</record>
|
|
||||||
</odoo>
|
|
||||||
|
|
@ -1,120 +0,0 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<odoo>
|
|
||||||
|
|
||||||
<!-- Equipment Tree View Extension - Wiki Link -->
|
|
||||||
<record id="maintenance_equipment_view_tree_dokuwiki" model="ir.ui.view">
|
|
||||||
<field name="name">maintenance.equipment.tree.dokuwiki</field>
|
|
||||||
<field name="model">maintenance.equipment</field>
|
|
||||||
<field name="inherit_id" ref="maintenance.hr_equipment_view_tree"/>
|
|
||||||
<field name="arch" type="xml">
|
|
||||||
<!-- Wiki Doku-Link nach dem Namen -->
|
|
||||||
<field name="name" position="after">
|
|
||||||
<field name="wiki_doku_link" widget="html" string="Wiki Doku" optional="show"/>
|
|
||||||
<field name="wiki_status_link" widget="html" string="Wiki Status" optional="hide"/>
|
|
||||||
</field>
|
|
||||||
</field>
|
|
||||||
</record>
|
|
||||||
|
|
||||||
<!-- Equipment Form View Extension -->
|
|
||||||
<record id="maintenance_equipment_view_form_dokuwiki" model="ir.ui.view">
|
|
||||||
<field name="name">maintenance.equipment.form.dokuwiki</field>
|
|
||||||
<field name="model">maintenance.equipment</field>
|
|
||||||
<field name="inherit_id" ref="maintenance.hr_equipment_view_form"/>
|
|
||||||
<field name="arch" type="xml">
|
|
||||||
|
|
||||||
<!-- Bild-Feld oben rechts neben den Smart Buttons -->
|
|
||||||
<xpath expr="//sheet/div[@name='button_box']" position="before">
|
|
||||||
<field name="image_1920" widget="image" class="oe_avatar" options="{'preview_image': 'image_1920'}"/>
|
|
||||||
</xpath>
|
|
||||||
|
|
||||||
<!-- Smart Button im Header -->
|
|
||||||
<xpath expr="//div[@name='button_box']" position="inside">
|
|
||||||
<button name="action_open_wiki"
|
|
||||||
type="object"
|
|
||||||
class="oe_stat_button"
|
|
||||||
icon="fa-book"
|
|
||||||
invisible="not wiki_page_url">
|
|
||||||
<div class="o_field_widget o_stat_info">
|
|
||||||
<span class="o_stat_text">Wiki</span>
|
|
||||||
<span class="o_stat_text text-success" invisible="not wiki_synced">Synchronisiert</span>
|
|
||||||
<span class="o_stat_text text-warning" invisible="wiki_synced">Nicht synchronisiert</span>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
</xpath>
|
|
||||||
|
|
||||||
<!-- Wiki-Felder in neuem Tab -->
|
|
||||||
<xpath expr="//notebook" position="inside">
|
|
||||||
<page string="Wiki" name="wiki">
|
|
||||||
<group>
|
|
||||||
<group string="Wiki Dokumentations-Seite (Benutzer-Doku)" colspan="2">
|
|
||||||
<field name="wiki_doku_id" string="Seitenname"/>
|
|
||||||
<field name="wiki_page_url" widget="url" string="🔗 Zur Doku-Seite" class="oe_link"/>
|
|
||||||
</group>
|
|
||||||
</group>
|
|
||||||
<group>
|
|
||||||
<group string="Wiki Status-Seite (Odoo-Daten, automatisch)">
|
|
||||||
<field name="wiki_status_id" string="Seitenname" readonly="1"/>
|
|
||||||
</group>
|
|
||||||
<group string="Synchronisations-Status">
|
|
||||||
<field name="wiki_synced" readonly="1"/>
|
|
||||||
<field name="wiki_last_sync" readonly="1"/>
|
|
||||||
<field name="wiki_auto_sync"/>
|
|
||||||
</group>
|
|
||||||
</group>
|
|
||||||
|
|
||||||
<group>
|
|
||||||
<group string="Aktionen">
|
|
||||||
<button name="action_sync_to_dokuwiki"
|
|
||||||
string="Zum Wiki synchronisieren"
|
|
||||||
type="object"
|
|
||||||
class="btn-primary"
|
|
||||||
icon="fa-refresh"/>
|
|
||||||
<button name="action_open_wiki"
|
|
||||||
string="Wiki im Browser öffnen"
|
|
||||||
type="object"
|
|
||||||
class="btn-secondary"
|
|
||||||
icon="fa-external-link"
|
|
||||||
invisible="not wiki_page_url"/>
|
|
||||||
</group>
|
|
||||||
</group>
|
|
||||||
|
|
||||||
<group string="Hilfe">
|
|
||||||
<div class="alert alert-info" role="alert">
|
|
||||||
<h4 class="alert-heading">📚 DokuWiki-Integration</h4>
|
|
||||||
<p>
|
|
||||||
Dieses Equipment ist mit DokuWiki verbunden. Die Synchronisation erstellt <strong>zwei unterschiedliche Seiten</strong>:
|
|
||||||
</p>
|
|
||||||
<hr/>
|
|
||||||
<h5>📊 Wiki Status Seite (automatisch, nur lesbar)</h5>
|
|
||||||
<ul>
|
|
||||||
<li><strong>Zweck:</strong> Enthält aktuelle Odoo-Daten (Status, Standort, Hersteller etc.)</li>
|
|
||||||
<li><strong>Format:</strong> werkstatt:ausstattung:odoo-status:[name-seriennummer]</li>
|
|
||||||
<li><strong>Eindeutigkeit:</strong> Jedes Equipment hat eine eigene Status-Seite</li>
|
|
||||||
<li><strong>Update:</strong> Wird bei jeder Synchronisation automatisch aktualisiert</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<h5>📖 Wiki Dokumentations Seite (manuell, editierbar)</h5>
|
|
||||||
<ul>
|
|
||||||
<li><strong>Zweck:</strong> Benutzer-Dokumentation, Anleitungen, Tipps</li>
|
|
||||||
<li><strong>Format:</strong> werkstatt:ausstattung:[bereich]:[doku-name]</li>
|
|
||||||
<li><strong>Besonderheit:</strong> Kann von mehreren Equipment geteilt werden (z.B. "akkuschrauber")</li>
|
|
||||||
<li><strong>Erstellung:</strong> Wird NICHT von Odoo erstellt - Benutzer erstellen diese manuell</li>
|
|
||||||
</ul>
|
|
||||||
<hr/>
|
|
||||||
<p class="mb-0">
|
|
||||||
<strong>💡 Tipp:</strong> Die Status-Seite kann mit dem include-Plugin in die Dokumentations-Seite eingebunden werden:<br/>
|
|
||||||
<code>{{page>werkstatt:ausstattung:odoo-status:[name-seriennummer]}}</code>
|
|
||||||
</p>
|
|
||||||
<hr/>
|
|
||||||
<p class="mb-0">
|
|
||||||
<strong>⚙️ Automatische Synchronisation:</strong> Wenn aktiviert, werden Änderungen automatisch zur Status-Seite übertragen.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</group>
|
|
||||||
</page>
|
|
||||||
</xpath>
|
|
||||||
|
|
||||||
</field>
|
|
||||||
</record>
|
|
||||||
|
|
||||||
</odoo>
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
from . import equipment_wiki_sync_wizard
|
|
||||||
|
|
@ -1,157 +0,0 @@
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
import logging
|
|
||||||
from odoo import api, fields, models, _
|
|
||||||
from odoo.exceptions import UserError
|
|
||||||
|
|
||||||
_logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class EquipmentWikiSyncWizard(models.TransientModel):
|
|
||||||
_name = 'equipment.wiki.sync.wizard'
|
|
||||||
_description = 'Massen-Synchronisation von Equipment zu DokuWiki'
|
|
||||||
|
|
||||||
sync_mode = fields.Selection([
|
|
||||||
('all', 'Alle Equipment'),
|
|
||||||
('area', 'Nach Bereich filtern'),
|
|
||||||
('unsynced', 'Nur nicht synchronisierte'),
|
|
||||||
('overview_table', 'Übersichtstabelle aktualisieren'),
|
|
||||||
('reset_sync', 'Sync-Status zurücksetzen'),
|
|
||||||
], string='Synchronisations-Modus', default='all', required=True)
|
|
||||||
|
|
||||||
ows_area_id = fields.Many2one(
|
|
||||||
'ows.machine.area',
|
|
||||||
string='Bereich',
|
|
||||||
help='Nur Equipment aus diesem Bereich synchronisieren'
|
|
||||||
)
|
|
||||||
|
|
||||||
# Statistik-Felder (nach Sync)
|
|
||||||
total_count = fields.Integer(string='Gefunden', readonly=True)
|
|
||||||
success_count = fields.Integer(string='Erfolgreich', readonly=True)
|
|
||||||
error_count = fields.Integer(string='Fehler', readonly=True)
|
|
||||||
error_messages = fields.Text(string='Fehlermeldungen', readonly=True)
|
|
||||||
overview_url = fields.Char(string='Übersichtsseite URL', readonly=True)
|
|
||||||
|
|
||||||
@api.onchange('sync_mode')
|
|
||||||
def _onchange_sync_mode(self):
|
|
||||||
"""Bereich-Feld nur bei 'area' Modus anzeigen"""
|
|
||||||
if self.sync_mode != 'area':
|
|
||||||
self.ows_area_id = False
|
|
||||||
|
|
||||||
def action_sync_equipment(self):
|
|
||||||
"""
|
|
||||||
Hauptaktion: Equipment synchronisieren basierend auf gewähltem Modus
|
|
||||||
"""
|
|
||||||
self.ensure_one()
|
|
||||||
|
|
||||||
# Übersichtstabelle-Modus: Schneller Update ohne Equipment-Iteration
|
|
||||||
if self.sync_mode == 'overview_table':
|
|
||||||
return self._sync_overview_table()
|
|
||||||
|
|
||||||
# Reset-Modus: Sync-Status zurücksetzen
|
|
||||||
if self.sync_mode == 'reset_sync':
|
|
||||||
return self.env['maintenance.equipment'].action_reset_wiki_sync_status()
|
|
||||||
|
|
||||||
# Equipment-Liste basierend auf Modus ermitteln
|
|
||||||
domain = []
|
|
||||||
|
|
||||||
if self.sync_mode == 'area':
|
|
||||||
if not self.ows_area_id:
|
|
||||||
raise UserError("Bitte einen Bereich auswählen!")
|
|
||||||
domain.append(('ows_area_id', '=', self.ows_area_id.id))
|
|
||||||
elif self.sync_mode == 'unsynced':
|
|
||||||
domain.append(('wiki_synced', '=', False))
|
|
||||||
|
|
||||||
# Equipment finden
|
|
||||||
equipment_records = self.env['maintenance.equipment'].search(domain)
|
|
||||||
|
|
||||||
if not equipment_records:
|
|
||||||
raise UserError("Keine Equipment-Einträge gefunden, die synchronisiert werden können!")
|
|
||||||
|
|
||||||
total = len(equipment_records)
|
|
||||||
success = 0
|
|
||||||
errors = 0
|
|
||||||
error_list = []
|
|
||||||
|
|
||||||
_logger.info(f"Starte Wiki-Synchronisation für {total} Equipment-Einträge")
|
|
||||||
|
|
||||||
# Jeden Equipment-Eintrag synchronisieren
|
|
||||||
for equipment in equipment_records:
|
|
||||||
try:
|
|
||||||
# Prüfen ob Bereich gesetzt ist
|
|
||||||
if not equipment.ows_area_id:
|
|
||||||
error_msg = f"{equipment.name}: Kein Bereich gesetzt"
|
|
||||||
error_list.append(error_msg)
|
|
||||||
errors += 1
|
|
||||||
_logger.warning(error_msg)
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Standard-Synchronisation (erstellt nur Odoo-Status-Seiten)
|
|
||||||
equipment.sync_to_dokuwiki()
|
|
||||||
success += 1
|
|
||||||
_logger.info(f"✓ {equipment.name} synchronisiert")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
error_msg = f"{equipment.name}: {str(e)}"
|
|
||||||
error_list.append(error_msg)
|
|
||||||
errors += 1
|
|
||||||
_logger.error(f"✗ {error_msg}")
|
|
||||||
|
|
||||||
# Statistik speichern
|
|
||||||
self.write({
|
|
||||||
'total_count': total,
|
|
||||||
'success_count': success,
|
|
||||||
'error_count': errors,
|
|
||||||
'error_messages': '\n'.join(error_list) if error_list else 'Keine Fehler',
|
|
||||||
})
|
|
||||||
|
|
||||||
# Ergebnis-View anzeigen
|
|
||||||
return {
|
|
||||||
'type': 'ir.actions.act_window',
|
|
||||||
'name': 'Wiki-Synchronisation Ergebnis',
|
|
||||||
'res_model': 'equipment.wiki.sync.wizard',
|
|
||||||
'view_mode': 'form',
|
|
||||||
'res_id': self.id,
|
|
||||||
'target': 'new',
|
|
||||||
'context': {'show_result': True},
|
|
||||||
}
|
|
||||||
|
|
||||||
def _sync_overview_table(self):
|
|
||||||
"""
|
|
||||||
Aktualisiert die Übersichtstabelle in DokuWiki.
|
|
||||||
Ruft die Methode im maintenance.equipment Model auf.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
dict: Action zum Anzeigen des Ergebnisses
|
|
||||||
"""
|
|
||||||
self.ensure_one()
|
|
||||||
|
|
||||||
_logger.info("Starte Übersichtstabellen-Synchronisation")
|
|
||||||
|
|
||||||
# Equipment Model aufrufen
|
|
||||||
result = self.env['maintenance.equipment'].action_sync_overview_table()
|
|
||||||
|
|
||||||
# Statistik speichern
|
|
||||||
self.write({
|
|
||||||
'total_count': result.get('total', 0),
|
|
||||||
'success_count': result.get('success', 0),
|
|
||||||
'error_count': result.get('total', 0) - result.get('success', 0),
|
|
||||||
'error_messages': result.get('error_messages', 'Keine Fehler'),
|
|
||||||
'overview_url': result.get('overview_url', ''),
|
|
||||||
})
|
|
||||||
|
|
||||||
# Ergebnis-View anzeigen
|
|
||||||
return {
|
|
||||||
'type': 'ir.actions.act_window',
|
|
||||||
'name': 'Übersichtstabelle aktualisiert',
|
|
||||||
'res_model': 'equipment.wiki.sync.wizard',
|
|
||||||
'view_mode': 'form',
|
|
||||||
'res_id': self.id,
|
|
||||||
'target': 'new',
|
|
||||||
'context': {'show_result': True},
|
|
||||||
}
|
|
||||||
|
|
||||||
def action_clear_template_cache(self):
|
|
||||||
"""
|
|
||||||
Leert den Template-Cache (delegiert an maintenance.equipment).
|
|
||||||
"""
|
|
||||||
return self.env['maintenance.equipment'].action_clear_template_cache()
|
|
||||||
|
|
@ -1,64 +0,0 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<odoo>
|
|
||||||
<!-- Wizard Form View -->
|
|
||||||
<record id="equipment_wiki_sync_wizard_form" model="ir.ui.view">
|
|
||||||
<field name="name">equipment.wiki.sync.wizard.form</field>
|
|
||||||
<field name="model">equipment.wiki.sync.wizard</field>
|
|
||||||
<field name="arch" type="xml">
|
|
||||||
<form string="Wiki-Synchronisation">
|
|
||||||
<group invisible="context.get('show_result', False)">
|
|
||||||
<group>
|
|
||||||
<field name="sync_mode" widget="radio"/>
|
|
||||||
<field name="ows_area_id" invisible="sync_mode != 'area'" required="sync_mode == 'area'"/>
|
|
||||||
</group>
|
|
||||||
</group>
|
|
||||||
|
|
||||||
<!-- Ergebnis-Anzeige -->
|
|
||||||
<group invisible="not context.get('show_result', False)">
|
|
||||||
<group>
|
|
||||||
<field name="total_count"/>
|
|
||||||
<field name="success_count"/>
|
|
||||||
<field name="error_count"/>
|
|
||||||
</group>
|
|
||||||
<group>
|
|
||||||
<field name="overview_url" widget="url" string="Übersichtsseite" invisible="not overview_url"/>
|
|
||||||
<field name="error_messages" widget="text" colspan="2" invisible="error_count == 0"/>
|
|
||||||
</group>
|
|
||||||
</group>
|
|
||||||
|
|
||||||
<footer>
|
|
||||||
<button string="Synchronisieren"
|
|
||||||
name="action_sync_equipment"
|
|
||||||
type="object"
|
|
||||||
class="btn-primary"
|
|
||||||
invisible="context.get('show_result', False)"/>
|
|
||||||
<button string="Template-Cache leeren"
|
|
||||||
name="action_clear_template_cache"
|
|
||||||
type="object"
|
|
||||||
class="btn-warning"
|
|
||||||
invisible="context.get('show_result', False)"
|
|
||||||
help="Leert den Template-Cache und erzwingt Neuladen beim nächsten Sync"/>
|
|
||||||
<button string="Schließen"
|
|
||||||
special="cancel"
|
|
||||||
class="btn-secondary"/>
|
|
||||||
</footer>
|
|
||||||
</form>
|
|
||||||
</field>
|
|
||||||
</record>
|
|
||||||
|
|
||||||
<!-- Menu-Action -->
|
|
||||||
<record id="action_equipment_wiki_sync_wizard" model="ir.actions.act_window">
|
|
||||||
<field name="name">Wiki-Synchronisation</field>
|
|
||||||
<field name="res_model">equipment.wiki.sync.wizard</field>
|
|
||||||
<field name="view_mode">form</field>
|
|
||||||
<field name="target">new</field>
|
|
||||||
<field name="context">{}</field>
|
|
||||||
</record>
|
|
||||||
|
|
||||||
<!-- Menu-Eintrag unter Wartung → Konfiguration -->
|
|
||||||
<menuitem id="menu_equipment_wiki_sync"
|
|
||||||
name="Wiki-Synchronisation"
|
|
||||||
parent="maintenance.menu_maintenance_configuration"
|
|
||||||
action="action_equipment_wiki_sync_wizard"
|
|
||||||
sequence="99"/>
|
|
||||||
</odoo>
|
|
||||||
|
|
@ -1,139 +0,0 @@
|
||||||
# Open Workshop - Employee Image Generator
|
|
||||||
|
|
||||||
Erstelle professionelle Namensschilder (Thekenschilder) für Mitarbeiter direkt in Odoo.
|
|
||||||
|
|
||||||
## Beschreibung
|
|
||||||
|
|
||||||
Dieses Modul ersetzt die externe "thekenheld" WebApp und integriert die Funktionalität direkt in Odoo's HR-Modul. Mitarbeiter-Namensschilder können nun bequem aus dem Employee-Formular heraus erstellt werden.
|
|
||||||
|
|
||||||
## Features
|
|
||||||
|
|
||||||
- ✅ **Foto-Upload & Zuschnitt**: Cropper.js Integration für professionelle Bildbearbeitung
|
|
||||||
- ✅ **Festes Seitenverhältnis**: 369x492 Pixel (optimiert für Thekenschilder)
|
|
||||||
- ✅ **Text-Overlay**: Name und Schwerpunkte werden automatisch hinzugefügt
|
|
||||||
- ✅ **Direkte Integration**: Button im Employee-Formular
|
|
||||||
- ✅ **Sofortige Verfügbarkeit**: Generiertes Bild wird direkt als Employee Avatar gespeichert
|
|
||||||
- ✅ **POS Integration**: Bilder werden automatisch im Customer Display angezeigt (via open_workshop_pos_customer_display)
|
|
||||||
|
|
||||||
## Installation
|
|
||||||
|
|
||||||
1. Modul in Odoo installieren
|
|
||||||
2. Berechtigungen: HR / Mitarbeiter Module erforderlich
|
|
||||||
|
|
||||||
## Verwendung
|
|
||||||
|
|
||||||
### Namensschild erstellen:
|
|
||||||
|
|
||||||
1. Öffne einen Mitarbeiter-Datensatz (HR → Mitarbeiter)
|
|
||||||
2. Klicke auf den Button **"Namensschild erstellen"** im Header
|
|
||||||
3. **Schritt 1**: Wähle ein Foto aus (mindestens 369x492 Pixel empfohlen)
|
|
||||||
4. **Schritt 2**: Schneide das Foto zu - bewege und zoome für den perfekten Ausschnitt
|
|
||||||
5. **Schritt 3**: Überprüfe Name und Schwerpunkte, passe bei Bedarf an
|
|
||||||
6. **Vorschau**: Sieh dir das finale Namensschild an
|
|
||||||
7. Klicke **"Speichern"** - das Bild wird als Employee Avatar gesetzt
|
|
||||||
|
|
||||||
### Schwerpunkte definieren:
|
|
||||||
|
|
||||||
Im Employee-Formular gibt es ein neues Feld **"Schwerpunkte"** (job_focus):
|
|
||||||
- Trage hier die Tätigkeitsschwerpunkte ein
|
|
||||||
- Z.B. "Fahrrad-Reparatur, E-Bikes, Werkstatt"
|
|
||||||
- Max. 2 Zeilen für optimale Darstellung
|
|
||||||
- Falls leer, wird alternativ das job_title Feld verwendet
|
|
||||||
|
|
||||||
## Technische Details
|
|
||||||
|
|
||||||
### Architektur
|
|
||||||
|
|
||||||
**Frontend-only Lösung** mit:
|
|
||||||
- **OWL Component**: Client Action für Dialog
|
|
||||||
- **Cropper.js**: Professionelles Bild-Cropping
|
|
||||||
- **Canvas API**: Text-Overlay Generation
|
|
||||||
- **Base64 Upload**: Direktes Speichern als image_1920
|
|
||||||
|
|
||||||
### Dateien
|
|
||||||
|
|
||||||
```
|
|
||||||
open_workshop_employee_imagegenerator/
|
|
||||||
├── __init__.py
|
|
||||||
├── __manifest__.py
|
|
||||||
├── models/
|
|
||||||
│ ├── __init__.py
|
|
||||||
│ └── hr_employee.py # Erweiterung von hr.employee
|
|
||||||
├── views/
|
|
||||||
│ └── hr_employee_views.xml # Button im Form + job_focus Feld
|
|
||||||
├── static/
|
|
||||||
│ ├── src/
|
|
||||||
│ │ ├── js/
|
|
||||||
│ │ │ └── employee_image_widget.js # OWL Component
|
|
||||||
│ │ ├── xml/
|
|
||||||
│ │ │ └── employee_image_widget.xml # Template
|
|
||||||
│ │ └── css/
|
|
||||||
│ │ └── employee_image_widget.css # Styles
|
|
||||||
│ └── lib/
|
|
||||||
│ └── cropperjs/
|
|
||||||
│ ├── cropper.js # Cropper.js Library
|
|
||||||
│ └── cropper.css # Cropper.js Styles
|
|
||||||
```
|
|
||||||
|
|
||||||
### Workflow
|
|
||||||
|
|
||||||
1. User klickt "Namensschild erstellen"
|
|
||||||
2. Python Action öffnet Client Action mit Context (employee_id, name, job_focus)
|
|
||||||
3. JavaScript Dialog (3 Schritte):
|
|
||||||
- Upload → FileReader lädt Bild als Data URL
|
|
||||||
- Crop → Cropper.js erzeugt zugeschnittenes Bild (369x492)
|
|
||||||
- Text → Canvas API zeichnet Text-Overlay
|
|
||||||
4. Base64 Image wird zu hr.employee.image_1920 gespeichert
|
|
||||||
5. Page Reload zeigt neues Bild
|
|
||||||
|
|
||||||
### Canvas Text-Overlay
|
|
||||||
|
|
||||||
Das generierte Namensschild hat:
|
|
||||||
- **Foto**: 369x492 Pixel (Vollbild)
|
|
||||||
- **Text-Overlay**: 120px Höhe am unteren Rand
|
|
||||||
- **Hintergrund**: Halbtransparent weiß (92% Opazität)
|
|
||||||
- **Name**: Fett, 24px, zentriert
|
|
||||||
- **Schwerpunkte**: Normal, 16px, zentriert, max. 2 Zeilen
|
|
||||||
|
|
||||||
## Integration
|
|
||||||
|
|
||||||
### Mit POS Customer Display
|
|
||||||
|
|
||||||
Dieses Modul arbeitet nahtlos mit `open_workshop_pos_customer_display` zusammen:
|
|
||||||
- Generierte Bilder werden automatisch im Customer Display angezeigt
|
|
||||||
- Verwendung der hr.employee.public URL
|
|
||||||
- Keine zusätzliche Konfiguration nötig
|
|
||||||
|
|
||||||
### Ersetzt thekenheld WebApp
|
|
||||||
|
|
||||||
Die externe Docker-Container WebApp ist nicht mehr nötig:
|
|
||||||
- ✅ Keine separate Instanz erforderlich
|
|
||||||
- ✅ Alle Daten in Odoo
|
|
||||||
- ✅ Berechtigungsverwaltung über Odoo
|
|
||||||
- ✅ Besserer Workflow
|
|
||||||
|
|
||||||
## Migration von thekenheld
|
|
||||||
|
|
||||||
Falls bereits Bilder in der alten WebApp erstellt wurden:
|
|
||||||
1. Bilder aus `/var/www/html/uploads/` kopieren
|
|
||||||
2. Manuell in Odoo Employee-Datensätze hochladen
|
|
||||||
3. Oder: Neues Namensschild mit diesem Tool erstellen
|
|
||||||
|
|
||||||
## Abhängigkeiten
|
|
||||||
|
|
||||||
- `hr` - Human Resources Module
|
|
||||||
- `web` - Odoo Web Framework
|
|
||||||
- Cropper.js v1.6.1 (inkludiert)
|
|
||||||
|
|
||||||
## Version
|
|
||||||
|
|
||||||
18.0.1.0.0
|
|
||||||
|
|
||||||
## Lizenz
|
|
||||||
|
|
||||||
LGPL-3
|
|
||||||
|
|
||||||
## Credits
|
|
||||||
|
|
||||||
Entwickelt für Open Workshop / Hobbyhimmel
|
|
||||||
Basierend auf der thekenheld WebApp
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
from . import models
|
|
||||||
|
|
@ -1,53 +0,0 @@
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
{
|
|
||||||
'name': 'Open Workshop - Employee Image Generator',
|
|
||||||
'version': '18.0.1.0.0',
|
|
||||||
'category': 'Human Resources',
|
|
||||||
'summary': 'Upload and crop employee photos (369x492) with job focus',
|
|
||||||
'description': """
|
|
||||||
Employee Image Generator
|
|
||||||
=========================
|
|
||||||
|
|
||||||
Upload and crop employee photos directly in Odoo.
|
|
||||||
|
|
||||||
Features:
|
|
||||||
---------
|
|
||||||
* Upload and crop employee photos to fixed 369x492 pixels
|
|
||||||
* Add job focus areas (Schwerpunkte)
|
|
||||||
* Integrated Cropper.js for professional image editing
|
|
||||||
* Fixed crop frame - only image moves and zooms
|
|
||||||
* Direct integration in Employee form
|
|
||||||
* Saved photo can be used in POS Customer Display
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
------
|
|
||||||
1. Go to Employee form
|
|
||||||
2. Click "Namensschild erstellen" button
|
|
||||||
3. Upload photo, zoom and position it in fixed crop frame
|
|
||||||
4. Enter job focus areas (optional)
|
|
||||||
5. Save - photo and job_focus are saved to employee
|
|
||||||
""",
|
|
||||||
'author': 'Open Workshop',
|
|
||||||
'website': 'https://www.open-workshop.de',
|
|
||||||
'license': 'LGPL-3',
|
|
||||||
'depends': ['hr', 'web'],
|
|
||||||
'data': [
|
|
||||||
'views/hr_employee_views.xml',
|
|
||||||
],
|
|
||||||
'assets': {
|
|
||||||
'web.assets_backend': [
|
|
||||||
# Cropper.js CSS
|
|
||||||
'open_workshop_employee_imagegenerator/static/lib/cropperjs/cropper.css',
|
|
||||||
'open_workshop_employee_imagegenerator/static/src/css/employee_image_widget.css',
|
|
||||||
'open_workshop_employee_imagegenerator/static/src/css/badge_template.css',
|
|
||||||
# Cropper.js Library
|
|
||||||
'open_workshop_employee_imagegenerator/static/lib/cropperjs/cropper.js',
|
|
||||||
# Our custom widgets
|
|
||||||
'open_workshop_employee_imagegenerator/static/src/js/employee_image_widget.js',
|
|
||||||
'open_workshop_employee_imagegenerator/static/src/xml/employee_image_widget.xml',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
'installable': True,
|
|
||||||
'application': False,
|
|
||||||
'auto_install': False,
|
|
||||||
}
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
from . import hr_employee
|
|
||||||
|
|
@ -1,32 +0,0 @@
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
from odoo import models, fields, api
|
|
||||||
|
|
||||||
|
|
||||||
class HrEmployee(models.Model):
|
|
||||||
_inherit = 'hr.employee'
|
|
||||||
|
|
||||||
# Zusätzliches Feld für Schwerpunkte (falls nicht job_title verwendet wird)
|
|
||||||
job_focus = fields.Char(
|
|
||||||
string='Schwerpunkte',
|
|
||||||
help='Tätigkeitsschwerpunkte für Namensschild'
|
|
||||||
)
|
|
||||||
|
|
||||||
def action_open_image_generator(self):
|
|
||||||
"""Öffnet den Image Generator Dialog"""
|
|
||||||
self.ensure_one()
|
|
||||||
return {
|
|
||||||
'type': 'ir.actions.client',
|
|
||||||
'tag': 'employee_image_generator',
|
|
||||||
'name': 'Namensschild erstellen',
|
|
||||||
'target': 'new',
|
|
||||||
'params': {
|
|
||||||
'employee_id': self.id,
|
|
||||||
'employee_name': self.name,
|
|
||||||
'job_focus': self.job_focus or self.job_title or '',
|
|
||||||
},
|
|
||||||
'context': {
|
|
||||||
'employee_id': self.id,
|
|
||||||
'employee_name': self.name,
|
|
||||||
'job_focus': self.job_focus or self.job_title or '',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
/*!
|
|
||||||
* Cropper.js v1.6.1
|
|
||||||
* https://fengyuanchen.github.io/cropperjs
|
|
||||||
*
|
|
||||||
* Copyright 2015-present Chen Fengyuan
|
|
||||||
* Released under the MIT license
|
|
||||||
*
|
|
||||||
* Date: 2023-09-17T03:44:17.565Z
|
|
||||||
*/.cropper-container{direction:ltr;font-size:0;line-height:0;position:relative;-ms-touch-action:none;touch-action:none;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.cropper-container img{backface-visibility:hidden;display:block;height:100%;image-orientation:0deg;max-height:none!important;max-width:none!important;min-height:0!important;min-width:0!important;width:100%}.cropper-canvas,.cropper-crop-box,.cropper-drag-box,.cropper-modal,.cropper-wrap-box{bottom:0;left:0;position:absolute;right:0;top:0}.cropper-canvas,.cropper-wrap-box{overflow:hidden}.cropper-drag-box{background-color:#fff;opacity:0}.cropper-modal{background-color:#000;opacity:.5}.cropper-view-box{display:block;height:100%;outline:1px solid #39f;outline-color:rgba(51,153,255,.75);overflow:hidden;width:100%}.cropper-dashed{border:0 dashed #eee;display:block;opacity:.5;position:absolute}.cropper-dashed.dashed-h{border-bottom-width:1px;border-top-width:1px;height:33.33333%;left:0;top:33.33333%;width:100%}.cropper-dashed.dashed-v{border-left-width:1px;border-right-width:1px;height:100%;left:33.33333%;top:0;width:33.33333%}.cropper-center{display:block;height:0;left:50%;opacity:.75;position:absolute;top:50%;width:0}.cropper-center:after,.cropper-center:before{background-color:#eee;content:" ";display:block;position:absolute}.cropper-center:before{height:1px;left:-3px;top:0;width:7px}.cropper-center:after{height:7px;left:0;top:-3px;width:1px}.cropper-face,.cropper-line,.cropper-point{display:block;height:100%;opacity:.1;position:absolute;width:100%}.cropper-face{background-color:#fff;left:0;top:0}.cropper-line{background-color:#39f}.cropper-line.line-e{cursor:ew-resize;right:-3px;top:0;width:5px}.cropper-line.line-n{cursor:ns-resize;height:5px;left:0;top:-3px}.cropper-line.line-w{cursor:ew-resize;left:-3px;top:0;width:5px}.cropper-line.line-s{bottom:-3px;cursor:ns-resize;height:5px;left:0}.cropper-point{background-color:#39f;height:5px;opacity:.75;width:5px}.cropper-point.point-e{cursor:ew-resize;margin-top:-3px;right:-3px;top:50%}.cropper-point.point-n{cursor:ns-resize;left:50%;margin-left:-3px;top:-3px}.cropper-point.point-w{cursor:ew-resize;left:-3px;margin-top:-3px;top:50%}.cropper-point.point-s{bottom:-3px;cursor:s-resize;left:50%;margin-left:-3px}.cropper-point.point-ne{cursor:nesw-resize;right:-3px;top:-3px}.cropper-point.point-nw{cursor:nwse-resize;left:-3px;top:-3px}.cropper-point.point-sw{bottom:-3px;cursor:nesw-resize;left:-3px}.cropper-point.point-se{bottom:-3px;cursor:nwse-resize;height:20px;opacity:1;right:-3px;width:20px}@media (min-width:768px){.cropper-point.point-se{height:15px;width:15px}}@media (min-width:992px){.cropper-point.point-se{height:10px;width:10px}}@media (min-width:1200px){.cropper-point.point-se{height:5px;opacity:.75;width:5px}}.cropper-point.point-se:before{background-color:#39f;bottom:-50%;content:" ";display:block;height:200%;opacity:0;position:absolute;right:-50%;width:200%}.cropper-invisible{opacity:0}.cropper-bg{background-image:url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQAQMAAAAlPW0iAAAAA3NCSVQICAjb4U/gAAAABlBMVEXMzMz////TjRV2AAAACXBIWXMAAArrAAAK6wGCiw1aAAAAHHRFWHRTb2Z0d2FyZQBBZG9iZSBGaXJld29ya3MgQ1M26LyyjAAAABFJREFUCJlj+M/AgBVhF/0PAH6/D/HkDxOGAAAAAElFTkSuQmCC")}.cropper-hide{display:block;height:0;position:absolute;width:0}.cropper-hidden{display:none!important}.cropper-move{cursor:move}.cropper-crop{cursor:crosshair}.cropper-disabled .cropper-drag-box,.cropper-disabled .cropper-face,.cropper-disabled .cropper-line,.cropper-disabled .cropper-point{cursor:not-allowed}
|
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -1,96 +0,0 @@
|
||||||
/* Badge Template CSS - 1:1 von thekenheld print.html */
|
|
||||||
|
|
||||||
.badge-template {
|
|
||||||
position: relative;
|
|
||||||
background: white;
|
|
||||||
width: 21cm;
|
|
||||||
height: 29.7cm;
|
|
||||||
margin: 0 auto;
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge-template header,
|
|
||||||
.badge-template footer {
|
|
||||||
position: absolute;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
background-color: rgb(255, 255, 255);
|
|
||||||
padding-right: 1.5cm;
|
|
||||||
padding-left: 1.5cm;
|
|
||||||
height: 30mm;
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge-template header {
|
|
||||||
top: 0;
|
|
||||||
padding-top: 5mm;
|
|
||||||
padding-bottom: 3mm;
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge-template footer {
|
|
||||||
bottom: 0;
|
|
||||||
color: rgb(241, 245, 247);
|
|
||||||
padding-top: 3mm;
|
|
||||||
padding-bottom: 5mm;
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge-template .content {
|
|
||||||
padding-top: 40mm;
|
|
||||||
padding-bottom: 40mm;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge-template .logo {
|
|
||||||
width: 43mm;
|
|
||||||
height: 20mm;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge-template .headline1 {
|
|
||||||
height: 13mm;
|
|
||||||
font-size: 10mm;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge-template .headline2 {
|
|
||||||
height: 9mm;
|
|
||||||
font-size: 6mm;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge-template .headline3 {
|
|
||||||
height: 10mm;
|
|
||||||
font-size: 7mm;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge-template .centered-image {
|
|
||||||
height: 130mm;
|
|
||||||
width: 100mm;
|
|
||||||
display: block;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 5mm 0 0 0;
|
|
||||||
object-fit: cover;
|
|
||||||
object-position: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge-template .your-name {
|
|
||||||
height: 22mm;
|
|
||||||
font-size: 19mm;
|
|
||||||
padding: 0;
|
|
||||||
font-weight: bold;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge-template .topics {
|
|
||||||
height: 10mm;
|
|
||||||
font-size: 8mm;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge-template .warnung {
|
|
||||||
font-size: 9mm;
|
|
||||||
color: red;
|
|
||||||
flex: 1;
|
|
||||||
text-align: center;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
@ -1,94 +0,0 @@
|
||||||
/* Employee Image Generator Styles */
|
|
||||||
|
|
||||||
.o_employee_image_generator {
|
|
||||||
max-width: 900px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.o_employee_image_generator .img-container {
|
|
||||||
background: #f8f9fa;
|
|
||||||
border: 1px solid #dee2e6;
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.o_employee_image_generator .img-container img {
|
|
||||||
max-width: 100%;
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.o_employee_image_generator canvas {
|
|
||||||
border: 1px solid #dee2e6;
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Cropper.js Container Anpassungen */
|
|
||||||
.o_employee_image_generator .cropper-container {
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Progress Bar */
|
|
||||||
.o_employee_image_generator .progress {
|
|
||||||
height: 3px;
|
|
||||||
background-color: #e9ecef;
|
|
||||||
}
|
|
||||||
|
|
||||||
.o_employee_image_generator .progress-bar {
|
|
||||||
background-color: #0d6efd;
|
|
||||||
transition: width 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Step Indicators */
|
|
||||||
.o_employee_image_generator .step-indicator {
|
|
||||||
font-size: 0.875rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Upload Area */
|
|
||||||
.o_employee_image_generator .upload-area {
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.o_employee_image_generator .upload-area:hover {
|
|
||||||
background-color: #f8f9fa;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Canvas Preview */
|
|
||||||
.o_employee_image_generator canvas {
|
|
||||||
display: block;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Modal Anpassungen */
|
|
||||||
.o_employee_image_generator .modal-dialog {
|
|
||||||
margin: 1.75rem auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.o_employee_image_generator .modal-content {
|
|
||||||
border: none;
|
|
||||||
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
|
|
||||||
}
|
|
||||||
|
|
||||||
.o_employee_image_generator .modal-header {
|
|
||||||
border-bottom: 1px solid #dee2e6;
|
|
||||||
padding: 1rem 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.o_employee_image_generator .modal-body {
|
|
||||||
padding: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.o_employee_image_generator .modal-footer {
|
|
||||||
border-top: 1px solid #dee2e6;
|
|
||||||
padding: 1rem 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Responsive */
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.o_employee_image_generator .modal-dialog {
|
|
||||||
max-width: 95%;
|
|
||||||
margin: 0.5rem auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.o_employee_image_generator .modal-body {
|
|
||||||
padding: 1rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,228 +0,0 @@
|
||||||
/** @odoo-module */
|
|
||||||
|
|
||||||
import { registry } from "@web/core/registry";
|
|
||||||
import { Component, useState, useRef, onMounted } from "@odoo/owl";
|
|
||||||
import { useService } from "@web/core/utils/hooks";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Employee Image Generator Client Action
|
|
||||||
*
|
|
||||||
* Ermöglicht das Erstellen von professionellen Namensschildern für Mitarbeiter.
|
|
||||||
* Verwendet Cropper.js für Bildbearbeitung und Canvas API für Text-Overlay.
|
|
||||||
*/
|
|
||||||
class EmployeeImageGenerator extends Component {
|
|
||||||
static template = "open_workshop_employee_imagegenerator.ImageGeneratorDialog";
|
|
||||||
static props = {
|
|
||||||
"*": true,
|
|
||||||
};
|
|
||||||
|
|
||||||
setup() {
|
|
||||||
this.orm = useService("orm");
|
|
||||||
this.action = useService("action");
|
|
||||||
this.notification = useService("notification");
|
|
||||||
|
|
||||||
// Hole Context aus props oder action
|
|
||||||
const context = this.props.action?.context || this.props.context || {};
|
|
||||||
|
|
||||||
this.state = useState({
|
|
||||||
step: 1, // 1: Upload, 2: Crop, 3: Text, 4: Preview
|
|
||||||
employeeId: context.employee_id,
|
|
||||||
employeeName: context.employee_name || '',
|
|
||||||
jobFocus: context.job_focus || '',
|
|
||||||
companyLogo: null,
|
|
||||||
imageDataUrl: null,
|
|
||||||
croppedImageDataUrl: null,
|
|
||||||
finalImageDataUrl: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
this.fileInput = useRef("fileInput");
|
|
||||||
this.imagePreview = useRef("imagePreview");
|
|
||||||
this.cropperImage = useRef("cropperImage");
|
|
||||||
this.badgeTemplate = useRef("badgeTemplate");
|
|
||||||
|
|
||||||
this.cropper = null;
|
|
||||||
// Zielgröße für das zugeschnittene Foto
|
|
||||||
this.cropWidth = 369;
|
|
||||||
this.cropHeight = 492;
|
|
||||||
// Zielgröße für das finale Badge (A4-Format)
|
|
||||||
this.badgeWidth = 794;
|
|
||||||
this.badgeHeight = 1123;
|
|
||||||
// Umrechnungsfaktor für mm (falls benötigt)
|
|
||||||
this.pxPerMm = this.badgeWidth / 210;
|
|
||||||
|
|
||||||
// Lade Company-Logo beim Start
|
|
||||||
this.loadCompanyLogo();
|
|
||||||
}
|
|
||||||
|
|
||||||
async loadCompanyLogo() {
|
|
||||||
try {
|
|
||||||
// Hole das Logo der aktuellen Company
|
|
||||||
const company = await this.orm.call('res.company', 'search_read', [[]], {
|
|
||||||
fields: ['logo'],
|
|
||||||
limit: 1,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (company && company.length > 0 && company[0].logo) {
|
|
||||||
// Company-Logo ist base64-codiert, füge data:image/png prefix hinzu
|
|
||||||
this.state.companyLogo = `data:image/png;base64,${company[0].logo}`;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('Konnte Company-Logo nicht laden:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Schritt 1: Datei-Upload
|
|
||||||
onFileSelect(ev) {
|
|
||||||
const files = ev.target.files;
|
|
||||||
if (files && files.length > 0) {
|
|
||||||
const file = files[0];
|
|
||||||
|
|
||||||
// Prüfe ob es ein Bild ist
|
|
||||||
if (!file.type.startsWith('image/')) {
|
|
||||||
this.notification.add('Bitte wähle eine Bilddatei aus', {
|
|
||||||
type: 'warning'
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Lade Bild als Data URL
|
|
||||||
const reader = new FileReader();
|
|
||||||
reader.onload = (e) => {
|
|
||||||
this.state.imageDataUrl = e.target.result;
|
|
||||||
this.state.step = 2;
|
|
||||||
|
|
||||||
// Initialisiere Cropper im nächsten Tick
|
|
||||||
setTimeout(() => this.initCropper(), 100);
|
|
||||||
};
|
|
||||||
reader.readAsDataURL(file);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Schritt 2: Initialisiere Cropper.js
|
|
||||||
initCropper() {
|
|
||||||
if (this.cropper) {
|
|
||||||
this.cropper.destroy();
|
|
||||||
}
|
|
||||||
|
|
||||||
const imageElement = this.cropperImage.el;
|
|
||||||
if (!imageElement || !window.Cropper) {
|
|
||||||
console.error('Cropper.js not loaded or image element not found');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const aspectRatio = this.cropWidth / this.cropHeight;
|
|
||||||
|
|
||||||
this.cropper = new window.Cropper(imageElement, {
|
|
||||||
aspectRatio: aspectRatio,
|
|
||||||
viewMode: 1, // Erlaubt unbegrenztes Zoomen
|
|
||||||
dragMode: 'move', // Bild verschieben
|
|
||||||
autoCropArea: 0.7, // Crop-Rahmen ist 70% des Containers - besser sichtbar
|
|
||||||
restore: false,
|
|
||||||
guides: true,
|
|
||||||
center: true,
|
|
||||||
highlight: false,
|
|
||||||
cropBoxMovable: false, // Crop-Rahmen NICHT verschiebbar
|
|
||||||
cropBoxResizable: false, // Crop-Rahmen NICHT in Größe veränderbar
|
|
||||||
toggleDragModeOnDblclick: false,
|
|
||||||
zoomable: true,
|
|
||||||
zoomOnWheel: true, // Zoom mit Mausrad
|
|
||||||
zoomOnTouch: true, // Zoom mit Touch-Gesten
|
|
||||||
minContainerWidth: 200,
|
|
||||||
minContainerHeight: 200,
|
|
||||||
ready: function() {
|
|
||||||
// Setze initiales Zoom-Level auf "fit in container"
|
|
||||||
this.cropper.zoomTo(0);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bild zuschneiden und weiter zu Schritt 3
|
|
||||||
cropImage() {
|
|
||||||
if (!this.cropper) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const canvas = this.cropper.getCroppedCanvas({
|
|
||||||
width: this.cropWidth,
|
|
||||||
height: this.cropHeight,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (canvas) {
|
|
||||||
this.state.croppedImageDataUrl = canvas.toDataURL('image/jpeg', 0.9);
|
|
||||||
this.state.step = 3;
|
|
||||||
|
|
||||||
// Destroy cropper um Ressourcen freizugeben
|
|
||||||
this.cropper.destroy();
|
|
||||||
this.cropper = null;
|
|
||||||
|
|
||||||
// Gehe zu Schritt 3 (das Template ist bereits sichtbar via t-if)
|
|
||||||
// Die Vorschau wird automatisch angezeigt durch das HTML-Template
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Schritt 3: Text-Änderung → Vorschau ist Live durch HTML-Template
|
|
||||||
onTextChange() {
|
|
||||||
// Das Template aktualisiert sich automatisch durch t-model
|
|
||||||
// Keine zusätzliche Logik nötig - OWL reactive state macht das automatisch!
|
|
||||||
}
|
|
||||||
|
|
||||||
// Schritt 4: Finale Speicherung - Speichere nur das zugeschnittene Foto + job_focus
|
|
||||||
async saveFinalImage() {
|
|
||||||
try {
|
|
||||||
// Verwende das bereits zugeschnittene Foto (369×492px)
|
|
||||||
if (!this.state.croppedImageDataUrl) {
|
|
||||||
this.notification.add('Kein zugeschnittenes Foto vorhanden', { type: 'danger' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extrahiere Base64-Daten (entferne data:image/jpeg;base64, prefix)
|
|
||||||
const imageData = this.state.croppedImageDataUrl.split(',')[1];
|
|
||||||
|
|
||||||
// Speichere Foto + job_focus in Odoo
|
|
||||||
await this.orm.write('hr.employee', [this.state.employeeId], {
|
|
||||||
image_1920: imageData,
|
|
||||||
job_focus: this.state.jobFocus || '',
|
|
||||||
});
|
|
||||||
|
|
||||||
this.notification.add('Foto erfolgreich gespeichert!', {
|
|
||||||
type: 'success',
|
|
||||||
});
|
|
||||||
|
|
||||||
// Schließe Dialog und kehre zurück zum Mitarbeiter-Formular
|
|
||||||
await this.action.doAction({
|
|
||||||
type: 'ir.actions.act_window',
|
|
||||||
res_model: 'hr.employee',
|
|
||||||
res_id: this.state.employeeId,
|
|
||||||
views: [[false, 'form']],
|
|
||||||
target: 'current',
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Fehler beim Speichern:', error);
|
|
||||||
this.notification.add(`Fehler beim Speichern: ${error.message}`, {
|
|
||||||
type: 'danger',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Abbrechen
|
|
||||||
cancel() {
|
|
||||||
if (this.props.close) {
|
|
||||||
this.props.close();
|
|
||||||
} else {
|
|
||||||
this.action.doAction({
|
|
||||||
type: 'ir.actions.act_window_close'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Zurück zum vorherigen Schritt
|
|
||||||
previousStep() {
|
|
||||||
if (this.state.step > 1) {
|
|
||||||
this.state.step--;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Registriere als Client Action
|
|
||||||
registry.category("actions").add("employee_image_generator", EmployeeImageGenerator);
|
|
||||||
|
|
@ -1,178 +0,0 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<templates xml:space="preserve">
|
|
||||||
<t t-name="open_workshop_employee_imagegenerator.ImageGeneratorDialog">
|
|
||||||
<div class="o_employee_image_generator modal-dialog modal-lg">
|
|
||||||
<div class="modal-content">
|
|
||||||
<!-- Header -->
|
|
||||||
<div class="modal-header">
|
|
||||||
<h4 class="modal-title">
|
|
||||||
<i class="fa fa-image me-2"/>
|
|
||||||
Namensschild erstellen
|
|
||||||
</h4>
|
|
||||||
<button type="button" class="btn-close" t-on-click="cancel"/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Body -->
|
|
||||||
<div class="modal-body">
|
|
||||||
<!-- Schritt-Anzeige -->
|
|
||||||
<div class="mb-3">
|
|
||||||
<div class="progress" style="height: 3px;">
|
|
||||||
<div class="progress-bar"
|
|
||||||
role="progressbar"
|
|
||||||
t-att-style="'width: ' + (state.step * 33) + '%'"
|
|
||||||
aria-valuenow="state.step"
|
|
||||||
aria-valuemin="1"
|
|
||||||
aria-valuemax="3"/>
|
|
||||||
</div>
|
|
||||||
<div class="d-flex justify-content-between mt-2 text-muted small">
|
|
||||||
<span t-att-class="{'text-primary fw-bold': state.step === 1}">1. Bild hochladen</span>
|
|
||||||
<span t-att-class="{'text-primary fw-bold': state.step === 2}">2. Bild zuschneiden</span>
|
|
||||||
<span t-att-class="{'text-primary fw-bold': state.step === 3}">3. Text & Vorschau</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Schritt 1: Upload -->
|
|
||||||
<div t-if="state.step === 1" class="text-center p-5">
|
|
||||||
<i class="fa fa-cloud-upload fa-5x text-muted mb-3"/>
|
|
||||||
<h5>Wähle ein Foto aus</h5>
|
|
||||||
<p class="text-muted">Das Bild sollte mindestens 369x492 Pixel groß sein</p>
|
|
||||||
<input type="file"
|
|
||||||
t-ref="fileInput"
|
|
||||||
class="d-none"
|
|
||||||
accept="image/*"
|
|
||||||
t-on-change="onFileSelect"/>
|
|
||||||
<button class="btn btn-primary btn-lg"
|
|
||||||
t-on-click="() => this.fileInput.el.click()">
|
|
||||||
<i class="fa fa-folder-open me-2"/>
|
|
||||||
Datei auswählen
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Schritt 2: Crop -->
|
|
||||||
<div t-if="state.step === 2">
|
|
||||||
<div class="alert alert-info mb-3">
|
|
||||||
<i class="fa fa-info-circle me-2"/>
|
|
||||||
Bewege und zoome das Bild, um den gewünschten Ausschnitt zu wählen
|
|
||||||
</div>
|
|
||||||
<div class="img-container" style="max-height: 500px; overflow: hidden; display: flex; align-items: center; justify-content: center;">
|
|
||||||
<img t-ref="cropperImage"
|
|
||||||
t-att-src="state.imageDataUrl"
|
|
||||||
style="max-width: 100%; max-height: 100%; display: block;"/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Schritt 3: Text und Vorschau -->
|
|
||||||
<div t-if="state.step === 3">
|
|
||||||
<div class="row">
|
|
||||||
<!-- Linke Spalte: Eingabefelder -->
|
|
||||||
<div class="col-md-6">
|
|
||||||
<h5 class="mb-3">Textinformationen</h5>
|
|
||||||
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="employeeName" class="form-label">Name</label>
|
|
||||||
<input type="text"
|
|
||||||
class="form-control"
|
|
||||||
id="employeeName"
|
|
||||||
t-model="state.employeeName"
|
|
||||||
t-on-input="onTextChange"
|
|
||||||
placeholder="Max Mustermann"/>
|
|
||||||
<small class="form-text text-muted">
|
|
||||||
Dieser Name wird auf dem Namensschild angezeigt
|
|
||||||
</small>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="jobFocus" class="form-label">Schwerpunkte</label>
|
|
||||||
<textarea class="form-control"
|
|
||||||
id="jobFocus"
|
|
||||||
rows="3"
|
|
||||||
t-model="state.jobFocus"
|
|
||||||
t-on-input="onTextChange"
|
|
||||||
placeholder="z.B. Fahrrad-Reparatur, E-Bikes, Werkstatt"/>
|
|
||||||
<small class="form-text text-muted">
|
|
||||||
Tätigkeitsbereiche oder Spezialisierung (optional)
|
|
||||||
</small>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="alert alert-light">
|
|
||||||
<strong>Tipp:</strong> Kurze Texte sehen besser aus.
|
|
||||||
Maximal 2 Zeilen für Schwerpunkte.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Rechte Spalte: Vorschau -->
|
|
||||||
<div class="col-md-6">
|
|
||||||
<h5 class="mb-3">Vorschau</h5>
|
|
||||||
<div class="border rounded p-3 d-flex justify-content-center align-items-center"
|
|
||||||
style="background: rgb(204, 204, 204); min-height: 400px;">
|
|
||||||
<!-- Wrapper nimmt nur den skalierten Platz ein (21cm × 29.7cm × 0.35 = 7.35cm × 10.4cm) -->
|
|
||||||
<div style="width: 7.35cm; height: 10.4cm; position: relative;">
|
|
||||||
<div style="width: 21cm; height: 29.7cm; transform: scale(0.35); transform-origin: top left; position: absolute; top: 0; left: 0;">
|
|
||||||
<div t-ref="badgeTemplate" class="badge-template">
|
|
||||||
<header>
|
|
||||||
<div class="logo">
|
|
||||||
<img t-if="state.companyLogo" t-att-src="state.companyLogo" alt="Logo" class="logo"/>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
<div class="content">
|
|
||||||
<div class="headline1">An der Theke für Dich</div>
|
|
||||||
<div class="headline2">Werkstattaufsicht und Werkzeugausgabe</div>
|
|
||||||
<img t-if="state.croppedImageDataUrl"
|
|
||||||
t-att-src="state.croppedImageDataUrl"
|
|
||||||
alt="avatar"
|
|
||||||
class="centered-image"/>
|
|
||||||
<div class="your-name" t-esc="state.employeeName || 'Dein Name'"/>
|
|
||||||
<hr/>
|
|
||||||
<div class="headline3">Themenschwerpunkte:</div>
|
|
||||||
<div class="topics" t-esc="state.jobFocus || 'Deine Schwerpunkte'"/>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
<footer><div class="warnung">! Zugang zur Theke nur für Berechtigte !</div></footer>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<small class="text-muted d-block mt-2 text-center">
|
|
||||||
A4-Format (21×29.7cm) - 35% Vorschau
|
|
||||||
</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Footer -->
|
|
||||||
<div class="modal-footer">
|
|
||||||
<button type="button"
|
|
||||||
class="btn btn-secondary"
|
|
||||||
t-on-click="cancel">
|
|
||||||
Abbrechen
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button t-if="state.step > 1 and state.step < 3"
|
|
||||||
type="button"
|
|
||||||
class="btn btn-light"
|
|
||||||
t-on-click="previousStep">
|
|
||||||
<i class="fa fa-arrow-left me-2"/>
|
|
||||||
Zurück
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button t-if="state.step === 2"
|
|
||||||
type="button"
|
|
||||||
class="btn btn-primary"
|
|
||||||
t-on-click="cropImage">
|
|
||||||
Weiter
|
|
||||||
<i class="fa fa-arrow-right ms-2"/>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button t-if="state.step === 3"
|
|
||||||
type="button"
|
|
||||||
class="btn btn-success"
|
|
||||||
t-on-click="saveFinalImage">
|
|
||||||
<i class="fa fa-check me-2"/>
|
|
||||||
Speichern
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</t>
|
|
||||||
</templates>
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<odoo>
|
|
||||||
<!-- Erweitere das Employee Form View -->
|
|
||||||
<record id="view_employee_form_image_generator" model="ir.ui.view">
|
|
||||||
<field name="name">hr.employee.form.image.generator</field>
|
|
||||||
<field name="model">hr.employee</field>
|
|
||||||
<field name="inherit_id" ref="hr.view_employee_form"/>
|
|
||||||
<field name="arch" type="xml">
|
|
||||||
<!-- Füge Button nach dem image_1920 Feld ein -->
|
|
||||||
<xpath expr="//field[@name='image_1920']" position="after">
|
|
||||||
<button name="action_open_image_generator"
|
|
||||||
type="object"
|
|
||||||
string="✏️ Bild bearbeiten"
|
|
||||||
class="btn-link"
|
|
||||||
style="margin-left: 8px;"
|
|
||||||
invisible="not id"/>
|
|
||||||
</xpath>
|
|
||||||
|
|
||||||
<!-- Füge job_focus Feld nach job_title ein -->
|
|
||||||
<xpath expr="//field[@name='job_title']" position="after">
|
|
||||||
<field name="job_focus" placeholder="z.B. Fahrrad-Reparatur, E-Bikes, Werkstatt"/>
|
|
||||||
</xpath>
|
|
||||||
</field>
|
|
||||||
</record>
|
|
||||||
</odoo>
|
|
||||||
|
|
@ -1,3 +1,2 @@
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
from . import models
|
from . import models
|
||||||
from .hooks import post_init_hook
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
{
|
{
|
||||||
'name': 'Open Workshop POS',
|
'name': 'Open Workshop POS',
|
||||||
'version': '18.0.1.0.1',
|
'version': '18.0.1.0.0',
|
||||||
'category': 'Point of Sale',
|
'category': 'Point of Sale',
|
||||||
'summary': 'POS Integration für Open Workshop - Machine Access & Customer UI',
|
'summary': 'POS Integration für Open Workshop - Machine Access & Customer UI',
|
||||||
'description': """
|
'description': """
|
||||||
|
|
@ -27,7 +27,6 @@ Autor: HobbyHimmel
|
||||||
'depends': [
|
'depends': [
|
||||||
'open_workshop_base',
|
'open_workshop_base',
|
||||||
'point_of_sale',
|
'point_of_sale',
|
||||||
'l10n_de', # Kontenplan für Deutschland (erstellt Bankjournal für POS Demo-Daten)
|
|
||||||
],
|
],
|
||||||
'data': [
|
'data': [
|
||||||
# Views
|
# Views
|
||||||
|
|
@ -39,24 +38,12 @@ Autor: HobbyHimmel
|
||||||
'open_workshop_pos/static/src/js/ows_pos_customer_sidebar.js',
|
'open_workshop_pos/static/src/js/ows_pos_customer_sidebar.js',
|
||||||
'open_workshop_pos/static/src/js/ows_machine_access_list.js',
|
'open_workshop_pos/static/src/js/ows_machine_access_list.js',
|
||||||
'open_workshop_pos/static/src/js/ows_product_screen_template_patch.js',
|
'open_workshop_pos/static/src/js/ows_product_screen_template_patch.js',
|
||||||
'open_workshop_pos/static/src/js/ows_product_screen_default_category.js',
|
|
||||||
'open_workshop_pos/static/src/js/ows_order_patch.js',
|
|
||||||
'open_workshop_pos/static/src/js/ows_receipt_header_patch.js',
|
|
||||||
'open_workshop_pos/static/src/js/ows_control_buttons_patch.js',
|
|
||||||
'open_workshop_pos/static/src/js/ows_product_card_patch.js',
|
|
||||||
'open_workshop_pos/static/src/js/ows_partner_search_mode_patch.js',
|
|
||||||
|
|
||||||
# XML Templates
|
# XML Templates
|
||||||
'open_workshop_pos/static/src/xml/ows_pos_sidebar.xml',
|
'open_workshop_pos/static/src/xml/ows_pos_sidebar.xml',
|
||||||
'open_workshop_pos/static/src/xml/ows_pos_customer_sidebar.xml',
|
'open_workshop_pos/static/src/xml/ows_pos_customer_sidebar.xml',
|
||||||
'open_workshop_pos/static/src/xml/ows_machine_access_list.xml',
|
'open_workshop_pos/static/src/xml/ows_machine_access_list.xml',
|
||||||
'open_workshop_pos/static/src/xml/ows_product_screen_template_patch.xml',
|
'open_workshop_pos/static/src/xml/ows_product_screen_template_patch.xml',
|
||||||
'open_workshop_pos/static/src/xml/ows_product_card_patch.xml',
|
|
||||||
'open_workshop_pos/static/src/xml/ows_receipt_header_patch.xml',
|
|
||||||
'open_workshop_pos/static/src/xml/ows_voucher_codes_patch.xml',
|
|
||||||
'open_workshop_pos/static/src/xml/ows_control_buttons_patch.xml',
|
|
||||||
'open_workshop_pos/static/src/xml/ows_payment_screen_patch.xml',
|
|
||||||
'open_workshop_pos/static/src/xml/ows_partner_list_patch.xml',
|
|
||||||
|
|
||||||
# CSS
|
# CSS
|
||||||
'open_workshop_pos/static/src/css/pos.css',
|
'open_workshop_pos/static/src/css/pos.css',
|
||||||
|
|
@ -65,5 +52,4 @@ Autor: HobbyHimmel
|
||||||
'installable': True,
|
'installable': True,
|
||||||
'application': False,
|
'application': False,
|
||||||
'auto_install': False,
|
'auto_install': False,
|
||||||
'post_init_hook': 'post_init_hook',
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,46 +0,0 @@
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
import logging
|
|
||||||
|
|
||||||
_logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
def post_init_hook(env):
|
|
||||||
"""
|
|
||||||
Workaround für Odoo 18 pos_sms Bug:
|
|
||||||
- pos_sms hat auto_install=True in älteren Odoo 18 Versionen
|
|
||||||
- Das Modul definiert ein Feld pos_sms_receipt_template_id in res.config.settings
|
|
||||||
- Wenn pos_sms nicht installiert ist, aber die View geladen wird, gibt es einen Fehler
|
|
||||||
|
|
||||||
Dieser Hook:
|
|
||||||
1. Deaktiviert auto_install für pos_sms falls vorhanden
|
|
||||||
2. Löscht verwaiste Views von pos_sms
|
|
||||||
"""
|
|
||||||
_logger.info("🔧 Open Workshop POS: Prüfe pos_sms Kompatibilität...")
|
|
||||||
|
|
||||||
# Prüfe ob pos_sms Modul existiert
|
|
||||||
pos_sms_module = env['ir.module.module'].search([('name', '=', 'pos_sms')], limit=1)
|
|
||||||
|
|
||||||
if not pos_sms_module:
|
|
||||||
_logger.info("✅ pos_sms Modul nicht gefunden - kein Patch nötig")
|
|
||||||
return
|
|
||||||
|
|
||||||
# Deaktiviere auto_install wenn das Modul nicht installiert ist
|
|
||||||
if pos_sms_module.state != 'installed':
|
|
||||||
_logger.info(f"ℹ️ pos_sms Status: {pos_sms_module.state}")
|
|
||||||
|
|
||||||
# Lösche verwaiste Views von pos_sms (falls vorhanden)
|
|
||||||
orphaned_views = env['ir.ui.view'].search([
|
|
||||||
('arch_db', 'ilike', 'pos_sms_receipt_template_id')
|
|
||||||
])
|
|
||||||
|
|
||||||
if orphaned_views:
|
|
||||||
_logger.warning(
|
|
||||||
f"🗑️ Lösche {len(orphaned_views)} verwaiste pos_sms View(s): "
|
|
||||||
f"{orphaned_views.mapped('name')}"
|
|
||||||
)
|
|
||||||
orphaned_views.sudo().unlink()
|
|
||||||
_logger.info("✅ Verwaiste Views erfolgreich gelöscht")
|
|
||||||
else:
|
|
||||||
_logger.info("✅ Keine verwaisten pos_sms Views gefunden")
|
|
||||||
else:
|
|
||||||
_logger.info("✅ pos_sms ist installiert - kein Patch nötig")
|
|
||||||
|
|
@ -1,2 +1,2 @@
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
from . import pos_order
|
# POS-spezifische Modelle (falls benötigt)
|
||||||
|
|
|
||||||
|
|
@ -55,9 +55,3 @@
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.bg-200, .text-bg-200 {
|
|
||||||
--background-color: rgb(122, 122, 122);
|
|
||||||
background-color: var(--background-color) !important;
|
|
||||||
}
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
/** @odoo-module **/
|
|
||||||
|
|
||||||
import { ControlButtons } from "@point_of_sale/app/screens/product_screen/control_buttons/control_buttons";
|
|
||||||
import { patch } from "@web/core/utils/patch";
|
|
||||||
|
|
||||||
patch(ControlButtons.prototype, {
|
|
||||||
/**
|
|
||||||
* Override to hide internal note button
|
|
||||||
*/
|
|
||||||
get showInternalNote() {
|
|
||||||
return false;
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Override to show pricelist button always (not just in modal)
|
|
||||||
*/
|
|
||||||
get showPricelistButton() {
|
|
||||||
return !this.props.showRemainingButtons && !this.ui.isSmall;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
@ -1,36 +0,0 @@
|
||||||
/** @odoo-module **/
|
|
||||||
|
|
||||||
import { PosOrder } from "@point_of_sale/app/models/pos_order";
|
|
||||||
import { patch } from "@web/core/utils/patch";
|
|
||||||
|
|
||||||
// Patch the PosOrder to collect voucher codes from orderlines
|
|
||||||
patch(PosOrder.prototype, {
|
|
||||||
export_for_printing(baseUrl, headerData) {
|
|
||||||
const result = super.export_for_printing(baseUrl, headerData);
|
|
||||||
|
|
||||||
// Add partner name to headerData if partner exists
|
|
||||||
const partner = this.get_partner();
|
|
||||||
if (partner && result.headerData) {
|
|
||||||
result.headerData.partnerName = partner.name;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Collect all voucher codes from orderlines
|
|
||||||
const voucherCodes = [];
|
|
||||||
for (const line of this.lines) {
|
|
||||||
if (line.voucher_code) {
|
|
||||||
voucherCodes.push({
|
|
||||||
code: line.voucher_code,
|
|
||||||
name: line.product_id?.display_name || '',
|
|
||||||
value: line.get_price_with_tax()
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add voucher codes to result if any exist
|
|
||||||
if (voucherCodes.length > 0) {
|
|
||||||
result.voucherCodes = voucherCodes;
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
@ -1,75 +0,0 @@
|
||||||
/** @odoo-module **/
|
|
||||||
|
|
||||||
import { patch } from "@web/core/utils/patch";
|
|
||||||
import { PartnerList } from "@point_of_sale/app/screens/partner_list/partner_list";
|
|
||||||
import { unaccent } from "@web/core/utils/strings";
|
|
||||||
|
|
||||||
patch(PartnerList.prototype, {
|
|
||||||
setup() {
|
|
||||||
super.setup(...arguments);
|
|
||||||
this.state.searchMode = this.state.searchMode || "all";
|
|
||||||
},
|
|
||||||
|
|
||||||
setSearchMode(mode) {
|
|
||||||
if (this.state.searchMode === mode) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.state.searchMode = mode;
|
|
||||||
this.state.currentOffset = 0;
|
|
||||||
this.state.previousQuery = "";
|
|
||||||
},
|
|
||||||
|
|
||||||
getPartners() {
|
|
||||||
if (this.state.searchMode !== "name") {
|
|
||||||
return super.getPartners();
|
|
||||||
}
|
|
||||||
|
|
||||||
const searchWord = unaccent((this.state.query || "").trim(), false).toLowerCase();
|
|
||||||
const partners = this.pos.models["res.partner"].getAll();
|
|
||||||
|
|
||||||
if (!searchWord) {
|
|
||||||
return partners
|
|
||||||
.slice(0, 1000)
|
|
||||||
.toSorted((a, b) =>
|
|
||||||
this.props.partner?.id === a.id
|
|
||||||
? -1
|
|
||||||
: this.props.partner?.id === b.id
|
|
||||||
? 1
|
|
||||||
: (a.name || "").localeCompare(b.name || "")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const exactMatches = partners.filter((partner) => {
|
|
||||||
const name = unaccent(partner.name || "", false).toLowerCase();
|
|
||||||
return name === searchWord;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (exactMatches.length > 0) {
|
|
||||||
return exactMatches;
|
|
||||||
}
|
|
||||||
|
|
||||||
return partners.filter((partner) => {
|
|
||||||
const name = unaccent(partner.name || "", false).toLowerCase();
|
|
||||||
return name.includes(searchWord);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
async getNewPartners() {
|
|
||||||
if (this.state.searchMode !== "name") {
|
|
||||||
return super.getNewPartners();
|
|
||||||
}
|
|
||||||
|
|
||||||
let domain = [];
|
|
||||||
const limit = 30;
|
|
||||||
if (this.state.query) {
|
|
||||||
domain = [["name", "ilike", this.state.query + "%"]];
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await this.pos.data.searchRead("res.partner", domain, [], {
|
|
||||||
limit: limit,
|
|
||||||
offset: this.state.currentOffset,
|
|
||||||
});
|
|
||||||
|
|
||||||
return result;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
@ -35,17 +35,7 @@ export class OwsPosCustomerSidebar extends Component {
|
||||||
}
|
}
|
||||||
|
|
||||||
getDate(order) {
|
getDate(order) {
|
||||||
// Odoo sendet date_order als UTC-String, der in lokale Zeit konvertiert werden muss
|
const date = new Date(order.date_order);
|
||||||
// Wenn der String kein 'Z' oder Timezone-Info hat, wird er als UTC behandelt
|
|
||||||
let dateStr = order.date_order;
|
|
||||||
|
|
||||||
// Falls der String im Format "YYYY-MM-DD HH:MM:SS" ohne Timezone ist,
|
|
||||||
// wird er als UTC interpretiert (Odoo-Standard)
|
|
||||||
if (dateStr && !dateStr.includes('T') && !dateStr.includes('Z')) {
|
|
||||||
dateStr = dateStr.replace(' ', 'T') + 'Z';
|
|
||||||
}
|
|
||||||
|
|
||||||
const date = new Date(dateStr);
|
|
||||||
const dd = String(date.getDate()).padStart(2, '0');
|
const dd = String(date.getDate()).padStart(2, '0');
|
||||||
const mm = String(date.getMonth() + 1).padStart(2, '0');
|
const mm = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
const hh = String(date.getHours()).padStart(2, '0');
|
const hh = String(date.getHours()).padStart(2, '0');
|
||||||
|
|
|
||||||
|
|
@ -1,36 +0,0 @@
|
||||||
/** @odoo-module **/
|
|
||||||
|
|
||||||
import { ProductCard } from "@point_of_sale/app/generic_components/product_card/product_card";
|
|
||||||
import { patch } from "@web/core/utils/patch";
|
|
||||||
|
|
||||||
patch(ProductCard.prototype, {
|
|
||||||
/**
|
|
||||||
* Gibt den formatierten Preis des Produkts zurück, basierend auf der aktuellen Preisliste.
|
|
||||||
* Dieser Getter wird automatisch neu berechnet, wenn sich die Preisliste ändert.
|
|
||||||
*/
|
|
||||||
get productPrice() {
|
|
||||||
if (!this.props.product) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hole die aktuelle Bestellung, um auf deren Preisliste zuzugreifen
|
|
||||||
const order = this.env.services.pos.get_order();
|
|
||||||
if (!order) {
|
|
||||||
return this.env.services.pos.getProductPriceFormatted(this.props.product);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Zugriff auf die Preisliste der Bestellung, damit Owl Änderungen tracken kann
|
|
||||||
const pricelist = order.pricelist_id;
|
|
||||||
|
|
||||||
// Berechne den Preis mit der Preisliste
|
|
||||||
const price = this.props.product.get_price(pricelist, 1);
|
|
||||||
const formattedPrice = this.env.utils.formatCurrency(price);
|
|
||||||
|
|
||||||
// Füge Maßeinheit hinzu, wenn das Produkt nach Gewicht verkauft wird
|
|
||||||
if (this.props.product.to_weight) {
|
|
||||||
return `${formattedPrice}/${this.props.product.uom_id.name}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return formattedPrice;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
@ -1,40 +0,0 @@
|
||||||
// ows_product_screen_default_category.js
|
|
||||||
// @odoo-module
|
|
||||||
|
|
||||||
import { ProductScreen } from "@point_of_sale/app/screens/product_screen/product_screen";
|
|
||||||
import { patch } from "@web/core/utils/patch";
|
|
||||||
import { onMounted } from "@odoo/owl";
|
|
||||||
|
|
||||||
// Konfiguration: Tragen Sie hier den Namen oder die ID der Standardkategorie ein
|
|
||||||
const DEFAULT_CATEGORY_NAME = "Nutzung"; // Name der Kategorie
|
|
||||||
// oder verwenden Sie eine ID:
|
|
||||||
// const DEFAULT_CATEGORY_ID = 1; // ID der Kategorie
|
|
||||||
|
|
||||||
patch(ProductScreen.prototype, {
|
|
||||||
setup() {
|
|
||||||
super.setup();
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
// Setze die Standardkategorie beim Laden des Screens
|
|
||||||
this.setDefaultCategory();
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
setDefaultCategory() {
|
|
||||||
// Suche die Kategorie nach Name oder ID
|
|
||||||
let category = null;
|
|
||||||
|
|
||||||
if (typeof DEFAULT_CATEGORY_NAME !== 'undefined' && DEFAULT_CATEGORY_NAME) {
|
|
||||||
// Suche nach Name
|
|
||||||
const categories = this.pos.models["pos.category"].getAll();
|
|
||||||
category = categories.find(cat => cat.name === DEFAULT_CATEGORY_NAME);
|
|
||||||
} else if (typeof DEFAULT_CATEGORY_ID !== 'undefined' && DEFAULT_CATEGORY_ID) {
|
|
||||||
// Suche nach ID
|
|
||||||
category = this.pos.models["pos.category"].get(DEFAULT_CATEGORY_ID);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (category) {
|
|
||||||
this.pos.setSelectedCategory(category.id);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
@ -1,14 +1,15 @@
|
||||||
// product_screen_template_patch.js
|
// product_screen_template_patch.js
|
||||||
// @odoo-module
|
// @odoo-module
|
||||||
|
|
||||||
|
import { registry } from "@web/core/registry";
|
||||||
import { ProductScreen } from "@point_of_sale/app/screens/product_screen/product_screen";
|
import { ProductScreen } from "@point_of_sale/app/screens/product_screen/product_screen";
|
||||||
import { patch } from "@web/core/utils/patch";
|
|
||||||
import { OwsPosSidebar } from "./ows_pos_sidebar";
|
import { OwsPosSidebar } from "./ows_pos_sidebar";
|
||||||
|
|
||||||
// Patch the ProductScreen components to add the OwsPosSidebar
|
class OwsProductScreen extends ProductScreen {
|
||||||
patch(ProductScreen, {
|
static components = Object.assign({}, ProductScreen.components, {
|
||||||
components: {
|
|
||||||
...ProductScreen.components,
|
|
||||||
OwsPosSidebar,
|
OwsPosSidebar,
|
||||||
},
|
});
|
||||||
});
|
}
|
||||||
|
|
||||||
|
registry.category("pos_screens").remove("ProductScreen");
|
||||||
|
registry.category("pos_screens").add("ProductScreen", OwsProductScreen);
|
||||||
|
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
/** @odoo-module */
|
|
||||||
|
|
||||||
// This file is now deprecated - order patching moved to ows_order_patch.js
|
|
||||||
// Keeping for backwards compatibility
|
|
||||||
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<templates id="template" xml:space="preserve">
|
|
||||||
|
|
||||||
<t t-name="point_of_sale.ControlButtons" t-inherit="point_of_sale.ControlButtons" t-inherit-mode="extension">
|
|
||||||
|
|
||||||
<!-- Hide the internal note button completely -->
|
|
||||||
<xpath expr="//t[@t-if='!props.showRemainingButtons || (ui.isSmall and props.showRemainingButtons)']" position="attributes">
|
|
||||||
<attribute name="t-if">false</attribute>
|
|
||||||
</xpath>
|
|
||||||
|
|
||||||
<!-- Add pricelist button directly visible (not just in Actions dialog) -->
|
|
||||||
<xpath expr="//SelectPartnerButton[@partner='partner']" position="after">
|
|
||||||
<button t-if="!props.showRemainingButtons and !ui.isSmall"
|
|
||||||
class="o_pricelist_button btn btn-light btn-lg lh-lg"
|
|
||||||
t-on-click="() => this.clickPricelist()">
|
|
||||||
<i class="fa fa-th-list me-1" role="img" aria-label="Price list" title="Price list" />
|
|
||||||
<t t-if="currentOrder?.pricelist_id" t-esc="currentOrder.pricelist_id.display_name" />
|
|
||||||
<t t-else="">Pricelist</t>
|
|
||||||
</button>
|
|
||||||
</xpath>
|
|
||||||
|
|
||||||
</t>
|
|
||||||
|
|
||||||
</templates>
|
|
||||||
|
|
@ -59,7 +59,7 @@
|
||||||
<t t-if="area.machines.length > 0">
|
<t t-if="area.machines.length > 0">
|
||||||
<div class="client-details-area" t-att-style="'border: solid 3px ' + area.color_hex + '; margin: 5px;'">
|
<div class="client-details-area" t-att-style="'border: solid 3px ' + area.color_hex + '; margin: 5px;'">
|
||||||
<ul>
|
<ul>
|
||||||
<t t-foreach="area.machines" t-as="machine" t-key="machine.id">
|
<t t-foreach="area.machines" t-as="machine" t-key="machine.name">
|
||||||
<li class="client-detail">
|
<li class="client-detail">
|
||||||
<span t-attf-class="detail {{ machine.has_access ? 'client-details-vvow_briefing' : 'client-details-vvow_briefing_error' }}">
|
<span t-attf-class="detail {{ machine.has_access ? 'client-details-vvow_briefing' : 'client-details-vvow_briefing_error' }}">
|
||||||
<t t-esc="machine.has_access ? '✅' : '❌'" />
|
<t t-esc="machine.has_access ? '✅' : '❌'" />
|
||||||
|
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<templates id="template" xml:space="preserve">
|
|
||||||
<t t-name="point_of_sale.PartnerList" t-inherit="point_of_sale.PartnerList" t-inherit-mode="extension">
|
|
||||||
<xpath expr="//t[@t-set-slot='header']" position="inside">
|
|
||||||
<div class="btn-group ms-2" role="group" aria-label="Search mode">
|
|
||||||
<button
|
|
||||||
class="btn btn-outline-secondary"
|
|
||||||
t-att-class="{ active: state.searchMode === 'name' }"
|
|
||||||
t-on-click="() => this.setSearchMode('name')">
|
|
||||||
Name
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="btn btn-outline-secondary"
|
|
||||||
t-att-class="{ active: state.searchMode === 'all' }"
|
|
||||||
t-on-click="() => this.setSearchMode('all')">
|
|
||||||
Alle Felder
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</xpath>
|
|
||||||
</t>
|
|
||||||
</templates>
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<templates id="template" xml:space="preserve">
|
|
||||||
|
|
||||||
<t t-name="point_of_sale.PaymentScreenButtons" t-inherit="point_of_sale.PaymentScreenButtons" t-inherit-mode="extension">
|
|
||||||
|
|
||||||
<!-- Remove the Invoice button completely -->
|
|
||||||
<xpath expr="//button[contains(@class, 'js_invoice')]" position="replace">
|
|
||||||
<!-- Invoice button removed -->
|
|
||||||
</xpath>
|
|
||||||
|
|
||||||
</t>
|
|
||||||
|
|
||||||
</templates>
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<templates id="template" xml:space="preserve">
|
|
||||||
<!-- Erweitere ProductCard um den Preis anzuzeigen -->
|
|
||||||
<t t-name="point_of_sale.ProductCard" t-inherit="point_of_sale.ProductCard" t-inherit-mode="extension">
|
|
||||||
<!-- Ändere das Layout zu flex-column mit justify-content-end für Ausrichtung am unteren Rand -->
|
|
||||||
<xpath expr="//div[hasclass('product-content')]" position="attributes">
|
|
||||||
<attribute name="class">product-content d-flex flex-column px-2 justify-content-end rounded-bottom rounded-3 flex-shrink-1</attribute>
|
|
||||||
</xpath>
|
|
||||||
|
|
||||||
<!-- Entferne die Margins vom Produktnamen für kompakteres Layout -->
|
|
||||||
<xpath expr="//div[hasclass('product-name')]" position="attributes">
|
|
||||||
<attribute name="class">overflow-hidden lh-sm product-name mb-1</attribute>
|
|
||||||
</xpath>
|
|
||||||
|
|
||||||
<!-- Füge den Preis nach dem Produktnamen hinzu (preislistenabhängig) -->
|
|
||||||
<xpath expr="//div[hasclass('product-name')]" position="after">
|
|
||||||
<div t-if="props.product" class="product-price text-primary fw-bold fs-6 mb-2">
|
|
||||||
<t t-esc="productPrice"/>
|
|
||||||
</div>
|
|
||||||
</xpath>
|
|
||||||
|
|
||||||
<!-- Entferne die Warenkorb-Mengenanzeige für ruhigere Optik -->
|
|
||||||
<xpath expr="//h1[hasclass('product-cart-qty')]" position="replace"/>
|
|
||||||
</t>
|
|
||||||
</templates>
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<templates id="template" xml:space="preserve">
|
|
||||||
<!-- Add partner name to tracking number in receipt header -->
|
|
||||||
<t t-name="point_of_sale.ReceiptHeader" t-inherit="point_of_sale.ReceiptHeader" t-inherit-mode="extension">
|
|
||||||
<!-- Add partner name above the big tracking number -->
|
|
||||||
<xpath expr="//h1[hasclass('tracking-number')][hasclass('text-center')]" position="before">
|
|
||||||
<div t-if="props.data.partnerName"
|
|
||||||
class="partner-name text-center fw-bold"
|
|
||||||
style="font-size: 48px; margin-bottom: 10px;">
|
|
||||||
<t t-esc="props.data.partnerName" />
|
|
||||||
</div>
|
|
||||||
</xpath>
|
|
||||||
</t>
|
|
||||||
</templates>
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user