diff --git a/fieldservice_subcontracting/README.rst b/fieldservice_subcontracting/README.rst new file mode 100644 index 0000000000..e318b467ee --- /dev/null +++ b/fieldservice_subcontracting/README.rst @@ -0,0 +1,259 @@ +============================== +Field Service - Subcontracting +============================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:41b5c72d6fa5b02d85ebaad0832f8fa7d1c9c22aa7709b4d38c602797304345b + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Ffield--service-lightgray.png?logo=github + :target: https://github.com/OCA/field-service/tree/18.0/fieldservice_subcontracting + :alt: OCA/field-service +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/field-service-18-0/field-service-18-0-fieldservice_subcontracting + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/field-service&target_branch=18.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module integrates Field Service with Purchasing to automate the +subcontracting workflow. + +It allows users to: + +1. Mark Field Service workers as subcontractors. +2. Configure a service product on Field Service order templates. +3. Create draft Purchase Orders for subcontracted orders. +4. Set the Purchase Order Expected Arrival from the Field Service Order + Scheduled End. +5. Keep the Purchase Order Expected Arrival synchronized when the Field + Service Order planned dates change. +6. Update delivered quantities from Field Service timesheets. +7. Reassign workers on orders with linked subcontract Purchase Orders. + +The module uses ``fieldservice_stage_server_action`` to trigger +automation on stage transitions. + +**Table of contents** + +.. contents:: + :local: + +Use Cases / Context +=================== + +1. Field Service operations sometimes require external workers or + companies to perform part of the service delivery. +2. A Field Service Order can be assigned to a subcontractor worker. +3. The company can create a Purchase Order for that external service + without manually duplicating information between Field Service and + Purchase. +4. The Purchase Order Expected Arrival is set from the Field Service + Order Scheduled End (``scheduled_date_end``). +5. When the Field Service Order planned dates change, the linked + subcontract Purchase Order Expected Arrival is updated to match the + current Scheduled End. +6. The Purchase Order remains under the standard Odoo purchase flow: it + is created as a draft, reviewed and confirmed manually, and later + billed by the vendor. +7. Timesheet hours logged on the Field Service Order can be pushed to + the Purchase Order line as delivered quantity. +8. Vendor bills based on received quantities can then be created with + the correct quantity. +9. Worker reassignment is available for orders with at least one linked + subcontract Purchase Order, even if all linked subcontract Purchase + Orders are cancelled. +10. Worker reassignment is only available before the Field Service Order + reaches a closed stage. + +Configuration +============= + +Worker setup +------------ + +1. Go to Field Service > Master Data > Workers. +2. Open the worker that represents the external vendor. +3. Make sure the related partner is configured as a vendor. +4. Enable Is Subcontractor on the worker. + +|Subcontractor checkbox on the worker form| + +Template setup +-------------- + +1. Go to Field Service > Master Data > Templates. +2. Open the template that can create subcontract Purchase Orders. +3. Set the Subcontracting Service Product. +4. Use a service product that is purchased based on received quantities. +5. Configure a vendor price on the product for each subcontractor + partner that can receive a Purchase Order. +6. If vendor bills are controlled by received quantities, update the + delivered quantity before creating the vendor bill. + +|Subcontracting product on the Field Service template| + +Stage automation +---------------- + +1. Go to Field Service > Configuration > Stages. +2. Open the stage that should create the draft Purchase Order. +3. Assign the server action FSO: Create Subcontract PO. + +|Server action to create the subcontract Purchase Order| + +1. Open the closing stage that should update delivered quantities. +2. Assign the server action FSO: Update Subcontract PO Delivered Qty. +3. This action copies timesheet hours to the Purchase Order delivered + quantity. + +|Server action to update subcontract delivered quantity| + +.. |Subcontractor checkbox on the worker form| image:: https://raw.githubusercontent.com/OCA/field-service/18.0/fieldservice_subcontracting/static/readme/worker_subcontractor.png +.. |Subcontracting product on the Field Service template| image:: https://raw.githubusercontent.com/OCA/field-service/18.0/fieldservice_subcontracting/static/readme/template_subcontract_product.png +.. |Server action to create the subcontract Purchase Order| image:: https://raw.githubusercontent.com/OCA/field-service/18.0/fieldservice_subcontracting/static/readme/stage_create_subcontract_po_action.png +.. |Server action to update subcontract delivered quantity| image:: https://raw.githubusercontent.com/OCA/field-service/18.0/fieldservice_subcontracting/static/readme/stage_update_subcontract_po_qty_action.png + +Usage +===== + +Create the subcontract Purchase Order +------------------------------------- + +1. Create or open a Field Service Order that uses a template configured + for subcontracting. +2. Assign a subcontractor worker. +3. Move the order to the stage configured to create the subcontract + Purchase Order. + +|Field Service Order buttons for subcontracting| + +1. Use the Purchase Order smart button to open the generated draft + Purchase Order. +2. Review the Purchase Order. Its Expected Arrival is set from the Field + Service Order Scheduled End (``scheduled_date_end``). +3. Confirm the Purchase Order manually. + +|Field Service Order smart button on the Purchase Order| + +1. If the Purchase Order is not created, check the Field Service Order + chatter. +2. Review the reason posted by the module. +3. Fix the missing configuration or worker data. +4. Move the order through the configured stage again if needed. + +Update the Purchase Order Expected Arrival +------------------------------------------ + +1. Change the Field Service Order planned dates. +2. Save the Field Service Order. +3. The active subcontract Purchase Order Expected Arrival is updated + with the current Field Service Order Scheduled End + (``scheduled_date_end``). +4. The generated Purchase Order line expected date is updated as well, + so the Purchase Order header keeps the same Expected Arrival. + +Update delivered quantities +--------------------------- + +1. Log timesheet hours on the Field Service Order. +2. Move the Field Service Order to the stage configured to update + subcontract delivered quantities. +3. The module updates the delivered quantity on the subcontract Purchase + Order line with the total timesheet hours of the Field Service Order. +4. The ordered quantity remains unchanged after the Purchase Order is + created. +5. Create the vendor bill after the delivered quantity has been updated + when the product bills based on received quantities. + +Reassign or cancel an order +--------------------------- + +1. Use the Reassign Worker button when an order with at least one + subcontract Purchase Order must be reassigned. +2. The Reassign Worker button remains available even if all linked + subcontract Purchase Orders are cancelled. +3. The Reassign Worker button is only available while the Field Service + Order is not in a closed stage. +4. If the Field Service Order is already in a closed stage, move it to + a non-closed stage before reassigning the worker, if the business + process allows it. +5. Select the new worker in the reassignment wizard. +6. Confirm the wizard. +7. The wizard cancels draft vendor bills linked to active subcontract + Purchase Orders before cancelling those Purchase Orders. +8. The wizard cancels active subcontract Purchase Orders. +9. If the new worker is also a subcontractor, the module creates a new + Purchase Order for that subcontractor. +10. To cancel a Field Service Order with active subcontract Purchase + Orders, use the standard cancel action. +11. Choose whether to cancel only the Field Service Order or also its + active subcontract Purchase Orders. +12. If there are posted vendor bills, manage the Purchase Orders and + vendor bills manually before reassigning or cancelling the Field + Service Order. + +.. |Field Service Order buttons for subcontracting| image:: https://raw.githubusercontent.com/OCA/field-service/18.0/fieldservice_subcontracting/static/readme/fso_purchase_order_buttons.png +.. |Field Service Order smart button on the Purchase Order| image:: https://raw.githubusercontent.com/OCA/field-service/18.0/fieldservice_subcontracting/static/readme/purchase_order_fso_button.png + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* Binhex + +Contributors +------------ + +- `Binhex `__: + + - Edilio Escalona Almira e.escalona@binhex.cloud + +Maintainers +----------- + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +.. |maintainer-edescalona| image:: https://github.com/edescalona.png?size=40px + :target: https://github.com/edescalona + :alt: edescalona + +Current `maintainer `__: + +|maintainer-edescalona| + +This module is part of the `OCA/field-service `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/fieldservice_subcontracting/__init__.py b/fieldservice_subcontracting/__init__.py new file mode 100644 index 0000000000..aee8895e7a --- /dev/null +++ b/fieldservice_subcontracting/__init__.py @@ -0,0 +1,2 @@ +from . import models +from . import wizards diff --git a/fieldservice_subcontracting/__manifest__.py b/fieldservice_subcontracting/__manifest__.py new file mode 100644 index 0000000000..c7bddd891d --- /dev/null +++ b/fieldservice_subcontracting/__manifest__.py @@ -0,0 +1,32 @@ +# Copyright 2026 Binhex +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +{ + "name": "Field Service - Subcontracting", + "version": "18.0.1.0.0", + "category": "Field Service", + "license": "AGPL-3", + "summary": "Auto-create Purchase Orders when FSOs are assigned to " + "subcontractor workers", + "author": "Binhex, Odoo Community Association (OCA)", + "website": "https://github.com/OCA/field-service", + "depends": [ + "fieldservice", + "fieldservice_project", + "fieldservice_timesheet", + "fieldservice_stage_server_action", + "purchase", + ], + "data": [ + "security/ir.model.access.csv", + "data/server_action_data.xml", + "wizards/fsm_order_cancel_confirm_views.xml", + "wizards/fsm_order_reassign_confirm_views.xml", + "views/fsm_person_views.xml", + "views/fsm_template_views.xml", + "views/fsm_order_views.xml", + "views/purchase_order_views.xml", + ], + "installable": True, + "development_status": "Beta", + "maintainers": ["edescalona"], +} diff --git a/fieldservice_subcontracting/data/server_action_data.xml b/fieldservice_subcontracting/data/server_action_data.xml new file mode 100644 index 0000000000..85950e6a3c --- /dev/null +++ b/fieldservice_subcontracting/data/server_action_data.xml @@ -0,0 +1,20 @@ + + + + + + FSO: Create Subcontract PO + + code + (record or records)._create_subcontract_po() + + + + + FSO: Update Subcontract PO Delivered Qty + + code + (record or records)._update_subcontract_po_qty() + + diff --git a/fieldservice_subcontracting/i18n/es.po b/fieldservice_subcontracting/i18n/es.po new file mode 100644 index 0000000000..24e811e0ad --- /dev/null +++ b/fieldservice_subcontracting/i18n/es.po @@ -0,0 +1,433 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * fieldservice_subcontracting +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 18.0\n" +"Report-Msgid-Bugs-To: \n" +"Last-Translator: \n" +"Language-Team: \n" +"Language: es\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#. module: fieldservice_subcontracting +#. odoo-python +#: code:addons/fieldservice_subcontracting/models/fsm_order.py:0 +msgid "" +"%(count)d draft vendor bill(s) cancelled before cancelling the subcontract " +"Purchase Order." +msgstr "" +"%(count)d factura(s) de proveedor en borrador cancelada(s) antes de cancelar " +"la orden de compra de subcontratación." + +#. module: fieldservice_subcontracting +#. odoo-python +#: code:addons/fieldservice_subcontracting/models/fsm_order.py:0 +msgid "(none)" +msgstr "(ninguna)" + +#. module: fieldservice_subcontracting +#: model_terms:ir.ui.view,arch_db:fieldservice_subcontracting.fsm_order_form_view_subcontracting +msgid "" +"Reassign\n" +" Worker" +msgstr "" +"Reasignar\n" +" Trabajador" + +#. module: fieldservice_subcontracting +#: model_terms:ir.ui.view,arch_db:fieldservice_subcontracting.fsm_order_reassign_confirm_form_view +msgid "Cancel" +msgstr "Cancelar" + +#. module: fieldservice_subcontracting +#. odoo-python +#: code:addons/fieldservice_subcontracting/models/fsm_order.py:0 +#: model_terms:ir.ui.view,arch_db:fieldservice_subcontracting.fsm_order_cancel_confirm_form_view +msgid "Cancel Field Service Order" +msgstr "Cancelar orden de servicio de campo" + +#. module: fieldservice_subcontracting +#. odoo-python +#: code:addons/fieldservice_subcontracting/models/fsm_order.py:0 +msgid "" +"Cancel Field Service Orders with active subcontract Purchase Orders one at a " +"time." +msgstr "" +"Cancele las órdenes de servicio de campo con órdenes de compra de " +"subcontratación activas de una en una." + +#. module: fieldservice_subcontracting +#: model_terms:ir.ui.view,arch_db:fieldservice_subcontracting.fsm_order_cancel_confirm_form_view +msgid "Cancel Order Only" +msgstr "Cancelar solo la orden" + +#. module: fieldservice_subcontracting +#: model_terms:ir.ui.view,arch_db:fieldservice_subcontracting.fsm_order_cancel_confirm_form_view +msgid "Cancel Order and Purchase Orders" +msgstr "Cancelar orden y órdenes de compra" + +#. module: fieldservice_subcontracting +#: model_terms:ir.ui.view,arch_db:fieldservice_subcontracting.fsm_order_reassign_confirm_form_view +msgid "Cancel POs and Reassign" +msgstr "Cancelar órdenes de compra y reasignar" + +#. module: fieldservice_subcontracting +#. odoo-python +#: code:addons/fieldservice_subcontracting/models/fsm_order.py:0 +msgid "" +"Cannot cancel Purchase Orders because they have %(count)d posted vendor " +"bill(s). Please manage the POs manually first." +msgstr "" +"No se pueden cancelar las órdenes de compra porque tienen %(count)d " +"factura(s) de proveedor contabilizada(s). Gestione primero las órdenes de " +"compra manualmente." + +#. module: fieldservice_subcontracting +#: model:ir.model,name:fieldservice_subcontracting.model_fsm_order_cancel_confirm +msgid "Confirm FSO Cancellation with PO Cancellation" +msgstr "Confirmar cancelación de FSO con cancelación de orden de compra" + +#. module: fieldservice_subcontracting +#: model:ir.model,name:fieldservice_subcontracting.model_fsm_order_reassign_confirm +msgid "Confirm FSO Worker Reassignment with PO Cancellation" +msgstr "" +"Confirmar reasignación del trabajador de FSO con cancelación de orden de " +"compra" + +#. module: fieldservice_subcontracting +#. odoo-python +#: code:addons/fieldservice_subcontracting/models/fsm_order.py:0 +#: model:ir.actions.act_window,name:fieldservice_subcontracting.action_fsm_order_reassign_confirm +#: model_terms:ir.ui.view,arch_db:fieldservice_subcontracting.fsm_order_reassign_confirm_form_view +msgid "Confirm Worker Reassignment" +msgstr "Confirmar reasignación del trabajador" + +#. module: fieldservice_subcontracting +#: model:ir.model.fields,field_description:fieldservice_subcontracting.field_fsm_order_cancel_confirm__create_uid +#: model:ir.model.fields,field_description:fieldservice_subcontracting.field_fsm_order_reassign_confirm__create_uid +msgid "Created by" +msgstr "Creado por" + +#. module: fieldservice_subcontracting +#: model:ir.model.fields,field_description:fieldservice_subcontracting.field_fsm_order_cancel_confirm__create_date +#: model:ir.model.fields,field_description:fieldservice_subcontracting.field_fsm_order_reassign_confirm__create_date +msgid "Created on" +msgstr "Creado el" + +#. module: fieldservice_subcontracting +#. odoo-python +#: code:addons/fieldservice_subcontracting/models/fsm_order.py:0 +msgid "Delivered quantity updated to %(hours).2f hours from FSO %(fso)s." +msgstr "Cantidad entregada actualizada a %(hours).2f horas desde FSO %(fso)s." + +#. module: fieldservice_subcontracting +#: model:ir.model.fields,help:fieldservice_subcontracting.field_fsm_order__reassign_worker +msgid "Determine if a worker reassignment can be performed." +msgstr "Determina si se puede realizar una reasignación de trabajador." + +#. module: fieldservice_subcontracting +#: model_terms:ir.ui.view,arch_db:fieldservice_subcontracting.fsm_order_cancel_confirm_form_view +msgid "Discard" +msgstr "Descartar" + +#. module: fieldservice_subcontracting +#: model:ir.model.fields,field_description:fieldservice_subcontracting.field_fsm_order_cancel_confirm__display_name +#: model:ir.model.fields,field_description:fieldservice_subcontracting.field_fsm_order_reassign_confirm__display_name +msgid "Display Name" +msgstr "Nombre mostrado" + +#. module: fieldservice_subcontracting +#: model_terms:ir.ui.view,arch_db:fieldservice_subcontracting.purchase_order_form_view_subcontracting +msgid "FSO" +msgstr "FSO" + +#. module: fieldservice_subcontracting +#: model:ir.model.fields,field_description:fieldservice_subcontracting.field_purchase_order__fsm_order_count +msgid "FSO Count" +msgstr "Conteo de FSO" + +#. module: fieldservice_subcontracting +#: model:ir.actions.server,name:fieldservice_subcontracting.action_create_subcontract_po +msgid "FSO: Create Subcontract PO" +msgstr "FSO: Crear orden de compra de subcontratación" + +#. module: fieldservice_subcontracting +#: model:ir.actions.server,name:fieldservice_subcontracting.action_update_subcontract_po_qty +msgid "FSO: Update Subcontract PO Delivered Qty" +msgstr "" +"FSO: Actualizar cantidad entregada de la orden de compra de subcontratación" + +#. module: fieldservice_subcontracting +#: model:ir.model,name:fieldservice_subcontracting.model_fsm_order +#: model:ir.model.fields,field_description:fieldservice_subcontracting.field_fsm_order_cancel_confirm__fsm_order_id +#: model:ir.model.fields,field_description:fieldservice_subcontracting.field_fsm_order_reassign_confirm__fsm_order_id +#: model:ir.model.fields,field_description:fieldservice_subcontracting.field_purchase_order__fsm_order_id +msgid "Field Service Order" +msgstr "Orden de servicio de campo" + +#. module: fieldservice_subcontracting +#: model:ir.model,name:fieldservice_subcontracting.model_fsm_template +msgid "Field Service Order Template" +msgstr "Plantilla de orden de servicio de campo" + +#. module: fieldservice_subcontracting +#: model:ir.model,name:fieldservice_subcontracting.model_fsm_person +msgid "Field Service Worker" +msgstr "Trabajador de servicio de campo" + +#. module: fieldservice_subcontracting +#: model:ir.model.fields,field_description:fieldservice_subcontracting.field_fsm_order_cancel_confirm__id +#: model:ir.model.fields,field_description:fieldservice_subcontracting.field_fsm_order_reassign_confirm__id +msgid "ID" +msgstr "ID" + +#. module: fieldservice_subcontracting +#: model:ir.model.fields,field_description:fieldservice_subcontracting.field_fsm_person__is_subcontractor +msgid "Is Subcontractor" +msgstr "Es subcontratista" + +#. module: fieldservice_subcontracting +#. odoo-python +#: code:addons/fieldservice_subcontracting/models/fsm_order.py:0 +msgid "It does not have an assigned worker." +msgstr "No tiene un trabajador asignado." + +#. module: fieldservice_subcontracting +#: model:ir.model.fields,field_description:fieldservice_subcontracting.field_fsm_order_cancel_confirm__write_uid +#: model:ir.model.fields,field_description:fieldservice_subcontracting.field_fsm_order_reassign_confirm__write_uid +msgid "Last Updated by" +msgstr "Última actualización por" + +#. module: fieldservice_subcontracting +#: model:ir.model.fields,field_description:fieldservice_subcontracting.field_fsm_order_cancel_confirm__write_date +#: model:ir.model.fields,field_description:fieldservice_subcontracting.field_fsm_order_reassign_confirm__write_date +msgid "Last Updated on" +msgstr "Última actualización el" + +#. module: fieldservice_subcontracting +#: model:ir.model.fields,field_description:fieldservice_subcontracting.field_fsm_order_reassign_confirm__new_person_id +msgid "New Worker" +msgstr "Nuevo trabajador" + +#. module: fieldservice_subcontracting +#. odoo-python +#: code:addons/fieldservice_subcontracting/wizards/fsm_order_reassign_confirm.py:0 +msgid "" +"One or more Purchase Orders have posted vendor bills and cannot be cancelled " +"automatically. You must manage the POs manually before reassigning the " +"worker." +msgstr "" +"Una o más órdenes de compra tienen facturas de proveedor contabilizadas y no " +"se pueden cancelar automáticamente. Debe gestionar las órdenes de compra " +"manualmente antes de reasignar el trabajador." + +#. module: fieldservice_subcontracting +#. odoo-python +#: code:addons/fieldservice_subcontracting/wizards/fsm_order_cancel_confirm.py:0 +msgid "" +"One or more related Purchase Orders have posted vendor bills and cannot be " +"cancelled automatically." +msgstr "" +"Una o más órdenes de compra relacionadas tienen facturas de proveedor " +"contabilizadas y no se pueden cancelar automáticamente." + +#. module: fieldservice_subcontracting +#. odoo-python +#: code:addons/fieldservice_subcontracting/models/fsm_order.py:0 +msgid "" +"Partner '%(partner)s' associated with the worker is not associated as a " +"supplier." +msgstr "" +"El partner '%(partner)s' asociado al trabajador no está configurado como " +"proveedor." + +#. module: fieldservice_subcontracting +#: model:ir.model,name:fieldservice_subcontracting.model_purchase_order +#: model_terms:ir.ui.view,arch_db:fieldservice_subcontracting.fsm_order_form_view_subcontracting +msgid "Purchase Order" +msgstr "Orden de compra" + +#. module: fieldservice_subcontracting +#: model:ir.model.fields,field_description:fieldservice_subcontracting.field_fsm_order__purchase_order_count +msgid "Purchase Order Count" +msgstr "Conteo de órdenes de compra" + +#. module: fieldservice_subcontracting +#: model:ir.model.fields,help:fieldservice_subcontracting.field_fsm_order__purchase_order_ids +msgid "" +"Purchase Order auto-created when this FSO was assigned to a subcontractor " +"worker." +msgstr "" +"Orden de compra creada automáticamente cuando este FSO fue asignado a un " +"trabajador subcontratista." + +#. module: fieldservice_subcontracting +#: model:ir.model.fields,field_description:fieldservice_subcontracting.field_fsm_order_cancel_confirm__purchase_order_ids +#: model:ir.model.fields,field_description:fieldservice_subcontracting.field_fsm_order_reassign_confirm__purchase_order_ids +msgid "Purchase Orders" +msgstr "Órdenes de compra" + +#. module: fieldservice_subcontracting +#: model:ir.model.fields,field_description:fieldservice_subcontracting.field_fsm_order__reassign_worker +msgid "Reassign Worker" +msgstr "Reasignar trabajador" + +#. module: fieldservice_subcontracting +#. odoo-python +#: code:addons/fieldservice_subcontracting/wizards/fsm_order_reassign_confirm.py:0 +msgid "" +"Reassigning the worker will cancel Purchase Orders %(purchase_orders)s. Do " +"you want to proceed?" +msgstr "" +"Reasignar el trabajador cancelará las órdenes de compra %(purchase_orders)s. " +"¿Desea continuar?" + +#. module: fieldservice_subcontracting +#: model:ir.model.fields,help:fieldservice_subcontracting.field_fsm_template__subcontract_product_id +msgid "" +"Service product used on the Purchase Order line when an FSO of this type is " +"subcontracted to an external worker. The product should have invoicing " +"policy 'Based on Received Quantities' and supplierinfo configured for each " +"vendor." +msgstr "" +"Producto de servicio usado en la línea de la orden de compra cuando un FSO " +"de este tipo se subcontrata a un trabajador externo. El producto debe tener " +"la política de facturación 'Basado en cantidades recibidas' e información de " +"proveedor configurada para cada proveedor." + +#. module: fieldservice_subcontracting +#: model:ir.model.fields,field_description:fieldservice_subcontracting.field_fsm_order__purchase_order_ids +msgid "Subcontract POs" +msgstr "Órdenes de compra de subcontratación" + +#. module: fieldservice_subcontracting +#. odoo-python +#: code:addons/fieldservice_subcontracting/models/fsm_order.py:0 +msgid "" +"Subcontract Purchase Order: %(link_purchase_order)s created for vendor " +"%(vendor)s." +msgstr "" +"Orden de compra de subcontratación: %(link_purchase_order)s creada para el " +"proveedor %(vendor)s." + +#. module: fieldservice_subcontracting +#. odoo-python +#: code:addons/fieldservice_subcontracting/models/fsm_order.py:0 +msgid "Subcontracted service: %(fso)s" +msgstr "Servicio subcontratado: %(fso)s" + +#. module: fieldservice_subcontracting +#: model:ir.model.fields,field_description:fieldservice_subcontracting.field_fsm_template__subcontract_product_id +msgid "Subcontracting Service Product" +msgstr "Producto de servicio de subcontratación" + +#. module: fieldservice_subcontracting +#: model:ir.model.fields,help:fieldservice_subcontracting.field_purchase_order__fsm_order_id +msgid "The Field Service Order that generated this Purchase Order." +msgstr "La orden de servicio de campo que generó esta orden de compra." + +#. module: fieldservice_subcontracting +#. odoo-python +#: code:addons/fieldservice_subcontracting/models/fsm_order.py:0 +msgid "The assigned worker '%(worker)s' is not a subcontractor." +msgstr "El trabajador asignado '%(worker)s' no es subcontratista." + +#. module: fieldservice_subcontracting +#. odoo-python +#: code:addons/fieldservice_subcontracting/models/fsm_order.py:0 +msgid "" +"The outsourcing product '%(product)s' has no vendor price configured for " +"'%(vendor)s'." +msgstr "" +"El producto de subcontratación '%(product)s' no tiene precio de proveedor " +"configurado para '%(vendor)s'." + +#. module: fieldservice_subcontracting +#. odoo-python +#: code:addons/fieldservice_subcontracting/models/fsm_order.py:0 +msgid "The outsourcing product '%(product)s' must be a service." +msgstr "El producto de subcontratación '%(product)s' debe ser un servicio." + +#. module: fieldservice_subcontracting +#. odoo-python +#: code:addons/fieldservice_subcontracting/models/fsm_order.py:0 +msgid "" +"The outsourcing product '%(product)s' must bill based on received quantities." +msgstr "" +"El producto de subcontratación '%(product)s' debe facturarse según " +"cantidades recibidas." + +#. module: fieldservice_subcontracting +#. odoo-python +#: code:addons/fieldservice_subcontracting/models/fsm_order.py:0 +msgid "" +"The outsourcing product is not configured in the template " +"'%(template_name)s'." +msgstr "" +"El producto de subcontratación no está configurado en la plantilla " +"'%(template_name)s'." + +#. module: fieldservice_subcontracting +#. odoo-python +#: code:addons/fieldservice_subcontracting/models/fsm_order.py:0 +msgid "The subcontracting purchase order cannot be created:" +msgstr "No se puede crear la orden de compra de subcontratación:" + +#. module: fieldservice_subcontracting +#. odoo-python +#: code:addons/fieldservice_subcontracting/models/fsm_order.py:0 +msgid "There is no subcontract Purchase Order." +msgstr "No hay una orden de compra de subcontratación." + +#. module: fieldservice_subcontracting +#. odoo-python +#: code:addons/fieldservice_subcontracting/wizards/fsm_order_cancel_confirm.py:0 +msgid "" +"This Field Service Order has active Purchase Orders %(purchase_orders)s. Do " +"you want to cancel them too?" +msgstr "" +"Esta orden de servicio de campo tiene órdenes de compra activas " +"%(purchase_orders)s. ¿Desea cancelarlas también?" + +#. module: fieldservice_subcontracting +#: model:ir.model.fields,field_description:fieldservice_subcontracting.field_fsm_order_cancel_confirm__warning_message +#: model:ir.model.fields,field_description:fieldservice_subcontracting.field_fsm_order_reassign_confirm__warning_message +msgid "Warning Message" +msgstr "Mensaje de advertencia" + +#. module: fieldservice_subcontracting +#. odoo-python +#: code:addons/fieldservice_subcontracting/models/fsm_person.py:0 +msgid "" +"Worker '%(worker)s' is marked as subcontractor but their partner " +"'%(partner)s' is not configured as a vendor. Please set the partner as a " +"vendor first." +msgstr "" +"El trabajador '%(worker)s' está marcado como subcontratista, pero su partner " +"'%(partner)s' no está configurado como proveedor. Configure primero el " +"partner como proveedor." + +#. module: fieldservice_subcontracting +#. odoo-python +#: code:addons/fieldservice_subcontracting/models/fsm_order.py:0 +msgid "" +"You cannot directly reassign an order with a subcontract Purchase Order. Use " +"the Reassign Worker button instead." +msgstr "" +"No puede reasignar directamente una orden con una orden de compra de " +"subcontratación. Use el botón Reasignar trabajador." + +#. module: fieldservice_subcontracting +#. odoo-python +#: code:addons/fieldservice_subcontracting/models/fsm_order.py:0 +msgid "" +"You cannot reassign the worker because the Field Service Order is in a " +"closed stage." +msgstr "" +"No puede reasignar el trabajador porque la orden de servicio de campo está " +"en una etapa cerrada." diff --git a/fieldservice_subcontracting/i18n/es_419.po b/fieldservice_subcontracting/i18n/es_419.po new file mode 100644 index 0000000000..3c3255e49e --- /dev/null +++ b/fieldservice_subcontracting/i18n/es_419.po @@ -0,0 +1,433 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * fieldservice_subcontracting +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 18.0\n" +"Report-Msgid-Bugs-To: \n" +"Last-Translator: \n" +"Language-Team: \n" +"Language: es_419\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#. module: fieldservice_subcontracting +#. odoo-python +#: code:addons/fieldservice_subcontracting/models/fsm_order.py:0 +msgid "" +"%(count)d draft vendor bill(s) cancelled before cancelling the subcontract " +"Purchase Order." +msgstr "" +"%(count)d factura(s) de proveedor en borrador cancelada(s) antes de cancelar " +"la orden de compra de subcontratación." + +#. module: fieldservice_subcontracting +#. odoo-python +#: code:addons/fieldservice_subcontracting/models/fsm_order.py:0 +msgid "(none)" +msgstr "(ninguna)" + +#. module: fieldservice_subcontracting +#: model_terms:ir.ui.view,arch_db:fieldservice_subcontracting.fsm_order_form_view_subcontracting +msgid "" +"Reassign\n" +" Worker" +msgstr "" +"Reasignar\n" +" Trabajador" + +#. module: fieldservice_subcontracting +#: model_terms:ir.ui.view,arch_db:fieldservice_subcontracting.fsm_order_reassign_confirm_form_view +msgid "Cancel" +msgstr "Cancelar" + +#. module: fieldservice_subcontracting +#. odoo-python +#: code:addons/fieldservice_subcontracting/models/fsm_order.py:0 +#: model_terms:ir.ui.view,arch_db:fieldservice_subcontracting.fsm_order_cancel_confirm_form_view +msgid "Cancel Field Service Order" +msgstr "Cancelar orden de servicio de campo" + +#. module: fieldservice_subcontracting +#. odoo-python +#: code:addons/fieldservice_subcontracting/models/fsm_order.py:0 +msgid "" +"Cancel Field Service Orders with active subcontract Purchase Orders one at a " +"time." +msgstr "" +"Cancele las órdenes de servicio de campo con órdenes de compra de " +"subcontratación activas de una en una." + +#. module: fieldservice_subcontracting +#: model_terms:ir.ui.view,arch_db:fieldservice_subcontracting.fsm_order_cancel_confirm_form_view +msgid "Cancel Order Only" +msgstr "Cancelar solo la orden" + +#. module: fieldservice_subcontracting +#: model_terms:ir.ui.view,arch_db:fieldservice_subcontracting.fsm_order_cancel_confirm_form_view +msgid "Cancel Order and Purchase Orders" +msgstr "Cancelar orden y órdenes de compra" + +#. module: fieldservice_subcontracting +#: model_terms:ir.ui.view,arch_db:fieldservice_subcontracting.fsm_order_reassign_confirm_form_view +msgid "Cancel POs and Reassign" +msgstr "Cancelar órdenes de compra y reasignar" + +#. module: fieldservice_subcontracting +#. odoo-python +#: code:addons/fieldservice_subcontracting/models/fsm_order.py:0 +msgid "" +"Cannot cancel Purchase Orders because they have %(count)d posted vendor " +"bill(s). Please manage the POs manually first." +msgstr "" +"No se pueden cancelar las órdenes de compra porque tienen %(count)d " +"factura(s) de proveedor contabilizada(s). Gestione primero las órdenes de " +"compra manualmente." + +#. module: fieldservice_subcontracting +#: model:ir.model,name:fieldservice_subcontracting.model_fsm_order_cancel_confirm +msgid "Confirm FSO Cancellation with PO Cancellation" +msgstr "Confirmar cancelación de FSO con cancelación de orden de compra" + +#. module: fieldservice_subcontracting +#: model:ir.model,name:fieldservice_subcontracting.model_fsm_order_reassign_confirm +msgid "Confirm FSO Worker Reassignment with PO Cancellation" +msgstr "" +"Confirmar reasignación del trabajador de FSO con cancelación de orden de " +"compra" + +#. module: fieldservice_subcontracting +#. odoo-python +#: code:addons/fieldservice_subcontracting/models/fsm_order.py:0 +#: model:ir.actions.act_window,name:fieldservice_subcontracting.action_fsm_order_reassign_confirm +#: model_terms:ir.ui.view,arch_db:fieldservice_subcontracting.fsm_order_reassign_confirm_form_view +msgid "Confirm Worker Reassignment" +msgstr "Confirmar reasignación del trabajador" + +#. module: fieldservice_subcontracting +#: model:ir.model.fields,field_description:fieldservice_subcontracting.field_fsm_order_cancel_confirm__create_uid +#: model:ir.model.fields,field_description:fieldservice_subcontracting.field_fsm_order_reassign_confirm__create_uid +msgid "Created by" +msgstr "Creado por" + +#. module: fieldservice_subcontracting +#: model:ir.model.fields,field_description:fieldservice_subcontracting.field_fsm_order_cancel_confirm__create_date +#: model:ir.model.fields,field_description:fieldservice_subcontracting.field_fsm_order_reassign_confirm__create_date +msgid "Created on" +msgstr "Creado el" + +#. module: fieldservice_subcontracting +#. odoo-python +#: code:addons/fieldservice_subcontracting/models/fsm_order.py:0 +msgid "Delivered quantity updated to %(hours).2f hours from FSO %(fso)s." +msgstr "Cantidad entregada actualizada a %(hours).2f horas desde FSO %(fso)s." + +#. module: fieldservice_subcontracting +#: model:ir.model.fields,help:fieldservice_subcontracting.field_fsm_order__reassign_worker +msgid "Determine if a worker reassignment can be performed." +msgstr "Determina si se puede realizar una reasignación de trabajador." + +#. module: fieldservice_subcontracting +#: model_terms:ir.ui.view,arch_db:fieldservice_subcontracting.fsm_order_cancel_confirm_form_view +msgid "Discard" +msgstr "Descartar" + +#. module: fieldservice_subcontracting +#: model:ir.model.fields,field_description:fieldservice_subcontracting.field_fsm_order_cancel_confirm__display_name +#: model:ir.model.fields,field_description:fieldservice_subcontracting.field_fsm_order_reassign_confirm__display_name +msgid "Display Name" +msgstr "Nombre mostrado" + +#. module: fieldservice_subcontracting +#: model_terms:ir.ui.view,arch_db:fieldservice_subcontracting.purchase_order_form_view_subcontracting +msgid "FSO" +msgstr "FSO" + +#. module: fieldservice_subcontracting +#: model:ir.model.fields,field_description:fieldservice_subcontracting.field_purchase_order__fsm_order_count +msgid "FSO Count" +msgstr "Conteo de FSO" + +#. module: fieldservice_subcontracting +#: model:ir.actions.server,name:fieldservice_subcontracting.action_create_subcontract_po +msgid "FSO: Create Subcontract PO" +msgstr "FSO: Crear orden de compra de subcontratación" + +#. module: fieldservice_subcontracting +#: model:ir.actions.server,name:fieldservice_subcontracting.action_update_subcontract_po_qty +msgid "FSO: Update Subcontract PO Delivered Qty" +msgstr "" +"FSO: Actualizar cantidad entregada de la orden de compra de subcontratación" + +#. module: fieldservice_subcontracting +#: model:ir.model,name:fieldservice_subcontracting.model_fsm_order +#: model:ir.model.fields,field_description:fieldservice_subcontracting.field_fsm_order_cancel_confirm__fsm_order_id +#: model:ir.model.fields,field_description:fieldservice_subcontracting.field_fsm_order_reassign_confirm__fsm_order_id +#: model:ir.model.fields,field_description:fieldservice_subcontracting.field_purchase_order__fsm_order_id +msgid "Field Service Order" +msgstr "Orden de servicio de campo" + +#. module: fieldservice_subcontracting +#: model:ir.model,name:fieldservice_subcontracting.model_fsm_template +msgid "Field Service Order Template" +msgstr "Plantilla de orden de servicio de campo" + +#. module: fieldservice_subcontracting +#: model:ir.model,name:fieldservice_subcontracting.model_fsm_person +msgid "Field Service Worker" +msgstr "Trabajador de servicio de campo" + +#. module: fieldservice_subcontracting +#: model:ir.model.fields,field_description:fieldservice_subcontracting.field_fsm_order_cancel_confirm__id +#: model:ir.model.fields,field_description:fieldservice_subcontracting.field_fsm_order_reassign_confirm__id +msgid "ID" +msgstr "ID" + +#. module: fieldservice_subcontracting +#: model:ir.model.fields,field_description:fieldservice_subcontracting.field_fsm_person__is_subcontractor +msgid "Is Subcontractor" +msgstr "Es subcontratista" + +#. module: fieldservice_subcontracting +#. odoo-python +#: code:addons/fieldservice_subcontracting/models/fsm_order.py:0 +msgid "It does not have an assigned worker." +msgstr "No tiene un trabajador asignado." + +#. module: fieldservice_subcontracting +#: model:ir.model.fields,field_description:fieldservice_subcontracting.field_fsm_order_cancel_confirm__write_uid +#: model:ir.model.fields,field_description:fieldservice_subcontracting.field_fsm_order_reassign_confirm__write_uid +msgid "Last Updated by" +msgstr "Última actualización por" + +#. module: fieldservice_subcontracting +#: model:ir.model.fields,field_description:fieldservice_subcontracting.field_fsm_order_cancel_confirm__write_date +#: model:ir.model.fields,field_description:fieldservice_subcontracting.field_fsm_order_reassign_confirm__write_date +msgid "Last Updated on" +msgstr "Última actualización el" + +#. module: fieldservice_subcontracting +#: model:ir.model.fields,field_description:fieldservice_subcontracting.field_fsm_order_reassign_confirm__new_person_id +msgid "New Worker" +msgstr "Nuevo trabajador" + +#. module: fieldservice_subcontracting +#. odoo-python +#: code:addons/fieldservice_subcontracting/wizards/fsm_order_reassign_confirm.py:0 +msgid "" +"One or more Purchase Orders have posted vendor bills and cannot be cancelled " +"automatically. You must manage the POs manually before reassigning the " +"worker." +msgstr "" +"Una o más órdenes de compra tienen facturas de proveedor contabilizadas y no " +"se pueden cancelar automáticamente. Debe gestionar las órdenes de compra " +"manualmente antes de reasignar el trabajador." + +#. module: fieldservice_subcontracting +#. odoo-python +#: code:addons/fieldservice_subcontracting/wizards/fsm_order_cancel_confirm.py:0 +msgid "" +"One or more related Purchase Orders have posted vendor bills and cannot be " +"cancelled automatically." +msgstr "" +"Una o más órdenes de compra relacionadas tienen facturas de proveedor " +"contabilizadas y no se pueden cancelar automáticamente." + +#. module: fieldservice_subcontracting +#. odoo-python +#: code:addons/fieldservice_subcontracting/models/fsm_order.py:0 +msgid "" +"Partner '%(partner)s' associated with the worker is not associated as a " +"supplier." +msgstr "" +"El partner '%(partner)s' asociado al trabajador no está configurado como " +"proveedor." + +#. module: fieldservice_subcontracting +#: model:ir.model,name:fieldservice_subcontracting.model_purchase_order +#: model_terms:ir.ui.view,arch_db:fieldservice_subcontracting.fsm_order_form_view_subcontracting +msgid "Purchase Order" +msgstr "Orden de compra" + +#. module: fieldservice_subcontracting +#: model:ir.model.fields,field_description:fieldservice_subcontracting.field_fsm_order__purchase_order_count +msgid "Purchase Order Count" +msgstr "Conteo de órdenes de compra" + +#. module: fieldservice_subcontracting +#: model:ir.model.fields,help:fieldservice_subcontracting.field_fsm_order__purchase_order_ids +msgid "" +"Purchase Order auto-created when this FSO was assigned to a subcontractor " +"worker." +msgstr "" +"Orden de compra creada automáticamente cuando este FSO fue asignado a un " +"trabajador subcontratista." + +#. module: fieldservice_subcontracting +#: model:ir.model.fields,field_description:fieldservice_subcontracting.field_fsm_order_cancel_confirm__purchase_order_ids +#: model:ir.model.fields,field_description:fieldservice_subcontracting.field_fsm_order_reassign_confirm__purchase_order_ids +msgid "Purchase Orders" +msgstr "Órdenes de compra" + +#. module: fieldservice_subcontracting +#: model:ir.model.fields,field_description:fieldservice_subcontracting.field_fsm_order__reassign_worker +msgid "Reassign Worker" +msgstr "Reasignar trabajador" + +#. module: fieldservice_subcontracting +#. odoo-python +#: code:addons/fieldservice_subcontracting/wizards/fsm_order_reassign_confirm.py:0 +msgid "" +"Reassigning the worker will cancel Purchase Orders %(purchase_orders)s. Do " +"you want to proceed?" +msgstr "" +"Reasignar el trabajador cancelará las órdenes de compra %(purchase_orders)s. " +"¿Desea continuar?" + +#. module: fieldservice_subcontracting +#: model:ir.model.fields,help:fieldservice_subcontracting.field_fsm_template__subcontract_product_id +msgid "" +"Service product used on the Purchase Order line when an FSO of this type is " +"subcontracted to an external worker. The product should have invoicing " +"policy 'Based on Received Quantities' and supplierinfo configured for each " +"vendor." +msgstr "" +"Producto de servicio usado en la línea de la orden de compra cuando un FSO " +"de este tipo se subcontrata a un trabajador externo. El producto debe tener " +"la política de facturación 'Basado en cantidades recibidas' e información de " +"proveedor configurada para cada proveedor." + +#. module: fieldservice_subcontracting +#: model:ir.model.fields,field_description:fieldservice_subcontracting.field_fsm_order__purchase_order_ids +msgid "Subcontract POs" +msgstr "Órdenes de compra de subcontratación" + +#. module: fieldservice_subcontracting +#. odoo-python +#: code:addons/fieldservice_subcontracting/models/fsm_order.py:0 +msgid "" +"Subcontract Purchase Order: %(link_purchase_order)s created for vendor " +"%(vendor)s." +msgstr "" +"Orden de compra de subcontratación: %(link_purchase_order)s creada para el " +"proveedor %(vendor)s." + +#. module: fieldservice_subcontracting +#. odoo-python +#: code:addons/fieldservice_subcontracting/models/fsm_order.py:0 +msgid "Subcontracted service: %(fso)s" +msgstr "Servicio subcontratado: %(fso)s" + +#. module: fieldservice_subcontracting +#: model:ir.model.fields,field_description:fieldservice_subcontracting.field_fsm_template__subcontract_product_id +msgid "Subcontracting Service Product" +msgstr "Producto de servicio de subcontratación" + +#. module: fieldservice_subcontracting +#: model:ir.model.fields,help:fieldservice_subcontracting.field_purchase_order__fsm_order_id +msgid "The Field Service Order that generated this Purchase Order." +msgstr "La orden de servicio de campo que generó esta orden de compra." + +#. module: fieldservice_subcontracting +#. odoo-python +#: code:addons/fieldservice_subcontracting/models/fsm_order.py:0 +msgid "The assigned worker '%(worker)s' is not a subcontractor." +msgstr "El trabajador asignado '%(worker)s' no es subcontratista." + +#. module: fieldservice_subcontracting +#. odoo-python +#: code:addons/fieldservice_subcontracting/models/fsm_order.py:0 +msgid "" +"The outsourcing product '%(product)s' has no vendor price configured for " +"'%(vendor)s'." +msgstr "" +"El producto de subcontratación '%(product)s' no tiene precio de proveedor " +"configurado para '%(vendor)s'." + +#. module: fieldservice_subcontracting +#. odoo-python +#: code:addons/fieldservice_subcontracting/models/fsm_order.py:0 +msgid "The outsourcing product '%(product)s' must be a service." +msgstr "El producto de subcontratación '%(product)s' debe ser un servicio." + +#. module: fieldservice_subcontracting +#. odoo-python +#: code:addons/fieldservice_subcontracting/models/fsm_order.py:0 +msgid "" +"The outsourcing product '%(product)s' must bill based on received quantities." +msgstr "" +"El producto de subcontratación '%(product)s' debe facturarse según " +"cantidades recibidas." + +#. module: fieldservice_subcontracting +#. odoo-python +#: code:addons/fieldservice_subcontracting/models/fsm_order.py:0 +msgid "" +"The outsourcing product is not configured in the template " +"'%(template_name)s'." +msgstr "" +"El producto de subcontratación no está configurado en la plantilla " +"'%(template_name)s'." + +#. module: fieldservice_subcontracting +#. odoo-python +#: code:addons/fieldservice_subcontracting/models/fsm_order.py:0 +msgid "The subcontracting purchase order cannot be created:" +msgstr "No se puede crear la orden de compra de subcontratación:" + +#. module: fieldservice_subcontracting +#. odoo-python +#: code:addons/fieldservice_subcontracting/models/fsm_order.py:0 +msgid "There is no subcontract Purchase Order." +msgstr "No hay una orden de compra de subcontratación." + +#. module: fieldservice_subcontracting +#. odoo-python +#: code:addons/fieldservice_subcontracting/wizards/fsm_order_cancel_confirm.py:0 +msgid "" +"This Field Service Order has active Purchase Orders %(purchase_orders)s. Do " +"you want to cancel them too?" +msgstr "" +"Esta orden de servicio de campo tiene órdenes de compra activas " +"%(purchase_orders)s. ¿Desea cancelarlas también?" + +#. module: fieldservice_subcontracting +#: model:ir.model.fields,field_description:fieldservice_subcontracting.field_fsm_order_cancel_confirm__warning_message +#: model:ir.model.fields,field_description:fieldservice_subcontracting.field_fsm_order_reassign_confirm__warning_message +msgid "Warning Message" +msgstr "Mensaje de advertencia" + +#. module: fieldservice_subcontracting +#. odoo-python +#: code:addons/fieldservice_subcontracting/models/fsm_person.py:0 +msgid "" +"Worker '%(worker)s' is marked as subcontractor but their partner " +"'%(partner)s' is not configured as a vendor. Please set the partner as a " +"vendor first." +msgstr "" +"El trabajador '%(worker)s' está marcado como subcontratista, pero su partner " +"'%(partner)s' no está configurado como proveedor. Configure primero el " +"partner como proveedor." + +#. module: fieldservice_subcontracting +#. odoo-python +#: code:addons/fieldservice_subcontracting/models/fsm_order.py:0 +msgid "" +"You cannot directly reassign an order with a subcontract Purchase Order. Use " +"the Reassign Worker button instead." +msgstr "" +"No puede reasignar directamente una orden con una orden de compra de " +"subcontratación. Use el botón Reasignar trabajador." + +#. module: fieldservice_subcontracting +#. odoo-python +#: code:addons/fieldservice_subcontracting/models/fsm_order.py:0 +msgid "" +"You cannot reassign the worker because the Field Service Order is in a " +"closed stage." +msgstr "" +"No puede reasignar el trabajador porque la orden de servicio de campo está " +"en una etapa cerrada." diff --git a/fieldservice_subcontracting/i18n/es_ES.po b/fieldservice_subcontracting/i18n/es_ES.po new file mode 100644 index 0000000000..e202dd1a16 --- /dev/null +++ b/fieldservice_subcontracting/i18n/es_ES.po @@ -0,0 +1,433 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * fieldservice_subcontracting +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 18.0\n" +"Report-Msgid-Bugs-To: \n" +"Last-Translator: \n" +"Language-Team: \n" +"Language: es_ES\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#. module: fieldservice_subcontracting +#. odoo-python +#: code:addons/fieldservice_subcontracting/models/fsm_order.py:0 +msgid "" +"%(count)d draft vendor bill(s) cancelled before cancelling the subcontract " +"Purchase Order." +msgstr "" +"%(count)d factura(s) de proveedor en borrador cancelada(s) antes de cancelar " +"la orden de compra de subcontratación." + +#. module: fieldservice_subcontracting +#. odoo-python +#: code:addons/fieldservice_subcontracting/models/fsm_order.py:0 +msgid "(none)" +msgstr "(ninguna)" + +#. module: fieldservice_subcontracting +#: model_terms:ir.ui.view,arch_db:fieldservice_subcontracting.fsm_order_form_view_subcontracting +msgid "" +"Reassign\n" +" Worker" +msgstr "" +"Reasignar\n" +" Trabajador" + +#. module: fieldservice_subcontracting +#: model_terms:ir.ui.view,arch_db:fieldservice_subcontracting.fsm_order_reassign_confirm_form_view +msgid "Cancel" +msgstr "Cancelar" + +#. module: fieldservice_subcontracting +#. odoo-python +#: code:addons/fieldservice_subcontracting/models/fsm_order.py:0 +#: model_terms:ir.ui.view,arch_db:fieldservice_subcontracting.fsm_order_cancel_confirm_form_view +msgid "Cancel Field Service Order" +msgstr "Cancelar orden de servicio de campo" + +#. module: fieldservice_subcontracting +#. odoo-python +#: code:addons/fieldservice_subcontracting/models/fsm_order.py:0 +msgid "" +"Cancel Field Service Orders with active subcontract Purchase Orders one at a " +"time." +msgstr "" +"Cancele las órdenes de servicio de campo con órdenes de compra de " +"subcontratación activas de una en una." + +#. module: fieldservice_subcontracting +#: model_terms:ir.ui.view,arch_db:fieldservice_subcontracting.fsm_order_cancel_confirm_form_view +msgid "Cancel Order Only" +msgstr "Cancelar solo la orden" + +#. module: fieldservice_subcontracting +#: model_terms:ir.ui.view,arch_db:fieldservice_subcontracting.fsm_order_cancel_confirm_form_view +msgid "Cancel Order and Purchase Orders" +msgstr "Cancelar orden y órdenes de compra" + +#. module: fieldservice_subcontracting +#: model_terms:ir.ui.view,arch_db:fieldservice_subcontracting.fsm_order_reassign_confirm_form_view +msgid "Cancel POs and Reassign" +msgstr "Cancelar órdenes de compra y reasignar" + +#. module: fieldservice_subcontracting +#. odoo-python +#: code:addons/fieldservice_subcontracting/models/fsm_order.py:0 +msgid "" +"Cannot cancel Purchase Orders because they have %(count)d posted vendor " +"bill(s). Please manage the POs manually first." +msgstr "" +"No se pueden cancelar las órdenes de compra porque tienen %(count)d " +"factura(s) de proveedor contabilizada(s). Gestione primero las órdenes de " +"compra manualmente." + +#. module: fieldservice_subcontracting +#: model:ir.model,name:fieldservice_subcontracting.model_fsm_order_cancel_confirm +msgid "Confirm FSO Cancellation with PO Cancellation" +msgstr "Confirmar cancelación de FSO con cancelación de orden de compra" + +#. module: fieldservice_subcontracting +#: model:ir.model,name:fieldservice_subcontracting.model_fsm_order_reassign_confirm +msgid "Confirm FSO Worker Reassignment with PO Cancellation" +msgstr "" +"Confirmar reasignación del trabajador de FSO con cancelación de orden de " +"compra" + +#. module: fieldservice_subcontracting +#. odoo-python +#: code:addons/fieldservice_subcontracting/models/fsm_order.py:0 +#: model:ir.actions.act_window,name:fieldservice_subcontracting.action_fsm_order_reassign_confirm +#: model_terms:ir.ui.view,arch_db:fieldservice_subcontracting.fsm_order_reassign_confirm_form_view +msgid "Confirm Worker Reassignment" +msgstr "Confirmar reasignación del trabajador" + +#. module: fieldservice_subcontracting +#: model:ir.model.fields,field_description:fieldservice_subcontracting.field_fsm_order_cancel_confirm__create_uid +#: model:ir.model.fields,field_description:fieldservice_subcontracting.field_fsm_order_reassign_confirm__create_uid +msgid "Created by" +msgstr "Creado por" + +#. module: fieldservice_subcontracting +#: model:ir.model.fields,field_description:fieldservice_subcontracting.field_fsm_order_cancel_confirm__create_date +#: model:ir.model.fields,field_description:fieldservice_subcontracting.field_fsm_order_reassign_confirm__create_date +msgid "Created on" +msgstr "Creado el" + +#. module: fieldservice_subcontracting +#. odoo-python +#: code:addons/fieldservice_subcontracting/models/fsm_order.py:0 +msgid "Delivered quantity updated to %(hours).2f hours from FSO %(fso)s." +msgstr "Cantidad entregada actualizada a %(hours).2f horas desde FSO %(fso)s." + +#. module: fieldservice_subcontracting +#: model:ir.model.fields,help:fieldservice_subcontracting.field_fsm_order__reassign_worker +msgid "Determine if a worker reassignment can be performed." +msgstr "Determina si se puede realizar una reasignación de trabajador." + +#. module: fieldservice_subcontracting +#: model_terms:ir.ui.view,arch_db:fieldservice_subcontracting.fsm_order_cancel_confirm_form_view +msgid "Discard" +msgstr "Descartar" + +#. module: fieldservice_subcontracting +#: model:ir.model.fields,field_description:fieldservice_subcontracting.field_fsm_order_cancel_confirm__display_name +#: model:ir.model.fields,field_description:fieldservice_subcontracting.field_fsm_order_reassign_confirm__display_name +msgid "Display Name" +msgstr "Nombre mostrado" + +#. module: fieldservice_subcontracting +#: model_terms:ir.ui.view,arch_db:fieldservice_subcontracting.purchase_order_form_view_subcontracting +msgid "FSO" +msgstr "FSO" + +#. module: fieldservice_subcontracting +#: model:ir.model.fields,field_description:fieldservice_subcontracting.field_purchase_order__fsm_order_count +msgid "FSO Count" +msgstr "Conteo de FSO" + +#. module: fieldservice_subcontracting +#: model:ir.actions.server,name:fieldservice_subcontracting.action_create_subcontract_po +msgid "FSO: Create Subcontract PO" +msgstr "FSO: Crear orden de compra de subcontratación" + +#. module: fieldservice_subcontracting +#: model:ir.actions.server,name:fieldservice_subcontracting.action_update_subcontract_po_qty +msgid "FSO: Update Subcontract PO Delivered Qty" +msgstr "" +"FSO: Actualizar cantidad entregada de la orden de compra de subcontratación" + +#. module: fieldservice_subcontracting +#: model:ir.model,name:fieldservice_subcontracting.model_fsm_order +#: model:ir.model.fields,field_description:fieldservice_subcontracting.field_fsm_order_cancel_confirm__fsm_order_id +#: model:ir.model.fields,field_description:fieldservice_subcontracting.field_fsm_order_reassign_confirm__fsm_order_id +#: model:ir.model.fields,field_description:fieldservice_subcontracting.field_purchase_order__fsm_order_id +msgid "Field Service Order" +msgstr "Orden de servicio de campo" + +#. module: fieldservice_subcontracting +#: model:ir.model,name:fieldservice_subcontracting.model_fsm_template +msgid "Field Service Order Template" +msgstr "Plantilla de orden de servicio de campo" + +#. module: fieldservice_subcontracting +#: model:ir.model,name:fieldservice_subcontracting.model_fsm_person +msgid "Field Service Worker" +msgstr "Trabajador de servicio de campo" + +#. module: fieldservice_subcontracting +#: model:ir.model.fields,field_description:fieldservice_subcontracting.field_fsm_order_cancel_confirm__id +#: model:ir.model.fields,field_description:fieldservice_subcontracting.field_fsm_order_reassign_confirm__id +msgid "ID" +msgstr "ID" + +#. module: fieldservice_subcontracting +#: model:ir.model.fields,field_description:fieldservice_subcontracting.field_fsm_person__is_subcontractor +msgid "Is Subcontractor" +msgstr "Es subcontratista" + +#. module: fieldservice_subcontracting +#. odoo-python +#: code:addons/fieldservice_subcontracting/models/fsm_order.py:0 +msgid "It does not have an assigned worker." +msgstr "No tiene un trabajador asignado." + +#. module: fieldservice_subcontracting +#: model:ir.model.fields,field_description:fieldservice_subcontracting.field_fsm_order_cancel_confirm__write_uid +#: model:ir.model.fields,field_description:fieldservice_subcontracting.field_fsm_order_reassign_confirm__write_uid +msgid "Last Updated by" +msgstr "Última actualización por" + +#. module: fieldservice_subcontracting +#: model:ir.model.fields,field_description:fieldservice_subcontracting.field_fsm_order_cancel_confirm__write_date +#: model:ir.model.fields,field_description:fieldservice_subcontracting.field_fsm_order_reassign_confirm__write_date +msgid "Last Updated on" +msgstr "Última actualización el" + +#. module: fieldservice_subcontracting +#: model:ir.model.fields,field_description:fieldservice_subcontracting.field_fsm_order_reassign_confirm__new_person_id +msgid "New Worker" +msgstr "Nuevo trabajador" + +#. module: fieldservice_subcontracting +#. odoo-python +#: code:addons/fieldservice_subcontracting/wizards/fsm_order_reassign_confirm.py:0 +msgid "" +"One or more Purchase Orders have posted vendor bills and cannot be cancelled " +"automatically. You must manage the POs manually before reassigning the " +"worker." +msgstr "" +"Una o más órdenes de compra tienen facturas de proveedor contabilizadas y no " +"se pueden cancelar automáticamente. Debe gestionar las órdenes de compra " +"manualmente antes de reasignar el trabajador." + +#. module: fieldservice_subcontracting +#. odoo-python +#: code:addons/fieldservice_subcontracting/wizards/fsm_order_cancel_confirm.py:0 +msgid "" +"One or more related Purchase Orders have posted vendor bills and cannot be " +"cancelled automatically." +msgstr "" +"Una o más órdenes de compra relacionadas tienen facturas de proveedor " +"contabilizadas y no se pueden cancelar automáticamente." + +#. module: fieldservice_subcontracting +#. odoo-python +#: code:addons/fieldservice_subcontracting/models/fsm_order.py:0 +msgid "" +"Partner '%(partner)s' associated with the worker is not associated as a " +"supplier." +msgstr "" +"El partner '%(partner)s' asociado al trabajador no está configurado como " +"proveedor." + +#. module: fieldservice_subcontracting +#: model:ir.model,name:fieldservice_subcontracting.model_purchase_order +#: model_terms:ir.ui.view,arch_db:fieldservice_subcontracting.fsm_order_form_view_subcontracting +msgid "Purchase Order" +msgstr "Orden de compra" + +#. module: fieldservice_subcontracting +#: model:ir.model.fields,field_description:fieldservice_subcontracting.field_fsm_order__purchase_order_count +msgid "Purchase Order Count" +msgstr "Conteo de órdenes de compra" + +#. module: fieldservice_subcontracting +#: model:ir.model.fields,help:fieldservice_subcontracting.field_fsm_order__purchase_order_ids +msgid "" +"Purchase Order auto-created when this FSO was assigned to a subcontractor " +"worker." +msgstr "" +"Orden de compra creada automáticamente cuando este FSO fue asignado a un " +"trabajador subcontratista." + +#. module: fieldservice_subcontracting +#: model:ir.model.fields,field_description:fieldservice_subcontracting.field_fsm_order_cancel_confirm__purchase_order_ids +#: model:ir.model.fields,field_description:fieldservice_subcontracting.field_fsm_order_reassign_confirm__purchase_order_ids +msgid "Purchase Orders" +msgstr "Órdenes de compra" + +#. module: fieldservice_subcontracting +#: model:ir.model.fields,field_description:fieldservice_subcontracting.field_fsm_order__reassign_worker +msgid "Reassign Worker" +msgstr "Reasignar trabajador" + +#. module: fieldservice_subcontracting +#. odoo-python +#: code:addons/fieldservice_subcontracting/wizards/fsm_order_reassign_confirm.py:0 +msgid "" +"Reassigning the worker will cancel Purchase Orders %(purchase_orders)s. Do " +"you want to proceed?" +msgstr "" +"Reasignar el trabajador cancelará las órdenes de compra %(purchase_orders)s. " +"¿Desea continuar?" + +#. module: fieldservice_subcontracting +#: model:ir.model.fields,help:fieldservice_subcontracting.field_fsm_template__subcontract_product_id +msgid "" +"Service product used on the Purchase Order line when an FSO of this type is " +"subcontracted to an external worker. The product should have invoicing " +"policy 'Based on Received Quantities' and supplierinfo configured for each " +"vendor." +msgstr "" +"Producto de servicio usado en la línea de la orden de compra cuando un FSO " +"de este tipo se subcontrata a un trabajador externo. El producto debe tener " +"la política de facturación 'Basado en cantidades recibidas' e información de " +"proveedor configurada para cada proveedor." + +#. module: fieldservice_subcontracting +#: model:ir.model.fields,field_description:fieldservice_subcontracting.field_fsm_order__purchase_order_ids +msgid "Subcontract POs" +msgstr "Órdenes de compra de subcontratación" + +#. module: fieldservice_subcontracting +#. odoo-python +#: code:addons/fieldservice_subcontracting/models/fsm_order.py:0 +msgid "" +"Subcontract Purchase Order: %(link_purchase_order)s created for vendor " +"%(vendor)s." +msgstr "" +"Orden de compra de subcontratación: %(link_purchase_order)s creada para el " +"proveedor %(vendor)s." + +#. module: fieldservice_subcontracting +#. odoo-python +#: code:addons/fieldservice_subcontracting/models/fsm_order.py:0 +msgid "Subcontracted service: %(fso)s" +msgstr "Servicio subcontratado: %(fso)s" + +#. module: fieldservice_subcontracting +#: model:ir.model.fields,field_description:fieldservice_subcontracting.field_fsm_template__subcontract_product_id +msgid "Subcontracting Service Product" +msgstr "Producto de servicio de subcontratación" + +#. module: fieldservice_subcontracting +#: model:ir.model.fields,help:fieldservice_subcontracting.field_purchase_order__fsm_order_id +msgid "The Field Service Order that generated this Purchase Order." +msgstr "La orden de servicio de campo que generó esta orden de compra." + +#. module: fieldservice_subcontracting +#. odoo-python +#: code:addons/fieldservice_subcontracting/models/fsm_order.py:0 +msgid "The assigned worker '%(worker)s' is not a subcontractor." +msgstr "El trabajador asignado '%(worker)s' no es subcontratista." + +#. module: fieldservice_subcontracting +#. odoo-python +#: code:addons/fieldservice_subcontracting/models/fsm_order.py:0 +msgid "" +"The outsourcing product '%(product)s' has no vendor price configured for " +"'%(vendor)s'." +msgstr "" +"El producto de subcontratación '%(product)s' no tiene precio de proveedor " +"configurado para '%(vendor)s'." + +#. module: fieldservice_subcontracting +#. odoo-python +#: code:addons/fieldservice_subcontracting/models/fsm_order.py:0 +msgid "The outsourcing product '%(product)s' must be a service." +msgstr "El producto de subcontratación '%(product)s' debe ser un servicio." + +#. module: fieldservice_subcontracting +#. odoo-python +#: code:addons/fieldservice_subcontracting/models/fsm_order.py:0 +msgid "" +"The outsourcing product '%(product)s' must bill based on received quantities." +msgstr "" +"El producto de subcontratación '%(product)s' debe facturarse según " +"cantidades recibidas." + +#. module: fieldservice_subcontracting +#. odoo-python +#: code:addons/fieldservice_subcontracting/models/fsm_order.py:0 +msgid "" +"The outsourcing product is not configured in the template " +"'%(template_name)s'." +msgstr "" +"El producto de subcontratación no está configurado en la plantilla " +"'%(template_name)s'." + +#. module: fieldservice_subcontracting +#. odoo-python +#: code:addons/fieldservice_subcontracting/models/fsm_order.py:0 +msgid "The subcontracting purchase order cannot be created:" +msgstr "No se puede crear la orden de compra de subcontratación:" + +#. module: fieldservice_subcontracting +#. odoo-python +#: code:addons/fieldservice_subcontracting/models/fsm_order.py:0 +msgid "There is no subcontract Purchase Order." +msgstr "No hay una orden de compra de subcontratación." + +#. module: fieldservice_subcontracting +#. odoo-python +#: code:addons/fieldservice_subcontracting/wizards/fsm_order_cancel_confirm.py:0 +msgid "" +"This Field Service Order has active Purchase Orders %(purchase_orders)s. Do " +"you want to cancel them too?" +msgstr "" +"Esta orden de servicio de campo tiene órdenes de compra activas " +"%(purchase_orders)s. ¿Desea cancelarlas también?" + +#. module: fieldservice_subcontracting +#: model:ir.model.fields,field_description:fieldservice_subcontracting.field_fsm_order_cancel_confirm__warning_message +#: model:ir.model.fields,field_description:fieldservice_subcontracting.field_fsm_order_reassign_confirm__warning_message +msgid "Warning Message" +msgstr "Mensaje de advertencia" + +#. module: fieldservice_subcontracting +#. odoo-python +#: code:addons/fieldservice_subcontracting/models/fsm_person.py:0 +msgid "" +"Worker '%(worker)s' is marked as subcontractor but their partner " +"'%(partner)s' is not configured as a vendor. Please set the partner as a " +"vendor first." +msgstr "" +"El trabajador '%(worker)s' está marcado como subcontratista, pero su partner " +"'%(partner)s' no está configurado como proveedor. Configure primero el " +"partner como proveedor." + +#. module: fieldservice_subcontracting +#. odoo-python +#: code:addons/fieldservice_subcontracting/models/fsm_order.py:0 +msgid "" +"You cannot directly reassign an order with a subcontract Purchase Order. Use " +"the Reassign Worker button instead." +msgstr "" +"No puede reasignar directamente una orden con una orden de compra de " +"subcontratación. Use el botón Reasignar trabajador." + +#. module: fieldservice_subcontracting +#. odoo-python +#: code:addons/fieldservice_subcontracting/models/fsm_order.py:0 +msgid "" +"You cannot reassign the worker because the Field Service Order is in a " +"closed stage." +msgstr "" +"No puede reasignar el trabajador porque la orden de servicio de campo está " +"en una etapa cerrada." diff --git a/fieldservice_subcontracting/i18n/fieldservice_subcontracting.pot b/fieldservice_subcontracting/i18n/fieldservice_subcontracting.pot new file mode 100644 index 0000000000..89aa82a824 --- /dev/null +++ b/fieldservice_subcontracting/i18n/fieldservice_subcontracting.pot @@ -0,0 +1,389 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * fieldservice_subcontracting +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 18.0\n" +"Report-Msgid-Bugs-To: \n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: fieldservice_subcontracting +#. odoo-python +#: code:addons/fieldservice_subcontracting/models/fsm_order.py:0 +msgid "" +"%(count)d draft vendor bill(s) cancelled before cancelling the subcontract " +"Purchase Order." +msgstr "" + +#. module: fieldservice_subcontracting +#. odoo-python +#: code:addons/fieldservice_subcontracting/models/fsm_order.py:0 +msgid "(none)" +msgstr "" + +#. module: fieldservice_subcontracting +#: model_terms:ir.ui.view,arch_db:fieldservice_subcontracting.fsm_order_form_view_subcontracting +msgid "" +"Reassign\n" +" Worker" +msgstr "" + +#. module: fieldservice_subcontracting +#: model_terms:ir.ui.view,arch_db:fieldservice_subcontracting.fsm_order_reassign_confirm_form_view +msgid "Cancel" +msgstr "" + +#. module: fieldservice_subcontracting +#. odoo-python +#: code:addons/fieldservice_subcontracting/models/fsm_order.py:0 +#: model_terms:ir.ui.view,arch_db:fieldservice_subcontracting.fsm_order_cancel_confirm_form_view +msgid "Cancel Field Service Order" +msgstr "" + +#. module: fieldservice_subcontracting +#. odoo-python +#: code:addons/fieldservice_subcontracting/models/fsm_order.py:0 +msgid "" +"Cancel Field Service Orders with active subcontract Purchase Orders one at a" +" time." +msgstr "" + +#. module: fieldservice_subcontracting +#: model_terms:ir.ui.view,arch_db:fieldservice_subcontracting.fsm_order_cancel_confirm_form_view +msgid "Cancel Order Only" +msgstr "" + +#. module: fieldservice_subcontracting +#: model_terms:ir.ui.view,arch_db:fieldservice_subcontracting.fsm_order_cancel_confirm_form_view +msgid "Cancel Order and Purchase Orders" +msgstr "" + +#. module: fieldservice_subcontracting +#: model_terms:ir.ui.view,arch_db:fieldservice_subcontracting.fsm_order_reassign_confirm_form_view +msgid "Cancel POs and Reassign" +msgstr "" + +#. module: fieldservice_subcontracting +#. odoo-python +#: code:addons/fieldservice_subcontracting/models/fsm_order.py:0 +msgid "" +"Cannot cancel Purchase Orders because they have %(count)d posted vendor " +"bill(s). Please manage the POs manually first." +msgstr "" + +#. module: fieldservice_subcontracting +#: model:ir.model,name:fieldservice_subcontracting.model_fsm_order_cancel_confirm +msgid "Confirm FSO Cancellation with PO Cancellation" +msgstr "" + +#. module: fieldservice_subcontracting +#: model:ir.model,name:fieldservice_subcontracting.model_fsm_order_reassign_confirm +msgid "Confirm FSO Worker Reassignment with PO Cancellation" +msgstr "" + +#. module: fieldservice_subcontracting +#. odoo-python +#: code:addons/fieldservice_subcontracting/models/fsm_order.py:0 +#: model:ir.actions.act_window,name:fieldservice_subcontracting.action_fsm_order_reassign_confirm +#: model_terms:ir.ui.view,arch_db:fieldservice_subcontracting.fsm_order_reassign_confirm_form_view +msgid "Confirm Worker Reassignment" +msgstr "" + +#. module: fieldservice_subcontracting +#: model:ir.model.fields,field_description:fieldservice_subcontracting.field_fsm_order_cancel_confirm__create_uid +#: model:ir.model.fields,field_description:fieldservice_subcontracting.field_fsm_order_reassign_confirm__create_uid +msgid "Created by" +msgstr "" + +#. module: fieldservice_subcontracting +#: model:ir.model.fields,field_description:fieldservice_subcontracting.field_fsm_order_cancel_confirm__create_date +#: model:ir.model.fields,field_description:fieldservice_subcontracting.field_fsm_order_reassign_confirm__create_date +msgid "Created on" +msgstr "" + +#. module: fieldservice_subcontracting +#. odoo-python +#: code:addons/fieldservice_subcontracting/models/fsm_order.py:0 +msgid "Delivered quantity updated to %(hours).2f hours from FSO %(fso)s." +msgstr "" + +#. module: fieldservice_subcontracting +#: model:ir.model.fields,help:fieldservice_subcontracting.field_fsm_order__reassign_worker +msgid "Determine if a worker reassignment can be performed." +msgstr "" + +#. module: fieldservice_subcontracting +#: model_terms:ir.ui.view,arch_db:fieldservice_subcontracting.fsm_order_cancel_confirm_form_view +msgid "Discard" +msgstr "" + +#. module: fieldservice_subcontracting +#: model:ir.model.fields,field_description:fieldservice_subcontracting.field_fsm_order_cancel_confirm__display_name +#: model:ir.model.fields,field_description:fieldservice_subcontracting.field_fsm_order_reassign_confirm__display_name +msgid "Display Name" +msgstr "" + +#. module: fieldservice_subcontracting +#: model_terms:ir.ui.view,arch_db:fieldservice_subcontracting.purchase_order_form_view_subcontracting +msgid "FSO" +msgstr "" + +#. module: fieldservice_subcontracting +#: model:ir.model.fields,field_description:fieldservice_subcontracting.field_purchase_order__fsm_order_count +msgid "FSO Count" +msgstr "" + +#. module: fieldservice_subcontracting +#: model:ir.actions.server,name:fieldservice_subcontracting.action_create_subcontract_po +msgid "FSO: Create Subcontract PO" +msgstr "" + +#. module: fieldservice_subcontracting +#: model:ir.actions.server,name:fieldservice_subcontracting.action_update_subcontract_po_qty +msgid "FSO: Update Subcontract PO Delivered Qty" +msgstr "" + +#. module: fieldservice_subcontracting +#: model:ir.model,name:fieldservice_subcontracting.model_fsm_order +#: model:ir.model.fields,field_description:fieldservice_subcontracting.field_fsm_order_cancel_confirm__fsm_order_id +#: model:ir.model.fields,field_description:fieldservice_subcontracting.field_fsm_order_reassign_confirm__fsm_order_id +#: model:ir.model.fields,field_description:fieldservice_subcontracting.field_purchase_order__fsm_order_id +msgid "Field Service Order" +msgstr "" + +#. module: fieldservice_subcontracting +#: model:ir.model,name:fieldservice_subcontracting.model_fsm_template +msgid "Field Service Order Template" +msgstr "" + +#. module: fieldservice_subcontracting +#: model:ir.model,name:fieldservice_subcontracting.model_fsm_person +msgid "Field Service Worker" +msgstr "" + +#. module: fieldservice_subcontracting +#: model:ir.model.fields,field_description:fieldservice_subcontracting.field_fsm_order_cancel_confirm__id +#: model:ir.model.fields,field_description:fieldservice_subcontracting.field_fsm_order_reassign_confirm__id +msgid "ID" +msgstr "" + +#. module: fieldservice_subcontracting +#: model:ir.model.fields,field_description:fieldservice_subcontracting.field_fsm_person__is_subcontractor +msgid "Is Subcontractor" +msgstr "" + +#. module: fieldservice_subcontracting +#. odoo-python +#: code:addons/fieldservice_subcontracting/models/fsm_order.py:0 +msgid "It does not have an assigned worker." +msgstr "" + +#. module: fieldservice_subcontracting +#: model:ir.model.fields,field_description:fieldservice_subcontracting.field_fsm_order_cancel_confirm__write_uid +#: model:ir.model.fields,field_description:fieldservice_subcontracting.field_fsm_order_reassign_confirm__write_uid +msgid "Last Updated by" +msgstr "" + +#. module: fieldservice_subcontracting +#: model:ir.model.fields,field_description:fieldservice_subcontracting.field_fsm_order_cancel_confirm__write_date +#: model:ir.model.fields,field_description:fieldservice_subcontracting.field_fsm_order_reassign_confirm__write_date +msgid "Last Updated on" +msgstr "" + +#. module: fieldservice_subcontracting +#: model:ir.model.fields,field_description:fieldservice_subcontracting.field_fsm_order_reassign_confirm__new_person_id +msgid "New Worker" +msgstr "" + +#. module: fieldservice_subcontracting +#. odoo-python +#: code:addons/fieldservice_subcontracting/wizards/fsm_order_reassign_confirm.py:0 +msgid "" +"One or more Purchase Orders have posted vendor bills and cannot be cancelled" +" automatically. You must manage the POs manually before reassigning the " +"worker." +msgstr "" + +#. module: fieldservice_subcontracting +#. odoo-python +#: code:addons/fieldservice_subcontracting/wizards/fsm_order_cancel_confirm.py:0 +msgid "" +"One or more related Purchase Orders have posted vendor bills and cannot be " +"cancelled automatically." +msgstr "" + +#. module: fieldservice_subcontracting +#. odoo-python +#: code:addons/fieldservice_subcontracting/models/fsm_order.py:0 +msgid "" +"Partner '%(partner)s' associated with the worker is not associated as a " +"supplier." +msgstr "" + +#. module: fieldservice_subcontracting +#: model:ir.model,name:fieldservice_subcontracting.model_purchase_order +#: model_terms:ir.ui.view,arch_db:fieldservice_subcontracting.fsm_order_form_view_subcontracting +msgid "Purchase Order" +msgstr "" + +#. module: fieldservice_subcontracting +#: model:ir.model.fields,field_description:fieldservice_subcontracting.field_fsm_order__purchase_order_count +msgid "Purchase Order Count" +msgstr "" + +#. module: fieldservice_subcontracting +#: model:ir.model.fields,help:fieldservice_subcontracting.field_fsm_order__purchase_order_ids +msgid "" +"Purchase Order auto-created when this FSO was assigned to a subcontractor " +"worker." +msgstr "" + +#. module: fieldservice_subcontracting +#: model:ir.model.fields,field_description:fieldservice_subcontracting.field_fsm_order_cancel_confirm__purchase_order_ids +#: model:ir.model.fields,field_description:fieldservice_subcontracting.field_fsm_order_reassign_confirm__purchase_order_ids +msgid "Purchase Orders" +msgstr "" + +#. module: fieldservice_subcontracting +#: model:ir.model.fields,field_description:fieldservice_subcontracting.field_fsm_order__reassign_worker +msgid "Reassign Worker" +msgstr "" + +#. module: fieldservice_subcontracting +#. odoo-python +#: code:addons/fieldservice_subcontracting/wizards/fsm_order_reassign_confirm.py:0 +msgid "" +"Reassigning the worker will cancel Purchase Orders %(purchase_orders)s. Do " +"you want to proceed?" +msgstr "" + +#. module: fieldservice_subcontracting +#: model:ir.model.fields,help:fieldservice_subcontracting.field_fsm_template__subcontract_product_id +msgid "" +"Service product used on the Purchase Order line when an FSO of this type is " +"subcontracted to an external worker. The product should have invoicing " +"policy 'Based on Received Quantities' and supplierinfo configured for each " +"vendor." +msgstr "" + +#. module: fieldservice_subcontracting +#: model:ir.model.fields,field_description:fieldservice_subcontracting.field_fsm_order__purchase_order_ids +msgid "Subcontract POs" +msgstr "" + +#. module: fieldservice_subcontracting +#. odoo-python +#: code:addons/fieldservice_subcontracting/models/fsm_order.py:0 +msgid "" +"Subcontract Purchase Order: %(link_purchase_order)s created for vendor " +"%(vendor)s." +msgstr "" + +#. module: fieldservice_subcontracting +#. odoo-python +#: code:addons/fieldservice_subcontracting/models/fsm_order.py:0 +msgid "Subcontracted service: %(fso)s" +msgstr "" + +#. module: fieldservice_subcontracting +#: model:ir.model.fields,field_description:fieldservice_subcontracting.field_fsm_template__subcontract_product_id +msgid "Subcontracting Service Product" +msgstr "" + +#. module: fieldservice_subcontracting +#: model:ir.model.fields,help:fieldservice_subcontracting.field_purchase_order__fsm_order_id +msgid "The Field Service Order that generated this Purchase Order." +msgstr "" + +#. module: fieldservice_subcontracting +#. odoo-python +#: code:addons/fieldservice_subcontracting/models/fsm_order.py:0 +msgid "The assigned worker '%(worker)s' is not a subcontractor." +msgstr "" + +#. module: fieldservice_subcontracting +#. odoo-python +#: code:addons/fieldservice_subcontracting/models/fsm_order.py:0 +msgid "" +"The outsourcing product '%(product)s' has no vendor price configured for " +"'%(vendor)s'." +msgstr "" + +#. module: fieldservice_subcontracting +#. odoo-python +#: code:addons/fieldservice_subcontracting/models/fsm_order.py:0 +msgid "The outsourcing product '%(product)s' must be a service." +msgstr "" + +#. module: fieldservice_subcontracting +#. odoo-python +#: code:addons/fieldservice_subcontracting/models/fsm_order.py:0 +msgid "" +"The outsourcing product '%(product)s' must bill based on received " +"quantities." +msgstr "" + +#. module: fieldservice_subcontracting +#. odoo-python +#: code:addons/fieldservice_subcontracting/models/fsm_order.py:0 +msgid "" +"The outsourcing product is not configured in the template " +"'%(template_name)s'." +msgstr "" + +#. module: fieldservice_subcontracting +#. odoo-python +#: code:addons/fieldservice_subcontracting/models/fsm_order.py:0 +msgid "The subcontracting purchase order cannot be created:" +msgstr "" + +#. module: fieldservice_subcontracting +#. odoo-python +#: code:addons/fieldservice_subcontracting/models/fsm_order.py:0 +msgid "There is no subcontract Purchase Order." +msgstr "" + +#. module: fieldservice_subcontracting +#. odoo-python +#: code:addons/fieldservice_subcontracting/wizards/fsm_order_cancel_confirm.py:0 +msgid "" +"This Field Service Order has active Purchase Orders %(purchase_orders)s. Do " +"you want to cancel them too?" +msgstr "" + +#. module: fieldservice_subcontracting +#: model:ir.model.fields,field_description:fieldservice_subcontracting.field_fsm_order_cancel_confirm__warning_message +#: model:ir.model.fields,field_description:fieldservice_subcontracting.field_fsm_order_reassign_confirm__warning_message +msgid "Warning Message" +msgstr "" + +#. module: fieldservice_subcontracting +#. odoo-python +#: code:addons/fieldservice_subcontracting/models/fsm_person.py:0 +msgid "" +"Worker '%(worker)s' is marked as subcontractor but their partner " +"'%(partner)s' is not configured as a vendor. Please set the partner as a " +"vendor first." +msgstr "" + +#. module: fieldservice_subcontracting +#. odoo-python +#: code:addons/fieldservice_subcontracting/models/fsm_order.py:0 +msgid "" +"You cannot directly reassign an order with a subcontract Purchase Order. Use" +" the Reassign Worker button instead." +msgstr "" + +#. module: fieldservice_subcontracting +#. odoo-python +#: code:addons/fieldservice_subcontracting/models/fsm_order.py:0 +msgid "" +"You cannot reassign the worker because the Field Service Order is in a " +"closed stage." +msgstr "" diff --git a/fieldservice_subcontracting/models/__init__.py b/fieldservice_subcontracting/models/__init__.py new file mode 100644 index 0000000000..e154c7ac50 --- /dev/null +++ b/fieldservice_subcontracting/models/__init__.py @@ -0,0 +1,4 @@ +from . import fsm_person +from . import fsm_template +from . import fsm_order +from . import purchase_order diff --git a/fieldservice_subcontracting/models/fsm_order.py b/fieldservice_subcontracting/models/fsm_order.py new file mode 100644 index 0000000000..17d1fab649 --- /dev/null +++ b/fieldservice_subcontracting/models/fsm_order.py @@ -0,0 +1,385 @@ +# Copyright 2026 Binhex +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from markupsafe import Markup + +from odoo import _, api, fields, models +from odoo.exceptions import UserError + + +class FsmOrder(models.Model): + _inherit = "fsm.order" + + purchase_order_ids = fields.One2many( + comodel_name="purchase.order", + inverse_name="fsm_order_id", + string="Subcontract POs", + help="Purchase Order auto-created when this FSO was assigned " + "to a subcontractor worker.", + ) + + purchase_order_count = fields.Integer( + compute="_compute_purchase_order_count", + ) + + reassign_worker = fields.Boolean( + compute="_compute_reassign_worker", + help="Determine if a worker reassignment can be performed.", + ) + + @api.depends("purchase_order_ids") + def _compute_purchase_order_count(self): + for order in self: + order.purchase_order_count = len(order.purchase_order_ids) + + @api.depends("purchase_order_ids", "stage_id.is_closed") + def _compute_reassign_worker(self): + for order in self: + order.reassign_worker = order._can_reassign_subcontract_worker() + + def write(self, vals): + if "person_id" in vals and not self.env.context.get("skip_reassign_check"): + protected_orders = self.filtered("purchase_order_ids") + if protected_orders: + raise UserError( + self.env._( + "You cannot directly reassign an order with a " + "subcontract Purchase Order. Use the Reassign Worker " + "button instead." + ) + ) + scheduled_fields = { + "scheduled_date_end", + "scheduled_duration", + "scheduled_date_start", + } + update_purchase_dates = bool(scheduled_fields.intersection(vals)) + res = super().write(vals) + if update_purchase_dates: + self._update_subcontract_po_date_planned() + return res + + def action_view_purchase_order(self): + """Smart button action to open linked Purchase Orders.""" + self.ensure_one() + action = { + "type": "ir.actions.act_window", + "res_model": "purchase.order", + "view_mode": "list,form", + "domain": [("id", "in", self.purchase_order_ids.ids)], + "target": "current", + } + if len(self.purchase_order_ids) == 1: + action.update( + { + "res_id": self.purchase_order_ids.id, + "view_mode": "form", + } + ) + return action + + def action_open_reassign_confirm(self): + """Open the reassignment confirmation wizard.""" + self.ensure_one() + self._check_reassign_subcontract_worker_allowed() + return { + "type": "ir.actions.act_window", + "name": _("Confirm Worker Reassignment"), + "res_model": "fsm.order.reassign.confirm", + "view_mode": "form", + "target": "new", + "context": { + "default_fsm_order_id": self.id, + }, + } + + def action_cancel(self): + """Ask how to handle active subcontract POs before cancelling the FSO.""" + if not self.env.context.get("skip_subcontract_cancel_wizard"): + orders_with_purchase = self.filtered( + lambda order: order._get_active_subcontract_purchase_orders() + ) + if orders_with_purchase: + if len(self) > 1: + raise UserError( + self.env._( + "Cancel Field Service Orders with active subcontract " + "Purchase Orders one at a time." + ) + ) + return { + "type": "ir.actions.act_window", + "name": self.env._("Cancel Field Service Order"), + "res_model": "fsm.order.cancel.confirm", + "view_mode": "form", + "target": "new", + "context": { + "default_fsm_order_id": orders_with_purchase.id, + }, + } + return super().action_cancel() + + def _can_reassign_subcontract_worker(self): + self.ensure_one() + return bool(self.purchase_order_ids) and not self.stage_id.is_closed + + def _check_reassign_subcontract_worker_allowed(self): + self.ensure_one() + if self.stage_id.is_closed: + raise UserError( + self.env._( + "You cannot reassign the worker because the Field Service " + "Order is in a closed stage." + ) + ) + if not self.purchase_order_ids: + raise UserError(self.env._("There is no subcontract Purchase Order.")) + + def _get_active_subcontract_purchase_orders(self): + return self.purchase_order_ids.filtered(lambda po: po.state != "cancel") + + def _get_posted_subcontract_vendor_bills(self): + return self._get_active_subcontract_purchase_orders().invoice_ids.filtered( + lambda invoice: invoice.state == "posted" + ) + + def _get_draft_subcontract_vendor_bills(self): + return self._get_active_subcontract_purchase_orders().invoice_ids.filtered( + lambda invoice: invoice.state == "draft" + ) + + def _check_subcontract_purchase_orders_can_be_cancelled(self): + posted_bills = self._get_posted_subcontract_vendor_bills() + if posted_bills: + raise UserError( + self.env._( + "Cannot cancel Purchase Orders because they have %(count)d " + "posted vendor bill(s). Please manage the POs manually first.", + count=len(posted_bills), + ) + ) + + def _cancel_draft_subcontract_vendor_bills(self): + draft_bills = self._get_draft_subcontract_vendor_bills() + if draft_bills: + draft_bills.button_cancel() + for purchase_order in self._get_active_subcontract_purchase_orders(): + cancelled_bills = purchase_order.invoice_ids & draft_bills + if not cancelled_bills: + continue + purchase_order.message_post( + body=self.env._( + "%(count)d draft vendor bill(s) cancelled before " + "cancelling the subcontract Purchase Order.", + count=len(cancelled_bills), + ) + ) + return draft_bills + + def _cancel_active_subcontract_purchase_orders(self): + purchase_orders = self._get_active_subcontract_purchase_orders() + if purchase_orders: + self._check_subcontract_purchase_orders_can_be_cancelled() + self._cancel_draft_subcontract_vendor_bills() + purchase_orders.button_cancel() + return purchase_orders + + def _create_subcontract_po(self): + """Create a draft Purchase Order for the subcontractor. + + Called by the server action linked to the 'Assigned' stage + (or equivalent). Only creates a PO if: + - The assigned worker is a subcontractor + - The FSO template has a subcontracting product configured. + - No PO is already linked to this FSO + + Errors are logged to the chatter instead of raising exceptions, + to avoid blocking the stage transition. + """ + message_no_subcontracting = self.env._( + "The subcontracting purchase order cannot be created:" + ) + for order in self: + if order._get_active_subcontract_purchase_orders(): + continue + subcontracting_errors = [] + if not order.person_id or not order.person_id.is_subcontractor: + if not order.person_id: + subcontracting_errors.append( + self.env._("It does not have an assigned worker.") + ) + elif not order.person_id.is_subcontractor: + subcontracting_errors.append( + self.env._( + "The assigned worker '%(worker)s' is not a subcontractor.", + worker=order.person_id.name, + ) + ) + if not order.type or not order.template_id.subcontract_product_id: + subcontracting_errors.append( + self.env._( + "The outsourcing product is not configured in " + "the template '%(template_name)s'.", + template_name=order.template_id.name + if order.template_id + else self.env._("(none)"), + ) + ) + else: + subcontracting_errors.extend( + order._get_subcontract_product_configuration_errors() + ) + + partner_id = order.person_id.partner_id + if ( + order.person_id + and order.person_id.is_subcontractor + and partner_id.supplier_rank < 1 + ): + subcontracting_errors.append( + self.env._( + "Partner '%(partner)s' associated with the worker is " + "not associated as a supplier.", + partner=partner_id.name, + ) + ) + + if subcontracting_errors: + body = Markup("%s
  • %s
