diff --git a/README.md b/README.md index 0c66dcabc5..7cff809420 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,7 @@ addon | version | maintainers | summary [deltatech_data_sheet_website](deltatech_data_sheet_website/) | 17.0.1.0.0 | [![dhongu](https://github.com/dhongu.png?size=30px)](https://github.com/dhongu) | Data Sheet [deltatech_dc](deltatech_dc/) | 17.0.1.0.7 | [![dhongu](https://github.com/dhongu.png?size=30px)](https://github.com/dhongu) | Print Declaration of Conformity [deltatech_delivery_status](deltatech_delivery_status/) | 17.0.2.1.3 | [![dhongu](https://github.com/dhongu.png?size=30px)](https://github.com/dhongu) | Carrier status on picking +[deltatech_delivery_wave_doc](deltatech_delivery_wave_doc/) | 17.0.1.0.0 | | Document furnizor cu linii ce generează Batch/Wave pe recepții existente [deltatech_download](deltatech_download/) | 17.0.0.1.1 | [![dhongu](https://github.com/dhongu.png?size=30px)](https://github.com/dhongu) | Generare fisier [deltatech_dropshipping](deltatech_dropshipping/) | 17.0.1.0.0 | [![dhongu](https://github.com/dhongu.png?size=30px)](https://github.com/dhongu) | Delivery address in picking [deltatech_dummy_queue_job](deltatech_dummy_queue_job/) | 17.0.1.0.0 | [![dhongu](https://github.com/dhongu.png?size=30px)](https://github.com/dhongu) | Dummy Queue Job @@ -69,6 +70,7 @@ addon | version | maintainers | summary [deltatech_invoice_report](deltatech_invoice_report/) | 17.0.1.0.7 | [![dhongu](https://github.com/dhongu.png?size=30px)](https://github.com/dhongu) | Invoice Report [deltatech_invoice_to_draft](deltatech_invoice_to_draft/) | 17.0.2.0.1 | [![dhongu](https://github.com/dhongu.png?size=30px)](https://github.com/dhongu) | Restricted access to reset account move to draft [deltatech_invoice_weight](deltatech_invoice_weight/) | 17.0.1.0.2 | [![dhongu](https://github.com/dhongu.png?size=30px)](https://github.com/dhongu) | Invoice Weight +[deltatech_kit_price](deltatech_kit_price/) | 17.0.0.0.1 | [![danila12](https://github.com/danila12.png?size=30px)](https://github.com/danila12) | Compute product cost price in sale order line based on kit [deltatech_ledger](deltatech_ledger/) | 17.0.0.0.1 | [![VoicuStefan2001](https://github.com/VoicuStefan2001.png?size=30px)](https://github.com/VoicuStefan2001) | Deltatech Ledger [deltatech_list_view](deltatech_list_view/) | 17.0.1.0.1 | | List View Select Text [deltatech_logistic_docs](deltatech_logistic_docs/) | 17.0.1.0.3 | | Logistic Documents @@ -135,7 +137,7 @@ addon | version | maintainers | summary [deltatech_purchase_stock](deltatech_purchase_stock/) | 17.0.1.0.2 | [![dhongu](https://github.com/dhongu.png?size=30px)](https://github.com/dhongu) | Purchase Stock [deltatech_purchase_ubl](deltatech_purchase_ubl/) | 17.0.0.0.1 | [![dhongu](https://github.com/dhongu.png?size=30px)](https://github.com/dhongu) | Import UBL XML vendor invoices to update prices, validate receipts, and create vendor bills [deltatech_purchase_xls](deltatech_purchase_xls/) | 17.0.1.0.9 | [![dhongu](https://github.com/dhongu.png?size=30px)](https://github.com/dhongu) | Import/export purchase line from/to Excel -[deltatech_putaway_strategy](deltatech_putaway_strategy/) | 17.0.1.0.2 | [![dhongu](https://github.com/dhongu.png?size=30px)](https://github.com/dhongu) | Location capacities and enhanced putaway strategy for Inventory +[deltatech_putaway_strategy](deltatech_putaway_strategy/) | 17.0.1.0.5 | [![dhongu](https://github.com/dhongu.png?size=30px)](https://github.com/dhongu) | Location capacities and enhanced putaway strategy for Inventory [deltatech_queue_job](deltatech_queue_job/) | 17.0.1.0.4 | [![dhongu](https://github.com/dhongu.png?size=30px)](https://github.com/dhongu) | Deltatech Queue Job [deltatech_ral](deltatech_ral/) | 17.0.1.0.3 | | RAL [deltatech_reccurent_task_activity](deltatech_reccurent_task_activity/) | 17.0.0.0.0 | [![VoicuStefan2001](https://github.com/VoicuStefan2001.png?size=30px)](https://github.com/VoicuStefan2001) | Will automatically create an activity at the creation of the recurring task @@ -189,8 +191,8 @@ addon | version | maintainers | summary [deltatech_stock_inventory](deltatech_stock_inventory/) | 17.0.2.4.0 | [![dhongu](https://github.com/dhongu.png?size=30px)](https://github.com/dhongu) | Inventory Old Method [deltatech_stock_inventory_product_display](deltatech_stock_inventory_product_display/) | 17.0.0.0.0 | [![VoicuStefan2001](https://github.com/VoicuStefan2001.png?size=30px)](https://github.com/VoicuStefan2001) | Adds product display button on sales and invoices to see the stock of the products in the order [deltatech_stock_negative](deltatech_stock_negative/) | 17.0.2.0.5 | [![dhongu](https://github.com/dhongu.png?size=30px)](https://github.com/dhongu) | Negative stocks are not allowed -[deltatech_stock_picking_activity_report](deltatech_stock_picking_activity_report/) | 17.0.0.0.2 | [![VoicuStefan2001](https://github.com/VoicuStefan2001.png?size=30px)](https://github.com/VoicuStefan2001) | Tracks activities and changes on stock pickings -[deltatech_stock_removal_priority](deltatech_stock_removal_priority/) | 17.0.1.0.0 | | Stock Removal Location by Priority +[deltatech_stock_picking_activity_report](deltatech_stock_picking_activity_report/) | 17.0.0.0.1 | [![VoicuStefan2001](https://github.com/VoicuStefan2001.png?size=30px)](https://github.com/VoicuStefan2001) | Tracks activities and changes on stock pickings +[deltatech_stock_removal_priority](deltatech_stock_removal_priority/) | 17.0.1.0.3 | | Stock Removal Location by Priority [deltatech_stock_report](deltatech_stock_report/) | 17.0.1.0.3 | [![dhongu](https://github.com/dhongu.png?size=30px)](https://github.com/dhongu) | Report with positions from picking lists [deltatech_stock_reseller](deltatech_stock_reseller/) | 17.0.1.0.1 | [![dhongu](https://github.com/dhongu.png?size=30px)](https://github.com/dhongu) | Report report reseller [deltatech_stock_sn](deltatech_stock_sn/) | 17.0.1.0.0 | | Stock Serial Number diff --git a/deltatech_delivery_wave_doc/README.rst b/deltatech_delivery_wave_doc/README.rst new file mode 100644 index 0000000000..f3bec11ed7 --- /dev/null +++ b/deltatech_delivery_wave_doc/README.rst @@ -0,0 +1,151 @@ +================================ +Vendor Delivery Document to Wave +================================ + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:236508e10d4210c25aaf4ac1291ffc70a92eb102c44796a0749f45acf523e4c1 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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-LGPL--3-blue.png + :target: http://www.gnu.org/licenses/lgpl-3.0-standalone.html + :alt: License: LGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-dhongu%2Fdeltatech-lightgray.png?logo=github + :target: https://github.com/dhongu/deltatech/tree/17.0/deltatech_delivery_wave_doc + :alt: dhongu/deltatech + +|badge1| |badge2| |badge3| + +Titlu: Vendor Delivery Document → Wave (Batch Picking) + +Scop + +- Modulul permite înregistrarea unei „note de livrare” de la furnizor + (document cu linii: produs, cantitate, preț) și generarea automată a + unui Batch/Wave care reunește recepțiile (pickings) deschise provenite + din comenzi de achiziție (PO) existente pentru acel furnizor. + +Problemă adresată + +- Furnizorul livrează în timp, parțial și amestecat, pe baza unui + document propriu (număr/serie). +- În standard, recepțiile trebuie procesate per PO; acest modul + accelerează operațional recepțiile grupându-le într-un singur Wave pe + baza documentului de livrare. + +Caracteristici cheie + +- Obiect nou: „Vendor Delivery Document” cu câmpuri: Furnizor, Dată, + Număr document, Linii (Produs, Cantitate, UoM, Preț – informativ), + Responsabil, Tip de operație (opțional), „Allow excess”. +- Buton „Generate Wave” pe document: + + - caută recepțiile de tip „incoming” pentru același furnizor și + produsele din document, în stări confirmed/assigned; + - alocă cantitățile din document peste cantitățile „deschise” din + mișcările existente, în ordine cronologică (data programată a + recepțiilor); + - creează automat unul sau mai multe Wave-uri (un wave per combinație + Operation Type × Companie) și atașează liniile de mișcare folosind + API-ul standard ``stock.move.line._add_to_wave``; + - dacă documentul depășește cantitățile deschise din recepții: + + - blochează cu eroare (implicit), sau + - doar loghează diferențele în chatter dacă este bifat „Allow + excess”. + +- Trasabilitate: mesaj în chatter cu lista wave-urilor create și, dacă e + cazul, cu cantitățile neacoperite. +- Fără efecte în Purchase: nu se creează PO-uri sau recepții noi – se + folosește exclusiv ceea ce există deja din PO-urile confirmate. + +Navigare și UI + +- Inventory → Vendor Deliveries → Delivery Documents. +- Formularele includ tab „Lines” pentru produse și tab „Waves” + (read-only) cu valurile create. + +Instalare (Odoo 17) + +- Dependențe: ``mail``, ``stock``, ``stock_picking_batch``, + ``purchase_stock``. +- După instalare, utilizatorii din grupul „Inventory / User” pot crea și + procesa documente. + +Utilizare – pași rapizi + +1) Creați un „Delivery Document” și completați: Furnizor, Dată, Număr + document; opțional: Operation Type (Receipts), Responsabil, Allow + excess. +2) Adăugați linii cu produse și cantități (UoM). Prețul este informativ + și nu influențează recepția. +3) Apăsați „Generate Wave”. Modulul: + + - identifică recepțiile deschise (din PO-uri existente) pentru + furnizor și produsele din document; + - alocă cantitățile și creează Wave-ul/Wave-urile necesare; + - atașează recepțiile la Wave pentru procesare rapidă. + +4) Continuați procesarea valului/valurilor din Inventory (recepție, + validare, etc.). + +Note și limitări + +- Toate transferurile dintr-un Wave trebuie să aparțină aceleiași + companii și aceluiași Operation Type (regulă standard Odoo). Modulul + va crea mai multe Wave-uri dacă este necesar. +- Dacă „Allow excess” nu este bifat, orice cantitate neacoperită de + recepțiile deschise va bloca generarea Wave-ului cu un mesaj explicit + pe produs. +- Modulul nu setează automat ``qty_done``; atașează mișcările în Wave + pentru execuție. (Se poate extinde ulterior pentru precompletare.) +- Prețurile din document sunt doar pentru referință; evaluarea stocului + urmează regulile standard (pe baza PO/valuării configurate). + +Compatibilitate + +- Odoo 17.0 (Enterprise/Community) cu modulele listate la Dependențe. + +Rulare rapidă pentru test (fără HTTP) + +:: + + ./odoo/odoo-bin -c odoo17.conf -d devtest_wave_doc \ + --stop-after-init --no-http \ + -i stock_picking_batch,deltatech_delivery_wave_doc \ + --log-level=info + +**Table of contents** + +.. contents:: + :local: + +Bug Tracker +=========== + +Bugs are tracked on `Terrabit Issues `_. +In case of trouble, please check there if your issue has already been reported. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* Terrabit +* Dorin Hongu + +Maintainers +----------- + +This module is part of the `dhongu/deltatech `_ project on GitHub. + +You are welcome to contribute. \ No newline at end of file diff --git a/deltatech_delivery_wave_doc/__init__.py b/deltatech_delivery_wave_doc/__init__.py new file mode 100644 index 0000000000..9b4296142f --- /dev/null +++ b/deltatech_delivery_wave_doc/__init__.py @@ -0,0 +1,2 @@ +from . import models +from . import wizard diff --git a/deltatech_delivery_wave_doc/__manifest__.py b/deltatech_delivery_wave_doc/__manifest__.py new file mode 100644 index 0000000000..c132b212a7 --- /dev/null +++ b/deltatech_delivery_wave_doc/__manifest__.py @@ -0,0 +1,19 @@ +# © 2026 Deltatech +# See README.rst file on addons root folder for license details + +{ + "name": "Vendor Delivery Document to Wave", + "version": "17.0.1.0.0", + "summary": "Document furnizor cu linii ce generează Batch/Wave pe recepții existente", + "author": "Terrabit, Dorin Hongu", + "website": "https://www.terrabit.ro", + "license": "LGPL-3", + "depends": ["mail", "stock", "stock_picking_batch", "purchase_stock"], + "data": [ + "security/ir.model.access.csv", + "data/sequence.xml", + "views/delivery_vendor_document_views.xml", + ], + "installable": True, + "development_status": "Beta", +} diff --git a/deltatech_delivery_wave_doc/data/sequence.xml b/deltatech_delivery_wave_doc/data/sequence.xml new file mode 100644 index 0000000000..d6f42c259b --- /dev/null +++ b/deltatech_delivery_wave_doc/data/sequence.xml @@ -0,0 +1,10 @@ + + + + Delivery Vendor Document + delivery.vendor.document + 5 + DVD/ + + + diff --git a/deltatech_delivery_wave_doc/models/__init__.py b/deltatech_delivery_wave_doc/models/__init__.py new file mode 100644 index 0000000000..c5d3ca54e9 --- /dev/null +++ b/deltatech_delivery_wave_doc/models/__init__.py @@ -0,0 +1 @@ +from . import delivery_vendor_document diff --git a/deltatech_delivery_wave_doc/models/delivery_vendor_document.py b/deltatech_delivery_wave_doc/models/delivery_vendor_document.py new file mode 100644 index 0000000000..147d23b3e7 --- /dev/null +++ b/deltatech_delivery_wave_doc/models/delivery_vendor_document.py @@ -0,0 +1,195 @@ +# © 2026 Deltatech +# See README.rst file on addons root folder for license details + +from odoo import _, api, fields, models +from odoo.exceptions import UserError + + +class DeliveryVendorDocument(models.Model): + _name = "delivery.vendor.document" + _description = "Vendor Delivery Document" + _order = "id desc" + _inherit = ["mail.thread", "mail.activity.mixin"] + + name = fields.Char(default="New", readonly=True) + partner_id = fields.Many2one("res.partner", required=True, domain=[("supplier_rank", ">", 0)]) + date = fields.Date(required=True, default=fields.Date.context_today) + document_no = fields.Char(required=True) + currency_id = fields.Many2one("res.currency", default=lambda self: self.env.company.currency_id.id) + line_ids = fields.One2many("delivery.vendor.document.line", "document_id") + company_id = fields.Many2one("res.company", default=lambda self: self.env.company.id, required=True) + picking_type_id = fields.Many2one( + "stock.picking.type", domain="[('code', '=', 'incoming'), ('company_id', '=', company_id)]" + ) + responsible_id = fields.Many2one("res.users") + wave_id = fields.Many2one("stock.picking.batch", readonly=True) + state = fields.Selection([("draft", "Draft"), ("processed", "Processed")], default="draft") + allow_excess = fields.Boolean(string="Allow excess (log only)") + + @api.model_create_multi + def create(self, vals_list): + for vals in vals_list: + if vals.get("name") in (False, "New"): + vals["name"] = self.env["ir.sequence"].next_by_code("delivery.vendor.document") or "New" + records = super().create(vals_list) + return records + + def _moves_domain(self, product_ids): + self.ensure_one() + domain = [ + ("picking_id.partner_id", "=", self.partner_id.id), + ("company_id", "=", self.company_id.id), + ("product_id", "in", product_ids), + ("state", "in", ("confirmed", "assigned")), + ("picking_id.state", "in", ("confirmed", "assigned")), + ("picking_id.picking_type_id.code", "=", "incoming"), + ] + if self.picking_type_id: + domain.append(("picking_id.picking_type_id", "=", self.picking_type_id.id)) + return domain + + def action_generate_wave(self): + self.ensure_one() + if not self.line_ids: + raise UserError(_("There are no lines on the document.")) + if any(l.quantity <= 0 for l in self.line_ids): + raise UserError(_("All quantities must be > 0.")) + + products = self.line_ids.product_id + moves = self.env["stock.move"].search(self._moves_domain(products.ids)) + # sortează cronologic după data programată sau data mișcării + moves = moves.sorted(key=lambda m: (m.picking_id.scheduled_date or m.date, m.picking_id.id, m.id)) + + # open qty în UoM de produs + open_qty = { + m.id: ( + m.product_uom._compute_quantity(m.product_uom_qty, m.product_id.uom_id) + - m.product_uom._compute_quantity(m.quantity_done, m.product_id.uom_id) + ) + for m in moves + } + + allocations = {} # move_id -> qty (in product UoM) + not_covered = [] + + for line in self.line_ids: + need = line.product_uom._compute_quantity(line.quantity, line.product_id.uom_id) + for m in (mv for mv in moves if mv.product_id == line.product_id and open_qty.get(mv.id, 0) > 0): + take = min(need, open_qty[m.id]) + if take <= 0: + continue + allocations[m.id] = allocations.get(m.id, 0) + take + open_qty[m.id] -= take + need -= take + if need <= 0: + break + if need > 0: + if not self.allow_excess: + # compute the remaining qty in the line UoM for the message + rest_in_line_uom = line.product_id.uom_id._compute_quantity(need, line.product_uom) + raise UserError( + _( + f"Product {line.product_id.display_name}: quantity {rest_in_line_uom} {line.product_uom.name} exceeds the open quantity in receipts." + ) + ) + not_covered.append((line, need)) + + if not allocations: + raise UserError(_("No open receipts found for the selected vendor/products.")) + + mls_to_add = self.env["stock.move.line"] + for move in self.env["stock.move"].browse(list(allocations.keys())): + # creează un move line minim dacă nu există niciunul nefinalizat + pending_mls = move.move_line_ids.filtered(lambda ml: ml.state != "done") + if pending_mls: + mls_to_add |= pending_mls + else: + ml = self.env["stock.move.line"].create( + { + "move_id": move.id, + "product_id": move.product_id.id, + "product_uom_id": move.product_uom.id, + "qty_done": 0.0, + "location_id": move.location_id.id, + "location_dest_id": move.location_dest_id.id, + } + ) + mls_to_add |= ml + + # Validations for a single Operation Type and a single company + if not mls_to_add: + raise UserError(_("There are no move lines to add to the wave.")) + picking_types = mls_to_add.picking_id.picking_type_id + companies = mls_to_add.company_id + if len(companies) > 1: + raise UserError( + _( + "The selected operations belong to multiple companies. Please restrict the document to a single company." + ) + ) + if self.picking_type_id and any(pt != self.picking_type_id for pt in picking_types): + raise UserError( + _("There are receipts with a different Operation Type than the one selected on the document.") + ) + # dacă nu e setat pe document, deducem unicitatea + if len(picking_types) > 1: + raise UserError( + _( + "The identified receipts have different Operation Types. Set the Operation Type field on the document to narrow the selection." + ) + ) + + # Creează un singur wave conform cerinței și atașează toate liniile + target_pt = self.picking_type_id or picking_types[0] + target_company = self.company_id or companies[0] + wave = self.env["stock.picking.batch"].create( + { + "is_wave": True, + "picking_type_id": target_pt.id, + "company_id": target_company.id, + "user_id": self.responsible_id.id if self.responsible_id else False, + } + ) + mls_to_add.with_context(active_owner_id=self.responsible_id.id if self.responsible_id else False)._add_to_wave( + wave + ) + + body = _("Generated wave: %s") % (wave.name) + if not_covered: + lines = "\n".join( + f"- {l.product_id.display_name}: missing {l.product_id.uom_id._compute_quantity(need_qty, l.product_uom)} {l.product_uom.name}" + for l, need_qty in not_covered + ) + body += "\n" + _("Quantities not covered by open receipts:") + "\n" + lines + self.message_post(body=body) + self.write({"state": "processed", "wave_id": wave.id}) + + action = self.env["ir.actions.act_window"]._for_xml_id("stock_picking_batch.action_picking_batch") + action.update({"res_id": wave.id, "view_mode": "form", "domain": [("id", "=", wave.id)]}) + return action + + def action_open_import_wizard(self): + self.ensure_one() + action = self.env.ref("deltatech_delivery_wave_doc.action_delivery_document_import_wizard").read()[0] + action["context"] = dict(self.env.context, active_id=self.id, default_file_type="xlsx") + return action + + +class DeliveryVendorDocumentLine(models.Model): + _name = "delivery.vendor.document.line" + _description = "Vendor Delivery Document Line" + + document_id = fields.Many2one("delivery.vendor.document", required=True, ondelete="cascade") + product_id = fields.Many2one("product.product", required=True) + name = fields.Char() + price_unit = fields.Monetary() + quantity = fields.Float(required=True) + product_uom = fields.Many2one("uom.uom", required=True) + currency_id = fields.Many2one(related="document_id.currency_id", store=True, readonly=True) + + @api.onchange("product_id") + def _onchange_product(self): + for l in self: + if l.product_id: + l.name = l.product_id.display_name + l.product_uom = l.product_id.uom_id diff --git a/deltatech_delivery_wave_doc/pyproject.toml b/deltatech_delivery_wave_doc/pyproject.toml new file mode 100644 index 0000000000..4231d0cccb --- /dev/null +++ b/deltatech_delivery_wave_doc/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/deltatech_delivery_wave_doc/readme/DESCRIPTION.md b/deltatech_delivery_wave_doc/readme/DESCRIPTION.md new file mode 100644 index 0000000000..dab4131059 --- /dev/null +++ b/deltatech_delivery_wave_doc/readme/DESCRIPTION.md @@ -0,0 +1,57 @@ + + +Titlu: Vendor Delivery Document → Wave (Batch Picking) + +Scop +- Modulul permite înregistrarea unei „note de livrare” de la furnizor (document cu linii: produs, cantitate, preț) și generarea automată a unui Batch/Wave care reunește recepțiile (pickings) deschise provenite din comenzi de achiziție (PO) existente pentru acel furnizor. + +Problemă adresată +- Furnizorul livrează în timp, parțial și amestecat, pe baza unui document propriu (număr/serie). +- În standard, recepțiile trebuie procesate per PO; acest modul accelerează operațional recepțiile grupându-le într-un singur Wave pe baza documentului de livrare. + +Caracteristici cheie +- Obiect nou: „Vendor Delivery Document” cu câmpuri: Furnizor, Dată, Număr document, Linii (Produs, Cantitate, UoM, Preț – informativ), Responsabil, Tip de operație (opțional), „Allow excess”. +- Buton „Generate Wave” pe document: + - caută recepțiile de tip „incoming” pentru același furnizor și produsele din document, în stări confirmed/assigned; + - alocă cantitățile din document peste cantitățile „deschise” din mișcările existente, în ordine cronologică (data programată a recepțiilor); + - creează automat unul sau mai multe Wave-uri (un wave per combinație Operation Type × Companie) și atașează liniile de mișcare folosind API-ul standard `stock.move.line._add_to_wave`; + - dacă documentul depășește cantitățile deschise din recepții: + - blochează cu eroare (implicit), sau + - doar loghează diferențele în chatter dacă este bifat „Allow excess”. +- Trasabilitate: mesaj în chatter cu lista wave-urilor create și, dacă e cazul, cu cantitățile neacoperite. +- Fără efecte în Purchase: nu se creează PO-uri sau recepții noi – se folosește exclusiv ceea ce există deja din PO-urile confirmate. + +Navigare și UI +- Inventory → Vendor Deliveries → Delivery Documents. +- Formularele includ tab „Lines” pentru produse și tab „Waves” (read-only) cu valurile create. + +Instalare (Odoo 17) +- Dependențe: `mail`, `stock`, `stock_picking_batch`, `purchase_stock`. +- După instalare, utilizatorii din grupul „Inventory / User” pot crea și procesa documente. + +Utilizare – pași rapizi +1) Creați un „Delivery Document” și completați: Furnizor, Dată, Număr document; opțional: Operation Type (Receipts), Responsabil, Allow excess. +2) Adăugați linii cu produse și cantități (UoM). Prețul este informativ și nu influențează recepția. +3) Apăsați „Generate Wave”. Modulul: + - identifică recepțiile deschise (din PO-uri existente) pentru furnizor și produsele din document; + - alocă cantitățile și creează Wave-ul/Wave-urile necesare; + - atașează recepțiile la Wave pentru procesare rapidă. +4) Continuați procesarea valului/valurilor din Inventory (recepție, validare, etc.). + +Note și limitări +- Toate transferurile dintr-un Wave trebuie să aparțină aceleiași companii și aceluiași Operation Type (regulă standard Odoo). Modulul va crea mai multe Wave-uri dacă este necesar. +- Dacă „Allow excess” nu este bifat, orice cantitate neacoperită de recepțiile deschise va bloca generarea Wave-ului cu un mesaj explicit pe produs. +- Modulul nu setează automat `qty_done`; atașează mișcările în Wave pentru execuție. (Se poate extinde ulterior pentru precompletare.) +- Prețurile din document sunt doar pentru referință; evaluarea stocului urmează regulile standard (pe baza PO/valuării configurate). + +Compatibilitate +- Odoo 17.0 (Enterprise/Community) cu modulele listate la Dependențe. + +Rulare rapidă pentru test (fără HTTP) +``` +./odoo/odoo-bin -c odoo17.conf -d devtest_wave_doc \ + --stop-after-init --no-http \ + -i stock_picking_batch,deltatech_delivery_wave_doc \ + --log-level=info +``` + diff --git a/deltatech_delivery_wave_doc/security/ir.model.access.csv b/deltatech_delivery_wave_doc/security/ir.model.access.csv new file mode 100644 index 0000000000..11697d6a2b --- /dev/null +++ b/deltatech_delivery_wave_doc/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_delivery_vendor_document_user,delivery.vendor.document,model_delivery_vendor_document,stock.group_stock_user,1,1,1,1 +access_delivery_vendor_document_line_user,delivery.vendor.document.line,model_delivery_vendor_document_line,stock.group_stock_user,1,1,1,1 diff --git a/deltatech_delivery_wave_doc/static/description/icon.png b/deltatech_delivery_wave_doc/static/description/icon.png new file mode 100644 index 0000000000..ad6e34df77 Binary files /dev/null and b/deltatech_delivery_wave_doc/static/description/icon.png differ diff --git a/deltatech_delivery_wave_doc/static/description/index.html b/deltatech_delivery_wave_doc/static/description/index.html new file mode 100644 index 0000000000..e69de29bb2 diff --git a/deltatech_delivery_wave_doc/static/description/logo-terrabit.png b/deltatech_delivery_wave_doc/static/description/logo-terrabit.png new file mode 100644 index 0000000000..8b23ccdd37 Binary files /dev/null and b/deltatech_delivery_wave_doc/static/description/logo-terrabit.png differ diff --git a/deltatech_delivery_wave_doc/static/description/main_screenshot.png b/deltatech_delivery_wave_doc/static/description/main_screenshot.png new file mode 100644 index 0000000000..4292c67867 Binary files /dev/null and b/deltatech_delivery_wave_doc/static/description/main_screenshot.png differ diff --git a/deltatech_delivery_wave_doc/tests/__init__.py b/deltatech_delivery_wave_doc/tests/__init__.py new file mode 100644 index 0000000000..7ada811a88 --- /dev/null +++ b/deltatech_delivery_wave_doc/tests/__init__.py @@ -0,0 +1 @@ +from . import test_delivery_wave_doc diff --git a/deltatech_delivery_wave_doc/tests/test_delivery_wave_doc.py b/deltatech_delivery_wave_doc/tests/test_delivery_wave_doc.py new file mode 100644 index 0000000000..1f45154377 --- /dev/null +++ b/deltatech_delivery_wave_doc/tests/test_delivery_wave_doc.py @@ -0,0 +1,145 @@ +from odoo.tests import tagged +from odoo.tests.common import TransactionCase + + +@tagged("post_install", "-at_install") +class TestDeliveryWaveDoc(TransactionCase): + def setUp(self): + super().setUp() + self.company = self.env.company + self.partner = self.env["res.partner"].create( + { + "name": "Test Supplier", + "supplier_rank": 1, + "company_id": self.company.id, + } + ) + # Products + self.uom_unit = self.env.ref("uom.product_uom_unit") + self.product_a = self.env["product.product"].create( + { + "name": "Prod A", + "default_code": "PROD_A", + "type": "product", + "uom_id": self.uom_unit.id, + "uom_po_id": self.uom_unit.id, + } + ) + self.product_b = self.env["product.product"].create( + { + "name": "Prod B", + "default_code": "PROD_B", + "type": "product", + "uom_id": self.uom_unit.id, + "uom_po_id": self.uom_unit.id, + } + ) + + # Operation type: Receipts + self.picking_type_in = self.env["stock.picking.type"].search( + [("code", "=", "incoming"), ("warehouse_id.company_id", "=", self.company.id)], limit=1 + ) + if not self.picking_type_in: + # Fall back: any incoming type in company + self.picking_type_in = self.env["stock.picking.type"].search([("code", "=", "incoming")], limit=1) + + # Create a PO with two lines and confirm -> creates incoming picking with moves + self.po = self.env["purchase.order"].create( + { + "partner_id": self.partner.id, + "order_line": [ + ( + 0, + 0, + { + "product_id": self.product_a.id, + "name": "A", + "product_qty": 5, + "product_uom": self.uom_unit.id, + "price_unit": 10, + }, + ), + ( + 0, + 0, + { + "product_id": self.product_b.id, + "name": "B", + "product_qty": 3, + "product_uom": self.uom_unit.id, + "price_unit": 20, + }, + ), + ], + } + ) + self.po.button_confirm() + + # Find the created incoming picking + self.picking = self.env["stock.picking"].search( + [ + ("origin", "=", self.po.name), + ("picking_type_id.code", "=", "incoming"), + ], + limit=1, + ) + self.assertTrue(self.picking, "Incoming picking should have been created after PO confirmation") + + def test_generate_wave_single(self): + Doc = self.env["delivery.vendor.document"] + doc = Doc.create( + { + "partner_id": self.partner.id, + "document_no": "DN-001", + "company_id": self.company.id, + "picking_type_id": self.picking_type_in.id, + "line_ids": [ + (0, 0, {"product_id": self.product_a.id, "quantity": 2, "product_uom": self.uom_unit.id}), + (0, 0, {"product_id": self.product_b.id, "quantity": 1, "product_uom": self.uom_unit.id}), + ], + } + ) + + # Action should create a single wave and set wave_id + action = doc.action_generate_wave() + self.assertTrue(action, "Action should return an act_window") + self.assertTrue(doc.wave_id, "A wave should be created and linked on the document") + self.assertEqual(doc.state, "processed") + + # The wave should contain our picking in draft/in_progress state + batch = doc.wave_id + self.assertEqual(batch.picking_type_id.code, "incoming") + self.assertIn(self.picking, batch.picking_ids, "The PO receipt picking should be part of the wave") + + def test_import_wizard_adds_lines(self): + # Prepare a small CSV in-memory + content = """product,quantity,uom,price_unit\nPROD_A,1,Unit(s),9.5\nPROD_B,2,Unit(s),19\n""" + file_b64 = self.env["ir.binary"]._encode_bytes(content.encode("utf-8")) + + doc = self.env["delivery.vendor.document"].create( + { + "partner_id": self.partner.id, + "document_no": "DN-CSV", + "company_id": self.company.id, + "picking_type_id": self.picking_type_in.id, + } + ) + + wiz = ( + self.env["delivery.vendor.document.import.wizard"] + .with_context(active_id=doc.id) + .create( + { + "file": file_b64, + "filename": "lines.csv", + "file_type": "csv", + "product_match_by": "default_code", + } + ) + ) + wiz.action_import() + + self.assertEqual(len(doc.line_ids), 2, "Two lines should be imported from CSV") + qtys = {l.product_id.default_code: l.quantity for l in doc.line_ids} + self.assertEqual(qtys.get("PROD_A"), 1.0) + self.assertEqual(qtys.get("PROD_B"), 2.0) diff --git a/deltatech_delivery_wave_doc/views/delivery_vendor_document_views.xml b/deltatech_delivery_wave_doc/views/delivery_vendor_document_views.xml new file mode 100644 index 0000000000..721adf4805 --- /dev/null +++ b/deltatech_delivery_wave_doc/views/delivery_vendor_document_views.xml @@ -0,0 +1,121 @@ + + + + delivery.vendor.document.form + delivery.vendor.document + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + + delivery.vendor.document.tree + delivery.vendor.document + + + + + + + + + + + + + Vendor Delivery Documents + delivery.vendor.document + tree,form + + + + + delivery.vendor.document.import.wizard.form + delivery.vendor.document.import.wizard + +
+ + + + + + + + +
+
+ + + Import Lines + delivery.vendor.document.import.wizard + form + new + + + + +
diff --git a/deltatech_delivery_wave_doc/wizard/__init__.py b/deltatech_delivery_wave_doc/wizard/__init__.py new file mode 100644 index 0000000000..71ca6d33fa --- /dev/null +++ b/deltatech_delivery_wave_doc/wizard/__init__.py @@ -0,0 +1 @@ +from . import import_lines diff --git a/deltatech_delivery_wave_doc/wizard/import_lines.py b/deltatech_delivery_wave_doc/wizard/import_lines.py new file mode 100644 index 0000000000..c2cab055db --- /dev/null +++ b/deltatech_delivery_wave_doc/wizard/import_lines.py @@ -0,0 +1,161 @@ +# © 2026 Deltatech +# See README.rst file on addons root folder for license details + +import base64 +import csv +from io import BytesIO, StringIO + +from odoo import _, fields, models +from odoo.exceptions import UserError + + +class DeliveryVendorDocumentImportWizard(models.TransientModel): + _name = "delivery.vendor.document.import.wizard" + _description = "Import Delivery Document Lines" + + file = fields.Binary(string="File", required=True, help="Upload an Excel (.xlsx) or CSV file") + filename = fields.Char(string="Filename") + file_type = fields.Selection( + [ + ("xlsx", "Excel (.xlsx)"), + ("csv", "CSV"), + ], + string="File Type", + default="xlsx", + required=True, + ) + product_match_by = fields.Selection( + [ + ("default_code", "Internal Reference"), + ("barcode", "Barcode"), + ("name", "Name"), + ], + string="Match Product By", + default="default_code", + required=True, + ) + delimiter = fields.Char(string="CSV Delimiter", help="Used only for CSV; leave empty to auto-detect", size=1) + + def action_import(self): + self.ensure_one() + doc = self.env["delivery.vendor.document"].browse(self.env.context.get("active_id")) + if not doc: + raise UserError(_("No active delivery document found.")) + if doc.state != "draft": + raise UserError(_("You can import lines only in Draft state.")) + + rows = self._read_rows() + if not rows: + raise UserError(_("The file does not contain any rows.")) + + # expected columns: product, quantity, (optional) uom, (optional) price_unit, (optional) name + errors = [] + for index, row in enumerate(rows, start=2): # assume header at row 1 + try: + product_key = (row.get("product") or "").strip() + qty_val = row.get("quantity") + uom_key = (row.get("uom") or "").strip() + price_val = row.get("price_unit") + name = (row.get("name") or "").strip() + + if not product_key: + raise UserError(_("Row %s: product is required.") % index) + if qty_val in (None, ""): + raise UserError(_("Row %s: quantity is required.") % index) + try: + qty = float(qty_val) + except Exception as err: + raise UserError(_("Row %s: quantity is not a number.") % index) from err + if qty <= 0: + raise UserError(_("Row %s: quantity must be > 0.") % index) + + product = self._match_product(product_key) + uom = product.uom_id + if uom_key: + uom = self.env["uom.uom"].search([("name", "=", uom_key)], limit=1) or uom + + price_unit = None + if price_val not in (None, ""): + try: + price_unit = float(price_val) + except Exception as err: + raise UserError(_("Row %s: price_unit is not a number.") % index) from err + + vals = { + "document_id": doc.id, + "product_id": product.id, + "name": name or product.display_name, + "quantity": qty, + "product_uom": uom.id, + } + if price_unit is not None: + vals["price_unit"] = price_unit + + self.env["delivery.vendor.document.line"].create(vals) + except Exception as e: + errors.append(str(e)) + + if errors: + raise UserError("\n".join(errors)) + + return { + "type": "ir.actions.act_window", + "res_model": "delivery.vendor.document", + "res_id": doc.id, + "view_mode": "form", + "target": "current", + } + + def _read_rows(self): + if self.file_type == "xlsx": + try: + import openpyxl # noqa: F401 + except Exception as err: + raise UserError(_('XLSX support requires the "openpyxl" Python package.')) from err + content = base64.b64decode(self.file or b"") + from openpyxl import load_workbook + + wb = load_workbook(filename=BytesIO(content), read_only=True) + ws = wb.active + rows_iter = ws.iter_rows(values_only=True) + headers = next(rows_iter) + headers = [h.strip() if isinstance(h, str) else h for h in headers] + result = [] + for r in rows_iter: + row = {} + for i, h in enumerate(headers): + if not h: + continue + val = r[i] if i < len(r) else None + row[h] = ( + val + if val is None or isinstance(val, str) + else (str(val) if isinstance(val, int | float) else val) + ) + result.append(row) + return result + # CSV + data = base64.b64decode(self.file or b"") + text = data.decode("utf-8") + if self.delimiter: + reader = csv.DictReader(StringIO(text), delimiter=self.delimiter) + else: + try: + dialect = csv.Sniffer().sniff(text.splitlines()[0]) + reader = csv.DictReader(StringIO(text), dialect=dialect) + except Exception: + reader = csv.DictReader(StringIO(text)) + return list(reader) + + def _match_product(self, key): + key = str(key).strip() + Product = self.env["product.product"] + if self.product_match_by == "default_code": + product = Product.search([("default_code", "=", key)], limit=1) + elif self.product_match_by == "barcode": + product = Product.search([("barcode", "=", key)], limit=1) + else: + product = Product.search([("name", "=", key)], limit=1) + if not product: + raise UserError(_('Product "%s" not found.') % key) + return product diff --git a/deltatech_putaway_strategy/README.rst b/deltatech_putaway_strategy/README.rst index c9cc204b06..bf83ed53fd 100644 --- a/deltatech_putaway_strategy/README.rst +++ b/deltatech_putaway_strategy/README.rst @@ -37,18 +37,27 @@ Key features - ``Max products``: computed capacity for any location (sum of children for non‑leaf locations). - ``Current quantity``: computed on‑hand quantity per location. + - ``Planned quantity``: computed incoming quantity per location based + on pending moves. - ``Occupancy``: computed ratio ``current/max`` (clamped to [0, 1]). - Optimized computation for large hierarchies: - Single batched ``read_group`` on ``stock.quant`` for all leaf locations in the batch. + - Batched ``read_group`` on ``stock.move.line`` for planned + quantities. - Bottom‑up in‑memory aggregation for parent locations. - Smarter putaway: - Respects capacity on leaf locations when suggesting destinations. - - Prefers empty child locations when possible. + - Automatically splits move lines if a destination location reaches + its maximum capacity. + - Prefers empty child locations when possible (if search sublocation + is enabled). + - Optimized rule lookup with database indexes on ``product_id`` and + ``sequence`` for ``stock.putaway.rule``. - Keeps full compatibility with Odoo’s storage category rules (max weight, product/pack capacities, allow new product rules, etc.). @@ -84,11 +93,13 @@ Compatibility Tests ----- -- Includes minimal TransactionCase tests that validate: +- Includes TransactionCase tests that validate: - capacity enforcement via ``_check_can_be_used``, - putaway preference for empty child locations via - ``_get_putaway_strategy``. + ``_get_putaway_strategy``, + - automatic splitting of move lines when capacity is reached, + - planned quantity calculations. Screenshots ----------- diff --git a/deltatech_putaway_strategy/static/description/index.html b/deltatech_putaway_strategy/static/description/index.html index bbbc5b8a81..70eca8a3cf 100644 --- a/deltatech_putaway_strategy/static/description/index.html +++ b/deltatech_putaway_strategy/static/description/index.html @@ -382,18 +382,27 @@

Key features

  • Max products: computed capacity for any location (sum of children for non‑leaf locations).
  • Current quantity: computed on‑hand quantity per location.
  • +
  • Planned quantity: computed incoming quantity per location based +on pending moves.
  • Occupancy: computed ratio current/max (clamped to [0, 1]).
  • Optimized computation for large hierarchies:
    • Single batched read_group on stock.quant for all leaf locations in the batch.
    • +
    • Batched read_group on stock.move.line for planned +quantities.
    • Bottom‑up in‑memory aggregation for parent locations.
  • Smarter putaway:
    • Respects capacity on leaf locations when suggesting destinations.
    • -
    • Prefers empty child locations when possible.
    • +
    • Automatically splits move lines if a destination location reaches +its maximum capacity.
    • +
    • Prefers empty child locations when possible (if search sublocation +is enabled).
    • +
    • Optimized rule lookup with database indexes on product_id and +sequence for stock.putaway.rule.
    • Keeps full compatibility with Odoo’s storage category rules (max weight, product/pack capacities, allow new product rules, etc.).
    @@ -435,10 +444,12 @@

    Compatibility

    Tests

      -
    • Includes minimal TransactionCase tests that validate:
        +
      • Includes TransactionCase tests that validate:
        • capacity enforcement via _check_can_be_used,
        • putaway preference for empty child locations via -_get_putaway_strategy.
        • +_get_putaway_strategy, +
        • automatic splitting of move lines when capacity is reached,
        • +
        • planned quantity calculations.