diff --git a/ina_mp_production_fichar/README.rst b/ina_mp_production_fichar/README.rst new file mode 100644 index 00000000..6ebc3267 --- /dev/null +++ b/ina_mp_production_fichar/README.rst @@ -0,0 +1,106 @@ +.. image:: https://img.shields.io/badge/license-AGPL--3-blue.svg + :target: https://opensource.org/licenses/AGPL-3.0 + :alt: License: AGPL-3 + +========================= +Ina MP Production Fichar +========================= + +Gestión de fichajes de fábrica: entrada/salida de empleados e imputación de +su tiempo y cantidades producidas a órdenes de fabricación, reparaciones, +trabajos de láser o tiempo improductivo. + +El fichaje real de presencia se apoya en el modelo core ``hr.attendance`` en +lugar de un modelo propio de presencia. + +Cómo funciona +============== + +Modelos +------- + +* ``hr.employee`` (extendido): hace de "perfil" de fichaje — una fila fija + por empleado con su orden actual (``tipo``, y según el tipo + ``workorder_id``, ``repara_id``, ``laser_id`` o ``improductivo``), su + puesto y su estado de presencia (``proximo_movi``: si le toca fichar + entrada o salida). +* ``hr.attendance`` (core, sin campos añadidos por este módulo): el log real + de entradas y salidas. Cada fichaje de "Entrada - Salida" crea o cierra un + registro aquí. +* ``hr.puesto.fichar``: un puesto de trabajo físico (centro de trabajo, + ubicación de consumo, ubicación de desechos) asociado a un usuario de + Odoo. Pensado para que cada terminal/ordenador de fábrica tenga su propio + usuario fijo, de forma que el sistema sepa en qué puesto está sin + preguntar. +* ``hr.fichar.historico``: histórico de líneas de trabajo por empleado (tipo + de orden, fechas, cantidades aceptadas/rechazadas, puesto), de solo + consulta. +* ``wiz.fichar.orden``: wizard que se abre al fichar entrada sin ninguna + orden activa, o al pulsar "Nueva Orden" / "Comunicar Cantidad". + +Flujo de fichaje +----------------- + +1. El empleado ficha **Entrada - Salida** desde el tablero del mismo nombre + (una fila por empleado). +2. Si no tiene ninguna orden activa y ficha en modo "Fabrica", se abre + automáticamente el wizard **Fichar Orden**: ahí elige en qué va a + trabajar (Fabricación, Reparación, Láser o Improductivo) y en qué + **Puesto** está (por defecto se sugiere el del usuario de Odoo actual, + pero se puede cambiar cada vez). +3. Al confirmar ("Crear Orden") se abre un registro de tiempo — según el + tipo, en ``mrp.workcenter.productivity``, ``mrp.repair.tiempos``, + ``order.olaser.tiempos`` o ``mrp.improductivo`` — y una línea nueva en el + histórico. +4. Mientras trabaja, puede pulsar **Comunicar Cantidad** para informar de + piezas aceptadas/rechazadas sin dejar la orden: cierra el tramo de + tiempo/histórico actual y abre uno nuevo para seguir imputando en la + misma orden. +5. **Parar Orden** cierra el tiempo/histórico actual sin comunicar ninguna + cantidad y deja al empleado sin orden asignada, reabriendo el wizard para + que elija la siguiente tarea. +6. Al fichar salida se cierra la asistencia (``hr.attendance``) y, si tenía + una orden activa, ésta queda marcada como interrumpida (sin comunicar + cantidad). + +Finalizar formalmente una orden de trabajo (dejarla en estado "Hecho") se +sigue haciendo desde la pantalla nativa de Fabricación de Odoo, no desde +este módulo — el fichaje solo imputa tiempo y cantidades parciales. + +Menús (Fichar) +--------------- + +* **Entrada - Salida**: tablero de fichaje, una fila por empleado con su + estado actual. +* **Alta en Fichajes**: lista editable para asignar el código de barras a + los empleados que fichan. +* **Puesto para Fichar**: alta y configuración de los puestos de trabajo + físicos. + +Otras acciones desde la ficha de fichaje +------------------------------------------- + +* **Desechos**: abre el formulario de scrap con la orden y el puesto ya + precargados. +* **Material de la Orden**: lista del material a consumir de la orden de + fabricación activa, con acceso rápido a existencias. +* **Histórico**: pestaña con todas las líneas de trabajo del empleado. Se + pueden borrar si hay un error al comunicar una cantidad — al borrar, se + revierte automáticamente lo que esa línea había sumado a la orden de + trabajo. + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues +`_. + +Credits +======= + +Contributors +------------ + +* INAEL, jag +* Ana Juaristi +* Lucía Echeverría diff --git a/ina_mp_production_fichar/__init__.py b/ina_mp_production_fichar/__init__.py new file mode 100644 index 00000000..9b429614 --- /dev/null +++ b/ina_mp_production_fichar/__init__.py @@ -0,0 +1,2 @@ +from . import models +from . import wizard diff --git a/ina_mp_production_fichar/__manifest__.py b/ina_mp_production_fichar/__manifest__.py new file mode 100644 index 00000000..de14cdfb --- /dev/null +++ b/ina_mp_production_fichar/__manifest__.py @@ -0,0 +1,30 @@ +# Copyright 2026 Inael +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). +{ + "name": "Ina MP Production Fichar", + "version": "18.0.1.0.0", + "category": "Inael MP", + "license": "AGPL-3", + "author": "INAEL, jag, AvanzOSC", + "website": "https://github.com/avanzosc/custom-addons", + "depends": [ + "mrp", + "hr_attendance", + "stock", + "ina_mc_permisos", + "ina_mc_product_campos", + "ina_mp_mrp_repara", + "ina_mp_production_olaser", + "mrp_productivity_employee", + ], + "data": [ + "security/ir.model.access.csv", + "views/hr_puesto_fichar_views.xml", + "views/hr_employee_views.xml", + "views/hr_employee_fichar_views.xml", + "views/stock_scrap_views.xml", + "views/stock_move_views.xml", + "wizard/wiz_fichar_orden.xml", + ], + "installable": True, +} diff --git a/ina_mp_production_fichar/migrations/18.0.1.0.0/post-migrate.py b/ina_mp_production_fichar/migrations/18.0.1.0.0/post-migrate.py new file mode 100644 index 00000000..4b4ba2d3 --- /dev/null +++ b/ina_mp_production_fichar/migrations/18.0.1.0.0/post-migrate.py @@ -0,0 +1,120 @@ +# Copyright 2026 Inael +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +import logging + +_logger = logging.getLogger(__name__) + + +def _table_exists(cr, table_name): + cr.execute( + "SELECT 1 FROM information_schema.tables WHERE table_name = %s", + (table_name,), + ) + return bool(cr.fetchone()) + + +def _column_exists(cr, table_name, column_name): + cr.execute( + "SELECT 1 FROM information_schema.columns " + "WHERE table_name = %s AND column_name = %s", + (table_name, column_name), + ) + return bool(cr.fetchone()) + + +def _migrate_perfil(cr): + """hr_employee_fichar -> columnas nuevas de hr_employee.""" + if not _table_exists(cr, "hr_employee_fichar"): + _logger.info( + "ina_mp_production_fichar: no existe hr_employee_fichar, nada " + "que migrar del perfil." + ) + return + + candidate_columns = [ + "tipo", + "workorder_id", + "repara_id", + "laser_id", + "improductivo", + "puesto_id", + "entrada_ultima", + "salida_ultima", + ] + columns = [ + c + for c in candidate_columns + if _column_exists(cr, "hr_employee_fichar", c) + and _column_exists(cr, "hr_employee", c) + ] + if not columns: + _logger.warning( + "ina_mp_production_fichar: hr_employee_fichar existe pero no " + "hay columnas migrables en comun con hr_employee." + ) + return + + # Los nombres de columna no se pueden pasar como parametro %s de + # psycopg2 (eso solo vale para valores, no para identificadores). "columns" + # sale de una lista blanca fija (candidate_columns) filtrada por + # _column_exists, no de entrada de usuario, asi que es seguro. + set_clause = ", ".join(f"{c} = hef.{c}" for c in columns) + query = f""" + UPDATE hr_employee he + SET {set_clause} + FROM hr_employee_fichar hef + WHERE hef.employee_id = he.id + """ + cr.execute(query) # pylint: disable=sql-injection + _logger.info( + "ina_mp_production_fichar: %s empleados actualizados con su perfil " + "de fichar antiguo (%s)", + cr.rowcount, + ", ".join(columns), + ) + + +def _migrate_historico(cr): + """hr_fichar_historico.fichar_id (perfil) -> employee_id (empleado).""" + if not _table_exists(cr, "hr_fichar_historico"): + return + if not _column_exists(cr, "hr_fichar_historico", "fichar_id"): + _logger.info( + "ina_mp_production_fichar: hr_fichar_historico ya no tiene " + "fichar_id, nada que repuntar." + ) + return + if not _table_exists(cr, "hr_employee_fichar"): + _logger.warning( + "ina_mp_production_fichar: hr_fichar_historico.fichar_id existe " + "pero hr_employee_fichar no. No se puede repuntar a " + "employee_id automaticamente." + ) + return + + cr.execute( + """ + UPDATE hr_fichar_historico hfh + SET employee_id = hef.employee_id + FROM hr_employee_fichar hef + WHERE hfh.fichar_id = hef.id + AND hfh.employee_id IS NULL + """ + ) + _logger.info( + "ina_mp_production_fichar: %s lineas de historico repuntadas de " + "fichar_id a employee_id", + cr.rowcount, + ) + + +def migrate(cr, version): + _migrate_perfil(cr) + _migrate_historico(cr) + _logger.info( + "ina_mp_production_fichar: migracion %s completada. Las tablas/" + "columnas antiguas no se borran aqui explicitamente; Odoo las " + "limpiara solo al terminar de cargar los modulos.", + version, + ) diff --git a/ina_mp_production_fichar/models/__init__.py b/ina_mp_production_fichar/models/__init__.py new file mode 100644 index 00000000..dfd99ef0 --- /dev/null +++ b/ina_mp_production_fichar/models/__init__.py @@ -0,0 +1,7 @@ +from . import hr_puesto_fichar +from . import hr_employee +from . import mrp_improductivo +from . import mrp_workorder +from . import mrp_workcenter_productivity +from . import stock_scrap +from . import stock_move diff --git a/ina_mp_production_fichar/models/hr_employee.py b/ina_mp_production_fichar/models/hr_employee.py new file mode 100644 index 00000000..8fbdadcc --- /dev/null +++ b/ina_mp_production_fichar/models/hr_employee.py @@ -0,0 +1,679 @@ +# Copyright 2026 Inael +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from odoo import fields, models + + +class HrEmployee(models.Model): + _inherit = "hr.employee" + + fichar = fields.Selection( + selection=[ + ("no", "No Ficha"), + ("oficina", "Oficina/Almacen"), + ("fabrica", "Fabrica"), + ], + string="Fichar en", + default="fabrica", + ) + + entrada_ultima = fields.Datetime("Ultima Entrada") + salida_ultima = fields.Datetime("Ultima Salida") + proximo_movi = fields.Selection( + selection=[("Entrada", "Entrada"), ("Salida", "Salida")], + string="Tienes que hacer", + default="Entrada", + ) + # Definicion real: compute="_compute_presencia" (ver metodo comentado + # mas abajo). + + tipo = fields.Selection( + selection=[ + ("of", "En Fabricacion"), + ("repara", "En Reparacion"), + ("laser", "En Laser"), + ("impro", "En Improductivo"), + ("no", "No tienes Orden"), + ], + string="Orden Actual", + default="no", + ) + workorder_id = fields.Many2one( + comodel_name="mrp.workorder", string="Orden de Trabajo" + ) + production_id = fields.Many2one( + comodel_name="mrp.production", string="OF", related="workorder_id.production_id" + ) + repara_id = fields.Many2one(comodel_name="repair.order", string="Reparacion") + laser_id = fields.Many2one(comodel_name="order.olaser", string="Laser") + improductivo = fields.Many2one(comodel_name="mrp.workcenter.productivity.loss") + puesto_id = fields.Many2one(comodel_name="hr.puesto.fichar") + + ot_qty_aproducir = fields.Float( + "Cant. a producir", digits=(9, 2), related="workorder_id.qty_production" + ) + ot_qty_comunicada = fields.Float("Cant. Comunicada", digits=(9, 2)) + # Definicion real: compute="_compute_ot_qty_comunicada" (ver metodo + # comentado mas abajo). + ot_qty_producida = fields.Float( + "Cant. Producida", digits=(9, 2), related="workorder_id.qty_produced" + ) + ot_product_id = fields.Many2one( + comodel_name="product.product", related="workorder_id.product_id" + ) + + la_qty_aproducir = fields.Float( + "Cant. a producir", digits=(9, 2), related="laser_id.cantidad" + ) + la_qty_comunicada = fields.Float( + "Cant. Comunicada", digits=(9, 2), related="laser_id.comunicado" + ) + la_qty_producida = fields.Float( + "Cant. Producida", digits=(9, 2), related="laser_id.terminado" + ) + la_product_id = fields.Many2one( + comodel_name="product.product", related="laser_id.product_id" + ) + + editar_orden = fields.Boolean("Editar orden", default=False) + # Definicion real: compute="_compute_grupos" (ver metodo comentado mas + # abajo). + + line_ids = fields.One2many( + comodel_name="hr.fichar.historico", + inverse_name="employee_id", + string="Lineas Fichar", + copy=False, + ) + + falta_material = fields.Boolean( + related="workorder_id.falta_material", + ) + + # --- Logica operativa comentada: solo referencia, no se ejecuta. --- + + # @api.depends() + # def _compute_presencia(self): + # for r in self: + # attendance = self.env["hr.attendance"].search( + # [ + # ("employee_id", "=", r.id), + # ("check_out", "=", False), + # ], + # limit=1, + # ) + # r.proximo_movi = "Salida" if attendance else "Entrada" + + # def _compute_grupos(self): + # for r in self: + # r.editar_orden = self.env.user.has_group( + # "ina_mc_permisos.group_inael_fabrica_respon_fichar" + # ) + + # @api.depends("workorder_id.aceptada", "workorder_id.rechazada") + # def _compute_ot_qty_comunicada(self): + # for r in self: + # r.ot_qty_comunicada = r.workorder_id.aceptada + r.workorder_id.rechazada + + # def _get_ahora(self): + # ff = datetime.utcnow().replace(microsecond=0) + # tz = pytz.timezone("Europe/Madrid") + # ff_local = pytz.utc.localize(ff).astimezone(tz) + # ahora = ff + # if ff_local.minute >= 30 and ff_local.hour in (5, 13): + # ahora = ff.replace(minute=0, second=0) + timedelta(hours=1) + # return ahora + + # def _get_attendance_abierta(self, employee_id): + # return self.env["hr.attendance"].search( + # [ + # ("employee_id", "=", employee_id), + # ("check_out", "=", False), + # ], + # order="check_in DESC", + # limit=1, + # ) + + def action_entrada_salida(self): + return True + # self.ensure_one() + # ff = datetime.utcnow().replace(microsecond=0) + # + # w_usuario = self.env.user.id + # w_puesto = w_centro = False + # puesto = self.env["hr.puesto.fichar"].search( + # [("user_id", "=", w_usuario)], limit=1 + # ) + # if puesto: + # w_puesto = puesto.id + # w_centro = puesto.centro_id.id + # + # attendance = self._get_attendance_abierta(self.id) + # + # if attendance: + # attendance.write({"check_out": ff}) + # self.write({"salida_ultima": ff, "puesto_id": w_puesto}) + # w_estado = "interrumpir" + # else: + # self.env["hr.attendance"].create( + # {"employee_id": self.id, "check_in": ff} + # ) + # self.write({"entrada_ultima": ff, "puesto_id": w_puesto}) + # w_estado = "abrir" + # + # if self.workorder_id: + # if w_estado == "interrumpir": + # self.comuni_fab(self, 0, 0, False, True, "interrumpida") + # else: + # self.comuni_fab(self, 0, 0, False, True, "abrir") + # elif self.repara_id: + # if w_estado == "interrumpir": + # self.comuni_rep(self, 0, False, True, "interrumpida") + # else: + # self.comuni_rep(self, 0, False, True, "abrir") + # elif self.laser_id: + # if w_estado == "interrumpir": + # self.comuni_las(self, 0, False, True, "interrumpida") + # else: + # self.comuni_las(self, 0, False, True, "abrir") + # elif self.improductivo: + # if w_estado == "interrumpir": + # self.comuni_imp(self, 0, False, True, "interrumpida") + # else: + # self.comuni_imp(self, 0, False, True, "abrir") + # + # if w_estado == "abrir" and self.tipo == "no" and self.fichar == "fabrica": + # return { + # "name": "Fichar Orden", + # "type": "ir.actions.act_window", + # "res_model": "wiz.fichar.orden", + # "view_mode": "form", + # "target": "new", + # "context": dict( + # self.env.context, + # active_ids=self.ids, + # active_model="hr.employee", + # ), + # } + # return True + + def action_ver_a_consumir(self): + return True + # self.ensure_one() + # return { + # "name": "Material a Consumir", + # "type": "ir.actions.act_window", + # "res_model": "stock.move", + # "view_mode": "list", + # "view_id": self.env.ref( + # "ina_mp_production_fichar.ina_stock_move_fichar_view" + # ).id, + # "target": "current", + # "domain": [("workorder_id", "=", self.workorder_id.id)], + # } + + def action_parada(self): + return True + # self.ensure_one() + # for r in self: + # if r.workorder_id: + # self.comuni_fab(r, 0, 0, False, True, "procesada") + # elif r.repara_id: + # self.comuni_rep(r, 0, False, True, "cerrada") + # elif r.laser_id: + # self.comuni_las(r, 0, False, True, "cerrada") + # elif r.improductivo: + # self.comuni_imp(r, 0, False, True, "cerrada") + # return self.action_fichar_nueva() + + def action_completada(self): + return True + # for r in self: + # if r.workorder_id: + # r.workorder_id.write({"falta_material": False}) + # return True + + def action_fichar_nueva(self): + return True + # self.ensure_one() + # return { + # "name": "Fichar Orden", + # "type": "ir.actions.act_window", + # "res_model": "wiz.fichar.orden", + # "view_mode": "form", + # "target": "new", + # "context": dict( + # self.env.context, + # active_ids=self.ids, + # active_model="hr.employee", + # ), + # } + + def button_scrap(self): + return True + # self.ensure_one() + # production = self.workorder_id.production_id + # product_ids = ( + # production.move_raw_ids.filtered( + # lambda x: x.state not in ("done", "cancel") + # ) + # | production.move_finished_ids.filtered(lambda x: x.state == "done") + # ).mapped("product_id").ids + # return { + # "name": _("Scrap"), + # "view_mode": "form", + # "res_model": "stock.scrap", + # "view_id": self.env.ref("stock.stock_scrap_form_view2").id, + # "type": "ir.actions.act_window", + # "context": { + # "default_workorder_id": self.workorder_id.id, + # "default_puesto_id": self.puesto_id.id, + # "default_production_id": production.id, + # "product_ids": product_ids, + # }, + # "target": "new", + # } + + # def _get_loss_productive(self): + # return self.env["mrp.workcenter.productivity.loss"].search( + # [("loss_type", "=", "productive")], limit=1 + # ) + + # def grabar_fab(self, r, ahora): + # self.env["mrp.workcenter.productivity"].create( + # { + # "loss_id": self._get_loss_productive().id, + # "workcenter_id": r.workorder_id.workcenter_id.id, + # "workorder_id": r.workorder_id.id, + # "employee_id": r.id, + # "date_start": ahora, + # "aceptada": 0, + # "estado": "activa", + # } + # ) + # self.env["hr.fichar.historico"].create( + # { + # "employee_id": r.id, + # "tipo": "of", + # "orden": r.workorder_id.name, + # "workorder_id": r.workorder_id.id, + # "fecha_ini": ahora, + # "aceptada": 0, + # "rechazada": 0, + # "puesto_id": r.puesto_id.id, + # "estado": "activa", + # } + # ) + + # def grabar_rep(self, r, ahora): + # self.env["mrp.repair.tiempos"].create( + # { + # "repara_id": r.repara_id.id, + # "workcenter_id": r.puesto_id.centro_id.id, + # "employee_id": r.id, + # "entrada": ahora, + # "estado": "activa", + # } + # ) + # self.env["hr.fichar.historico"].create( + # { + # "employee_id": r.id, + # "tipo": "repara", + # "orden": r.repara_id.name, + # "fecha_ini": ahora, + # "puesto_id": r.puesto_id.id, + # "estado": "activa", + # } + # ) + + # def grabar_las(self, r, ahora): + # self.env["order.olaser.tiempos"].create( + # { + # "laser_tiempos_id": r.laser_id.id, + # "employee_id": r.id, + # "entrada": ahora, + # "estado": "activa", + # } + # ) + # self.env["hr.fichar.historico"].create( + # { + # "employee_id": r.id, + # "tipo": "laser", + # "orden": r.laser_id.name, + # "fecha_ini": ahora, + # "puesto_id": r.puesto_id.id, + # "estado": "activa", + # } + # ) + + # def grabar_imp(self, r, ahora): + # self.env["mrp.improductivo"].create( + # { + # "loss_id": r.improductivo.id, + # "workcenter_id": r.puesto_id.centro_id.id, + # "employee_id": r.id, + # "entrada": ahora, + # "estado": "activa", + # } + # ) + # self.env["hr.fichar.historico"].create( + # { + # "employee_id": r.id, + # "tipo": "impro", + # "orden": r.improductivo.name, + # "fecha_ini": ahora, + # "puesto_id": r.puesto_id.id, + # "estado": "activa", + # } + # ) + + # def comuni_fab( + # self, r_fichar, w_canti, w_rechazada, w_refer_faltan, solo_parar, w_estado + # ): + # ahora = self._get_ahora() + # ref_ids = self.env["mrp.workcenter.productivity"].search( + # [ + # ("workorder_id", "=", r_fichar.workorder_id.id), + # ("estado", "in", ("activa", "interrumpida")), + # ("employee_id", "=", r_fichar.id), + # ], + # limit=5, + # order="date_start DESC", + # ) + # if not ref_ids: + # raise exceptions.UserError( + # _("No se ha encontrado ningun fichaje en tiempos.") + # ) + # sw = 0 + # for r in ref_ids: + # if w_estado == "abrir": + # r.write({"estado": "procesada"}) + # if r.workorder_id.state in ("done", "cancel"): + # r_fichar.write({"workorder_id": False, "tipo": "no"}) + # return True + # self.grabar_fab(r_fichar, ahora) + # return True + # + # ahora = self._get_ahora() + # sw += 1 + # if ahora < r.date_start: + # ahora = r.date_start + # r.write( + # { + # "date_end": ahora, + # "aceptada": w_canti, + # "rechazada": w_rechazada, + # "estado": w_estado, + # "falta_material": bool(w_refer_faltan), + # } + # ) + # if r.duration > 540: + # r.date_end = r.date_start + # + # w_acep = r.workorder_id.aceptada + w_canti + # w_recha = r.workorder_id.rechazada + w_rechazada + # w_refer2 = (r.workorder_id.refer_falta_material or "") + ( + # (w_refer_faltan.strip() + " ") if w_refer_faltan else "" + # ) + # r.workorder_id.write( + # { + # "state": "progress", + # "falta_material": bool(w_refer2), + # "refer_falta_material": w_refer2, + # "aceptada": w_acep, + # "rechazada": w_recha, + # } + # ) + # historico = self.env["hr.fichar.historico"].search( + # [ + # ("employee_id", "=", r_fichar.id), + # ("tipo", "=", "of"), + # ("fecha_fin", "=", False), + # ], + # limit=1, + # order="id DESC", + # ) + # vals_hist = { + # "fecha_fin": ahora, + # "aceptada": w_canti, + # "rechazada": w_rechazada, + # "estado": w_estado, + # "workorder_id": r_fichar.workorder_id.id, + # } + # if historico: + # historico.write(vals_hist) + # else: + # vals_hist.update({ + # "employee_id": r_fichar.id, + # "tipo": "of", + # "orden": r_fichar.workorder_id.name, + # "puesto_id": r_fichar.puesto_id.id, + # }) + # self.env["hr.fichar.historico"].create(vals_hist) + # if w_estado == "interrumpida": + # return True + # if solo_parar: + # r_fichar.write({"tipo": "no", "workorder_id": False}) + # elif sw == 1: + # self.grabar_fab(r_fichar, ahora) + # return True + + # def comuni_las(self, r_fichar, w_canti, w_refer_faltan, solo_parar, w_estado): + # ahora = self._get_ahora() + # ref_ids = self.env["order.olaser.tiempos"].search( + # [ + # ("laser_tiempos_id", "=", r_fichar.laser_id.id), + # ("estado", "in", ("activa", "interrumpida")), + # ("employee_id", "=", r_fichar.id), + # ], + # limit=5, + # order="entrada DESC", + # ) + # if not ref_ids: + # raise exceptions.UserError( + # _("No se ha encontrado ningun fichaje en tiempos.") + # ) + # sw = 0 + # for r in ref_ids: + # if w_estado == "abrir": + # r.write({"estado": "cerrada"}) + # self.grabar_las(r_fichar, ahora) + # return True + # + # ahora = self._get_ahora() + # sw += 1 + # r.write({"salida": ahora, "comunicado": w_canti, "estado": w_estado}) + # if r.tiempo > 9: + # r.salida = r.entrada + # r.laser_tiempos_id.write( + # { + # "comunicado": r.laser_tiempos_id.comunicado + w_canti, + # "state": "proceso", + # } + # ) + # historico = self.env["hr.fichar.historico"].search( + # [ + # ("employee_id", "=", r_fichar.id), + # ("tipo", "=", "laser"), + # ("fecha_fin", "=", False), + # ], + # limit=1, + # order="id DESC", + # ) + # vals_hist = {"fecha_fin": ahora, "aceptada": w_canti, "estado": w_estado} + # if historico: + # historico.write(vals_hist) + # else: + # vals_hist.update({ + # "employee_id": r_fichar.id, + # "tipo": "laser", + # "orden": r_fichar.laser_id.name, + # "puesto_id": r_fichar.puesto_id.id, + # }) + # self.env["hr.fichar.historico"].create(vals_hist) + # if w_estado == "interrumpida": + # return True + # if solo_parar: + # r_fichar.write({"tipo": "no", "laser_id": False}) + # elif sw == 1: + # self.grabar_las(r_fichar, ahora) + # return True + + # def comuni_rep(self, r_fichar, w_canti, w_refer_faltan, solo_parar, w_estado): + # ahora = self._get_ahora() + # ref_ids = self.env["mrp.repair.tiempos"].search( + # [ + # ("repara_id", "=", r_fichar.repara_id.id), + # ("estado", "in", ("activa", "interrumpida")), + # ("employee_id", "=", r_fichar.id), + # ], + # limit=5, + # order="entrada DESC", + # ) + # if not ref_ids: + # raise exceptions.UserError( + # _("No se ha encontrado ningun fichaje en tiempos.") + # ) + # sw = 0 + # for r in ref_ids: + # if w_estado == "abrir": + # r.write({"estado": "cerrada"}) + # self.grabar_rep(r_fichar, ahora) + # return True + # + # ahora = self._get_ahora() + # sw += 1 + # r.write({"salida": ahora, "estado": w_estado}) + # if r.tiempo > 9: + # r.salida = r.entrada + # historico = self.env["hr.fichar.historico"].search( + # [ + # ("employee_id", "=", r_fichar.id), + # ("tipo", "=", "repara"), + # ("fecha_fin", "=", False), + # ], + # limit=1, + # order="id DESC", + # ) + # vals_hist = {"fecha_fin": ahora, "estado": w_estado} + # if historico: + # historico.write(vals_hist) + # else: + # vals_hist.update({ + # "employee_id": r_fichar.id, + # "tipo": "repara", + # "orden": r_fichar.repara_id.name, + # "puesto_id": r_fichar.puesto_id.id, + # }) + # self.env["hr.fichar.historico"].create(vals_hist) + # if w_estado == "interrumpida": + # return True + # if solo_parar: + # r_fichar.write({"tipo": "no", "repara_id": False}) + # elif sw == 1: + # self.grabar_rep(r_fichar, ahora) + # return True + + # def comuni_imp(self, r_fichar, w_canti, w_refer_faltan, solo_parar, w_estado): + # ahora = self._get_ahora() + # ref_ids = self.env["mrp.improductivo"].search( + # [ + # ("estado", "in", ("activa", "interrumpida")), + # ("employee_id", "=", r_fichar.id), + # ], + # limit=5, + # order="entrada DESC", + # ) + # if not ref_ids: + # raise exceptions.UserError( + # _("No se ha encontrado ningun fichaje en improductivo.") + # ) + # sw = 0 + # for r in ref_ids: + # if w_estado == "abrir": + # r.write({"estado": "cerrada"}) + # self.grabar_imp(r_fichar, ahora) + # return True + # + # ahora = self._get_ahora() + # sw += 1 + # r.write({"salida": ahora, "estado": w_estado}) + # if r.tiempo > 9: + # r.salida = r.entrada + # historico = self.env["hr.fichar.historico"].search( + # [ + # ("employee_id", "=", r_fichar.id), + # ("tipo", "=", "impro"), + # ("fecha_fin", "=", False), + # ], + # limit=1, + # order="id DESC", + # ) + # vals_hist = {"fecha_fin": ahora, "estado": w_estado} + # if historico: + # historico.write(vals_hist) + # else: + # vals_hist.update({ + # "employee_id": r_fichar.id, + # "tipo": "impro", + # "orden": r_fichar.improductivo.name, + # "puesto_id": r_fichar.puesto_id.id, + # }) + # self.env["hr.fichar.historico"].create(vals_hist) + # if w_estado == "interrumpida": + # return True + # if solo_parar: + # r_fichar.write({"tipo": "no", "improductivo": False}) + # elif sw == 1: + # self.grabar_imp(r_fichar, ahora) + # return True + + +class HrFicharHistorico(models.Model): + _name = "hr.fichar.historico" + _description = "Historico de fichajes" + _order = "id DESC" + + employee_id = fields.Many2one( + comodel_name="hr.employee", + string="Empleado", + ondelete="cascade", + ) + tipo = fields.Selection( + selection=[ + ("of", "Fabricacion"), + ("repara", "Reparacion"), + ("laser", "Laser"), + ("impro", "Improductivo"), + ("no", ""), + ], + string="Fichado en", + default="no", + ) + orden = fields.Char() + workorder_id = fields.Many2one( + comodel_name="mrp.workorder", + string="Orden de Trabajo", + ondelete="set null", + ) + fecha_ini = fields.Datetime("Inicio") + fecha_fin = fields.Datetime("Final") + aceptada = fields.Float("Cantidad Aceptada") + rechazada = fields.Float("Cantidad Rechazada") + puesto_id = fields.Many2one(comodel_name="hr.puesto.fichar") + estado = fields.Char() + + # --- Logica operativa comentada: solo referencia, no se ejecuta. Al + # borrar una linea ya no se resta automaticamente de la OT lo que esa + # linea hubiera sumado. --- + + # def unlink(self): + # for r in self: + # if r.tipo == "of" and r.workorder_id and (r.aceptada or r.rechazada): + # r.workorder_id.write( + # { + # "aceptada": max(r.workorder_id.aceptada - r.aceptada, 0), + # "rechazada": max(r.workorder_id.rechazada - r.rechazada, 0), + # } + # ) + # return super().unlink() diff --git a/ina_mp_production_fichar/models/hr_puesto_fichar.py b/ina_mp_production_fichar/models/hr_puesto_fichar.py new file mode 100644 index 00000000..891129fe --- /dev/null +++ b/ina_mp_production_fichar/models/hr_puesto_fichar.py @@ -0,0 +1,28 @@ +# Copyright 2026 Inael +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). +from odoo import fields, models + + +class HrPuestoFichar(models.Model): + _name = "hr.puesto.fichar" + _description = "Puestos para fichar" + + name = fields.Char(required=True) + user_id = fields.Many2one( + comodel_name="res.users", string="Usuario Odoo", required=True + ) + centro_id = fields.Many2one( + comodel_name="mrp.workcenter", string="Centro asociado", required=True + ) + location_id = fields.Many2one( + comodel_name="stock.location", + string="Ubicacion", + domain="[('usage', '=', 'internal')]", + required=True, + ) + scrap_location_id = fields.Many2one( + comodel_name="stock.location", + string="Ubicacion Deshechos", + domain="[('scrap_location', '=', True)]", + required=True, + ) diff --git a/ina_mp_production_fichar/models/mrp_improductivo.py b/ina_mp_production_fichar/models/mrp_improductivo.py new file mode 100644 index 00000000..9eb0d04d --- /dev/null +++ b/ina_mp_production_fichar/models/mrp_improductivo.py @@ -0,0 +1,48 @@ +# Copyright 2026 Inael +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). +from odoo import fields, models + + +class MrpImproductivo(models.Model): + _name = "mrp.improductivo" + _description = "Tiempos Improductivos" + _order = "entrada DESC" + + # --- Logica operativa comentada: solo referencia, no se ejecuta. --- + + # @api.depends("entrada", "salida") + # def _compute_tiempo(self): + # for r in self: + # if r.entrada and r.salida: + # delta = r.salida - r.entrada + # r.tiempo = delta.total_seconds() / 3600.0 + # else: + # r.tiempo = 0.0 + + loss_id = fields.Many2one( + comodel_name="mrp.workcenter.productivity.loss", + string="Tipo Improductivo", + ) + workcenter_id = fields.Many2one( + comodel_name="mrp.workcenter", string="Centro de Produccion" + ) + employee_id = fields.Many2one(comodel_name="hr.employee", string="Empleado") + entrada = fields.Datetime(string="Inicio") + salida = fields.Datetime(string="Final") + tiempo = fields.Float( + string="Duracion HH:MM", + digits=(12, 6), + ) + # Definicion real: compute="_compute_tiempo", store=True (ver metodo + # comentado mas arriba). + estado = fields.Selection( + selection=[ + ("pendiente", "Pendiente"), + ("activa", "Activa"), + ("interrumpida", "Interrumpida"), + ("cerrada", "Cerrada"), + ("cancelada", "Cancelada"), + ], + default="pendiente", + required=True, + ) diff --git a/ina_mp_production_fichar/models/mrp_workcenter_productivity.py b/ina_mp_production_fichar/models/mrp_workcenter_productivity.py new file mode 100644 index 00000000..dc58c068 --- /dev/null +++ b/ina_mp_production_fichar/models/mrp_workcenter_productivity.py @@ -0,0 +1,19 @@ +# Copyright 2026 Inael +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). +from odoo import fields, models + + +class MrpWorkcenterProductivity(models.Model): + _inherit = "mrp.workcenter.productivity" + + aceptada = fields.Float("Cant. Aceptada", digits=(9, 2)) + rechazada = fields.Float("Cant. Rechazada", digits=(9, 2)) + falta_material = fields.Boolean(default=False) + estado = fields.Selection( + selection=[ + ("activa", "Activa"), + ("interrumpida", "Interrumpida"), + ("procesada", "Procesada"), + ("cancelada", "Cancelada"), + ], + ) diff --git a/ina_mp_production_fichar/models/mrp_workorder.py b/ina_mp_production_fichar/models/mrp_workorder.py new file mode 100644 index 00000000..a07fe064 --- /dev/null +++ b/ina_mp_production_fichar/models/mrp_workorder.py @@ -0,0 +1,12 @@ +# Copyright 2026 Inael +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). +from odoo import fields, models + + +class MrpWorkorder(models.Model): + _inherit = "mrp.workorder" + + falta_material = fields.Boolean(default=False) + refer_falta_material = fields.Text("Referencias que Faltan") + aceptada = fields.Float("Cant. Aceptada", digits=(9, 2)) + rechazada = fields.Float("Cant. Rechazada", digits=(9, 2)) diff --git a/ina_mp_production_fichar/models/stock_move.py b/ina_mp_production_fichar/models/stock_move.py new file mode 100644 index 00000000..98066c8d --- /dev/null +++ b/ina_mp_production_fichar/models/stock_move.py @@ -0,0 +1,11 @@ +# Copyright 2026 Inael +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). +from odoo import models + + +class StockMove(models.Model): + _inherit = "stock.move" + + def action_ver_stock(self): + return True + # return self.mapped("product_id").action_open_quants() diff --git a/ina_mp_production_fichar/models/stock_scrap.py b/ina_mp_production_fichar/models/stock_scrap.py new file mode 100644 index 00000000..b068cf88 --- /dev/null +++ b/ina_mp_production_fichar/models/stock_scrap.py @@ -0,0 +1,32 @@ +# Copyright 2026 Inael +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). +from odoo import fields, models + + +class StockScrap(models.Model): + _inherit = "stock.scrap" + + puesto_id = fields.Many2one(comodel_name="hr.puesto.fichar", string="Puesto") + motivo_rechazo = fields.Selection( + selection=[ + ("no cumple plano", "No cumple plano"), + ("pintura", "Pintura"), + ("fallo de montaje interno", "Fallo de montaje interno"), + ("rebabas", "Rebabas"), + ("material obsoleto", "Material Obsoleto"), + ("utillaje", "Utillaje"), + ("rotura durante montaje", "Rotura durante montaje interno"), + ("sobrantes", "Material Sobrante"), + ("ensayos", "Ensayos"), + ], + string="Motivo", + default="no cumple plano", + ) + + # --- Logica operativa comentada: solo referencia, no se ejecuta. --- + + # @api.onchange("puesto_id") + # def _onchange_puesto_id(self): + # if self.puesto_id: + # self.location_id = self.puesto_id.location_id + # self.scrap_location_id = self.puesto_id.scrap_location_id diff --git a/ina_mp_production_fichar/pyproject.toml b/ina_mp_production_fichar/pyproject.toml new file mode 100644 index 00000000..4231d0cc --- /dev/null +++ b/ina_mp_production_fichar/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/ina_mp_production_fichar/security/ir.model.access.csv b/ina_mp_production_fichar/security/ir.model.access.csv new file mode 100644 index 00000000..8d7a3359 --- /dev/null +++ b/ina_mp_production_fichar/security/ir.model.access.csv @@ -0,0 +1,5 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_hr_puesto_fichar_user,hr.puesto.fichar user,model_hr_puesto_fichar,base.group_user,1,1,1,1 +access_hr_fichar_historico_user,hr.fichar.historico user,model_hr_fichar_historico,base.group_user,1,1,1,1 +access_mrp_improductivo_user,mrp.improductivo user,model_mrp_improductivo,base.group_user,1,1,1,1 +access_wiz_fichar_orden_user,wiz.fichar.orden user,model_wiz_fichar_orden,base.group_user,1,1,1,1 diff --git a/ina_mp_production_fichar/static/description/icon.png b/ina_mp_production_fichar/static/description/icon.png new file mode 100644 index 00000000..094d057a Binary files /dev/null and b/ina_mp_production_fichar/static/description/icon.png differ diff --git a/ina_mp_production_fichar/views/hr_employee_fichar_views.xml b/ina_mp_production_fichar/views/hr_employee_fichar_views.xml new file mode 100644 index 00000000..5b74d059 --- /dev/null +++ b/ina_mp_production_fichar/views/hr_employee_fichar_views.xml @@ -0,0 +1,247 @@ + + + + hr.entrada.salida.tree + hr.employee + + + + +