From a341d623fa67974d353883c3ec5c1ebd5ef52a02 Mon Sep 17 00:00:00 2001 From: Maxime Chambreuil Date: Fri, 24 Jul 2026 23:45:11 -0600 Subject: [PATCH] [ADD] fieldservice_sign Add configurable on-site signature capture and sign_oca document signing on Field Service orders. --- fieldservice_sign/README.rst | 145 +++++ fieldservice_sign/__init__.py | 4 + fieldservice_sign/__manifest__.py | 29 + fieldservice_sign/demo/fsm_order.xml | 8 + fieldservice_sign/demo/res_company.xml | 13 + fieldservice_sign/demo/sign_oca_role.xml | 10 + fieldservice_sign/demo/sign_oca_template.xml | 10 + .../demo/sign_oca_template_item.xml | 16 + fieldservice_sign/models/__init__.py | 7 + fieldservice_sign/models/fsm_order.py | 117 +++++ fieldservice_sign/models/res_company.py | 31 ++ .../models/res_config_settings.py | 29 + fieldservice_sign/models/sign_oca_request.py | 24 + fieldservice_sign/pyproject.toml | 3 + fieldservice_sign/readme/CONFIGURE.md | 9 + fieldservice_sign/readme/CONTRIBUTORS.md | 2 + fieldservice_sign/readme/DESCRIPTION.md | 12 + fieldservice_sign/readme/USAGE.md | 20 + .../report/fsm_order_report_templates.xml | 32 ++ fieldservice_sign/static/description/icon.png | Bin 0 -> 29789 bytes .../static/description/index.html | 496 ++++++++++++++++++ fieldservice_sign/tests/__init__.py | 4 + .../tests/test_fsm_order_sign.py | 171 ++++++ fieldservice_sign/views/fsm_order_views.xml | 78 +++ .../views/res_config_settings_views.xml | 69 +++ .../views/sign_oca_request_views.xml | 54 ++ 26 files changed, 1393 insertions(+) create mode 100644 fieldservice_sign/README.rst create mode 100644 fieldservice_sign/__init__.py create mode 100644 fieldservice_sign/__manifest__.py create mode 100644 fieldservice_sign/demo/fsm_order.xml create mode 100644 fieldservice_sign/demo/res_company.xml create mode 100644 fieldservice_sign/demo/sign_oca_role.xml create mode 100644 fieldservice_sign/demo/sign_oca_template.xml create mode 100644 fieldservice_sign/demo/sign_oca_template_item.xml create mode 100644 fieldservice_sign/models/__init__.py create mode 100644 fieldservice_sign/models/fsm_order.py create mode 100644 fieldservice_sign/models/res_company.py create mode 100644 fieldservice_sign/models/res_config_settings.py create mode 100644 fieldservice_sign/models/sign_oca_request.py create mode 100644 fieldservice_sign/pyproject.toml create mode 100644 fieldservice_sign/readme/CONFIGURE.md create mode 100644 fieldservice_sign/readme/CONTRIBUTORS.md create mode 100644 fieldservice_sign/readme/DESCRIPTION.md create mode 100644 fieldservice_sign/readme/USAGE.md create mode 100644 fieldservice_sign/report/fsm_order_report_templates.xml create mode 100644 fieldservice_sign/static/description/icon.png create mode 100644 fieldservice_sign/static/description/index.html create mode 100644 fieldservice_sign/tests/__init__.py create mode 100644 fieldservice_sign/tests/test_fsm_order_sign.py create mode 100644 fieldservice_sign/views/fsm_order_views.xml create mode 100644 fieldservice_sign/views/res_config_settings_views.xml create mode 100644 fieldservice_sign/views/sign_oca_request_views.xml diff --git a/fieldservice_sign/README.rst b/fieldservice_sign/README.rst new file mode 100644 index 0000000000..5116673eb7 --- /dev/null +++ b/fieldservice_sign/README.rst @@ -0,0 +1,145 @@ +.. image:: https://odoo-community.org/readme-banner-image + :target: https://odoo-community.org/get-involved?utm_source=readme + :alt: Odoo Community Association + +==================== +Field Service - Sign +==================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:18abaccc11d7489d636730dae5d0a0ff39ca484f527b3a6ef803ea6c172d48aa + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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/license-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/19.0/fieldservice_sign + :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-19-0/field-service-19-0-fieldservice_sign + :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=19.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module adds configurable customer signing features on Field Service +orders: + +- **On-site signature capture** — uses the native signature widget and + the signature fields already provided by ``fieldservice`` + (``signature``, ``signed_by``, ``signed_on``). Capture can be enabled + or disabled per company, and optionally required before an order can + be completed. +- **Document signing** — integrates with ``sign_oca`` to launch a + signature request for a document linked to the order, expose its + status on the order, and optionally require a signed document before + completion. + +Both features are independent. An administrator can enable none, either +one, or both from Field Service settings. + +**Table of contents** + +.. contents:: + :local: + +Configuration +============= + +Go to *Field Service > Configuration > Settings* and open the +**Signatures** section: + +1. Enable **On-site Signature Capture** to surface customer signature + capture on orders. Optionally enable **Require Signature to + Complete**. +2. Enable **Document Signing** to allow launching ``sign_oca`` requests + from orders. Optionally enable **Require Document Signed to + Complete**. +3. Select the **FSM Order Sign Template** used when requesting a + document signature. The template model must be ``fsm.order``. + +Usage +===== + +On-site signature capture +------------------------- + +1. Open a Field Service order. +2. When the order stage requires a signature, use **Sign** to capture + the customer signature. +3. Complete the order. If the company requires a signature, completion + is blocked until one is captured. + +The signature is also printed on the Service Order report when capture +is enabled. + +Document signing +---------------- + +1. Open a Field Service order. +2. Click **Request Signature** to create and send a ``sign_oca`` request + from the company template. +3. Follow the linked signature requests from the smart button or the + **Document Signing** tab. +4. Complete the order. If the company requires a signed document, + completion is blocked until at least one request is signed. + +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 +------- + +* Gray Matter Logic + +Contributors +------------ + +- `Gray Matter Logic `__: + + - Maxime Chambreuil + +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-max3903| image:: https://github.com/max3903.png?size=40px + :target: https://github.com/max3903 + :alt: max3903 + +Current `maintainer `__: + +|maintainer-max3903| + +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_sign/__init__.py b/fieldservice_sign/__init__.py new file mode 100644 index 0000000000..10b976be9c --- /dev/null +++ b/fieldservice_sign/__init__.py @@ -0,0 +1,4 @@ +# Copyright (C) 2026 Gray Matter Logic (). +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from . import models diff --git a/fieldservice_sign/__manifest__.py b/fieldservice_sign/__manifest__.py new file mode 100644 index 0000000000..2db31dc926 --- /dev/null +++ b/fieldservice_sign/__manifest__.py @@ -0,0 +1,29 @@ +# Copyright (C) 2026 Gray Matter Logic (). +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +{ + "name": "Field Service - Sign", + "summary": "Customer signature capture and document signing on FSM orders", + "version": "19.0.1.0.0", + "category": "Field Service", + "author": "Gray Matter Logic, Odoo Community Association (OCA)", + "website": "https://github.com/OCA/field-service", + "license": "AGPL-3", + "depends": ["fieldservice", "sign_oca"], + "data": [ + "views/res_config_settings_views.xml", + "views/fsm_order_views.xml", + "views/sign_oca_request_views.xml", + "report/fsm_order_report_templates.xml", + ], + "demo": [ + "demo/sign_oca_role.xml", + "demo/sign_oca_template.xml", + "demo/sign_oca_template_item.xml", + "demo/res_company.xml", + "demo/fsm_order.xml", + ], + "development_status": "Beta", + "maintainers": ["max3903"], + "installable": True, +} diff --git a/fieldservice_sign/demo/fsm_order.xml b/fieldservice_sign/demo/fsm_order.xml new file mode 100644 index 0000000000..add90aa327 --- /dev/null +++ b/fieldservice_sign/demo/fsm_order.xml @@ -0,0 +1,8 @@ + + + + + + + diff --git a/fieldservice_sign/demo/res_company.xml b/fieldservice_sign/demo/res_company.xml new file mode 100644 index 0000000000..a296d87e35 --- /dev/null +++ b/fieldservice_sign/demo/res_company.xml @@ -0,0 +1,13 @@ + + + + + + + + + diff --git a/fieldservice_sign/demo/sign_oca_role.xml b/fieldservice_sign/demo/sign_oca_role.xml new file mode 100644 index 0000000000..72820650c8 --- /dev/null +++ b/fieldservice_sign/demo/sign_oca_role.xml @@ -0,0 +1,10 @@ + + + + + FSM Order Customer + expression + {{object.location_id.owner_id.id}} + + diff --git a/fieldservice_sign/demo/sign_oca_template.xml b/fieldservice_sign/demo/sign_oca_template.xml new file mode 100644 index 0000000000..3c924637ce --- /dev/null +++ b/fieldservice_sign/demo/sign_oca_template.xml @@ -0,0 +1,10 @@ + + + + + FSM Order Approval + + + + diff --git a/fieldservice_sign/demo/sign_oca_template_item.xml b/fieldservice_sign/demo/sign_oca_template_item.xml new file mode 100644 index 0000000000..10a9b29313 --- /dev/null +++ b/fieldservice_sign/demo/sign_oca_template_item.xml @@ -0,0 +1,16 @@ + + + + + + + + 1 + 10 + 10 + 30 + 4 + + + diff --git a/fieldservice_sign/models/__init__.py b/fieldservice_sign/models/__init__.py new file mode 100644 index 0000000000..e58b05e6b9 --- /dev/null +++ b/fieldservice_sign/models/__init__.py @@ -0,0 +1,7 @@ +# Copyright (C) 2026 Gray Matter Logic (). +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from . import fsm_order +from . import res_company +from . import res_config_settings +from . import sign_oca_request diff --git a/fieldservice_sign/models/fsm_order.py b/fieldservice_sign/models/fsm_order.py new file mode 100644 index 0000000000..5f39038ad9 --- /dev/null +++ b/fieldservice_sign/models/fsm_order.py @@ -0,0 +1,117 @@ +# Copyright (C) 2026 Gray Matter Logic (). +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import api, fields, models +from odoo.exceptions import UserError + + +class FSMOrder(models.Model): + _inherit = "fsm.order" + + fsm_signature_capture = fields.Boolean( + related="company_id.fsm_signature_capture", + ) + fsm_document_signing = fields.Boolean( + related="company_id.fsm_document_signing", + ) + sign_request_ids = fields.One2many( + comodel_name="sign.oca.request", + inverse_name="fsm_order_id", + string="Signature Requests", + ) + sign_request_count = fields.Integer( + compute="_compute_sign_request_count", + compute_sudo=True, + ) + sign_request_id = fields.Many2one( + comodel_name="sign.oca.request", + string="Signature Request", + compute="_compute_sign_request_id", + compute_sudo=True, + store=True, + ) + sign_request_state = fields.Selection( + related="sign_request_id.state", + string="Signature Request Status", + ) + + @api.depends("sign_request_ids") + def _compute_sign_request_count(self): + for order in self: + order.sign_request_count = len(order.sign_request_ids) + + @api.depends("sign_request_ids", "sign_request_ids.state") + def _compute_sign_request_id(self): + for order in self: + order.sign_request_id = order.sign_request_ids.sorted("id", reverse=True)[ + :1 + ] + + def action_view_sign_requests(self): + self.ensure_one() + action = self.env["ir.actions.act_window"]._for_xml_id( + "sign_oca.sign_oca_request_act_window" + ) + action["domain"] = [("id", "in", self.sign_request_ids.ids)] + action["context"] = dict( + self.env.context, + default_record_ref=f"{self._name},{self.id}", + ) + return action + + def action_request_document_signature(self): + self.ensure_one() + if not self.company_id.fsm_document_signing: + raise UserError( + self.env._( + "Document signing is disabled for company %(company)s.", + company=self.company_id.display_name, + ) + ) + template = self.company_id.fsm_order_sign_oca_template_id + if not template: + raise UserError( + self.env._( + "Configure an FSM Order Sign Template on the company " + "before requesting a document signature." + ) + ) + request = ( + self.env["sign.oca.request"] + .sudo() + .create(template._prepare_sign_oca_request_vals_from_record(self)) + ) + request.action_send() + return self.action_view_sign_requests() + + def _check_signature_requirements_before_complete(self): + for order in self: + company = order.company_id + if ( + company.fsm_signature_capture + and company.fsm_require_signature_to_complete + and not order.signature + ): + raise UserError( + self.env._( + "Customer signature is required before completing " + "order %(order)s.", + order=order.display_name, + ) + ) + if ( + company.fsm_document_signing + and company.fsm_require_document_signed_to_complete + and not order.sign_request_ids.filtered(lambda r: r.state == "2_signed") + ): + raise UserError( + self.env._( + "A signed document is required before completing " + "order %(order)s.", + order=order.display_name, + ) + ) + + def action_complete(self): + self._check_signature_requirements_before_complete() + return super().action_complete() diff --git a/fieldservice_sign/models/res_company.py b/fieldservice_sign/models/res_company.py new file mode 100644 index 0000000000..8a97631130 --- /dev/null +++ b/fieldservice_sign/models/res_company.py @@ -0,0 +1,31 @@ +# Copyright (C) 2026 Gray Matter Logic (). +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class ResCompany(models.Model): + _inherit = "res.company" + + fsm_signature_capture = fields.Boolean( + string="FSM Signature Capture", + help="Enable on-site customer signature capture on Field Service orders.", + ) + fsm_document_signing = fields.Boolean( + string="FSM Document Signing", + help="Enable sign_oca document signing on Field Service orders.", + ) + fsm_require_signature_to_complete = fields.Boolean( + string="Require Signature to Complete", + help="Block completing an order until a customer signature is captured.", + ) + fsm_require_document_signed_to_complete = fields.Boolean( + string="Require Document Signed to Complete", + help="Block completing an order until a linked signature request is signed.", + ) + fsm_order_sign_oca_template_id = fields.Many2one( + comodel_name="sign.oca.template", + string="FSM Order Sign Template", + domain="[('model_id.model', '=', 'fsm.order')]", + help="Default sign_oca template used when requesting a document signature.", + ) diff --git a/fieldservice_sign/models/res_config_settings.py b/fieldservice_sign/models/res_config_settings.py new file mode 100644 index 0000000000..7db37ff0f8 --- /dev/null +++ b/fieldservice_sign/models/res_config_settings.py @@ -0,0 +1,29 @@ +# Copyright (C) 2026 Gray Matter Logic (). +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class ResConfigSettings(models.TransientModel): + _inherit = "res.config.settings" + + fsm_signature_capture = fields.Boolean( + related="company_id.fsm_signature_capture", + readonly=False, + ) + fsm_document_signing = fields.Boolean( + related="company_id.fsm_document_signing", + readonly=False, + ) + fsm_require_signature_to_complete = fields.Boolean( + related="company_id.fsm_require_signature_to_complete", + readonly=False, + ) + fsm_require_document_signed_to_complete = fields.Boolean( + related="company_id.fsm_require_document_signed_to_complete", + readonly=False, + ) + fsm_order_sign_oca_template_id = fields.Many2one( + related="company_id.fsm_order_sign_oca_template_id", + readonly=False, + ) diff --git a/fieldservice_sign/models/sign_oca_request.py b/fieldservice_sign/models/sign_oca_request.py new file mode 100644 index 0000000000..d355bd94e3 --- /dev/null +++ b/fieldservice_sign/models/sign_oca_request.py @@ -0,0 +1,24 @@ +# Copyright (C) 2026 Gray Matter Logic (). +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import api, fields, models + + +class SignOcaRequest(models.Model): + _inherit = "sign.oca.request" + + fsm_order_id = fields.Many2one( + comodel_name="fsm.order", + string="Field Service Order", + compute="_compute_fsm_order_id", + readonly=True, + store=True, + ) + + @api.depends("record_ref") + def _compute_fsm_order_id(self): + for request in self: + if request.record_ref and request.record_ref._name == "fsm.order": + request.fsm_order_id = request.record_ref.id + else: + request.fsm_order_id = False diff --git a/fieldservice_sign/pyproject.toml b/fieldservice_sign/pyproject.toml new file mode 100644 index 0000000000..4231d0cccb --- /dev/null +++ b/fieldservice_sign/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/fieldservice_sign/readme/CONFIGURE.md b/fieldservice_sign/readme/CONFIGURE.md new file mode 100644 index 0000000000..124ffb2708 --- /dev/null +++ b/fieldservice_sign/readme/CONFIGURE.md @@ -0,0 +1,9 @@ +Go to *Field Service \> Configuration \> Settings* and open the **Signatures** +section: + +1. Enable **On-site Signature Capture** to surface customer signature capture + on orders. Optionally enable **Require Signature to Complete**. +2. Enable **Document Signing** to allow launching `sign_oca` requests from + orders. Optionally enable **Require Document Signed to Complete**. +3. Select the **FSM Order Sign Template** used when requesting a document + signature. The template model must be `fsm.order`. diff --git a/fieldservice_sign/readme/CONTRIBUTORS.md b/fieldservice_sign/readme/CONTRIBUTORS.md new file mode 100644 index 0000000000..52c0a8351b --- /dev/null +++ b/fieldservice_sign/readme/CONTRIBUTORS.md @@ -0,0 +1,2 @@ +- [Gray Matter Logic](https://www.graymatterlogic.com): + - Maxime Chambreuil \<\> diff --git a/fieldservice_sign/readme/DESCRIPTION.md b/fieldservice_sign/readme/DESCRIPTION.md new file mode 100644 index 0000000000..ddac536060 --- /dev/null +++ b/fieldservice_sign/readme/DESCRIPTION.md @@ -0,0 +1,12 @@ +This module adds configurable customer signing features on Field Service orders: + +* **On-site signature capture** — uses the native signature widget and the + signature fields already provided by `fieldservice` (`signature`, + `signed_by`, `signed_on`). Capture can be enabled or disabled per company, + and optionally required before an order can be completed. +* **Document signing** — integrates with `sign_oca` to launch a signature + request for a document linked to the order, expose its status on the order, + and optionally require a signed document before completion. + +Both features are independent. An administrator can enable none, either one, +or both from Field Service settings. diff --git a/fieldservice_sign/readme/USAGE.md b/fieldservice_sign/readme/USAGE.md new file mode 100644 index 0000000000..d164d6a756 --- /dev/null +++ b/fieldservice_sign/readme/USAGE.md @@ -0,0 +1,20 @@ +## On-site signature capture + +1. Open a Field Service order. +2. When the order stage requires a signature, use **Sign** to capture the + customer signature. +3. Complete the order. If the company requires a signature, completion is + blocked until one is captured. + +The signature is also printed on the Service Order report when capture is +enabled. + +## Document signing + +1. Open a Field Service order. +2. Click **Request Signature** to create and send a `sign_oca` request from the + company template. +3. Follow the linked signature requests from the smart button or the + **Document Signing** tab. +4. Complete the order. If the company requires a signed document, completion is + blocked until at least one request is signed. diff --git a/fieldservice_sign/report/fsm_order_report_templates.xml b/fieldservice_sign/report/fsm_order_report_templates.xml new file mode 100644 index 0000000000..c64ca14d97 --- /dev/null +++ b/fieldservice_sign/report/fsm_order_report_templates.xml @@ -0,0 +1,32 @@ + + + + + diff --git a/fieldservice_sign/static/description/icon.png b/fieldservice_sign/static/description/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..e3757caa15401e8ed72d8f824c645151bdf8536a GIT binary patch literal 29789 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 literal 0 HcmV?d00001 diff --git a/fieldservice_sign/static/description/index.html b/fieldservice_sign/static/description/index.html new file mode 100644 index 0000000000..799aa6da6e --- /dev/null +++ b/fieldservice_sign/static/description/index.html @@ -0,0 +1,496 @@ + + + + + +README.rst + + + +
+ + + +Odoo Community Association + +
+

