diff --git a/fieldservice_sale_timesheet_material/README.rst b/fieldservice_sale_timesheet_material/README.rst new file mode 100644 index 0000000000..46e2373cac --- /dev/null +++ b/fieldservice_sale_timesheet_material/README.rst @@ -0,0 +1,154 @@ +.. image:: https://odoo-community.org/readme-banner-image + :target: https://odoo-community.org/get-involved?utm_source=readme + :alt: Odoo Community Association + +========================================= +Field Service - Sale Timesheet & Material +========================================= + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:87e70d22046d3920703173709fe35da1f89ef609b22a87cbaf601fdaee96ed70 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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/16.0/fieldservice_sale_timesheet_material + :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-16-0/field-service-16-0-fieldservice_sale_timesheet_material + :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=16.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module lets you invoice the **time** spent and the **materials** consumed +on a field service order by adding them as lines to the order's sale order, +which can then be invoiced through the standard sales flow. + +It applies to field service orders that were **generated from a sale order** +(see *Field Service - Sale*): the recorded work is pushed back to that same +sale order. It builds on ``fsm.order``: + +* timesheets are the ``account.analytic.line`` records linked to the order + (from *Field Service - Analytic Accounting*), grouped by their *Time Type* + product; +* materials are the consumed stock moves of the order (from + *Field Service - Stock*), grouped by product. ``fieldservice_stock`` only + displays these moves read-only, so creating them requires a companion module + such as **Field Service - Stock Request** (``fieldservice_stock_request``), + or deliveries coming from the sale order. + +A single **Add to Sale Order** button on the field service order pushes both +timesheets and materials onto the linked sale order. The button is only shown +when the order is linked to a sale order; it never creates one. The operation +is idempotent: only work that has not yet been billed is added. + +**Table of contents** + +.. contents:: + :local: + +Usage +===== + +#. Start from a confirmed **sale order** that generated a field service order + (see *Field Service - Sale*), and make sure the order's **location** has an + *Analytic Account* set (*Field Service - Analytic Accounting*). +#. On the field service order, record the time spent in the **Timesheets** + tab, choosing a *Time Type* product for each line. +#. Record the materials consumed on site. These are the order's **done + outgoing stock moves**, which *Field Service - Stock* only displays + read-only — so a companion module is required to create them. Install + **Field Service - Stock Request** (``fieldservice_stock_request``) and enter + the products in the **Stock Requests** list of the order's *Inventory* tab, + then process them through that module's flow until the moves reach the + *Done* state. Materials delivered from the sale order appear here too. Only + *Done* moves are billed. +#. Click **Add to Sale Order**. A line is added to the linked sale order for + each *Time Type* product (with the total hours) and for each consumed + product (with the net delivered quantity). The button is hidden when the + order is not linked to a sale order. +#. Open the sale order with the **Sale Order** smart button and create the + invoice as usual. + +Clicking **Add to Sale Order** again only adds timesheets and materials that +have not been billed yet, so it is safe to use repeatedly as work progresses. + +The **Service Order** report (*Print* menu on the field service order) is +extended with a **Time Spent** section listing each timesheet line with its +total, and a **Materials** section listing the *Done* stock moves per product +and direction (*Used* for outgoing moves, *Returned* for incoming ones), so the +printout shows both what was consumed on site and what came back. + +Recommended product configuration: + +The **service product on the original sale order** — the one that makes the +sale order generate the field service order — must have *Field Service +Tracking* set to *Per sale order* or *Per sale order line* (see *Field Service +- Sale*). That setting is what creates the field service order on sale +confirmation; without it there is no order to push the work back to. + +The **time and material products** added by *Add to Sale Order*: + +* *Time Type* products should be **services** measured in hours; +* the *Invoicing Policy* can be *Ordered quantities* or *Delivered + quantities*: the delivered quantity of time lines is set from the recorded + hours, while the delivered quantity of material lines comes from the field + service order's own stock moves (which are linked to the created lines, so + no extra delivery is generated); +* *Field Service Tracking* should be left at *Don't create FSM order*. The + lines this module creates are already linked back to the originating field + service order, so they never spawn further orders; but a *Per sale order + line* setting would switch the line's delivered-quantity method to *Field + Service* and override the quantity computed here. + +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 +~~~~~~~ + +* Innovyou + +Contributors +~~~~~~~~~~~~ + +* Lorenzo Battistini + +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. + +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_sale_timesheet_material/__init__.py b/fieldservice_sale_timesheet_material/__init__.py new file mode 100644 index 0000000000..0650744f6b --- /dev/null +++ b/fieldservice_sale_timesheet_material/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/fieldservice_sale_timesheet_material/__manifest__.py b/fieldservice_sale_timesheet_material/__manifest__.py new file mode 100644 index 0000000000..3d5bd1d37b --- /dev/null +++ b/fieldservice_sale_timesheet_material/__manifest__.py @@ -0,0 +1,24 @@ +# Copyright (C) 2026 Innovyou +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +{ + "name": "Field Service - Sale Timesheet & Material", + "summary": "Invoice field service timesheets and consumed materials " + "by adding them to the sale order", + "version": "16.0.1.0.0", + "category": "Field Service", + "author": "Innovyou, Odoo Community Association (OCA)", + "website": "https://github.com/OCA/field-service", + "depends": [ + "fieldservice_sale", + "fieldservice_account_analytic", + "fieldservice_stock", + ], + "data": [ + "views/fsm_order.xml", + "report/fsm_order_report_template.xml", + ], + "license": "AGPL-3", + "development_status": "Beta", + "installable": True, +} diff --git a/fieldservice_sale_timesheet_material/i18n/it.po b/fieldservice_sale_timesheet_material/i18n/it.po new file mode 100644 index 0000000000..8e5958ea61 --- /dev/null +++ b/fieldservice_sale_timesheet_material/i18n/it.po @@ -0,0 +1,145 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * fieldservice_sale_timesheet_material +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 16.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-06-08 14:12+0000\n" +"PO-Revision-Date: 2026-06-08 14:12+0000\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_sale_timesheet_material +#: model_terms:ir.ui.view,arch_db:fieldservice_sale_timesheet_material.report_fsm_order_document_timesheet_material +msgid "Total" +msgstr "Totale" + +#. module: fieldservice_sale_timesheet_material +#: model_terms:ir.ui.view,arch_db:fieldservice_sale_timesheet_material.fsm_order_form_sale_timesheet_material +msgid "" +"Add the recorded timesheets and consumed materials as lines on the order's " +"sale order." +msgstr "" +"Aggiunge i fogli ore registrati e i materiali consumati come righe " +"nell'ordine di vendita dell'ordine." + +#. module: fieldservice_sale_timesheet_material +#: model_terms:ir.ui.view,arch_db:fieldservice_sale_timesheet_material.fsm_order_form_sale_timesheet_material +msgid "Add to Sale Order" +msgstr "Aggiungi all'ordine di vendita" + +#. module: fieldservice_sale_timesheet_material +#: model:ir.model,name:fieldservice_sale_timesheet_material.model_account_analytic_line +msgid "Analytic Line" +msgstr "Riga analitica" + +#. module: fieldservice_sale_timesheet_material +#: model_terms:ir.ui.view,arch_db:fieldservice_sale_timesheet_material.report_fsm_order_document_timesheet_material +msgid "Date" +msgstr "Data" + +#. module: fieldservice_sale_timesheet_material +#: model_terms:ir.ui.view,arch_db:fieldservice_sale_timesheet_material.report_fsm_order_document_timesheet_material +msgid "Description" +msgstr "Descrizione" + +#. module: fieldservice_sale_timesheet_material +#: model_terms:ir.ui.view,arch_db:fieldservice_sale_timesheet_material.report_fsm_order_document_timesheet_material +msgid "Direction" +msgstr "Direzione" + +#. module: fieldservice_sale_timesheet_material +#: model_terms:ir.ui.view,arch_db:fieldservice_sale_timesheet_material.fsm_order_form_sale_timesheet_material +#: model_terms:ir.ui.view,arch_db:fieldservice_sale_timesheet_material.report_fsm_order_document_timesheet_material +msgid "Duration" +msgstr "Durata" + +#. module: fieldservice_sale_timesheet_material +#: model:ir.model.fields,field_description:fieldservice_sale_timesheet_material.field_account_analytic_line__fsm_sale_line_id +#: model:ir.model.fields,field_description:fieldservice_sale_timesheet_material.field_stock_move__fsm_sale_line_id +msgid "FSM Sale Order Line" +msgstr "Riga ordine di vendita FSM" + +#. module: fieldservice_sale_timesheet_material +#: model:ir.model,name:fieldservice_sale_timesheet_material.model_fsm_order +msgid "Field Service Order" +msgstr "Ordine assistenza sul campo" + +#. module: fieldservice_sale_timesheet_material +#: model_terms:ir.ui.view,arch_db:fieldservice_sale_timesheet_material.report_fsm_order_document_timesheet_material +msgid "Materials" +msgstr "Materiali" + +#. module: fieldservice_sale_timesheet_material +#: model_terms:ir.ui.view,arch_db:fieldservice_sale_timesheet_material.report_fsm_order_document_timesheet_material +msgid "Product" +msgstr "Prodotto" + +#. module: fieldservice_sale_timesheet_material +#: model_terms:ir.ui.view,arch_db:fieldservice_sale_timesheet_material.report_fsm_order_document_timesheet_material +msgid "Quantity" +msgstr "Quantità" + +#. module: fieldservice_sale_timesheet_material +#: model_terms:ir.ui.view,arch_db:fieldservice_sale_timesheet_material.report_fsm_order_document_timesheet_material +msgid "Returned" +msgstr "Restituito" + +#. module: fieldservice_sale_timesheet_material +#: model:ir.model.fields,help:fieldservice_sale_timesheet_material.field_stock_move__fsm_sale_line_id +msgid "" +"Sale order line on which this consumed material has been billed from the " +"field service order." +msgstr "" +"Riga dell'ordine di vendita su cui questo materiale consumato è stato " +"fatturato dall'ordine di assistenza sul campo." + +#. module: fieldservice_sale_timesheet_material +#: model:ir.model.fields,help:fieldservice_sale_timesheet_material.field_account_analytic_line__fsm_sale_line_id +msgid "Sale order line on which this field service timesheet has been billed." +msgstr "" +"Riga dell'ordine di vendita su cui questo foglio ore di assistenza sul campo " +"è stato fatturato." + +#. module: fieldservice_sale_timesheet_material +#: model:ir.model,name:fieldservice_sale_timesheet_material.model_stock_move +msgid "Stock Move" +msgstr "Movimento di magazzino" + +#. module: fieldservice_sale_timesheet_material +#: model_terms:ir.ui.view,arch_db:fieldservice_sale_timesheet_material.report_fsm_order_document_timesheet_material +msgid "Time Spent" +msgstr "Tempo impiegato" + +#. module: fieldservice_sale_timesheet_material +#: model_terms:ir.ui.view,arch_db:fieldservice_sale_timesheet_material.fsm_order_form_sale_timesheet_material +#: model_terms:ir.ui.view,arch_db:fieldservice_sale_timesheet_material.report_fsm_order_document_timesheet_material +msgid "Time Type" +msgstr "Tipo di tempo" + +#. module: fieldservice_sale_timesheet_material +#: model:ir.model.fields,field_description:fieldservice_sale_timesheet_material.field_fsm_order__timesheet_ids +#: model_terms:ir.ui.view,arch_db:fieldservice_sale_timesheet_material.fsm_order_form_sale_timesheet_material +msgid "Timesheets" +msgstr "Fogli ore" + +#. module: fieldservice_sale_timesheet_material +#: model_terms:ir.ui.view,arch_db:fieldservice_sale_timesheet_material.report_fsm_order_document_timesheet_material +msgid "Unit of Measure" +msgstr "Unità di misura" + +#. module: fieldservice_sale_timesheet_material +#: model_terms:ir.ui.view,arch_db:fieldservice_sale_timesheet_material.report_fsm_order_document_timesheet_material +msgid "Used" +msgstr "Utilizzato" + +#. module: fieldservice_sale_timesheet_material +#: model_terms:ir.ui.view,arch_db:fieldservice_sale_timesheet_material.report_fsm_order_document_timesheet_material +msgid "Worker" +msgstr "Operatore" diff --git a/fieldservice_sale_timesheet_material/models/__init__.py b/fieldservice_sale_timesheet_material/models/__init__.py new file mode 100644 index 0000000000..e40bc4489b --- /dev/null +++ b/fieldservice_sale_timesheet_material/models/__init__.py @@ -0,0 +1,3 @@ +from . import account_analytic_line +from . import stock_move +from . import fsm_order diff --git a/fieldservice_sale_timesheet_material/models/account_analytic_line.py b/fieldservice_sale_timesheet_material/models/account_analytic_line.py new file mode 100644 index 0000000000..2b44553c80 --- /dev/null +++ b/fieldservice_sale_timesheet_material/models/account_analytic_line.py @@ -0,0 +1,17 @@ +# Copyright (C) 2026 Innovyou +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class AccountAnalyticLine(models.Model): + _inherit = "account.analytic.line" + + fsm_sale_line_id = fields.Many2one( + "sale.order.line", + string="FSM Sale Order Line", + copy=False, + index=True, + help="Sale order line on which this field service timesheet has been " + "billed.", + ) diff --git a/fieldservice_sale_timesheet_material/models/fsm_order.py b/fieldservice_sale_timesheet_material/models/fsm_order.py new file mode 100644 index 0000000000..98f359b831 --- /dev/null +++ b/fieldservice_sale_timesheet_material/models/fsm_order.py @@ -0,0 +1,170 @@ +# Copyright (C) 2026 Innovyou +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from collections import defaultdict + +from odoo import fields, models +from odoo.tools import float_compare + + +class FSMOrder(models.Model): + _inherit = "fsm.order" + + timesheet_ids = fields.One2many( + "account.analytic.line", + "fsm_order_id", + string="Timesheets", + ) + + # ------------------------------------------------------------------ + # Sale order + # ------------------------------------------------------------------ + def _prepare_sale_line_vals(self, product, qty, uom): + self.ensure_one() + return { + "order_id": self.sale_id.id, + "product_id": product.id, + "product_uom_qty": qty, + "product_uom": uom.id, + "fsm_order_id": self.id, + } + + # ------------------------------------------------------------------ + # Timesheets -> sale order lines + # ------------------------------------------------------------------ + def _create_sale_lines_from_timesheets(self): + self.ensure_one() + SaleLine = self.env["sale.order.line"] + lines = self.timesheet_ids.filtered( + lambda t: t.product_id and not t.fsm_sale_line_id + ) + grouped = defaultdict(lambda: self.env["account.analytic.line"]) + for line in lines: + grouped[line.product_id] |= line + for product, ts_lines in grouped.items(): + uom = product.uom_id + qty = 0.0 + for ts in ts_lines: + src_uom = ts.product_uom_id or uom + if src_uom.category_id == uom.category_id and src_uom != uom: + qty += src_uom._compute_quantity(ts.unit_amount, uom) + else: + qty += ts.unit_amount + if float_compare(qty, 0.0, precision_rounding=uom.rounding) <= 0: + continue + vals = self._prepare_sale_line_vals(product, qty, uom) + # Time has no stock move backing it, so the delivered quantity is + # set explicitly (these service lines use the manual delivery + # method). + vals["qty_delivered"] = qty + sol = SaleLine.create(vals) + ts_lines.write({"fsm_sale_line_id": sol.id}) + + # ------------------------------------------------------------------ + # Consumed materials (stock moves) -> sale order lines + # ------------------------------------------------------------------ + @staticmethod + def _move_qty_in_uom(move, uom): + """``move.quantity_done`` converted to ``uom`` when compatible.""" + qty = move.quantity_done + if move.product_uom.category_id == uom.category_id and move.product_uom != uom: + qty = move.product_uom._compute_quantity(qty, uom) + return qty + + def _net_move_qty(self, product, moves): + """Net delivered quantity for ``moves``, expressed in ``product``'s UoM. + + Outgoing moves are materials consumed on site, incoming moves are + materials returned: they net each other out. + """ + uom = product.uom_id + qty = 0.0 + for move in moves: + sign = 1.0 if move.picking_id.picking_type_id.code == "outgoing" else -1.0 + qty += sign * self._move_qty_in_uom(move, uom) + return qty + + def _create_sale_lines_from_materials(self): + self.ensure_one() + SaleLine = self.env["sale.order.line"] + moves = self.move_ids.filtered( + lambda m: m.state == "done" and not m.fsm_sale_line_id + ) + grouped = defaultdict(lambda: self.env["stock.move"]) + for move in moves: + grouped[move.product_id] |= move + for product, prod_moves in grouped.items(): + uom = product.uom_id + qty = self._net_move_qty(product, prod_moves) + if float_compare(qty, 0.0, precision_rounding=uom.rounding) <= 0: + continue + # The materials have already been delivered through the field + # service order's own stock moves. Skip the procurement that would + # otherwise generate a duplicate delivery on the sale order, and + # link those moves to the new line so its delivered quantity is + # computed from them natively (stock move delivery method). + sol = SaleLine.with_context(skip_procurement=True).create( + self._prepare_sale_line_vals(product, qty, uom) + ) + prod_moves.write({"sale_line_id": sol.id, "fsm_sale_line_id": sol.id}) + + # ------------------------------------------------------------------ + # Action + # ------------------------------------------------------------------ + def action_create_sale_lines(self): + """Push the recorded timesheets and consumed materials to the order's + sale order for invoicing. + + The field service order must already be linked to a sale order (it was + generated from one); this never creates a sale order. Orders without a + sale order are skipped, and the button is hidden for them. + """ + for order in self.filtered("sale_id"): + order._create_sale_lines_from_timesheets() + order._create_sale_lines_from_materials() + if len(self) == 1: + return self.action_view_sales() + return True + + # ------------------------------------------------------------------ + # Report + # ------------------------------------------------------------------ + def _report_material_lines(self): + """Consumed and returned materials for the printed report. + + Returns a list of ``{"product", "direction", "qty", "uom"}`` dicts, + one row per product and direction, where ``direction`` is ``"out"`` + (materials used on site) or ``"in"`` (materials returned). Quantities + are summed per group and expressed in the product's UoM; returns are + listed on their own rows rather than netted against the consumption, + so the report shows both what went out and what came back. Rows are + ordered by product, used before returned. + """ + self.ensure_one() + grouped = defaultdict(lambda: self.env["stock.move"]) + for move in self.move_ids.filtered(lambda m: m.state == "done"): + direction = ( + "out" if move.picking_id.picking_type_id.code == "outgoing" else "in" + ) + grouped[(move.product_id, direction)] |= move + lines = [] + for (product, direction), moves in grouped.items(): + uom = product.uom_id + qty = sum(self._move_qty_in_uom(move, uom) for move in moves) + if float_compare(qty, 0.0, precision_rounding=uom.rounding) <= 0: + continue + lines.append( + { + "product": product, + "direction": direction, + "qty": qty, + "uom": uom, + } + ) + lines.sort( + key=lambda line: ( + line["product"].display_name, + 0 if line["direction"] == "out" else 1, + ) + ) + return lines diff --git a/fieldservice_sale_timesheet_material/models/stock_move.py b/fieldservice_sale_timesheet_material/models/stock_move.py new file mode 100644 index 0000000000..eaa011582e --- /dev/null +++ b/fieldservice_sale_timesheet_material/models/stock_move.py @@ -0,0 +1,17 @@ +# Copyright (C) 2026 Innovyou +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class StockMove(models.Model): + _inherit = "stock.move" + + fsm_sale_line_id = fields.Many2one( + "sale.order.line", + string="FSM Sale Order Line", + copy=False, + index=True, + help="Sale order line on which this consumed material has been billed " + "from the field service order.", + ) diff --git a/fieldservice_sale_timesheet_material/readme/CONTRIBUTORS.rst b/fieldservice_sale_timesheet_material/readme/CONTRIBUTORS.rst new file mode 100644 index 0000000000..f52b696bb0 --- /dev/null +++ b/fieldservice_sale_timesheet_material/readme/CONTRIBUTORS.rst @@ -0,0 +1 @@ +* Lorenzo Battistini diff --git a/fieldservice_sale_timesheet_material/readme/DESCRIPTION.rst b/fieldservice_sale_timesheet_material/readme/DESCRIPTION.rst new file mode 100644 index 0000000000..83f52493be --- /dev/null +++ b/fieldservice_sale_timesheet_material/readme/DESCRIPTION.rst @@ -0,0 +1,21 @@ +This module lets you invoice the **time** spent and the **materials** consumed +on a field service order by adding them as lines to the order's sale order, +which can then be invoiced through the standard sales flow. + +It applies to field service orders that were **generated from a sale order** +(see *Field Service - Sale*): the recorded work is pushed back to that same +sale order. It builds on ``fsm.order``: + +* timesheets are the ``account.analytic.line`` records linked to the order + (from *Field Service - Analytic Accounting*), grouped by their *Time Type* + product; +* materials are the consumed stock moves of the order (from + *Field Service - Stock*), grouped by product. ``fieldservice_stock`` only + displays these moves read-only, so creating them requires a companion module + such as **Field Service - Stock Request** (``fieldservice_stock_request``), + or deliveries coming from the sale order. + +A single **Add to Sale Order** button on the field service order pushes both +timesheets and materials onto the linked sale order. The button is only shown +when the order is linked to a sale order; it never creates one. The operation +is idempotent: only work that has not yet been billed is added. diff --git a/fieldservice_sale_timesheet_material/readme/USAGE.rst b/fieldservice_sale_timesheet_material/readme/USAGE.rst new file mode 100644 index 0000000000..ebfb0658fe --- /dev/null +++ b/fieldservice_sale_timesheet_material/readme/USAGE.rst @@ -0,0 +1,50 @@ +#. Start from a confirmed **sale order** that generated a field service order + (see *Field Service - Sale*), and make sure the order's **location** has an + *Analytic Account* set (*Field Service - Analytic Accounting*). +#. On the field service order, record the time spent in the **Timesheets** + tab, choosing a *Time Type* product for each line. +#. Record the materials consumed on site. These are the order's **done + outgoing stock moves**, which *Field Service - Stock* only displays + read-only — so a companion module is required to create them. Install + **Field Service - Stock Request** (``fieldservice_stock_request``) and enter + the products in the **Stock Requests** list of the order's *Inventory* tab, + then process them through that module's flow until the moves reach the + *Done* state. Materials delivered from the sale order appear here too. Only + *Done* moves are billed. +#. Click **Add to Sale Order**. A line is added to the linked sale order for + each *Time Type* product (with the total hours) and for each consumed + product (with the net delivered quantity). The button is hidden when the + order is not linked to a sale order. +#. Open the sale order with the **Sale Order** smart button and create the + invoice as usual. + +Clicking **Add to Sale Order** again only adds timesheets and materials that +have not been billed yet, so it is safe to use repeatedly as work progresses. + +The **Service Order** report (*Print* menu on the field service order) is +extended with a **Time Spent** section listing each timesheet line with its +total, and a **Materials** section listing the *Done* stock moves per product +and direction (*Used* for outgoing moves, *Returned* for incoming ones), so the +printout shows both what was consumed on site and what came back. + +Recommended product configuration: + +The **service product on the original sale order** — the one that makes the +sale order generate the field service order — must have *Field Service +Tracking* set to *Per sale order* or *Per sale order line* (see *Field Service +- Sale*). That setting is what creates the field service order on sale +confirmation; without it there is no order to push the work back to. + +The **time and material products** added by *Add to Sale Order*: + +* *Time Type* products should be **services** measured in hours; +* the *Invoicing Policy* can be *Ordered quantities* or *Delivered + quantities*: the delivered quantity of time lines is set from the recorded + hours, while the delivered quantity of material lines comes from the field + service order's own stock moves (which are linked to the created lines, so + no extra delivery is generated); +* *Field Service Tracking* should be left at *Don't create FSM order*. The + lines this module creates are already linked back to the originating field + service order, so they never spawn further orders; but a *Per sale order + line* setting would switch the line's delivered-quantity method to *Field + Service* and override the quantity computed here. diff --git a/fieldservice_sale_timesheet_material/report/fsm_order_report_template.xml b/fieldservice_sale_timesheet_material/report/fsm_order_report_template.xml new file mode 100644 index 0000000000..bbeee4e697 --- /dev/null +++ b/fieldservice_sale_timesheet_material/report/fsm_order_report_template.xml @@ -0,0 +1,88 @@ + + + + + diff --git a/fieldservice_sale_timesheet_material/static/description/index.html b/fieldservice_sale_timesheet_material/static/description/index.html new file mode 100644 index 0000000000..c74b1982aa --- /dev/null +++ b/fieldservice_sale_timesheet_material/static/description/index.html @@ -0,0 +1,500 @@ + + + + + +README.rst + + + +
+ + + +Odoo Community Association + +
+

