diff --git a/open_workshop/__init__.py b/open_workshop/__init__.py
new file mode 100644
index 0000000..d7af4f2
--- /dev/null
+++ b/open_workshop/__init__.py
@@ -0,0 +1,3 @@
+from . import models
+from . import controllers
+
diff --git a/open_workshop/__manifest__.py b/open_workshop/__manifest__.py
new file mode 100644
index 0000000..79cff82
--- /dev/null
+++ b/open_workshop/__manifest__.py
@@ -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.
+ """,
+}
diff --git a/open_workshop/controllers/__init__.py b/open_workshop/controllers/__init__.py
new file mode 100644
index 0000000..4dc11d1
--- /dev/null
+++ b/open_workshop/controllers/__init__.py
@@ -0,0 +1,3 @@
+# Datei: open_workshop/controllers/__init__.py
+
+from . import pos_access
diff --git a/open_workshop/controllers/pos_access.py b/open_workshop/controllers/pos_access.py
new file mode 100644
index 0000000..edd56c4
--- /dev/null
+++ b/open_workshop/controllers/pos_access.py
@@ -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)
+
diff --git a/open_workshop/data/data.xml b/open_workshop/data/data.xml
new file mode 100644
index 0000000..7fa434d
--- /dev/null
+++ b/open_workshop/data/data.xml
@@ -0,0 +1,153 @@
+
+
+
+ Fablab
+ #008000
+
+
+
+ Holzbereich
+ #ff0000
+
+
+
+ Metallbereich
+ #0000ff
+
+
+
+ Elektronikbereich
+ #ffff00
+
+
+
+
+ Sabako Laser
+ sabako_laser
+
+
+
+
+ Prusa
+ prusa
+
+
+
+
+ Prusa MMU
+ prusa_mmu
+
+
+
+
+ 3D Delta
+ 3d_delta
+
+
+
+
+ CNC Beamicon
+ cnc_beamicon
+
+
+
+
+
+ Formatkreissäge
+ formatkreissaege
+
+
+
+
+ Bandsäge
+ bandsaege_holz
+
+
+
+
+ Abricht Dickenhobel
+ dickenhobel
+
+
+
+
+ Drechselbank
+ drechselbank
+
+
+
+
+ Festool Domino Fräse
+ festool_domino
+
+
+
+
+ Maffel Duo Dübler
+ maffel_duo
+
+
+
+
+ Lamello Zeta P2
+ lamello_zeta_p2
+
+
+
+
+
+ Kreissäge
+ kreissaege_metall
+
+
+
+
+ Bandsäge
+ bandsaege_metall
+
+
+
+
+ MIG/MAG Schweißgeräte
+ mig_mag
+
+
+
+
+ WIG Schweißgerät
+ wig
+
+
+
+
+ Schweißen allgemein
+ schweissen_allgemein
+
+
+
+
+ Drehbank
+ drehbank
+
+
+
+
+ Fräse
+ fraese
+
+
+
+
+ Abkantbank
+ abkantbank
+
+
+
+
+
+ Lötkolben
+ loetkolben
+
+
+
+
diff --git a/open_workshop/models/__init__.py b/open_workshop/models/__init__.py
new file mode 100644
index 0000000..9f91cbd
--- /dev/null
+++ b/open_workshop/models/__init__.py
@@ -0,0 +1,5 @@
+from . import ows_models
+from . import pos_order
+
+
+
diff --git a/open_workshop/models/ows_models.py b/open_workshop/models/ows_models.py
new file mode 100644
index 0000000..7be4120
--- /dev/null
+++ b/open_workshop/models/ows_models.py
@@ -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"""
+
"
+
+ 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')
diff --git a/open_workshop/models/pos_order.py b/open_workshop/models/pos_order.py
new file mode 100644
index 0000000..326b719
--- /dev/null
+++ b/open_workshop/models/pos_order.py
@@ -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
diff --git a/open_workshop/security/ir.model.access.csv b/open_workshop/security/ir.model.access.csv
new file mode 100644
index 0000000..022703c
--- /dev/null
+++ b/open_workshop/security/ir.model.access.csv
@@ -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
diff --git a/open_workshop/static/src/css/category_color.css b/open_workshop/static/src/css/category_color.css
new file mode 100644
index 0000000..154419d
--- /dev/null
+++ b/open_workshop/static/src/css/category_color.css
@@ -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;
+}
diff --git a/open_workshop/static/src/css/pos.css b/open_workshop/static/src/css/pos.css
new file mode 100644
index 0000000..cfdfc4d
--- /dev/null
+++ b/open_workshop/static/src/css/pos.css
@@ -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;
+}
diff --git a/open_workshop/static/src/js/ows_machine_access_list.js b/open_workshop/static/src/js/ows_machine_access_list.js
new file mode 100644
index 0000000..f3ecc0f
--- /dev/null
+++ b/open_workshop/static/src/js/ows_machine_access_list.js
@@ -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);
+
diff --git a/open_workshop/static/src/js/ows_pos_customer_sidebar.js b/open_workshop/static/src/js/ows_pos_customer_sidebar.js
new file mode 100644
index 0000000..b14b7b6
--- /dev/null
+++ b/open_workshop/static/src/js/ows_pos_customer_sidebar.js
@@ -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');
+ }
+}
diff --git a/open_workshop/static/src/js/ows_pos_sidebar.js b/open_workshop/static/src/js/ows_pos_sidebar.js
new file mode 100644
index 0000000..dfdc8e3
--- /dev/null
+++ b/open_workshop/static/src/js/ows_pos_sidebar.js
@@ -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 };
+}
\ No newline at end of file
diff --git a/open_workshop/static/src/js/ows_product_screen_template_patch.js b/open_workshop/static/src/js/ows_product_screen_template_patch.js
new file mode 100644
index 0000000..29690f3
--- /dev/null
+++ b/open_workshop/static/src/js/ows_product_screen_template_patch.js
@@ -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);
diff --git a/open_workshop/static/src/xml/ows_machine_access_list.xml b/open_workshop/static/src/xml/ows_machine_access_list.xml
new file mode 100644
index 0000000..787441b
--- /dev/null
+++ b/open_workshop/static/src/xml/ows_machine_access_list.xml
@@ -0,0 +1,76 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/open_workshop/static/src/xml/ows_pos_customer_sidebar.xml b/open_workshop/static/src/xml/ows_pos_customer_sidebar.xml
new file mode 100644
index 0000000..31f5e35
--- /dev/null
+++ b/open_workshop/static/src/xml/ows_pos_customer_sidebar.xml
@@ -0,0 +1,27 @@
+
+
+
+
+
+
diff --git a/open_workshop/static/src/xml/ows_pos_sidebar.xml b/open_workshop/static/src/xml/ows_pos_sidebar.xml
new file mode 100644
index 0000000..1777b19
--- /dev/null
+++ b/open_workshop/static/src/xml/ows_pos_sidebar.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/open_workshop/static/src/xml/ows_product_screen_template_patch.xml b/open_workshop/static/src/xml/ows_product_screen_template_patch.xml
new file mode 100644
index 0000000..e243e82
--- /dev/null
+++ b/open_workshop/static/src/xml/ows_product_screen_template_patch.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
diff --git a/open_workshop/views/assets.xml b/open_workshop/views/assets.xml
new file mode 100644
index 0000000..2f88e74
--- /dev/null
+++ b/open_workshop/views/assets.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
diff --git a/open_workshop/views/machine_area_views.xml b/open_workshop/views/machine_area_views.xml
new file mode 100644
index 0000000..e843133
--- /dev/null
+++ b/open_workshop/views/machine_area_views.xml
@@ -0,0 +1,40 @@
+
+
+
+
+ Maschinenbereiche
+ ows.machine.area
+ list,form
+
+
+
+
+
+
+
+ ows.machine.area.tree
+ ows.machine.area
+
+
+
+
+
+
+
+
+
+
+
+ ows.machine.area.form
+ ows.machine.area
+
+
+
+
+
diff --git a/open_workshop/views/machine_product_training_views.xml b/open_workshop/views/machine_product_training_views.xml
new file mode 100644
index 0000000..621c9fe
--- /dev/null
+++ b/open_workshop/views/machine_product_training_views.xml
@@ -0,0 +1,49 @@
+
+
+
+ ows.machine.product.tree
+ ows.machine.product
+
+
+
+
+
+
+
+
+
+
+ ows.machine.training.tree
+ ows.machine.training
+
+
+
+
+
+
+
+
+
+
+ Maschinen-Nutzungsprodukte
+ ows.machine.product
+ list
+
+
+ Verwalte die Zuordnung von Maschinen zu Nutzungsprodukten.
+
+
+
+
+
+ Maschinen-Einweisungsprodukte
+ ows.machine.training
+ list
+
+
+ Verwalte die Zuordnung von Maschinen zu Einweisungsprodukten.
+
+
+
+
+
diff --git a/open_workshop/views/machine_views.xml b/open_workshop/views/machine_views.xml
new file mode 100644
index 0000000..1231267
--- /dev/null
+++ b/open_workshop/views/machine_views.xml
@@ -0,0 +1,68 @@
+
+
+
+
+ ows.machine.tree
+ ows.machine
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ows.machine.form
+ ows.machine
+
+
+
+
+
diff --git a/open_workshop/views/menu_views.xml b/open_workshop/views/menu_views.xml
new file mode 100644
index 0000000..23285b6
--- /dev/null
+++ b/open_workshop/views/menu_views.xml
@@ -0,0 +1,81 @@
+
+
+
+
+ Maschinen
+ ows.machine
+ list,form
+
+
+
+
+ Einweisungs-Produkte
+ ows.machine.product
+ list,form
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ows.machine.product.tree
+ ows.machine.product
+
+
+
+
+
+
+
+
+
+ ows.machine.product.form
+ ows.machine.product
+
+
+
+
+
diff --git a/open_workshop/views/res_partner_view.xml b/open_workshop/views/res_partner_view.xml
new file mode 100644
index 0000000..07a3dde
--- /dev/null
+++ b/open_workshop/views/res_partner_view.xml
@@ -0,0 +1,127 @@
+
+
+
+
+
+
+
+
+ res.partner.form.ows.tabs
+ res.partner
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ res.partner.form.ows.birthday
+ res.partner
+
+
+
+
+
+
+
+
+
+
+
+ res.partner.ows.tree
+ res.partner
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ res.partner.form.inherit.default_person
+ res.partner
+
+
+
+ person
+
+
+
+
+
+
+ list,kanban,form,activity
+
+
+
+
+
+
+
+
diff --git a/open_workshop_base/__manifest__.py b/open_workshop_base/__manifest__.py
index d4b7fcc..a49080c 100644
--- a/open_workshop_base/__manifest__.py
+++ b/open_workshop_base/__manifest__.py
@@ -1,9 +1,9 @@
{
'name': 'Open Workshop Base',
'license': 'AGPL-3',
- 'version': '18.0.1.0.4',
- 'summary': 'Kern-Modul für Maschinenfreigaben und Einweisungslogik',
- 'depends': ['base', 'account', 'hr','product','sale','contacts'],
+ 'version': '18.0.1.0.4', # Migration läuft bei 18.0.1.0.4
+ 'summary': 'Kern-Modul für Maschinenfreigaben - vereinfachte Equipment-Integration',
+ 'depends': ['base', 'account', 'hr', 'product', 'sale', 'contacts', 'maintenance'],
'author': 'matthias.lotz',
'category': 'Manufacturing',
@@ -28,11 +28,12 @@ Open Workshop Base - Kernmodul
Dieses Modul stellt die Basis-Funktionalität für Open Workshop bereit:
-* Maschinenmodelle (ows.machine)
+* Maschinenmodelle (ows.machine) → nutzt maintenance.equipment als Single Source of Truth
* Bereiche (ows.machine.area)
* Einweisungslogik und Freigaben
* Produktverknüpfungen für Einweisungen
* Backend-UI (Form, Tree, Kanban)
+* Automatische Migration bestehender Maschinen zu maintenance.equipment
Für POS-Integration installiere: open_workshop_pos
Für API-Zugriff installiere: open_workshop_api (erforderlich für WordPress Frontend)
diff --git a/open_workshop_base/data/data.xml b/open_workshop_base/data/data.xml
index 7fa434d..f9e1932 100644
--- a/open_workshop_base/data/data.xml
+++ b/open_workshop_base/data/data.xml
@@ -23,130 +23,130 @@
Sabako Laser
- sabako_laser
+ sabako_laser
Prusa
- prusa
+ prusa
Prusa MMU
- prusa_mmu
+ prusa_mmu
3D Delta
- 3d_delta
+ 3d_delta
CNC Beamicon
- cnc_beamicon
+ cnc_beamicon
Formatkreissäge
- formatkreissaege
+ formatkreissaege
Bandsäge
- bandsaege_holz
+ bandsaege_holz
Abricht Dickenhobel
- dickenhobel
+ dickenhobel
Drechselbank
- drechselbank
+ drechselbank
Festool Domino Fräse
- festool_domino
+ festool_domino
Maffel Duo Dübler
- maffel_duo
+ maffel_duo
Lamello Zeta P2
- lamello_zeta_p2
+ lamello_zeta_p2
Kreissäge
- kreissaege_metall
+ kreissaege_metall
Bandsäge
- bandsaege_metall
+ bandsaege_metall
MIG/MAG Schweißgeräte
- mig_mag
+ mig_mag
WIG Schweißgerät
- wig
+ wig
Schweißen allgemein
- schweissen_allgemein
+ schweissen_allgemein
Drehbank
- drehbank
+ drehbank
Fräse
- fraese
+ fraese
Abkantbank
- abkantbank
+ abkantbank
Lötkolben
- loetkolben
+ loetkolben
diff --git a/open_workshop_base/migrations/18.0.1.0.3/pre-migrate.py b/open_workshop_base/migrations/18.0.1.0.3/pre-migrate.py
deleted file mode 100644
index 7019b06..0000000
--- a/open_workshop_base/migrations/18.0.1.0.3/pre-migrate.py
+++ /dev/null
@@ -1,64 +0,0 @@
-# Migration: open_workshop → open_workshop_base
-# Dieses Skript wird automatisch vor dem Modul-Update ausgeführt
-
-import logging
-
-_logger = logging.getLogger(__name__)
-
-
-def migrate(cr, version):
- """
- Benennt das Modul 'open_workshop' in 'open_workshop_base' um.
-
- Wichtig:
- - Modellnamen (_name) bleiben unverändert (z.B. 'ows.machine')
- - Tabellennamen bleiben unverändert
- - Keine Datenmigration erforderlich
- - Nur Modul-Metadaten werden aktualisiert
-
- Dieses Skript läuft VOR dem normalen Update-Prozess.
- """
-
- # Prüfen ob altes Modul existiert
- cr.execute("""
- SELECT id, state FROM ir_module_module
- WHERE name = 'open_workshop'
- """)
- old_module = cr.fetchone()
-
- if old_module:
- _logger.info("=" * 70)
- _logger.info("MIGRATION: Renaming module 'open_workshop' to 'open_workshop_base'")
- _logger.info("=" * 70)
-
- # 1. Modulnamen in ir_module_module aktualisieren
- cr.execute("""
- UPDATE ir_module_module
- SET name = 'open_workshop_base'
- WHERE name = 'open_workshop'
- """)
- _logger.info(f"✓ Updated ir_module_module: {cr.rowcount} row(s)")
-
- # 2. Alle ir_model_data Referenzen aktualisieren
- cr.execute("""
- UPDATE ir_model_data
- SET module = 'open_workshop_base'
- WHERE module = 'open_workshop'
- """)
- _logger.info(f"✓ Updated ir_model_data: {cr.rowcount} row(s)")
-
- # 3. Abhängigkeiten in anderen Modulen aktualisieren (falls vorhanden)
- cr.execute("""
- UPDATE ir_module_module_dependency
- SET name = 'open_workshop_base'
- WHERE name = 'open_workshop'
- """)
- if cr.rowcount > 0:
- _logger.info(f"✓ Updated dependencies in other modules: {cr.rowcount} row(s)")
-
- _logger.info("=" * 70)
- _logger.info("MIGRATION COMPLETED: Module successfully renamed")
- _logger.info("Note: Model names (e.g., 'ows.machine') remain unchanged")
- _logger.info("=" * 70)
- else:
- _logger.info("INFO: Module 'open_workshop' not found - migration not needed")
diff --git a/open_workshop_base/migrations/18.0.1.0.4/post-migration.py b/open_workshop_base/migrations/18.0.1.0.4/post-migration.py
new file mode 100644
index 0000000..e8974c5
--- /dev/null
+++ b/open_workshop_base/migrations/18.0.1.0.4/post-migration.py
@@ -0,0 +1,106 @@
+# -*- coding: utf-8 -*-
+import logging
+from datetime import date
+from odoo import SUPERUSER_ID, api
+
+_logger = logging.getLogger(__name__)
+
+
+def migrate(cr, version):
+ """
+ Post-Migration: Migriere bestehende ows.machine Daten zu maintenance.equipment
+ Area wird in equipment.location gespeichert (nicht mehr in category!)
+ """
+ _logger.info("=== Post-Migration: Daten-Migration zu equipment ===")
+
+ env = api.Environment(cr, SUPERUSER_ID, {})
+
+ # 1. Zähle zu migrierende Maschinen
+ cr.execute("SELECT COUNT(*) FROM ows_machine WHERE equipment_id IS NULL")
+ machines_to_migrate = cr.fetchone()[0]
+ _logger.info(f"Found {machines_to_migrate} machines to migrate")
+
+ if machines_to_migrate == 0:
+ _logger.info("No machines to migrate - skipping")
+ return
+
+ _logger.info("Creating maintenance.equipment entries...")
+
+ # 2. Migriere alle Maschinen die noch kein equipment haben
+ cr.execute("""
+ SELECT
+ id, name, area_id,
+ active, create_uid, write_uid, create_date, write_date
+ FROM ows_machine
+ WHERE equipment_id IS NULL
+ """)
+
+ machines = cr.fetchall()
+ migrated_count = 0
+
+ for machine in machines:
+ (m_id, m_name_jsonb, m_area_id,
+ m_active, m_create_uid, m_write_uid, m_create_date, m_write_date) = machine
+
+ # Name aus JSONB extrahieren (de_DE oder en_US)
+ m_name = None
+ if m_name_jsonb:
+ m_name = m_name_jsonb.get('de_DE') or m_name_jsonb.get('en_US')
+
+ # Serial number generieren
+ m_serial_no = f"OWS-{m_id}"
+
+ # Location aus Area-Name ermitteln
+ location = None
+ if m_area_id:
+ # Hole Area-Name (JSONB!) und nutze als location
+ cr.execute("SELECT name FROM ows_machine_area WHERE id = %s", (m_area_id,))
+ area_result = cr.fetchone()
+ if area_result and area_result[0]:
+ area_name_jsonb = area_result[0]
+ if area_name_jsonb:
+ location = area_name_jsonb.get('de_DE') or area_name_jsonb.get('en_US')
+
+ # Equipment erstellen (name als JSONB!)
+ # Fallback: Wenn m_name leer, nutze serial_no
+ display_name = m_name if m_name and m_name.strip() else m_serial_no
+
+ # Verwende SQL direkt für korrektes JSONB handling
+ try:
+ cr.execute("""
+ INSERT INTO maintenance_equipment
+ (name, serial_no, location, cost, effective_date, equipment_assign_to,
+ active, create_uid, write_uid, create_date, write_date)
+ VALUES
+ (jsonb_build_object('de_DE', %s, 'en_US', %s), %s, %s, %s, %s, 'other',
+ %s, %s, %s, %s, %s)
+ RETURNING id
+ """, (display_name, display_name, m_serial_no,
+ location, 0.0, date.today(),
+ m_active if m_active is not None else True,
+ m_create_uid, m_write_uid, m_create_date, m_write_date))
+
+ equipment_id = cr.fetchone()[0]
+
+ # equipment_id in ows_machine setzen
+ cr.execute("""
+ UPDATE ows_machine
+ SET equipment_id = %s
+ WHERE id = %s
+ """, (equipment_id, m_id))
+
+ migrated_count += 1
+
+ except Exception as e:
+ _logger.error(f"Failed to migrate machine {m_id}: {e}")
+
+ # 3. Validierung
+ cr.execute("SELECT COUNT(*) FROM ows_machine WHERE equipment_id IS NULL")
+ remaining = cr.fetchone()[0]
+
+ if remaining > 0:
+ _logger.warning(f"⚠️ {remaining} machines could not be migrated!")
+ else:
+ _logger.info(f"✅ Successfully migrated {migrated_count} machines to equipment")
+
+ _logger.info("Post-migration completed")
diff --git a/open_workshop_base/migrations/18.0.1.0.4/pre-migration.py b/open_workshop_base/migrations/18.0.1.0.4/pre-migration.py
new file mode 100644
index 0000000..8b0db07
--- /dev/null
+++ b/open_workshop_base/migrations/18.0.1.0.4/pre-migration.py
@@ -0,0 +1,59 @@
+# -*- coding: utf-8 -*-
+import logging
+from odoo import SUPERUSER_ID, api
+
+_logger = logging.getLogger(__name__)
+
+
+def migrate(cr, version):
+ """
+ Pre-Migration: Bereite equipment_id Spalte vor
+ Installiert maintenance-Modul falls nötig
+ """
+ _logger.info("=== Pre-Migration: ows.machine → maintenance.equipment ===")
+
+ env = api.Environment(cr, SUPERUSER_ID, {})
+
+ # 1. Prüfe ob maintenance installiert ist, falls nicht: installieren
+ cr.execute("""
+ SELECT state FROM ir_module_module
+ WHERE name = 'maintenance'
+ """)
+ result = cr.fetchone()
+
+ if not result or result[0] != 'installed':
+ _logger.info("Installing maintenance module...")
+ maintenance_module = env['ir.module.module'].search([('name', '=', 'maintenance')])
+ if maintenance_module:
+ maintenance_module.button_immediate_install()
+ _logger.info("✅ Maintenance module installed")
+ else:
+ raise Exception("Maintenance module not found in addons_path!")
+ else:
+ _logger.info("✓ Maintenance module already installed")
+
+ # 2. Spalte equipment_id hinzufügen (falls nicht vorhanden)
+ cr.execute("""
+ SELECT column_name
+ FROM information_schema.columns
+ WHERE table_name = 'ows_machine' AND column_name = 'equipment_id'
+ """)
+
+ if not cr.fetchone():
+ _logger.info("Adding equipment_id column to ows_machine")
+ cr.execute("""
+ ALTER TABLE ows_machine
+ ADD COLUMN equipment_id INTEGER;
+ """)
+ cr.execute("""
+ ALTER TABLE ows_machine
+ ADD CONSTRAINT ows_machine_equipment_id_fkey
+ FOREIGN KEY (equipment_id)
+ REFERENCES maintenance_equipment(id)
+ ON DELETE CASCADE;
+ """)
+ _logger.info("✅ equipment_id column added")
+ else:
+ _logger.info("✓ equipment_id column already exists")
+
+ _logger.info("Pre-migration completed")
diff --git a/open_workshop_base/migrations/migrate_open_workshop_to_base.sql b/open_workshop_base/migrations/migrate_open_workshop_to_base.sql
index 42759be..0a2644b 100644
--- a/open_workshop_base/migrations/migrate_open_workshop_to_base.sql
+++ b/open_workshop_base/migrations/migrate_open_workshop_to_base.sql
@@ -41,9 +41,10 @@ BEGIN
-- ===== MIGRATION DURCHFÜHREN =====
- -- 1. Modulnamen aktualisieren
+ -- 1. Modulnamen und Version aktualisieren
UPDATE ir_module_module
- SET name = 'open_workshop_base'
+ SET name = 'open_workshop_base',
+ latest_version = '18.0.1.0.3' -- Auf 18.0.1.0.3 setzen, damit 18.0.1.0.4 Migration läuft
WHERE name = 'open_workshop';
RAISE NOTICE '✓ Updated ir_module_module: % row(s)', (SELECT 1);
@@ -57,7 +58,13 @@ BEGIN
SELECT COUNT(*) FROM ir_model_data WHERE module = 'open_workshop_base'
);
- -- 3. Abhängigkeiten in anderen Modulen aktualisieren (falls vorhanden)
+ -- 3. Alten ir_model_data Eintrag für module_open_workshop löschen
+ DELETE FROM ir_model_data
+ WHERE module = 'base' AND name = 'module_open_workshop';
+
+ RAISE NOTICE '✓ Cleaned up old module_open_workshop entry';
+
+ -- 4. Abhängigkeiten in anderen Modulen aktualisieren (falls vorhanden)
UPDATE ir_module_module_dependency
SET name = 'open_workshop_base'
WHERE name = 'open_workshop';
diff --git a/open_workshop_base/models/ows_models.py b/open_workshop_base/models/ows_models.py
index 7be4120..79876ff 100644
--- a/open_workshop_base/models/ows_models.py
+++ b/open_workshop_base/models/ows_models.py
@@ -440,12 +440,38 @@ class OwsMachineArea(models.Model):
class OwsMachine(models.Model):
+ """
+ Open Workshop Maschine - nutzt maintenance.equipment als Single Source of Truth.
+
+ Delegation Pattern (_inherits):
+ - Beim Erstellen wird automatisch ein maintenance.equipment erstellt
+ - Felder wie name, serial_no, cost etc. werden direkt von equipment übernommen
+ - Keine Datenduplizierung!
+ """
_name = 'ows.machine'
_table = 'ows_machine'
_description = 'OWS: Maschine'
+ _inherits = {'maintenance.equipment': 'equipment_id'}
+
+ # PFLICHT: Verknüpfung zu maintenance.equipment (Single Source of Truth)
+ equipment_id = fields.Many2one(
+ 'maintenance.equipment',
+ required=True,
+ ondelete='cascade',
+ string='Equipment',
+ help='Verknüpfung zum Maintenance Equipment (Single Source of Truth für name, code, Preis, etc.)'
+ )
- name = fields.Char(required=True, translate=True)
- code = fields.Char(required=True, help="Eindeutiger Kurzcode, z.B. 'lasercutter'")
+ # Delegierte Felder (kommen automatisch von equipment via _inherits):
+ # - name (equipment.name)
+ # - serial_no → wird als 'code' verwendet (siehe @api.depends unten)
+ # - cost → purchase_price
+ # - effective_date → purchase_date
+ # - location → storage_location
+ # - note → description
+ # - category_id → Wird mit area_id synchronisiert
+
+ # OWS-spezifische Felder (nur in ows.machine!)
category = fields.Selection([
('green', 'Kategorie 1: grün'),
('yellow', 'Kategorie 2: gelb'),
@@ -467,16 +493,11 @@ class OwsMachine(models.Model):
}
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):
@@ -490,12 +511,63 @@ class OwsMachine(models.Model):
names = machine.training_ids.mapped('training_id.name')
machine.training_names = ", ".join(names)
- _sql_constraints = [
- ('code_unique', 'unique(code)', 'Maschinencode muss eindeutig sein.')
- ]
+ # Keine eigenen SQL Constraints - Equipment hat bereits unique constraint für serial_no
+
+ @api.model
+ def create(self, vals):
+ """
+ Beim Erstellen einer ows.machine:
+ 1. Equipment erstellen FALLS nicht vorhanden
+ 2. Area → Location synchronisieren
+ """
+ # Nur Equipment erstellen, wenn keins ausgewählt wurde
+ if 'equipment_id' not in vals or not vals['equipment_id']:
+ equipment_vals = {
+ 'name': vals.get('name', 'Neue Maschine'),
+ }
+ equipment = self.env['maintenance.equipment'].create(equipment_vals)
+ vals['equipment_id'] = equipment.id
+
+ # 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:
+ # Setze location direkt auf area.name
+ vals['location'] = area.name
+
+ return super(OwsMachine, self).create(vals)
+
+ def write(self, vals):
+ """
+ Bei Updates Area → Location synchronisieren
+ """
+ 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
+
+ return super(OwsMachine, self).write(vals)
def name_get(self):
- return [(rec.id, f"{rec.name} ({rec.code})") for rec in self]
+ result = []
+ for rec in self:
+ name = rec.name or 'Unbenannt'
+ if rec.serial_no: # Kommt direkt von equipment via _inherits
+ name = f"[{rec.serial_no}] {name}"
+ result.append((rec.id, name))
+ return result
+
+ def action_open_equipment(self):
+ """Smart Button: Öffnet die Equipment-Detailansicht"""
+ self.ensure_one()
+ return {
+ 'type': 'ir.actions.act_window',
+ 'name': 'Equipment Details',
+ 'res_model': 'maintenance.equipment',
+ 'res_id': self.equipment_id.id,
+ 'view_mode': 'form',
+ 'target': 'current',
+ }
@api.model
def get_access_list_grouped(self, partner_id):
diff --git a/open_workshop_base/views/machine_views.xml b/open_workshop_base/views/machine_views.xml
index 1231267..be68a6c 100644
--- a/open_workshop_base/views/machine_views.xml
+++ b/open_workshop_base/views/machine_views.xml
@@ -8,14 +8,11 @@
+
-
-
-
-
@@ -29,19 +26,29 @@