") % ( + message_no_subcontracting, + Markup("
  • ").join(subcontracting_errors), + ) + order.message_post(body=body) + continue + + purchase_order_vals = order._prepare_subcontract_po_vals() + purchase_order_id = self.env["purchase.order"].create(purchase_order_vals) + order.message_post( + body=self.env._( + "Subcontract Purchase Order: " + " %(link_purchase_order)s " + "created for vendor %(vendor)s.", + link_purchase_order=purchase_order_id._get_html_link(), + vendor=partner_id.name, + ), + ) + + def _get_subcontract_product_configuration_errors(self): + self.ensure_one() + product = self.template_id.subcontract_product_id + partner = self.person_id.partner_id + errors = [] + + if product.type != "service": + errors.append( + self.env._( + "The outsourcing product '%(product)s' must be a service.", + product=product.display_name, + ) + ) + if product.purchase_method != "receive": + errors.append( + self.env._( + "The outsourcing product '%(product)s' must bill based on " + "received quantities.", + product=product.display_name, + ) + ) + if partner and not product._select_seller( + partner_id=partner, + quantity=self.scheduled_duration or 1.0, + date=fields.Date.context_today(self), + uom_id=product.uom_id, + ): + errors.append( + self.env._( + "The outsourcing product '%(product)s' has no vendor price " + "configured for '%(vendor)s'.", + product=product.display_name, + vendor=partner.display_name, + ) + ) + return errors + + def _prepare_subcontract_po_vals(self): + """ + Prepare the values dict for the subcontract Purchase Order. + :return: Dict of values + """ + self.ensure_one() + product = self.template_id.subcontract_product_id + partner_id = self.person_id.partner_id + analytic_distribution = {} + if self.project_id and self.project_id.account_id: + analytic_distribution[str(self.project_id.account_id.id)] = 100.0 + + purchase_line_vals = { + "product_id": product.id, + "name": self.env._("Subcontracted service: %(fso)s", fso=self.name), + "product_qty": self.scheduled_duration, + "product_uom": product.uom_id.id, + "date_planned": self.scheduled_date_end, + } + if analytic_distribution: + purchase_line_vals["analytic_distribution"] = analytic_distribution + + return { + "partner_id": partner_id.id, + "fsm_order_id": self.id, + "date_planned": self.scheduled_date_end, + "project_id": self.project_id.id, + "origin": self.name, + "order_line": [(0, 0, purchase_line_vals)], + } + + def _update_subcontract_po_date_planned(self): + for order in self: + purchase_orders = order._get_active_subcontract_purchase_orders() + if purchase_orders: + purchase_orders.write( + { + "date_planned": order.scheduled_date_end, + } + ) + purchase_orders.order_line.filtered( + lambda line: not line.display_type + ).write( + { + "date_planned": order.scheduled_date_end, + } + ) + + def _update_subcontract_po_qty(self): + """Update PO line quantity with actual FSO timesheet hours. + + Called by the server action linked to the 'Done' stage. + The source of hours is agnostic; they may be logged by + internal staff or by the vendor. + """ + for order in self: + purchase_order_ids = order._get_active_subcontract_purchase_orders() + if not purchase_order_ids: + continue + total_hours = sum(order.mapped("timesheet_ids.unit_amount")) + if not order.type or not order.template_id.subcontract_product_id: + continue + for purchase_order in purchase_order_ids: + po_line = purchase_order.order_line.filtered( + lambda line, product=order.template_id.subcontract_product_id: ( + line.product_id == product + ) + )[:1] + if po_line: + po_line.write( + { + "qty_received": total_hours, + } + ) + purchase_order.message_post( + body=self.env._( + "Delivered quantity updated to %(hours).2f hours " + "from FSO %(fso)s.", + hours=total_hours, + fso=order.name, + ), + ) diff --git a/fieldservice_subcontracting/models/fsm_person.py b/fieldservice_subcontracting/models/fsm_person.py new file mode 100644 index 0000000000..90b58af79f --- /dev/null +++ b/fieldservice_subcontracting/models/fsm_person.py @@ -0,0 +1,25 @@ +# Copyright 2026 Binhex +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import api, fields, models +from odoo.exceptions import ValidationError + + +class FsmPerson(models.Model): + _inherit = "fsm.person" + + is_subcontractor = fields.Boolean() + + @api.constrains("is_subcontractor", "partner_id") + def _check_subcontractor_is_vendor(self): + for person in self: + if person.is_subcontractor and person.partner_id.supplier_rank < 1: + raise ValidationError( + self.env._( + "Worker '%(worker)s' is marked as subcontractor but " + "their partner '%(partner)s' is not configured as a " + "vendor. Please set the partner as a vendor first.", + worker=person.name, + partner=person.partner_id.name, + ) + ) diff --git a/fieldservice_subcontracting/models/fsm_template.py b/fieldservice_subcontracting/models/fsm_template.py new file mode 100644 index 0000000000..b321ea7a26 --- /dev/null +++ b/fieldservice_subcontracting/models/fsm_template.py @@ -0,0 +1,18 @@ +# Copyright 2026 Binhex +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class FsmTemplate(models.Model): + _inherit = "fsm.template" + + subcontract_product_id = fields.Many2one( + comodel_name="product.product", + string="Subcontracting Service Product", + domain=[("type", "=", "service"), ("purchase_method", "=", "receive")], + help="Service product used on the Purchase Order line when an FSO " + "of this type is subcontracted to an external worker. The " + "product should have invoicing policy 'Based on Received " + "Quantities' and supplierinfo configured for each vendor.", + ) diff --git a/fieldservice_subcontracting/models/purchase_order.py b/fieldservice_subcontracting/models/purchase_order.py new file mode 100644 index 0000000000..82ff8a7f44 --- /dev/null +++ b/fieldservice_subcontracting/models/purchase_order.py @@ -0,0 +1,37 @@ +# Copyright 2026 Binhex +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import api, fields, models + + +class PurchaseOrder(models.Model): + _inherit = "purchase.order" + + fsm_order_id = fields.Many2one( + comodel_name="fsm.order", + string="Field Service Order", + copy=False, + index=True, + ondelete="set null", + help="The Field Service Order that generated this Purchase Order.", + ) + fsm_order_count = fields.Integer( + compute="_compute_fsm_order_count", + string="FSO Count", + ) + + @api.depends("fsm_order_id") + def _compute_fsm_order_count(self): + for po in self: + po.fsm_order_count = 1 if po.fsm_order_id else 0 + + def action_view_fsm_order(self): + """Smart button action to open the linked FSO.""" + self.ensure_one() + return { + "type": "ir.actions.act_window", + "res_model": "fsm.order", + "res_id": self.fsm_order_id.id, + "view_mode": "form", + "target": "current", + } diff --git a/fieldservice_subcontracting/pyproject.toml b/fieldservice_subcontracting/pyproject.toml new file mode 100644 index 0000000000..4231d0cccb --- /dev/null +++ b/fieldservice_subcontracting/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/fieldservice_subcontracting/readme/CONFIGURE.md b/fieldservice_subcontracting/readme/CONFIGURE.md new file mode 100644 index 0000000000..6c3483ece8 --- /dev/null +++ b/fieldservice_subcontracting/readme/CONFIGURE.md @@ -0,0 +1,35 @@ +## Worker setup + +1. Go to Field Service > Master Data > Workers. +2. Open the worker that represents the external vendor. +3. Make sure the related partner is configured as a vendor. +4. Enable Is Subcontractor on the worker. + +![Subcontractor checkbox on the worker form](../static/readme/worker_subcontractor.png) + +## Template setup + +1. Go to Field Service > Master Data > Templates. +2. Open the template that can create subcontract Purchase Orders. +3. Set the Subcontracting Service Product. +4. Use a service product that is purchased based on received quantities. +5. Configure a vendor price on the product for each subcontractor partner that + can receive a Purchase Order. +6. If vendor bills are controlled by received quantities, update the delivered + quantity before creating the vendor bill. + +![Subcontracting product on the Field Service template](../static/readme/template_subcontract_product.png) + +## Stage automation + +1. Go to Field Service > Configuration > Stages. +2. Open the stage that should create the draft Purchase Order. +3. Assign the server action FSO: Create Subcontract PO. + +![Server action to create the subcontract Purchase Order](../static/readme/stage_create_subcontract_po_action.png) + +1. Open the closing stage that should update delivered quantities. +2. Assign the server action FSO: Update Subcontract PO Delivered Qty. +3. This action copies timesheet hours to the Purchase Order delivered quantity. + +![Server action to update subcontract delivered quantity](../static/readme/stage_update_subcontract_po_qty_action.png) diff --git a/fieldservice_subcontracting/readme/CONTEXT.md b/fieldservice_subcontracting/readme/CONTEXT.md new file mode 100644 index 0000000000..8f99f38bef --- /dev/null +++ b/fieldservice_subcontracting/readme/CONTEXT.md @@ -0,0 +1,22 @@ +1. Field Service operations sometimes require external workers or companies to + perform part of the service delivery. +2. A Field Service Order can be assigned to a subcontractor worker. +3. The company can create a Purchase Order for that external service without + manually duplicating information between Field Service and Purchase. +4. The Purchase Order Expected Arrival is set from the Field Service Order + Scheduled End (`scheduled_date_end`). +5. When the Field Service Order planned dates change, the linked subcontract + Purchase Order Expected Arrival is updated to match the current Scheduled + End. +6. The Purchase Order remains under the standard Odoo purchase flow: it is + created as a draft, reviewed and confirmed manually, and later billed by the + vendor. +7. Timesheet hours logged on the Field Service Order can be pushed to the + Purchase Order line as delivered quantity. +8. Vendor bills based on received quantities can then be created with the + correct quantity. +9. Worker reassignment is available for orders with at least one linked + subcontract Purchase Order, even if all linked subcontract Purchase Orders + are cancelled. +10. Worker reassignment is only available before the Field Service Order reaches + a closed stage. diff --git a/fieldservice_subcontracting/readme/CONTRIBUTORS.md b/fieldservice_subcontracting/readme/CONTRIBUTORS.md new file mode 100644 index 0000000000..4c895949c9 --- /dev/null +++ b/fieldservice_subcontracting/readme/CONTRIBUTORS.md @@ -0,0 +1,2 @@ +- [Binhex](https://www.binhex.cloud): + - Edilio Escalona Almira diff --git a/fieldservice_subcontracting/readme/DESCRIPTION.md b/fieldservice_subcontracting/readme/DESCRIPTION.md new file mode 100644 index 0000000000..61dedec498 --- /dev/null +++ b/fieldservice_subcontracting/readme/DESCRIPTION.md @@ -0,0 +1,17 @@ +This module integrates Field Service with Purchasing to automate the +subcontracting workflow. + +It allows users to: + +1. Mark Field Service workers as subcontractors. +2. Configure a service product on Field Service order templates. +3. Create draft Purchase Orders for subcontracted orders. +4. Set the Purchase Order Expected Arrival from the Field Service Order + Scheduled End. +5. Keep the Purchase Order Expected Arrival synchronized when the Field Service + Order planned dates change. +6. Update delivered quantities from Field Service timesheets. +7. Reassign workers on orders with linked subcontract Purchase Orders. + +The module uses `fieldservice_stage_server_action` to trigger automation on +stage transitions. diff --git a/fieldservice_subcontracting/readme/USAGE.md b/fieldservice_subcontracting/readme/USAGE.md new file mode 100644 index 0000000000..911734a79e --- /dev/null +++ b/fieldservice_subcontracting/readme/USAGE.md @@ -0,0 +1,67 @@ +## Create the subcontract Purchase Order + +1. Create or open a Field Service Order that uses a template configured for + subcontracting. +2. Assign a subcontractor worker. +3. Move the order to the stage configured to create the subcontract Purchase + Order. + +![Field Service Order buttons for subcontracting](../static/readme/fso_purchase_order_buttons.png) + +1. Use the Purchase Order smart button to open the generated draft Purchase + Order. +2. Review the Purchase Order. Its Expected Arrival is set from the Field Service + Order Scheduled End (`scheduled_date_end`). +3. Confirm the Purchase Order manually. + +![Field Service Order smart button on the Purchase Order](../static/readme/purchase_order_fso_button.png) + +1. If the Purchase Order is not created, check the Field Service Order chatter. +2. Review the reason posted by the module. +3. Fix the missing configuration or worker data. +4. Move the order through the configured stage again if needed. + +## Update the Purchase Order Expected Arrival + +1. Change the Field Service Order planned dates. +2. Save the Field Service Order. +3. The active subcontract Purchase Order Expected Arrival is updated with the + current Field Service Order Scheduled End (`scheduled_date_end`). +4. The generated Purchase Order line expected date is updated as well, so the + Purchase Order header keeps the same Expected Arrival. + +## Update delivered quantities + +1. Log timesheet hours on the Field Service Order. +2. Move the Field Service Order to the stage configured to update subcontract + delivered quantities. +3. The module updates the delivered quantity on the subcontract Purchase Order + line with the total timesheet hours of the Field Service Order. +4. The ordered quantity remains unchanged after the Purchase Order is created. +5. Create the vendor bill after the delivered quantity has been updated when the + product bills based on received quantities. + +## Reassign or cancel an order + +1. Use the Reassign Worker button when an order with at least one subcontract + Purchase Order must be reassigned. +2. The Reassign Worker button remains available even if all linked subcontract + Purchase Orders are cancelled. +3. The Reassign Worker button is only available while the Field Service Order is + not in a closed stage. +4. If the Field Service Order is already in a closed stage, move it to a + non-closed stage before reassigning the worker, if the business process + allows it. +5. Select the new worker in the reassignment wizard. +6. Confirm the wizard. +7. The wizard cancels draft vendor bills linked to active subcontract Purchase + Orders before cancelling those Purchase Orders. +8. The wizard cancels active subcontract Purchase Orders. +9. If the new worker is also a subcontractor, the module creates a new Purchase + Order for that subcontractor. +10. To cancel a Field Service Order with active subcontract Purchase Orders, use + the standard cancel action. +11. Choose whether to cancel only the Field Service Order or also its active + subcontract Purchase Orders. +12. If there are posted vendor bills, manage the Purchase Orders and vendor + bills manually before reassigning or cancelling the Field Service Order. diff --git a/fieldservice_subcontracting/security/ir.model.access.csv b/fieldservice_subcontracting/security/ir.model.access.csv new file mode 100644 index 0000000000..67dc9ea238 --- /dev/null +++ b/fieldservice_subcontracting/security/ir.model.access.csv @@ -0,0 +1,3 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_fsm_order_cancel_confirm_user,access.fsm.order.cancel.confirm,model_fsm_order_cancel_confirm,fieldservice.group_fsm_user,1,1,1,1 +access_fsm_order_reassign_confirm_user,access.fsm.order.reassign.confirm,model_fsm_order_reassign_confirm,fieldservice.group_fsm_user,1,1,1,1 diff --git a/fieldservice_subcontracting/static/description/index.html b/fieldservice_subcontracting/static/description/index.html new file mode 100644 index 0000000000..273e8c56cb --- /dev/null +++ b/fieldservice_subcontracting/static/description/index.html @@ -0,0 +1,17 @@ +

    Field Service - Subcontracting

    +

    + This module integrates Field Service with Purchasing to automate + subcontracting workflows. When a Field Service Order (FSO) is assigned + to a subcontractor worker and reaches the configured stage, a draft + Purchase Order is automatically created for the vendor. +

    +

    Key Features

    +
      +
    • Mark workers as subcontractors via a boolean field
    • +
    • Configure a subcontracting service product per FSM Order Type
    • +
    • Auto-create draft Purchase Orders on stage transition
    • +
    • Propagate analytic account from the project to the PO line
    • +
    • Auto-update PO delivered quantity with actual timesheet hours
    • +
    • Smart buttons for cross-reference between FSO and PO
    • +
    • Reassignment protection wizard
    • +
    diff --git a/fieldservice_subcontracting/static/readme/fso_purchase_order_buttons.png b/fieldservice_subcontracting/static/readme/fso_purchase_order_buttons.png new file mode 100644 index 0000000000..0db30357c6 Binary files /dev/null and b/fieldservice_subcontracting/static/readme/fso_purchase_order_buttons.png differ diff --git a/fieldservice_subcontracting/static/readme/purchase_order_fso_button.png b/fieldservice_subcontracting/static/readme/purchase_order_fso_button.png new file mode 100644 index 0000000000..7c448479b0 Binary files /dev/null and b/fieldservice_subcontracting/static/readme/purchase_order_fso_button.png differ diff --git a/fieldservice_subcontracting/static/readme/stage_create_subcontract_po_action.png b/fieldservice_subcontracting/static/readme/stage_create_subcontract_po_action.png new file mode 100644 index 0000000000..bd6b0d4b18 Binary files /dev/null and b/fieldservice_subcontracting/static/readme/stage_create_subcontract_po_action.png differ diff --git a/fieldservice_subcontracting/static/readme/stage_update_subcontract_po_qty_action.png b/fieldservice_subcontracting/static/readme/stage_update_subcontract_po_qty_action.png new file mode 100644 index 0000000000..f5e7a0fccf Binary files /dev/null and b/fieldservice_subcontracting/static/readme/stage_update_subcontract_po_qty_action.png differ diff --git a/fieldservice_subcontracting/static/readme/template_subcontract_product.png b/fieldservice_subcontracting/static/readme/template_subcontract_product.png new file mode 100644 index 0000000000..475841ebb6 Binary files /dev/null and b/fieldservice_subcontracting/static/readme/template_subcontract_product.png differ diff --git a/fieldservice_subcontracting/static/readme/worker_subcontractor.png b/fieldservice_subcontracting/static/readme/worker_subcontractor.png new file mode 100644 index 0000000000..e9159dc19e Binary files /dev/null and b/fieldservice_subcontracting/static/readme/worker_subcontractor.png differ diff --git a/fieldservice_subcontracting/tests/__init__.py b/fieldservice_subcontracting/tests/__init__.py new file mode 100644 index 0000000000..e1c64adcf9 --- /dev/null +++ b/fieldservice_subcontracting/tests/__init__.py @@ -0,0 +1,6 @@ +# Copyright 2026 Binhex +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from . import test_subcontracting_common +from . import test_subcontracting_lifecycle +from . import test_subcontracting_purchase diff --git a/fieldservice_subcontracting/tests/test_subcontracting_common.py b/fieldservice_subcontracting/tests/test_subcontracting_common.py new file mode 100644 index 0000000000..b1f0fad5fe --- /dev/null +++ b/fieldservice_subcontracting/tests/test_subcontracting_common.py @@ -0,0 +1,174 @@ +# Copyright 2026 Binhex +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import Command, fields +from odoo.tests import new_test_user + +from odoo.addons.base.tests.common import BaseCommon + + +class SubcontractingCommon(BaseCommon): + @classmethod + def _setup_subcontracting_data( + cls, + vendor_name="Test Vendor", + worker_name="External Worker", + order_type_name="Test Type", + template_name="Test Template", + duration=1.0, + ): + cls.vendor_partner = cls.env["res.partner"].create( + { + "name": vendor_name, + "supplier_rank": 1, + } + ) + cls.location = cls.env["fsm.location"].create( + { + "name": "Test Location", + "partner_id": cls.env["res.partner"].create({"name": "Client"}).id, + "owner_id": cls.env["res.partner"] + .create({"name": "Location Owner"}) + .id, + } + ) + cls.service_product = cls.env["product.product"].create( + { + "name": "Subcontracted Service", + "type": "service", + "purchase_method": "receive", + "uom_id": cls.env.ref("uom.product_uom_hour").id, + } + ) + cls.env["product.supplierinfo"].create( + { + "partner_id": cls.vendor_partner.id, + "product_tmpl_id": cls.service_product.product_tmpl_id.id, + "price": 100.0, + } + ) + cls.order_type = cls.env["fsm.order.type"].create( + { + "name": order_type_name, + } + ) + cls.template = cls.env["fsm.template"].create( + { + "name": template_name, + "type_id": cls.order_type.id, + "duration": duration, + "subcontract_product_id": cls.service_product.id, + } + ) + cls.subcontractor = cls._create_worker( + worker_name, + partner=cls.vendor_partner, + supplier_rank=1, + is_subcontractor=True, + ) + + @classmethod + def _create_worker( + cls, + name, + partner=None, + supplier_rank=None, + is_subcontractor=None, + ): + if not partner: + partner = cls.env["res.partner"].create({"name": name}) + vals = { + "name": name, + "partner_id": partner.id, + } + if supplier_rank is not None: + vals["supplier_rank"] = supplier_rank + if is_subcontractor is not None: + vals["is_subcontractor"] = is_subcontractor + return cls.env["fsm.person"].create(vals) + + @classmethod + def _create_subcontracting_user(cls, login, *groups): + return new_test_user( + cls.env, + login=login, + groups=",".join(groups), + password="TestUser1!", + ) + + @classmethod + def _create_stage_with_action(cls, name, action_xmlid, **extra_vals): + vals = { + "name": name, + "stage_type": "order", + "action_id": cls.env.ref(action_xmlid).id, + } + vals.update(extra_vals) + return cls.env["fsm.stage"].create(vals) + + def _create_fso( + self, + worker=None, + order_type=None, + template=None, + project=None, + scheduled_duration=1.0, + ): + vals = { + "location_id": self.location.id, + } + if worker: + vals["person_id"] = worker.id + if order_type: + vals["type"] = order_type.id + if template: + vals["template_id"] = template.id + if project: + vals["project_id"] = project.id + if scheduled_duration is not None: + vals["scheduled_duration"] = scheduled_duration + return self.env["fsm.order"].create(vals) + + def _create_fso_with_po(self): + fso = self._create_fso( + worker=self.subcontractor, + order_type=self.order_type, + template=self.template, + ) + fso._create_subcontract_po() + self.assertTrue(fso.purchase_order_ids) + return fso + + def _create_draft_vendor_bill(self, purchase_order): + expense_account = self.env["account.account"].search( + [("account_type", "=", "expense")], + limit=1, + ) + self.assertTrue(expense_account) + bill = self.env["account.move"].create( + { + "move_type": "in_invoice", + "partner_id": purchase_order.partner_id.id, + "invoice_date": fields.Date.context_today(purchase_order), + "invoice_line_ids": [ + Command.create( + { + "name": purchase_order.order_line.name, + "product_id": purchase_order.order_line.product_id.id, + "quantity": 1.0, + "price_unit": 100.0, + "purchase_line_id": purchase_order.order_line.id, + "account_id": expense_account.id, + }, + ) + ], + } + ) + purchase_order.invalidate_recordset(["invoice_ids"]) + return bill + + def _create_posted_vendor_bill(self, purchase_order): + bill = self._create_draft_vendor_bill(purchase_order) + bill.action_post() + purchase_order.invalidate_recordset(["invoice_ids"]) + return bill diff --git a/fieldservice_subcontracting/tests/test_subcontracting_lifecycle.py b/fieldservice_subcontracting/tests/test_subcontracting_lifecycle.py new file mode 100644 index 0000000000..5570834a28 --- /dev/null +++ b/fieldservice_subcontracting/tests/test_subcontracting_lifecycle.py @@ -0,0 +1,481 @@ +# Copyright 2026 Binhex +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo.exceptions import UserError + +from .test_subcontracting_common import SubcontractingCommon + + +class TestSubcontractCancel(SubcontractingCommon): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls._setup_subcontracting_data() + cls.fsm_purchase_user = cls._create_subcontracting_user( + "subcontracting_fsm_purchase_cancel", + "fieldservice.group_fsm_dispatcher", + "purchase.group_purchase_user", + ) + cls.assigned_stage = cls._create_stage_with_action( + "Assigned Cancel Test", + "fieldservice_subcontracting.action_create_subcontract_po", + sequence=20, + ) + + def _create_fso_assigned_with_po(self): + fso = self._create_fso( + worker=self.subcontractor, + order_type=self.order_type, + template=self.template, + ) + fso.with_user(self.fsm_purchase_user).write( + {"stage_id": self.assigned_stage.id} + ) + fso.invalidate_recordset(["purchase_order_ids"]) + self.assertTrue(fso.purchase_order_ids) + return fso + + def test_action_cancel_opens_wizard_when_active_po_exists(self): + """Cancel action should ask what to do with active POs.""" + fso = self._create_fso_with_po() + + result = fso.action_cancel() + + self.assertEqual(result["res_model"], "fsm.order.cancel.confirm") + self.assertEqual(result["context"]["default_fsm_order_id"], fso.id) + self.assertNotEqual( + fso.stage_id, + self.env.ref("fieldservice.fsm_stage_cancelled"), + ) + + def test_action_cancel_blocks_multiple_orders_with_active_pos(self): + """Multiple FSOs with active POs should be cancelled one at a time.""" + fso_1 = self._create_fso_with_po() + fso_2 = self._create_fso_with_po() + + with self.assertRaises(UserError): + (fso_1 | fso_2).action_cancel() + + def test_cancel_wizard_warning_without_purchase_orders(self): + """Cancel wizard should stay quiet when there are no active POs.""" + fso = self._create_fso( + worker=self.subcontractor, + order_type=self.order_type, + template=self.template, + ) + wizard = self.env["fsm.order.cancel.confirm"].create( + { + "fsm_order_id": fso.id, + } + ) + + self.assertFalse(wizard.purchase_order_ids) + self.assertFalse(wizard.warning_message) + + def test_cancel_wizard_warning_with_active_purchase_order(self): + """Cancel wizard should list active POs in its warning.""" + fso = self._create_fso_with_po() + purchase_order = fso.purchase_order_ids + wizard = self.env["fsm.order.cancel.confirm"].create( + { + "fsm_order_id": fso.id, + } + ) + + self.assertEqual(wizard.purchase_order_ids, purchase_order) + self.assertIn(purchase_order.name, wizard.warning_message) + + def test_cancel_wizard_blocks_po_cancellation_with_posted_bill(self): + """Posted vendor bills should block automatic PO cancellation.""" + fso = self._create_fso_with_po() + purchase_order = fso.purchase_order_ids + bill = self._create_posted_vendor_bill(purchase_order) + wizard = self.env["fsm.order.cancel.confirm"].create( + { + "fsm_order_id": fso.id, + } + ) + + self.assertEqual(fso._get_posted_subcontract_vendor_bills(), bill) + self.assertIn("posted vendor bills", wizard.warning_message) + with self.assertRaises(UserError): + wizard.action_cancel_fsm_and_purchase_orders() + self.assertNotEqual(purchase_order.state, "cancel") + + def test_cancel_assigned_po_with_posted_bill_is_blocked(self): + """Posted bills should block cancelling a PO created by Assigned stage.""" + fso = self._create_fso_assigned_with_po() + purchase_order = fso.purchase_order_ids + bill = self._create_posted_vendor_bill(purchase_order) + result = fso.action_cancel() + wizard = self.env[result["res_model"]].create( + { + "fsm_order_id": fso.id, + } + ) + + self.assertEqual(fso._get_posted_subcontract_vendor_bills(), bill) + self.assertIn("posted vendor bills", wizard.warning_message) + with self.assertRaises(UserError): + wizard.action_cancel_fsm_and_purchase_orders() + self.assertEqual(bill.state, "posted") + self.assertNotEqual(purchase_order.state, "cancel") + self.assertNotEqual( + fso.stage_id, + self.env.ref("fieldservice.fsm_stage_cancelled"), + ) + + def test_cancel_wizard_cancels_draft_bill_before_purchase_order(self): + """Draft vendor bills should be cancelled before cancelling the PO.""" + fso = self._create_fso_with_po() + purchase_order = fso.purchase_order_ids + bill = self._create_draft_vendor_bill(purchase_order) + wizard = self.env["fsm.order.cancel.confirm"].create( + { + "fsm_order_id": fso.id, + } + ) + + wizard.action_cancel_fsm_and_purchase_orders() + + self.assertEqual(bill.state, "cancel") + self.assertEqual(purchase_order.state, "cancel") + self.assertEqual(fso.stage_id, self.env.ref("fieldservice.fsm_stage_cancelled")) + + def test_cancel_wizard_cancels_received_po_without_posted_bill(self): + """Received POs without posted bills should still be cancelled.""" + fso = self._create_fso_with_po() + purchase_order = fso.purchase_order_ids + purchase_order.button_confirm() + purchase_order.order_line.write({"qty_received": 2.0}) + wizard = self.env["fsm.order.cancel.confirm"].create( + { + "fsm_order_id": fso.id, + } + ) + + wizard.action_cancel_fsm_and_purchase_orders() + + self.assertEqual(purchase_order.order_line.qty_received, 2.0) + self.assertEqual(purchase_order.state, "cancel") + self.assertEqual(fso.stage_id, self.env.ref("fieldservice.fsm_stage_cancelled")) + + def test_cancel_fso_only_keeps_purchase_order_open(self): + """Wizard can cancel only the FSO.""" + fso = self._create_fso_with_po() + purchase_order = fso.purchase_order_ids + wizard = self.env["fsm.order.cancel.confirm"].create( + { + "fsm_order_id": fso.id, + } + ) + + wizard.action_cancel_fsm_only() + + self.assertEqual(fso.stage_id, self.env.ref("fieldservice.fsm_stage_cancelled")) + self.assertNotEqual(purchase_order.state, "cancel") + + def test_cancel_fso_and_purchase_orders(self): + """Wizard can cancel the FSO and related active POs.""" + fso = self._create_fso_with_po() + purchase_order = fso.purchase_order_ids + wizard = self.env["fsm.order.cancel.confirm"].create( + { + "fsm_order_id": fso.id, + } + ) + + wizard.action_cancel_fsm_and_purchase_orders() + + self.assertEqual(fso.stage_id, self.env.ref("fieldservice.fsm_stage_cancelled")) + self.assertEqual(purchase_order.state, "cancel") + + def test_cancel_fso_and_purchase_orders_with_fsm_and_purchase_user(self): + """FSM and Purchase user can cancel active POs through the wizard.""" + fso = self._create_fso_with_po() + purchase_order = fso.purchase_order_ids + wizard = ( + self.env["fsm.order.cancel.confirm"] + .with_user(self.fsm_purchase_user) + .create( + { + "fsm_order_id": fso.id, + } + ) + ) + + wizard.action_cancel_fsm_and_purchase_orders() + + self.assertEqual(fso.stage_id, self.env.ref("fieldservice.fsm_stage_cancelled")) + self.assertEqual(purchase_order.state, "cancel") + + +class TestSubcontractReassignment(SubcontractingCommon): + """Test worker reassignment protection when PO exists.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls._setup_subcontracting_data() + cls.closed_stage = cls.env["fsm.stage"].create( + { + "name": "Closed Reassignment Test", + "stage_type": "order", + "is_closed": True, + } + ) + cls.internal_worker = cls._create_worker("Internal Worker") + cls.replacement_worker = cls._create_worker("Replacement Worker") + cls.fsm_purchase_user = cls._create_subcontracting_user( + "subcontracting_fsm_purchase_lifecycle", + "fieldservice.group_fsm_dispatcher", + "purchase.group_purchase_user", + ) + + def test_reassignment_is_blocked_without_wizard(self): + """Changing worker on FSO with PO should be blocked.""" + fso = self._create_fso_with_po() + + with self.assertRaises(UserError): + fso.write({"person_id": self.internal_worker.id}) + self.assertEqual(fso.person_id, self.subcontractor) + + def test_reassignment_action_opens_wizard(self): + """Reassignment action should open the confirmation wizard.""" + fso = self._create_fso_with_po() + + result = fso.action_open_reassign_confirm() + self.assertEqual(result["res_model"], "fsm.order.reassign.confirm") + self.assertEqual( + result["context"]["default_fsm_order_id"], + fso.id, + ) + + def test_reassign_worker_button_requires_created_po_and_open_stage(self): + """Reassign Worker button should require a linked PO and open stage.""" + fso = self._create_fso( + worker=self.subcontractor, + order_type=self.order_type, + template=self.template, + ) + self.assertFalse(fso.reassign_worker) + + fso._create_subcontract_po() + self.assertTrue(fso.reassign_worker) + + fso._cancel_active_subcontract_purchase_orders() + self.assertTrue(fso.purchase_order_ids) + self.assertFalse(fso._get_active_subcontract_purchase_orders()) + self.assertTrue(fso.reassign_worker) + + fso.stage_id = self.closed_stage + self.assertFalse(fso.reassign_worker) + + def test_reassign_worker_button_ignores_current_worker_subcontractor_flag(self): + """Reassign Worker button should depend on linked PO and open stage.""" + fso = self._create_fso_with_po() + purchase_order = fso.purchase_order_ids + wizard = self.env["fsm.order.reassign.confirm"].create( + { + "fsm_order_id": fso.id, + "new_person_id": self.internal_worker.id, + } + ) + wizard.action_confirm() + + self.assertFalse(fso.person_id.is_subcontractor) + self.assertEqual(purchase_order.state, "cancel") + self.assertFalse(fso._get_active_subcontract_purchase_orders()) + self.assertTrue(fso.reassign_worker) + result = fso.action_open_reassign_confirm() + self.assertEqual(result["res_model"], "fsm.order.reassign.confirm") + + wizard = self.env["fsm.order.reassign.confirm"].create( + { + "fsm_order_id": fso.id, + "new_person_id": self.replacement_worker.id, + } + ) + wizard.action_confirm() + self.assertEqual(fso.person_id, self.replacement_worker) + + def test_reassign_worker_button_stays_hidden_on_closed_stage_for_internal_worker( + self, + ): + """Closed stage should hide reassignment even for internal current worker.""" + fso = self._create_fso_with_po() + fso.with_context(skip_reassign_check=True).person_id = self.internal_worker + fso._cancel_active_subcontract_purchase_orders() + fso.stage_id = self.closed_stage + + self.assertFalse(fso.person_id.is_subcontractor) + self.assertFalse(fso._get_active_subcontract_purchase_orders()) + self.assertFalse(fso.reassign_worker) + + def test_reassignment_action_without_po_is_blocked(self): + """Reassignment action should require a subcontract PO.""" + fso = self._create_fso( + worker=self.subcontractor, + order_type=self.order_type, + template=self.template, + ) + + with self.assertRaises(UserError): + fso.action_open_reassign_confirm() + + def test_reassignment_action_with_cancelled_po_opens_wizard(self): + """Reassignment action should allow an FSO with only cancelled POs.""" + fso = self._create_fso_with_po() + fso._cancel_active_subcontract_purchase_orders() + + result = fso.action_open_reassign_confirm() + + self.assertFalse(fso._get_active_subcontract_purchase_orders()) + self.assertEqual(result["res_model"], "fsm.order.reassign.confirm") + self.assertEqual( + result["context"]["default_fsm_order_id"], + fso.id, + ) + + def test_reassignment_action_on_closed_stage_is_blocked(self): + """Reassignment action should be blocked when FSO stage is closed.""" + fso = self._create_fso_with_po() + fso.stage_id = self.closed_stage + + with self.assertRaises(UserError): + fso.action_open_reassign_confirm() + + def test_reassign_wizard_warning_without_purchase_orders(self): + """Reassign wizard should stay quiet when there are no active POs.""" + fso = self._create_fso( + worker=self.subcontractor, + order_type=self.order_type, + template=self.template, + ) + wizard = self.env["fsm.order.reassign.confirm"].create( + { + "fsm_order_id": fso.id, + "new_person_id": self.internal_worker.id, + } + ) + + self.assertFalse(wizard.purchase_order_ids) + self.assertFalse(wizard.warning_message) + + def test_reassign_wizard_warning_with_active_purchase_order(self): + """Reassign wizard should list the POs that will be cancelled.""" + fso = self._create_fso_with_po() + purchase_order = fso.purchase_order_ids + wizard = self.env["fsm.order.reassign.confirm"].create( + { + "fsm_order_id": fso.id, + "new_person_id": self.internal_worker.id, + } + ) + + self.assertEqual(wizard.purchase_order_ids, purchase_order) + self.assertIn(purchase_order.name, wizard.warning_message) + + def test_reassign_wizard_warning_with_posted_bill(self): + """Posted vendor bills should block automatic PO cancellation.""" + fso = self._create_fso_with_po() + purchase_order = fso.purchase_order_ids + self._create_posted_vendor_bill(purchase_order) + wizard = self.env["fsm.order.reassign.confirm"].create( + { + "fsm_order_id": fso.id, + "new_person_id": self.internal_worker.id, + } + ) + + self.assertIn("posted vendor bills", wizard.warning_message) + with self.assertRaises(UserError): + wizard.action_confirm() + self.assertEqual(fso.person_id, self.subcontractor) + + def test_reassign_wizard_cancels_draft_bill_before_purchase_order(self): + """Draft vendor bills should be cancelled before worker reassignment.""" + fso = self._create_fso_with_po() + purchase_order = fso.purchase_order_ids + bill = self._create_draft_vendor_bill(purchase_order) + wizard = self.env["fsm.order.reassign.confirm"].create( + { + "fsm_order_id": fso.id, + "new_person_id": self.internal_worker.id, + } + ) + + wizard.action_confirm() + + self.assertEqual(bill.state, "cancel") + self.assertEqual(purchase_order.state, "cancel") + self.assertEqual(fso.person_id, self.internal_worker) + + def test_reassign_wizard_on_closed_stage_is_blocked(self): + """Wizard should not reassign when the FSO stage is closed.""" + fso = self._create_fso_with_po() + purchase_order = fso.purchase_order_ids + fso.stage_id = self.closed_stage + wizard = self.env["fsm.order.reassign.confirm"].create( + { + "fsm_order_id": fso.id, + "new_person_id": self.internal_worker.id, + } + ) + + with self.assertRaises(UserError): + wizard.action_confirm() + self.assertEqual(purchase_order.state, "draft") + self.assertEqual(fso.person_id, self.subcontractor) + + def test_reassignment_with_skip_context(self): + """Reassignment should succeed when skip_reassign_check is set.""" + fso = self._create_fso_with_po() + fso.with_context(skip_reassign_check=True).write( + {"person_id": self.internal_worker.id} + ) + self.assertEqual(fso.person_id, self.internal_worker) + + def test_reassignment_without_wizard_is_blocked_with_cancelled_po(self): + """Changing worker directly should be blocked when a PO was created.""" + fso = self._create_fso_with_po() + fso._cancel_active_subcontract_purchase_orders() + + with self.assertRaises(UserError): + fso.write({"person_id": self.internal_worker.id}) + + def test_wizard_cancels_po(self): + """Wizard action_confirm should cancel PO and reassign.""" + fso = self._create_fso_with_po() + po = fso.purchase_order_ids + + wizard = self.env["fsm.order.reassign.confirm"].create( + { + "fsm_order_id": fso.id, + "new_person_id": self.internal_worker.id, + } + ) + self.assertEqual(wizard.purchase_order_ids, po) + wizard.action_confirm() + self.assertEqual(po.state, "cancel") + self.assertEqual(fso.person_id, self.internal_worker) + + def test_reassign_wizard_with_fsm_and_purchase_user(self): + """FSM and Purchase user can cancel PO and reassign through wizard.""" + fso = self._create_fso_with_po() + po = fso.purchase_order_ids + + wizard = ( + self.env["fsm.order.reassign.confirm"] + .with_user(self.fsm_purchase_user) + .create( + { + "fsm_order_id": fso.id, + "new_person_id": self.internal_worker.id, + } + ) + ) + wizard.action_confirm() + + self.assertEqual(po.state, "cancel") + self.assertEqual(fso.person_id, self.internal_worker) diff --git a/fieldservice_subcontracting/tests/test_subcontracting_purchase.py b/fieldservice_subcontracting/tests/test_subcontracting_purchase.py new file mode 100644 index 0000000000..52025851a4 --- /dev/null +++ b/fieldservice_subcontracting/tests/test_subcontracting_purchase.py @@ -0,0 +1,687 @@ +# Copyright 2026 Binhex +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from datetime import datetime, timedelta + +from odoo.exceptions import AccessError, ValidationError +from odoo.tests import tagged + +from .test_subcontracting_common import SubcontractingCommon + + +@tagged("post_install", "-at_install") +class TestSubcontractPOCreation(SubcontractingCommon): + """Test automatic Purchase Order creation for subcontracted FSOs.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls._setup_subcontracting_data( + vendor_name="Test Vendor Co.", + worker_name="External Vendor Worker", + order_type_name="Test Service Type", + template_name="Test Service Template", + ) + cls.non_vendor_partner = cls.env["res.partner"].create( + { + "name": "Test Non-Vendor", + "supplier_rank": 0, + } + ) + cls.order_type_no_product = cls.env["fsm.order.type"].create( + { + "name": "Test Type No Product", + } + ) + cls.template_no_product = cls.env["fsm.template"].create( + { + "name": "Test Template No Product", + "type_id": cls.order_type_no_product.id, + } + ) + cls.product_without_supplierinfo = cls.env["product.product"].create( + { + "name": "Subcontracted Service Without Supplierinfo", + "type": "service", + "purchase_method": "receive", + "uom_id": cls.env.ref("uom.product_uom_hour").id, + } + ) + cls.template_without_supplierinfo = cls.env["fsm.template"].create( + { + "name": "Test Template Without Supplierinfo", + "type_id": cls.order_type.id, + "subcontract_product_id": cls.product_without_supplierinfo.id, + } + ) + cls.product_invalid_configuration = cls.env["product.product"].create( + { + "name": "Invalid Subcontracted Product", + "type": "service", + "purchase_method": "purchase", + "uom_id": cls.env.ref("uom.product_uom_hour").id, + } + ) + cls.env["product.supplierinfo"].create( + { + "partner_id": cls.vendor_partner.id, + "product_tmpl_id": cls.product_invalid_configuration.product_tmpl_id.id, + "price": 100.0, + } + ) + cls.template_invalid_product_configuration = cls.env["fsm.template"].create( + { + "name": "Test Template Invalid Product Configuration", + "type_id": cls.order_type.id, + "subcontract_product_id": cls.product_invalid_configuration.id, + } + ) + cls.internal_worker = cls._create_worker( + "Internal Worker", + is_subcontractor=False, + ) + cls.analytic_account = cls.env["account.analytic.account"].create( + { + "name": "Test Analytic", + "plan_id": cls.env.ref("analytic.analytic_plan_projects").id, + } + ) + cls.project = cls.env["project.project"].create( + { + "name": "Test Project", + "account_id": cls.analytic_account.id, + } + ) + cls.fsm_purchase_user = cls._create_subcontracting_user( + "subcontracting_fsm_purchase_po", + "fieldservice.group_fsm_dispatcher", + "purchase.group_purchase_user", + ) + cls.fsm_only_user = cls._create_subcontracting_user( + "subcontracting_fsm_only_po", + "fieldservice.group_fsm_dispatcher", + ) + cls.assigned_stage = cls._create_stage_with_action( + "Assigned Subcontracting Test", + "fieldservice_subcontracting.action_create_subcontract_po", + sequence=20, + ) + + def test_subcontractor_constraint_vendor(self): + """Marking a non-vendor partner as subcontractor should fail.""" + with self.assertRaises(ValidationError): + self.env["fsm.person"].create( + { + "name": "Bad Worker", + "partner_id": self.non_vendor_partner.id, + "is_subcontractor": True, + } + ) + + def test_create_po_for_subcontractor(self): + """PO should be created when _create_subcontract_po is called.""" + scheduled_start = datetime(2026, 1, 15, 8, 0, 0) + fso = self._create_fso( + worker=self.subcontractor, + order_type=self.order_type, + template=self.template, + project=self.project, + ) + fso.write( + { + "scheduled_date_start": scheduled_start, + "scheduled_duration": 1.0, + } + ) + self.assertFalse(fso.purchase_order_ids) + fso._create_subcontract_po() + self.assertTrue(fso.purchase_order_ids) + po = fso.purchase_order_ids + self.assertEqual(po.partner_id, self.vendor_partner) + self.assertEqual(po.fsm_order_id, fso) + self.assertEqual(po.state, "draft") + self.assertEqual(po.origin, fso.name) + self.assertEqual(len(po.order_line), 1) + line = po.order_line[0] + self.assertEqual(line.product_id, self.service_product) + self.assertEqual(line.product_qty, 1.0) + self.assertEqual(po.date_planned, fso.scheduled_date_end) + self.assertEqual(line.date_planned, fso.scheduled_date_end) + expected_dist = {str(self.analytic_account.id): 100.0} + self.assertEqual(line.analytic_distribution, expected_dist) + + def test_assigned_stage_creates_po_for_configured_subcontractor(self): + """Assigned stage should create PO through the configured server action.""" + scheduled_start = datetime(2026, 1, 15, 8, 0, 0) + fso = self._create_fso( + worker=self.subcontractor, + order_type=self.order_type, + template=self.template, + project=self.project, + ) + fso.write( + { + "scheduled_date_start": scheduled_start, + "scheduled_duration": 2.0, + } + ) + + fso.with_user(self.fsm_purchase_user).write( + {"stage_id": self.assigned_stage.id} + ) + + fso.invalidate_recordset(["purchase_order_ids"]) + po = fso.purchase_order_ids + self.assertEqual(len(po), 1) + self.assertEqual(po.create_uid, self.fsm_purchase_user) + self.assertEqual(po.partner_id, self.vendor_partner) + self.assertEqual(po.fsm_order_id, fso) + self.assertEqual(po.state, "draft") + self.assertEqual(po.origin, fso.name) + self.assertEqual(po.date_planned, fso.scheduled_date_end) + self.assertEqual(len(po.order_line), 1) + line = po.order_line + self.assertEqual(line.product_id, self.service_product) + self.assertEqual(line.product_qty, 2.0) + self.assertEqual(line.qty_received, 0.0) + self.assertEqual(line.date_planned, fso.scheduled_date_end) + self.assertEqual( + line.analytic_distribution, + {str(self.analytic_account.id): 100.0}, + ) + self.assertTrue( + any( + "Subcontract Purchase Order:" in message.body + for message in fso.message_ids + ) + ) + + def test_assigned_stage_does_not_create_po_without_subcontract_product(self): + """Assigned stage should not create PO when template lacks product.""" + fso = self._create_fso( + worker=self.subcontractor, + order_type=self.order_type_no_product, + template=self.template_no_product, + ) + + fso.write({"stage_id": self.assigned_stage.id}) + + self.assertFalse(fso.purchase_order_ids) + self.assertTrue( + any( + "The outsourcing product is not configured" in message.body + for message in fso.message_ids + ) + ) + + def test_assigned_stage_does_not_create_po_without_supplierinfo(self): + """Assigned stage should require a vendor price for the worker vendor.""" + fso = self._create_fso( + worker=self.subcontractor, + order_type=self.order_type, + template=self.template_without_supplierinfo, + ) + + fso.write({"stage_id": self.assigned_stage.id}) + + self.assertFalse(fso.purchase_order_ids) + self.assertTrue( + any( + "has no vendor price configured" in message.body + for message in fso.message_ids + ) + ) + + def test_assigned_stage_does_not_create_po_with_invalid_product_setup(self): + """Assigned stage should validate RF-03 product settings.""" + fso = self._create_fso( + worker=self.subcontractor, + order_type=self.order_type, + template=self.template_invalid_product_configuration, + ) + + fso.write({"stage_id": self.assigned_stage.id}) + + self.assertFalse(fso.purchase_order_ids) + self.assertTrue( + any( + "must bill based on received quantities" in message.body + for message in fso.message_ids + ) + ) + + def test_assigned_stage_does_not_create_po_for_internal_worker(self): + """Assigned stage should not create PO when worker is not subcontractor.""" + fso = self._create_fso( + worker=self.internal_worker, + order_type=self.order_type, + template=self.template, + ) + + fso.write({"stage_id": self.assigned_stage.id}) + + self.assertFalse(fso.purchase_order_ids) + self.assertTrue( + any("is not a subcontractor" in message.body for message in fso.message_ids) + ) + + def test_create_po_with_fsm_and_purchase_user(self): + """PO creation action requires FSM and Purchase permissions.""" + fso = self._create_fso( + worker=self.subcontractor, + order_type=self.order_type, + template=self.template, + ) + + fso.with_user(self.fsm_purchase_user)._create_subcontract_po() + + self.assertTrue(fso.purchase_order_ids) + self.assertEqual(fso.purchase_order_ids.create_uid, self.fsm_purchase_user) + + def test_create_po_without_purchase_user_is_blocked(self): + """FSM users without Purchase rights cannot create subcontract POs.""" + fso = self._create_fso( + worker=self.subcontractor, + order_type=self.order_type, + template=self.template, + ) + + with self.assertRaises(AccessError): + fso.with_user(self.fsm_only_user)._create_subcontract_po() + self.assertFalse(fso.purchase_order_ids) + + def test_po_date_planned_updates_when_fso_schedule_changes(self): + """Changing FSO schedule should update active subcontract POs.""" + scheduled_start = datetime(2026, 1, 15, 8, 0, 0) + fso = self._create_fso( + worker=self.subcontractor, + order_type=self.order_type, + template=self.template, + project=self.project, + ) + fso.write( + { + "scheduled_date_start": scheduled_start, + "scheduled_duration": 3.0, + } + ) + fso._create_subcontract_po() + po = fso.purchase_order_ids + + new_scheduled_start = scheduled_start + timedelta(days=1) + fso.write( + { + "scheduled_date_start": new_scheduled_start, + "scheduled_duration": 3.0, + } + ) + + expected_date = new_scheduled_start + timedelta(hours=3) + po.invalidate_recordset(["date_planned"]) + po.order_line.invalidate_recordset(["date_planned"]) + self.assertEqual(fso.scheduled_date_end, expected_date) + self.assertEqual(po.date_planned, expected_date) + self.assertEqual(po.order_line.date_planned, expected_date) + + def test_po_date_planned_updates_with_fsm_and_purchase_user(self): + """FSM and Purchase user can update FSO schedule and linked PO dates.""" + scheduled_start = datetime(2026, 1, 15, 8, 0, 0) + fso = self._create_fso( + worker=self.subcontractor, + order_type=self.order_type, + template=self.template, + ) + fso.write( + { + "scheduled_date_start": scheduled_start, + "scheduled_duration": 1.0, + } + ) + fso._create_subcontract_po() + po = fso.purchase_order_ids + + new_scheduled_start = scheduled_start + timedelta(days=2) + fso.with_user(self.fsm_purchase_user).write( + { + "scheduled_date_start": new_scheduled_start, + "scheduled_duration": 2.0, + } + ) + + expected_date = new_scheduled_start + timedelta(hours=2) + po.invalidate_recordset(["date_planned"]) + po.order_line.invalidate_recordset(["date_planned"]) + self.assertEqual(po.date_planned, expected_date) + self.assertEqual(po.order_line.date_planned, expected_date) + + def test_no_po_for_internal_worker(self): + """No PO should be created for internal (non-subcontractor) workers.""" + fso = self._create_fso( + worker=self.internal_worker, + order_type=self.order_type, + template=self.template, + ) + fso._create_subcontract_po() + self.assertFalse(fso.purchase_order_ids) + + def test_no_duplicate_po(self): + """Calling _create_subcontract_po twice should not create a second PO.""" + fso = self._create_fso( + worker=self.subcontractor, + order_type=self.order_type, + template=self.template, + ) + fso._create_subcontract_po() + po1 = fso.purchase_order_ids + fso._create_subcontract_po() + self.assertEqual(fso.purchase_order_ids, po1) + self.assertEqual(len(fso.purchase_order_ids), 1) + + def test_new_po_after_previous_po_cancelled(self): + """A new PO can be created after the previous PO was cancelled.""" + fso = self._create_fso( + worker=self.subcontractor, + order_type=self.order_type, + template=self.template, + ) + fso._create_subcontract_po() + po1 = fso.purchase_order_ids + po1.button_cancel() + + fso._create_subcontract_po() + self.assertEqual(len(fso.purchase_order_ids), 2) + self.assertEqual(len(fso._get_active_subcontract_purchase_orders()), 1) + + def test_no_po_without_product(self): + """No PO if the order type has no subcontracting product.""" + fso = self._create_fso( + worker=self.subcontractor, + order_type=self.order_type_no_product, + template=self.template_no_product, + ) + fso._create_subcontract_po() + self.assertFalse(fso.purchase_order_ids) + + def test_no_po_without_assigned_worker(self): + """No PO if the FSO has no assigned worker.""" + fso = self._create_fso( + order_type=self.order_type, + template=self.template, + ) + + fso._create_subcontract_po() + + self.assertFalse(fso.purchase_order_ids) + self.assertTrue( + any( + "It does not have an assigned worker." in message.body + for message in fso.message_ids + ) + ) + + def test_no_po_when_subcontractor_partner_is_not_supplier(self): + """No PO if subcontractor data becomes inconsistent.""" + self.vendor_partner.supplier_rank = 0 + fso = self._create_fso( + worker=self.subcontractor, + order_type=self.order_type, + template=self.template, + ) + + fso._create_subcontract_po() + + self.assertFalse(fso.purchase_order_ids) + self.assertTrue( + any( + "not associated as a supplier" in message.body + for message in fso.message_ids + ) + ) + + def test_smart_button_count(self): + """Purchase order count should reflect PO existence.""" + fso = self._create_fso( + worker=self.subcontractor, + order_type=self.order_type, + template=self.template, + ) + self.assertEqual(fso.purchase_order_count, 0) + fso._create_subcontract_po() + self.assertEqual(fso.purchase_order_count, 1) + + def test_action_view_purchase_order(self): + """Purchase Order smart button should open linked POs.""" + fso = self._create_fso( + worker=self.subcontractor, + order_type=self.order_type, + template=self.template, + ) + fso._create_subcontract_po() + purchase_order = fso.purchase_order_ids + + action = fso.action_view_purchase_order() + + self.assertEqual(action["res_model"], "purchase.order") + self.assertEqual(action["res_id"], purchase_order.id) + self.assertEqual(action["view_mode"], "form") + + purchase_order.button_cancel() + fso._create_subcontract_po() + action = fso.action_view_purchase_order() + + self.assertEqual(action["view_mode"], "list,form") + self.assertEqual(action["domain"], [("id", "in", fso.purchase_order_ids.ids)]) + self.assertNotIn("res_id", action) + + def test_action_view_fsm_order_from_purchase_order(self): + """Purchase Order smart button should open the linked FSO.""" + unlinked_purchase_order = self.env["purchase.order"].create( + { + "partner_id": self.vendor_partner.id, + } + ) + self.assertEqual(unlinked_purchase_order.fsm_order_count, 0) + + fso = self._create_fso( + worker=self.subcontractor, + order_type=self.order_type, + template=self.template, + ) + fso._create_subcontract_po() + purchase_order = fso.purchase_order_ids + + action = purchase_order.action_view_fsm_order() + + self.assertEqual(purchase_order.fsm_order_count, 1) + self.assertEqual(action["res_model"], "fsm.order") + self.assertEqual(action["res_id"], fso.id) + self.assertEqual(action["view_mode"], "form") + + +@tagged("post_install", "-at_install") +class TestSubcontractPOClose(SubcontractingCommon): + """Test PO delivered quantity update when FSO is closed.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls._setup_subcontracting_data() + cls.order_type_no_product = cls.env["fsm.order.type"].create( + { + "name": "Test Type No Product", + } + ) + cls.template_no_product = cls.env["fsm.template"].create( + { + "name": "Test Template No Product", + "type_id": cls.order_type_no_product.id, + } + ) + cls.employee = cls.env["hr.employee"].create( + { + "name": "Test Employee", + } + ) + cls.project = cls.env["project.project"].create( + { + "name": "Test Project", + } + ) + cls.fsm_purchase_user = cls._create_subcontracting_user( + "subcontracting_fsm_purchase_close", + "fieldservice.group_fsm_dispatcher", + "purchase.group_purchase_user", + ) + cls.done_stage = cls._create_stage_with_action( + "Done Subcontracting Test", + "fieldservice_subcontracting.action_update_subcontract_po_qty", + sequence=81, + is_closed=True, + ) + + def test_update_po_qty_on_close(self): + """Closing the FSO should update PO line qty_received.""" + fso = self._create_fso( + worker=self.subcontractor, + order_type=self.order_type, + template=self.template, + project=self.project, + ) + fso._create_subcontract_po() + po = fso.purchase_order_ids + self.assertTrue(po) + + self.env["account.analytic.line"].create( + { + "name": "Work done - day 1", + "project_id": self.project.id, + "fsm_order_id": fso.id, + "employee_id": self.employee.id, + "unit_amount": 4.0, + } + ) + self.env["account.analytic.line"].create( + { + "name": "Work done - day 2", + "project_id": self.project.id, + "fsm_order_id": fso.id, + "employee_id": self.employee.id, + "unit_amount": 3.5, + } + ) + + fso._update_subcontract_po_qty() + + po_line = po.order_line[0] + po_line.invalidate_recordset(["product_qty", "qty_received"]) + self.assertAlmostEqual(po_line.product_qty, 1.0) + self.assertAlmostEqual(po_line.qty_received, 7.5) + + def test_done_stage_updates_delivered_qty_from_timesheets(self): + """Done stage should update delivered qty through the server action.""" + fso = self._create_fso( + worker=self.subcontractor, + order_type=self.order_type, + template=self.template, + project=self.project, + ) + fso._create_subcontract_po() + po_line = fso.purchase_order_ids.order_line + self.env["account.analytic.line"].create( + { + "name": "Work done - stage action 1", + "project_id": self.project.id, + "fsm_order_id": fso.id, + "employee_id": self.employee.id, + "unit_amount": 1.5, + } + ) + self.env["account.analytic.line"].create( + { + "name": "Work done - stage action 2", + "project_id": self.project.id, + "fsm_order_id": fso.id, + "employee_id": self.employee.id, + "unit_amount": 2.0, + } + ) + + fso.with_user(self.fsm_purchase_user).write({"stage_id": self.done_stage.id}) + + po_line.invalidate_recordset(["product_qty", "qty_received"]) + self.assertAlmostEqual(po_line.product_qty, 1.0) + self.assertAlmostEqual(po_line.qty_received, 3.5) + self.assertTrue( + any( + "Delivered quantity updated to 3.50 hours" in message.body + for message in fso.purchase_order_ids.message_ids + ) + ) + + def test_update_po_qty_with_fsm_and_purchase_user(self): + """FSM and Purchase user can update PO delivered quantity.""" + fso = self._create_fso( + worker=self.subcontractor, + order_type=self.order_type, + template=self.template, + project=self.project, + ) + fso._create_subcontract_po() + po = fso.purchase_order_ids + self.env["account.analytic.line"].create( + { + "name": "Work done", + "project_id": self.project.id, + "fsm_order_id": fso.id, + "employee_id": self.employee.id, + "unit_amount": 2.25, + } + ) + + fso.with_user(self.fsm_purchase_user)._update_subcontract_po_qty() + + po_line = po.order_line[0] + po_line.invalidate_recordset(["product_qty", "qty_received"]) + self.assertAlmostEqual(po_line.product_qty, 1.0) + self.assertAlmostEqual(po_line.qty_received, 2.25) + + def test_no_update_without_subcontract_product(self): + """PO quantity should not update when FSO has no subcontract product.""" + fso = self._create_fso( + worker=self.subcontractor, + order_type=self.order_type, + template=self.template, + project=self.project, + ) + fso._create_subcontract_po() + po_line = fso.purchase_order_ids.order_line + self.env["account.analytic.line"].create( + { + "name": "Work done", + "project_id": self.project.id, + "fsm_order_id": fso.id, + "employee_id": self.employee.id, + "unit_amount": 2.0, + } + ) + fso.write( + { + "type": self.order_type_no_product.id, + "template_id": self.template_no_product.id, + } + ) + + fso._update_subcontract_po_qty() + + self.assertAlmostEqual(po_line.product_qty, 1.0) + self.assertAlmostEqual(po_line.qty_received, 0.0) + + def test_no_update_without_po(self): + """Should not fail if FSO has no PO linked.""" + fso = self._create_fso( + worker=self.subcontractor, + order_type=self.order_type, + template=self.template, + ) + + fso._update_subcontract_po_qty() diff --git a/fieldservice_subcontracting/views/fsm_order_views.xml b/fieldservice_subcontracting/views/fsm_order_views.xml new file mode 100644 index 0000000000..a25dfa782a --- /dev/null +++ b/fieldservice_subcontracting/views/fsm_order_views.xml @@ -0,0 +1,44 @@ + + + + + fsm.order.form.subcontracting + fsm.order + + + + + + + + + + + + + + diff --git a/fieldservice_subcontracting/views/fsm_person_views.xml b/fieldservice_subcontracting/views/fsm_person_views.xml new file mode 100644 index 0000000000..e00a0fcea5 --- /dev/null +++ b/fieldservice_subcontracting/views/fsm_person_views.xml @@ -0,0 +1,26 @@ + + + + + fsm.person.form.subcontracting + fsm.person + + + + + + + + + + fsm.person.tree.subcontracting + fsm.person + + + + + + + + diff --git a/fieldservice_subcontracting/views/fsm_template_views.xml b/fieldservice_subcontracting/views/fsm_template_views.xml new file mode 100644 index 0000000000..823202b75d --- /dev/null +++ b/fieldservice_subcontracting/views/fsm_template_views.xml @@ -0,0 +1,15 @@ + + + + + fsm.template.form.subcontracting + fsm.template + + + + + + + + diff --git a/fieldservice_subcontracting/views/purchase_order_views.xml b/fieldservice_subcontracting/views/purchase_order_views.xml new file mode 100644 index 0000000000..e97a785958 --- /dev/null +++ b/fieldservice_subcontracting/views/purchase_order_views.xml @@ -0,0 +1,23 @@ + + + + + purchase.order.form.subcontracting + purchase.order + + + + + + + + diff --git a/fieldservice_subcontracting/wizards/__init__.py b/fieldservice_subcontracting/wizards/__init__.py new file mode 100644 index 0000000000..41c9d6aec7 --- /dev/null +++ b/fieldservice_subcontracting/wizards/__init__.py @@ -0,0 +1,2 @@ +from . import fsm_order_cancel_confirm +from . import fsm_order_reassign_confirm diff --git a/fieldservice_subcontracting/wizards/fsm_order_cancel_confirm.py b/fieldservice_subcontracting/wizards/fsm_order_cancel_confirm.py new file mode 100644 index 0000000000..11e98392e5 --- /dev/null +++ b/fieldservice_subcontracting/wizards/fsm_order_cancel_confirm.py @@ -0,0 +1,70 @@ +# Copyright 2026 Binhex +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import api, fields, models + + +class FsmOrderCancelConfirm(models.TransientModel): + _name = "fsm.order.cancel.confirm" + _description = "Confirm FSO Cancellation with PO Cancellation" + + fsm_order_id = fields.Many2one( + comodel_name="fsm.order", + string="Field Service Order", + required=True, + readonly=True, + ) + purchase_order_ids = fields.Many2many( + comodel_name="purchase.order", + string="Purchase Orders", + compute="_compute_purchase_order_ids", + readonly=True, + ) + warning_message = fields.Text( + compute="_compute_warning_message", + ) + + @api.depends("fsm_order_id.purchase_order_ids.state") + def _compute_purchase_order_ids(self): + for wizard in self: + wizard.purchase_order_ids = ( + wizard.fsm_order_id._get_active_subcontract_purchase_orders() + ) + + @api.depends( + "fsm_order_id.purchase_order_ids.invoice_ids.state", + "fsm_order_id.purchase_order_ids.state", + ) + def _compute_warning_message(self): + for wizard in self: + posted_bills = wizard.fsm_order_id._get_posted_subcontract_vendor_bills() + if posted_bills: + wizard.warning_message = self.env._( + "One or more related Purchase Orders have posted vendor " + "bills and cannot be cancelled automatically." + ) + elif wizard.purchase_order_ids: + wizard.warning_message = self.env._( + "This Field Service Order has active Purchase Orders " + "%(purchase_orders)s. Do you want to cancel them too?", + purchase_orders=", ".join(wizard.purchase_order_ids.mapped("name")), + ) + else: + wizard.warning_message = "" + + def _cancel_fsm_order(self): + self.ensure_one() + self.fsm_order_id.with_context( + skip_subcontract_cancel_wizard=True + ).action_cancel() + return {"type": "ir.actions.client", "tag": "reload"} + + def action_cancel_fsm_only(self): + """Cancel the FSO and keep related Purchase Orders open.""" + return self._cancel_fsm_order() + + def action_cancel_fsm_and_purchase_orders(self): + """Cancel related Purchase Orders and then cancel the FSO.""" + self.ensure_one() + self.fsm_order_id._cancel_active_subcontract_purchase_orders() + return self._cancel_fsm_order() diff --git a/fieldservice_subcontracting/wizards/fsm_order_cancel_confirm_views.xml b/fieldservice_subcontracting/wizards/fsm_order_cancel_confirm_views.xml new file mode 100644 index 0000000000..50d65fea39 --- /dev/null +++ b/fieldservice_subcontracting/wizards/fsm_order_cancel_confirm_views.xml @@ -0,0 +1,40 @@ + + + + + fsm.order.cancel.confirm.form + fsm.order.cancel.confirm + +
    + + + + + + + +
    +
    +
    +
    +
    +
    diff --git a/fieldservice_subcontracting/wizards/fsm_order_reassign_confirm.py b/fieldservice_subcontracting/wizards/fsm_order_reassign_confirm.py new file mode 100644 index 0000000000..3d177f8230 --- /dev/null +++ b/fieldservice_subcontracting/wizards/fsm_order_reassign_confirm.py @@ -0,0 +1,70 @@ +# Copyright 2026 Binhex +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import api, fields, models + + +class FsmOrderReassignConfirm(models.TransientModel): + _name = "fsm.order.reassign.confirm" + _description = "Confirm FSO Worker Reassignment with PO Cancellation" + + fsm_order_id = fields.Many2one( + comodel_name="fsm.order", + string="Field Service Order", + required=True, + readonly=True, + ) + purchase_order_ids = fields.Many2many( + comodel_name="purchase.order", + string="Purchase Orders", + compute="_compute_purchase_order_ids", + readonly=True, + ) + new_person_id = fields.Many2one( + comodel_name="fsm.person", + string="New Worker", + required=True, + ) + warning_message = fields.Text( + compute="_compute_warning_message", + ) + + @api.depends("fsm_order_id.purchase_order_ids.state") + def _compute_purchase_order_ids(self): + for wiz in self: + wiz.purchase_order_ids = ( + wiz.fsm_order_id._get_active_subcontract_purchase_orders() + ) + + @api.depends( + "fsm_order_id.purchase_order_ids.invoice_ids.state", + "fsm_order_id.purchase_order_ids.state", + ) + def _compute_warning_message(self): + for wiz in self: + posted_bills = wiz.fsm_order_id._get_posted_subcontract_vendor_bills() + if posted_bills: + wiz.warning_message = self.env._( + "One or more Purchase Orders have posted vendor bills and " + "cannot be cancelled automatically. You must manage the " + "POs manually before reassigning the worker." + ) + elif wiz.purchase_order_ids: + wiz.warning_message = self.env._( + "Reassigning the worker will cancel Purchase Orders " + "%(purchase_orders)s. Do you want to proceed?", + purchase_orders=", ".join(wiz.purchase_order_ids.mapped("name")), + ) + else: + wiz.warning_message = "" + + def action_confirm(self): + """Cancel the POs and proceed with worker reassignment.""" + self.ensure_one() + self.fsm_order_id._check_reassign_subcontract_worker_allowed() + self.fsm_order_id._cancel_active_subcontract_purchase_orders() + self.fsm_order_id.with_context(skip_reassign_check=True).write( + {"person_id": self.new_person_id.id} + ) + self.fsm_order_id._create_subcontract_po() + return {"type": "ir.actions.act_window_close"} diff --git a/fieldservice_subcontracting/wizards/fsm_order_reassign_confirm_views.xml b/fieldservice_subcontracting/wizards/fsm_order_reassign_confirm_views.xml new file mode 100644 index 0000000000..d9598c7bd5 --- /dev/null +++ b/fieldservice_subcontracting/wizards/fsm_order_reassign_confirm_views.xml @@ -0,0 +1,48 @@ + + + + + fsm.order.reassign.confirm.form + fsm.order.reassign.confirm + +
    + + + + + + + + + + + + + +
    +
    + +
    +
    + + + Confirm Worker Reassignment + fsm.order.reassign.confirm + form + new + +