Field Service - Sale Timesheet & Material

+ +

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

+

This module lets you invoice the time spent and the materials consumed +on a field service order by adding them as lines to the order’s sale order, +which can then be invoiced through the standard sales flow.

+

It applies to field service orders that were generated from a sale order +(see Field Service - Sale): the recorded work is pushed back to that same +sale order. It builds on fsm.order:

+
    +
  • timesheets are the account.analytic.line records linked to the order +(from Field Service - Analytic Accounting), grouped by their Time Type +product;
  • +
  • materials are the consumed stock moves of the order (from +Field Service - Stock), grouped by product. fieldservice_stock only +displays these moves read-only, so creating them requires a companion module +such as Field Service - Stock Request (fieldservice_stock_request), +or deliveries coming from the sale order.
  • +
+

A single Add to Sale Order button on the field service order pushes both +timesheets and materials onto the linked sale order. The button is only shown +when the order is linked to a sale order; it never creates one. The operation +is idempotent: only work that has not yet been billed is added.

+

Table of contents

+ +
+

Usage

+
    +
  1. Start from a confirmed sale order that generated a field service order +(see Field Service - Sale), and make sure the order’s location has an +Analytic Account set (Field Service - Analytic Accounting).
  2. +
  3. On the field service order, record the time spent in the Timesheets +tab, choosing a Time Type product for each line.
  4. +
  5. Record the materials consumed on site. These are the order’s done +outgoing stock moves, which Field Service - Stock only displays +read-only — so a companion module is required to create them. Install +Field Service - Stock Request (fieldservice_stock_request) and enter +the products in the Stock Requests list of the order’s Inventory tab, +then process them through that module’s flow until the moves reach the +Done state. Materials delivered from the sale order appear here too. Only +Done moves are billed.
  6. +
  7. Click Add to Sale Order. A line is added to the linked sale order for +each Time Type product (with the total hours) and for each consumed +product (with the net delivered quantity). The button is hidden when the +order is not linked to a sale order.
  8. +
  9. Open the sale order with the Sale Order smart button and create the +invoice as usual.
  10. +