Field Service - Sign

+ +

Beta License: AGPL-3 OCA/field-service Translate me on Weblate Try me on Runboat

+

This module adds configurable customer signing features on Field Service +orders:

+
    +
  • On-site signature capture — uses the native signature widget and +the signature fields already provided by fieldservice +(signature, signed_by, signed_on). Capture can be enabled +or disabled per company, and optionally required before an order can +be completed.
  • +
  • Document signing — integrates with sign_oca to launch a +signature request for a document linked to the order, expose its +status on the order, and optionally require a signed document before +completion.
  • +
+

Both features are independent. An administrator can enable none, either +one, or both from Field Service settings.

+

Table of contents

+ +
+

Configuration

+

Go to Field Service > Configuration > Settings and open the +Signatures section:

+
    +
  1. Enable On-site Signature Capture to surface customer signature +capture on orders. Optionally enable Require Signature to +Complete.
  2. +
  3. Enable Document Signing to allow launching sign_oca requests +from orders. Optionally enable Require Document Signed to +Complete.
  4. +
  5. Select the FSM Order Sign Template used when requesting a +document signature. The template model must be fsm.order.
  6. +
+
+
+

Usage

+
+

On-site signature capture

+
    +
  1. Open a Field Service order.
  2. +
  3. When the order stage requires a signature, use Sign to capture +the customer signature.
  4. +
  5. Complete the order. If the company requires a signature, completion +is blocked until one is captured.
  6. +
+

The signature is also printed on the Service Order report when capture +is enabled.

+
+
+

Document signing

+
    +
  1. Open a Field Service order.
  2. +
  3. Click Request Signature to create and send a sign_oca request +from the company template.
  4. +
  5. Follow the linked signature requests from the smart button or the +Document Signing tab.
  6. +
  7. Complete the order. If the company requires a signed document, +completion is blocked until at least one request is signed.
  8. +
+
+
+
+

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

+
    +
  • Gray Matter Logic
  • +
+
+ +
+

Maintainers

+

This module is maintained by the OCA.

+ +Odoo Community Association + +

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.

+

Current maintainer:

+

max3903

+

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_sign/tests/__init__.py b/fieldservice_sign/tests/__init__.py new file mode 100644 index 0000000000..4c76e864b6 --- /dev/null +++ b/fieldservice_sign/tests/__init__.py @@ -0,0 +1,4 @@ +# Copyright (C) 2026 Gray Matter Logic (). +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from . import test_fsm_order_sign diff --git a/fieldservice_sign/tests/test_fsm_order_sign.py b/fieldservice_sign/tests/test_fsm_order_sign.py new file mode 100644 index 0000000000..a2f58a8ebc --- /dev/null +++ b/fieldservice_sign/tests/test_fsm_order_sign.py @@ -0,0 +1,171 @@ +# Copyright (C) 2026 Gray Matter Logic (). +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import base64 + +from odoo.exceptions import UserError +from odoo.tools.misc import file_open + +from odoo.addons.fieldservice.tests.test_fsm_common import FSMCommon + +TEST_IMAGE_BASE64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAACklEQVR4nGP4DwABAQEAGN2N9wAAAABJRU5ErkJggg==" # noqa: E501 + + +class TestFieldserviceSign(FSMCommon): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.env = cls.env(context=dict(cls.env.context, tracking_disable=True)) + cls.company = cls.env.company + cls.order = cls.env["fsm.order"].create({"location_id": cls.test_location.id}) + with file_open("sign_oca/tests/empty.pdf", "rb") as pdf_file: + pdf_data = base64.b64encode(pdf_file.read()) + cls.sign_role = cls.env["sign.oca.role"].create( + { + "name": "FSM Test Customer", + "partner_selection_policy": "expression", + "expression_partner": "{{object.location_id.owner_id.id}}", + } + ) + cls.template = cls.env["sign.oca.template"].create( + { + "name": "FSM Order Test Template", + "model_id": cls.env["ir.model"]._get_id("fsm.order"), + "data": pdf_data, + "item_ids": [ + ( + 0, + 0, + { + "field_id": cls.env.ref("sign_oca.sign_field_signature").id, + "role_id": cls.sign_role.id, + "page": 1, + "position_x": 10, + "position_y": 10, + "width": 30, + "height": 4, + "required": True, + }, + ) + ], + } + ) + cls.company.write( + { + "fsm_signature_capture": False, + "fsm_document_signing": False, + "fsm_require_signature_to_complete": False, + "fsm_require_document_signed_to_complete": False, + "fsm_order_sign_oca_template_id": False, + } + ) + + def test_complete_without_guards(self): + self.order.action_complete() + self.assertEqual( + self.order.stage_id, + self.env.ref("fieldservice.fsm_stage_completed"), + ) + + def test_require_signature_to_complete(self): + self.company.write( + { + "fsm_signature_capture": True, + "fsm_require_signature_to_complete": True, + } + ) + with self.assertRaisesRegex(UserError, "Customer signature is required"): + self.order.action_complete() + self.order.write( + { + "signed_by": "Test Customer", + "signature": TEST_IMAGE_BASE64, + } + ) + self.order.action_complete() + self.assertEqual( + self.order.stage_id, + self.env.ref("fieldservice.fsm_stage_completed"), + ) + + def test_request_document_signature_disabled(self): + with self.assertRaisesRegex(UserError, "Document signing is disabled"): + self.order.action_request_document_signature() + + def test_request_document_signature_without_template(self): + self.company.fsm_document_signing = True + with self.assertRaisesRegex(UserError, "Configure an FSM Order Sign Template"): + self.order.action_request_document_signature() + + def test_request_document_signature(self): + self.company.write( + { + "fsm_document_signing": True, + "fsm_order_sign_oca_template_id": self.template.id, + } + ) + action = self.order.action_request_document_signature() + self.assertEqual(self.order.sign_request_count, 1) + request = self.order.sign_request_id + self.assertTrue(request) + self.assertEqual(request.fsm_order_id, self.order) + self.assertEqual(request.state, "0_sent") + self.assertIn( + self.test_location.owner_id, + request.signer_ids.partner_id, + ) + self.assertEqual(self.order.sign_request_state, "0_sent") + self.assertEqual( + action["domain"], + [("id", "in", self.order.sign_request_ids.ids)], + ) + + def test_require_document_signed_to_complete(self): + self.company.write( + { + "fsm_document_signing": True, + "fsm_require_document_signed_to_complete": True, + "fsm_order_sign_oca_template_id": self.template.id, + } + ) + self.order.action_request_document_signature() + with self.assertRaisesRegex(UserError, "A signed document is required"): + self.order.action_complete() + self.order.sign_request_id.state = "2_signed" + self.order.action_complete() + self.assertEqual( + self.order.stage_id, + self.env.ref("fieldservice.fsm_stage_completed"), + ) + + def test_action_view_sign_requests(self): + self.company.write( + { + "fsm_document_signing": True, + "fsm_order_sign_oca_template_id": self.template.id, + } + ) + self.order.action_request_document_signature() + action = self.order.action_view_sign_requests() + self.assertEqual( + action["domain"], + [("id", "in", self.order.sign_request_ids.ids)], + ) + self.assertEqual( + action["context"]["default_record_ref"], + f"fsm.order,{self.order.id}", + ) + + def test_compute_fsm_order_id_clears_non_order_refs(self): + self.company.write( + { + "fsm_document_signing": True, + "fsm_order_sign_oca_template_id": self.template.id, + } + ) + self.order.action_request_document_signature() + request = self.order.sign_request_id + self.assertEqual(request.fsm_order_id, self.order) + partner = self.test_partner + request.record_ref = f"{partner._name},{partner.id}" + self.assertFalse(request.fsm_order_id) diff --git a/fieldservice_sign/views/fsm_order_views.xml b/fieldservice_sign/views/fsm_order_views.xml new file mode 100644 index 0000000000..d1e32d6dab --- /dev/null +++ b/fieldservice_sign/views/fsm_order_views.xml @@ -0,0 +1,78 @@ + + + + + fsm.order.form.inherit.fieldservice.sign + fsm.order + + + + + + + + + + +
+ +
+ + not fsm_signature_capture or (not require_signature and not signed_by and not signature and not signed_on) + + + + + + + + + + + + + + + + +
+
+
diff --git a/fieldservice_sign/views/res_config_settings_views.xml b/fieldservice_sign/views/res_config_settings_views.xml new file mode 100644 index 0000000000..6e854dea21 --- /dev/null +++ b/fieldservice_sign/views/res_config_settings_views.xml @@ -0,0 +1,69 @@ + + + + + res.config.settings.view.form.inherit.fieldservice.sign + res.config.settings + + + + + + +
+
+ +
+
+
+ + +
+
+ +
+
+
+
+
+
+
+
+
+
diff --git a/fieldservice_sign/views/sign_oca_request_views.xml b/fieldservice_sign/views/sign_oca_request_views.xml new file mode 100644 index 0000000000..a89c3545ae --- /dev/null +++ b/fieldservice_sign/views/sign_oca_request_views.xml @@ -0,0 +1,54 @@ + + + + + sign.oca.request.search.inherit.fieldservice.sign + sign.oca.request + + + + + + + + + + + + + sign.oca.request.form.inherit.fieldservice.sign + sign.oca.request + + + + + + + + + + sign.oca.request.list.inherit.fieldservice.sign + sign.oca.request + + + + + + + +