+

Clicking Add to Sale Order again only adds timesheets and materials that +have not been billed yet, so it is safe to use repeatedly as work progresses.

+

The Service Order report (Print menu on the field service order) is +extended with a Time Spent section listing each timesheet line with its +total, and a Materials section listing the Done stock moves per product +and direction (Used for outgoing moves, Returned for incoming ones), so the +printout shows both what was consumed on site and what came back.

+

Recommended product configuration:

+

The service product on the original sale order — the one that makes the +sale order generate the field service order — must have Field Service +Tracking set to Per sale order or Per sale order line (see Field Service +- Sale). That setting is what creates the field service order on sale +confirmation; without it there is no order to push the work back to.

+

The time and material products added by Add to Sale Order:

+
    +
  • Time Type products should be services measured in hours;
  • +
  • the Invoicing Policy can be Ordered quantities or Delivered +quantities: the delivered quantity of time lines is set from the recorded +hours, while the delivered quantity of material lines comes from the field +service order’s own stock moves (which are linked to the created lines, so +no extra delivery is generated);
  • +
  • Field Service Tracking should be left at Don’t create FSM order. The +lines this module creates are already linked back to the originating field +service order, so they never spawn further orders; but a Per sale order +line setting would switch the line’s delivered-quantity method to Field +Service and override the quantity computed here.
  • +
+
+
+

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

+
    +
  • Innovyou
  • +
+
+
+

Contributors

+
    +
  • Lorenzo Battistini
  • +
+
+
+

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.

+

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_sale_timesheet_material/tests/__init__.py b/fieldservice_sale_timesheet_material/tests/__init__.py new file mode 100644 index 0000000000..8c6bc84b15 --- /dev/null +++ b/fieldservice_sale_timesheet_material/tests/__init__.py @@ -0,0 +1 @@ +from . import test_fsm_sale_timesheet_material diff --git a/fieldservice_sale_timesheet_material/tests/test_fsm_sale_timesheet_material.py b/fieldservice_sale_timesheet_material/tests/test_fsm_sale_timesheet_material.py new file mode 100644 index 0000000000..c4b2215cf2 --- /dev/null +++ b/fieldservice_sale_timesheet_material/tests/test_fsm_sale_timesheet_material.py @@ -0,0 +1,253 @@ +# Copyright (C) 2026 Innovyou +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from datetime import timedelta + +from odoo import fields +from odoo.tests.common import TransactionCase + + +class TestFSMSaleTimesheetMaterial(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.customer = cls.env["res.partner"].create({"name": "FSM Customer"}) + cls.analytic_plan = cls.env["account.analytic.plan"].create( + {"name": "FSM Plan"} + ) + cls.analytic_account = cls.env["account.analytic.account"].create( + {"name": "FSM Account", "plan_id": cls.analytic_plan.id} + ) + cls.location = cls.env["fsm.location"].create( + { + "name": "FSM Location", + "partner_id": cls.customer.id, + "owner_id": cls.customer.id, + "customer_id": cls.customer.id, + "analytic_account_id": cls.analytic_account.id, + } + ) + cls.time_product = cls.env["product.product"].create( + { + "name": "Labor", + "detailed_type": "service", + "invoice_policy": "order", + "uom_id": cls.env.ref("uom.product_uom_hour").id, + "uom_po_id": cls.env.ref("uom.product_uom_hour").id, + } + ) + cls.material = cls.env["product.product"].create( + {"name": "Spare Part", "detailed_type": "consu"} + ) + # A "per sale order" tracking product makes the sale order generate a + # field service order on confirmation. + cls.service = cls.env["product.product"].create( + { + "name": "Call-out fee", + "detailed_type": "service", + "field_service_tracking": "sale", + } + ) + # The field service order is generated from the confirmed sale order + # (here holding a call-out fee); the recorded timesheets and consumed + # materials are pushed back to that same order for invoicing. + cls.sale = cls.env["sale.order"].create( + { + "partner_id": cls.customer.id, + "order_line": [ + (0, 0, {"product_id": cls.service.id, "product_uom_qty": 1}) + ], + } + ) + cls.sale.action_confirm() + cls.order = cls.sale.fsm_order_ids + + def _add_timesheet(self, hours): + return self.env["account.analytic.line"].create( + { + "name": "Work done", + "fsm_order_id": self.order.id, + "product_id": self.time_product.id, + "unit_amount": hours, + } + ) + + def _add_consumed_move(self, qty): + warehouse = self.env["stock.warehouse"].search( + [("company_id", "=", self.env.company.id)], limit=1 + ) + out_type = warehouse.out_type_id + picking = self.env["stock.picking"].create( + { + "picking_type_id": out_type.id, + "location_id": out_type.default_location_src_id.id, + "location_dest_id": self.env.ref("stock.stock_location_customers").id, + } + ) + move = self.env["stock.move"].create( + { + "name": self.material.name, + "product_id": self.material.id, + "product_uom": self.material.uom_id.id, + "product_uom_qty": qty, + "picking_id": picking.id, + "fsm_order_id": self.order.id, + "location_id": picking.location_id.id, + "location_dest_id": picking.location_dest_id.id, + } + ) + move.quantity_done = qty + move.state = "done" + return move + + def _add_returned_move(self, qty): + warehouse = self.env["stock.warehouse"].search( + [("company_id", "=", self.env.company.id)], limit=1 + ) + in_type = warehouse.in_type_id + picking = self.env["stock.picking"].create( + { + "picking_type_id": in_type.id, + "location_id": self.env.ref("stock.stock_location_customers").id, + "location_dest_id": in_type.default_location_dest_id.id, + } + ) + move = self.env["stock.move"].create( + { + "name": self.material.name, + "product_id": self.material.id, + "product_uom": self.material.uom_id.id, + "product_uom_qty": qty, + "picking_id": picking.id, + "fsm_order_id": self.order.id, + "location_id": picking.location_id.id, + "location_dest_id": picking.location_dest_id.id, + } + ) + move.quantity_done = qty + move.state = "done" + return move + + def _time_lines(self): + return self.sale.order_line.filtered( + lambda sol: sol.product_id == self.time_product + ) + + def _material_lines(self): + return self.sale.order_line.filtered( + lambda sol: sol.product_id == self.material + ) + + def test_create_sale_lines_timesheet_and_material(self): + self._add_timesheet(3.0) + self._add_timesheet(2.0) + self._add_consumed_move(4.0) + + self.order.action_create_sale_lines() + + # the order's existing sale order is reused, never recreated + self.assertEqual(self.order.sale_id, self.sale) + + time_lines = self._time_lines() + self.assertEqual(len(time_lines), 1) + self.assertEqual(time_lines.product_uom_qty, 5.0) + self.assertEqual(time_lines.qty_delivered, 5.0) + self.assertEqual(time_lines.fsm_order_id, self.order) + + material_lines = self._material_lines() + self.assertEqual(len(material_lines), 1) + self.assertEqual(material_lines.product_uom_qty, 4.0) + # the consumed move is linked to the line through the standard + # sale_line_id, so the delivered quantity is computed from it natively + self.assertEqual(self.order.move_ids.sale_line_id, material_lines) + self.assertEqual(material_lines.qty_delivered, 4.0) + # no duplicate delivery is generated for the already-consumed goods, + # even though the line is added to a confirmed order + self.assertFalse( + self.sale.picking_ids, + "no delivery should be created for already-consumed materials", + ) + + # all timesheets / moves are now linked to a sale order line + self.assertTrue(all(self.order.timesheet_ids.mapped("fsm_sale_line_id"))) + self.assertTrue(all(self.order.move_ids.mapped("fsm_sale_line_id"))) + + def test_idempotent(self): + self._add_timesheet(3.0) + self.order.action_create_sale_lines() + self.assertEqual(len(self._time_lines()), 1) + + # second call adds nothing for already-billed work + self.order.action_create_sale_lines() + self.assertEqual(len(self._time_lines()), 1) + + # new work is added to the same sale order + self._add_timesheet(1.0) + self.order.action_create_sale_lines() + self.assertEqual(len(self._time_lines()), 2) + + def test_report_material_lines_lists_used_and_returned(self): + # 4 consumed and 1 returned are shown as two separate rows, not netted + self._add_consumed_move(4.0) + self._add_returned_move(1.0) + + lines = self.order._report_material_lines() + self.assertEqual(len(lines), 2) + # used row comes first, returned row second + used, returned = lines + self.assertEqual(used["product"], self.material) + self.assertEqual(used["direction"], "out") + self.assertEqual(used["qty"], 4.0) + self.assertEqual(used["uom"], self.material.uom_id) + self.assertEqual(returned["direction"], "in") + self.assertEqual(returned["qty"], 1.0) + + def test_report_material_lines_empty_without_moves(self): + self.assertFalse(self.order._report_material_lines()) + + def test_report_renders_time_and_materials(self): + self._add_timesheet(3.0) + self._add_timesheet(1.5) + self._add_consumed_move(4.0) + self._add_returned_move(1.0) + + html = ( + self.env["ir.actions.report"] + ._render_qweb_html("fieldservice.report_fsm_order", self.order.ids)[0] + .decode() + ) + + self.assertIn("Time Spent", html) + self.assertIn("Materials", html) + # the timesheet total (4.5 h) is rendered as a float_time duration + self.assertIn("04:30", html) + # both directions are shown for the consumed material + self.assertIn(self.material.name, html) + self.assertIn("Used", html) + self.assertIn("Returned", html) + + def test_without_sale_order_does_nothing(self): + # An order not generated from a sale order has nothing to push to: the + # action is a no-op and never creates a sale order (the button is also + # hidden for such orders). + order = self.env["fsm.order"].create( + { + "location_id": self.location.id, + "date_start": fields.Datetime.today(), + "date_end": fields.Datetime.today() + timedelta(hours=2), + "request_early": fields.Datetime.today(), + } + ) + self.env["account.analytic.line"].create( + { + "name": "Work done", + "fsm_order_id": order.id, + "product_id": self.time_product.id, + "unit_amount": 1.0, + } + ) + + order.action_create_sale_lines() + + self.assertFalse(order.sale_id) + self.assertFalse(order.timesheet_ids.mapped("fsm_sale_line_id")) diff --git a/fieldservice_sale_timesheet_material/views/fsm_order.xml b/fieldservice_sale_timesheet_material/views/fsm_order.xml new file mode 100644 index 0000000000..fe275d75ed --- /dev/null +++ b/fieldservice_sale_timesheet_material/views/fsm_order.xml @@ -0,0 +1,51 @@ + + + + + fsm.order.form.sale.timesheet.material + fsm.order + + +
+
+ + + + + + + + + + + + + + + + +
+
+ +
diff --git a/setup/fieldservice_sale_timesheet_material/odoo/addons/fieldservice_sale_timesheet_material b/setup/fieldservice_sale_timesheet_material/odoo/addons/fieldservice_sale_timesheet_material new file mode 120000 index 0000000000..8850a5f89c --- /dev/null +++ b/setup/fieldservice_sale_timesheet_material/odoo/addons/fieldservice_sale_timesheet_material @@ -0,0 +1 @@ +../../../../fieldservice_sale_timesheet_material \ No newline at end of file diff --git a/setup/fieldservice_sale_timesheet_material/setup.py b/setup/fieldservice_sale_timesheet_material/setup.py new file mode 100644 index 0000000000..28c57bb640 --- /dev/null +++ b/setup/fieldservice_sale_timesheet_material/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +)