Initial commit: OCA Workflow Process packages (456 packages)

This commit is contained in:
Ernad Husremovic 2025-08-29 15:43:00 +02:00
commit d366e42934
18799 changed files with 1284507 additions and 0 deletions

View file

@ -0,0 +1,48 @@
# Sale Blanket Orders
Odoo addon: sale_blanket_order
## Installation
```bash
pip install odoo-bringout-oca-sale-workflow-sale_blanket_order
```
## Dependencies
This addon depends on:
- uom
- sale_management
- web_action_conditionable
## Manifest Information
- **Name**: Sale Blanket Orders
- **Version**: 16.0.1.3.1
- **Category**: Sale
- **License**: AGPL-3
- **Installable**: True
## Source
Based on [OCA/sale-workflow](https://github.com/OCA/sale-workflow) branch 16.0, addon `sale_blanket_order`.
## License
This package maintains the original AGPL-3 license from the upstream Odoo project.
## Documentation
- Overview: doc/OVERVIEW.md
- Architecture: doc/ARCHITECTURE.md
- Models: doc/MODELS.md
- Controllers: doc/CONTROLLERS.md
- Wizards: doc/WIZARDS.md
- Reports: doc/REPORTS.md
- Security: doc/SECURITY.md
- Install: doc/INSTALL.md
- Usage: doc/USAGE.md
- Configuration: doc/CONFIGURATION.md
- Dependencies: doc/DEPENDENCIES.md
- Troubleshooting: doc/TROUBLESHOOTING.md
- FAQ: doc/FAQ.md

View file

@ -0,0 +1,32 @@
# Architecture
```mermaid
flowchart TD
U[Users] -->|HTTP| V[Views and QWeb Templates]
V --> C[Controllers]
V --> W[Wizards Transient Models]
C --> M[Models and ORM]
W --> M
M --> R[Reports]
DX[Data XML] --> M
S[Security ACLs and Groups] -. enforces .-> M
subgraph Sale_blanket_order Module - sale_blanket_order
direction LR
M:::layer
W:::layer
C:::layer
V:::layer
R:::layer
S:::layer
DX:::layer
end
classDef layer fill:#eef8ff,stroke:#6ea8fe,stroke-width:1px
```
Notes
- Views include tree/form/kanban templates and report templates.
- Controllers provide website/portal routes when present.
- Wizards are UI flows implemented with `models.TransientModel`.
- Data XML loads data/demo records; Security defines groups and access.

View file

@ -0,0 +1,3 @@
# Configuration
Refer to Odoo settings for sale_blanket_order. Configure related models, access rights, and options as needed.

View file

@ -0,0 +1,3 @@
# Controllers
This module does not define custom HTTP controllers.

View file

@ -0,0 +1,7 @@
# Dependencies
This addon depends on:
- [uom](../../odoo-bringout-oca-ocb-uom)
- [sale_management](../../odoo-bringout-oca-ocb-sale_management)
- [web_action_conditionable](../../odoo-bringout-oca-web-web_action_conditionable)

View file

@ -0,0 +1,4 @@
# FAQ
- Q: Which Odoo version? A: 16.0 (OCA/OCB packaged).
- Q: How to enable? A: Start server with --addon sale_blanket_order or install in UI.

View file

@ -0,0 +1,7 @@
# Install
```bash
pip install odoo-bringout-oca-sale-workflow-sale_blanket_order"
# or
uv pip install odoo-bringout-oca-sale-workflow-sale_blanket_order"
```

View file

@ -0,0 +1,16 @@
# Models
Detected core models and extensions in sale_blanket_order.
```mermaid
classDiagram
class sale_blanket_order
class sale_blanket_order_line
class res_config_settings
class sale_order
class sale_order_line
```
Notes
- Classes show model technical names; fields omitted for brevity.
- Items listed under _inherit are extensions of existing models.

View file

@ -0,0 +1,6 @@
# Overview
Packaged Odoo addon: sale_blanket_order. Provides features documented in upstream Odoo 16 under this addon.
- Source: OCA/OCB 16.0, addon sale_blanket_order
- License: LGPL-3

View file

@ -0,0 +1,24 @@
# Reports
Report definitions and templates in sale_blanket_order.
```mermaid
classDiagram
```
## Available Reports
### PDF/Document Reports
- **Blanket Order** (PDF/Print)
## Report Files
- **report.xml** (XML template/definition)
- **templates.xml** (XML template/definition)
## Notes
- Named reports above are accessible through Odoo's reporting menu
- Python files define report logic and data processing
- XML files contain report templates, definitions, and formatting
- Reports are integrated with Odoo's printing and email systems

View file

@ -0,0 +1,42 @@
# Security
Access control and security definitions in sale_blanket_order.
## Access Control Lists (ACLs)
Model access permissions defined in:
- **[ir.model.access.csv](../sale_blanket_order/security/ir.model.access.csv)**
- 9 model access rules
## Record Rules
Row-level security rules defined in:
## Security Groups & Configuration
Security groups and permissions defined in:
- **[security.xml](../sale_blanket_order/security/security.xml)**
- 1 security groups defined
```mermaid
graph TB
subgraph "Security Layers"
A[Users] --> B[Groups]
B --> C[Access Control Lists]
C --> D[Models]
B --> E[Record Rules]
E --> F[Individual Records]
end
```
Security files overview:
- **[ir.model.access.csv](../sale_blanket_order/security/ir.model.access.csv)**
- Model access permissions (CRUD rights)
- **[security.xml](../sale_blanket_order/security/security.xml)**
- Security groups, categories, and XML-based rules
Notes
- Access Control Lists define which groups can access which models
- Record Rules provide row-level security (filter records by user/group)
- Security groups organize users and define permission sets
- All security is enforced at the ORM level by Odoo

View file

@ -0,0 +1,5 @@
# Troubleshooting
- Ensure Python and Odoo environment matches repo guidance.
- Check database connectivity and logs if startup fails.
- Validate that dependent addons listed in DEPENDENCIES.md are installed.

View file

@ -0,0 +1,7 @@
# Usage
Start Odoo including this addon (from repo root):
```bash
python3 scripts/nix_odoo_web_server.py --db-name mydb --addon sale_blanket_order
```

View file

@ -0,0 +1,9 @@
# Wizards
Transient models exposed as UI wizards in sale_blanket_order.
```mermaid
classDiagram
class BlanketOrderWizard
class BlanketOrderWizardLine
```

View file

@ -0,0 +1,44 @@
[project]
name = "odoo-bringout-oca-sale-workflow-sale_blanket_order"
version = "16.0.0"
description = "Sale Blanket Orders - Blanket Orders"
authors = [
{ name = "Ernad Husremovic", email = "hernad@bring.out.ba" }
]
dependencies = [
"odoo-bringout-oca-ocb-uom>=16.0.0",
"odoo-bringout-oca-sale-workflow-sale_management>=16.0.0",
"odoo-bringout-oca-sale-workflow-web_action_conditionable>=16.0.0",
"requests>=2.25.1"
]
readme = "README.md"
requires-python = ">= 3.11"
classifiers = [
"Development Status :: 5 - Production/Stable",
"Intended Audience :: Developers",
"License :: OSI Approved :: GNU Lesser General Public License v3 (LGPLv3)",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Topic :: Office/Business",
]
[project.urls]
homepage = "https://github.com/bringout/0"
repository = "https://github.com/bringout/0"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.metadata]
allow-direct-references = true
[tool.hatch.build.targets.wheel]
packages = ["sale_blanket_order"]
[tool.rye]
managed = true
dev-dependencies = [
"pytest>=8.4.1",
]

View file

@ -0,0 +1,152 @@
.. image:: https://odoo-community.org/readme-banner-image
:target: https://odoo-community.org/get-involved?utm_source=readme
:alt: Odoo Community Association
===================
Sale Blanket Orders
===================
..
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! This file is generated by oca-gen-addon-readme !!
!! changes will be overwritten. !!
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! source digest: sha256:2304d65f97e02bd0fec986276cb77baf60ddd090d6bc436ae7004c8276b7c666
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
.. |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%2Fsale--workflow-lightgray.png?logo=github
:target: https://github.com/OCA/sale-workflow/tree/16.0/sale_blanket_order
:alt: OCA/sale-workflow
.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png
:target: https://translation.odoo-community.org/projects/sale-workflow-16-0/sale-workflow-16-0-sale_blanket_order
: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/sale-workflow&target_branch=16.0
:alt: Try me on Runboat
|badge1| |badge2| |badge3| |badge4| |badge5|
A blanket order is a pre-agreement to sell a certain number of quantities of
products at a specific price. From a confirmed blanket order, the users can
create new sale orders at such price, until the blanket order expires, either
due to reaching the validity date or exhausting all the quantities of products.
**Table of contents**
.. contents::
:local:
Usage
=====
A new menu in the Sales area is created, allowing users to create new blanket orders.
To create a new Sale Blanket Order go to the sale menu in the Sales section:
.. figure:: https://raw.githubusercontent.com/OCA/sale-workflow/16.0/sale_blanket_order/static/description/BO_menu.png
:alt: Blanket Orders menu
Hitting the button create will open the form view in which we can introduce the following
information:
* Vendor
* Salesperson
* Payment Terms
* Validity date
* Order lines:
* Product
* Accorded price
* Original, Ordered, Invoiced, Received and Remaining quantities
* Terms and Conditions of the Blanket Order
.. figure:: https://raw.githubusercontent.com/OCA/sale-workflow/16.0/sale_blanket_order/static/description/BO_form.png
:alt: Blanket Orders form
From the form, once the Blanket Order has been confirmed and its state is open, the user can
create a Sale Order, check the Sale Orders associated to the Blanket Order and/or
see the Blanket Order lines associated to the BO.
.. figure:: https://raw.githubusercontent.com/OCA/sale-workflow/16.0/sale_blanket_order/static/description/BO_actions.png
:alt: Actions that can be done from Blanket Order
Hitting the button Create Sale Order will open a wizard that will ask for the amount of each
product in the BO lines for which the Sale Order will be created.
.. figure:: https://raw.githubusercontent.com/OCA/sale-workflow/16.0/sale_blanket_order/static/description/PO_from_BO.png
:alt: Create Sale Order from Blanket Order
Installing this module will add an additional menu which will show all the blanket order lines
currently defined in the system. From this list the user can create customized Sale Orders
selecting the lines for which the PO (or POs if the customers are different) is (are) created.
.. figure:: https://raw.githubusercontent.com/OCA/sale-workflow/16.0/sale_blanket_order/static/description/BO_lines.png
:alt: Blanket Order lines and actions
In the Sale Order form one field is added in the PO lines, the Blanket Order line field. This
field keeps track to which Blanket Order line the PO line is associated. Upon adding a new product
in a newly created Sale Order a blanket order line will be suggested depending on the following
factors:
* Closer Validity date
* Remaining quantity > Quantity introduced in the Sale Order line
.. figure:: https://raw.githubusercontent.com/OCA/sale-workflow/16.0/sale_blanket_order/static/description/PO_BOLine.png
:alt: New field added in Sale Order Line
Bug Tracker
===========
Bugs are tracked on `GitHub Issues <https://github.com/OCA/sale-workflow/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 <https://github.com/OCA/sale-workflow/issues/new?body=module:%20sale_blanket_order%0Aversion:%2016.0%0A%0A**Steps%20to%20reproduce**%0A-%20...%0A%0A**Current%20behavior**%0A%0A**Expected%20behavior**>`_.
Do not contact contributors directly about support or help with technical issues.
Credits
=======
Authors
~~~~~~~
* Acsone SA/NV
Contributors
~~~~~~~~~~~~
* André Pereira <github@andreparames.com> (https://www.acsone.eu/)
* Adrià Gil Sorribes <adria.gil@eficent.com> (https://www.eficent.com/)
* Jordi Ballester Alomar <jordi.ballester@eficent.com>
* Alex Comba <alex.comba@agilebg.com> (https://www.agilebg.com/)
* Jasper Jumelet <jasper.jumelet@codeforward.nl> (https://www.codeforward.nl/)
* `Trobz <https://trobz.com>`_:
* Nguyễn Minh Chiến <chien@trobz.com>
Other credits
~~~~~~~~~~~~~
The migration of this module from 15.0 to 16.0 was financially supported by Camptocamp
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/sale-workflow <https://github.com/OCA/sale-workflow/tree/16.0/sale_blanket_order>`_ project on GitHub.
You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

View file

@ -0,0 +1,4 @@
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
from . import models
from . import wizard

View file

@ -0,0 +1,26 @@
# Copyright 2018 Acsone
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
{
"name": "Sale Blanket Orders",
"category": "Sale",
"license": "AGPL-3",
"author": "Acsone SA/NV, Odoo Community Association (OCA)",
"version": "16.0.1.3.1",
"website": "https://github.com/OCA/sale-workflow",
"summary": "Blanket Orders",
"depends": ["uom", "sale_management", "web_action_conditionable"],
"data": [
"security/security.xml",
"security/ir.model.access.csv",
"data/sequence.xml",
"data/ir_cron.xml",
"wizard/create_sale_orders.xml",
"views/sale_config_settings.xml",
"views/sale_blanket_order_views.xml",
"views/sale_blanket_order_line_views.xml",
"views/sale_order_views.xml",
"report/templates.xml",
"report/report.xml",
],
"installable": True,
}

View file

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8" ?>
<!-- Copyright 2018 ACSONE SA/NV
License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). -->
<odoo noupdate="1">
<record forcecreate="True" id="expired_blanket_orders_cron" model="ir.cron">
<field name="name">Expire Blanket Orders</field>
<field name="interval_number">1</field>
<field name="interval_type">days</field>
<field
name="nextcall"
eval="(DateTime.now() + relativedelta(hour=00, minute=1, second=0)).strftime('%Y-%m-%d %H:%M:%S')"
/>
<field name="numbercall">-1</field>
<field name="doall" eval="False" />
<field name="model_id" ref="model_sale_blanket_order" />
<field name="state">code</field>
<field name="code">model.expire_orders()</field>
</record>
</odoo>

View file

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo noupdate="1">
<!-- Sequences for sale.blanket.order -->
<record id="seq_blanket_order" model="ir.sequence">
<field name="name">Blanket Order</field>
<field name="code">sale.blanket.order</field>
<field name="prefix">BO</field>
<field name="padding">3</field>
<field name="company_id" eval="False" />
</record>
</odoo>

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,3 @@
from . import blanket_orders
from . import sale_orders
from . import sale_config_settings

View file

@ -0,0 +1,716 @@
# Copyright 2018 ACSONE SA/NV
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import SUPERUSER_ID, _, api, fields, models
from odoo.exceptions import UserError
from odoo.tools import float_is_zero
from odoo.tools.misc import format_date
from odoo.addons.sale.models.sale_order import READONLY_FIELD_STATES
class BlanketOrder(models.Model):
_name = "sale.blanket.order"
_inherit = ["mail.thread", "mail.activity.mixin"]
_description = "Blanket Order"
_check_company_auto = True
@api.model
def _default_note(self):
return (
self.env["ir.config_parameter"]
.sudo()
.get_param("account.use_invoice_terms")
and self.env.company.invoice_terms
or ""
)
@api.depends("line_ids.price_total")
def _compute_amount_all(self):
for order in self.filtered("currency_id"):
amount_untaxed = amount_tax = 0.0
for line in order.line_ids:
amount_untaxed += line.price_subtotal
amount_tax += line.price_tax
order.update(
{
"amount_untaxed": order.currency_id.round(amount_untaxed),
"amount_tax": order.currency_id.round(amount_tax),
"amount_total": amount_untaxed + amount_tax,
}
)
name = fields.Char(default="Draft", readonly=True, copy=False)
partner_id = fields.Many2one(
"res.partner",
string="Partner",
states=READONLY_FIELD_STATES,
)
line_ids = fields.One2many(
"sale.blanket.order.line", "order_id", string="Order lines", copy=True
)
line_count = fields.Integer(
string="Sale Blanket Order Line count",
compute="_compute_line_count",
readonly=True,
)
product_id = fields.Many2one(
"product.product",
related="line_ids.product_id",
string="Product",
)
pricelist_id = fields.Many2one(
"product.pricelist",
string="Pricelist",
required=True,
states=READONLY_FIELD_STATES,
)
currency_id = fields.Many2one("res.currency", related="pricelist_id.currency_id")
analytic_account_id = fields.Many2one(
comodel_name="account.analytic.account",
string="Analytic Account",
copy=False,
check_company=True,
states=READONLY_FIELD_STATES,
domain="['|', ('company_id', '=', False), ('company_id', '=', company_id)]",
)
payment_term_id = fields.Many2one(
"account.payment.term",
string="Payment Terms",
states=READONLY_FIELD_STATES,
)
confirmed = fields.Boolean(copy=False)
state = fields.Selection(
selection=[
("draft", "Draft"),
("open", "Open"),
("done", "Done"),
("expired", "Expired"),
],
compute="_compute_state",
store=True,
copy=False,
)
validity_date = fields.Date(
states=READONLY_FIELD_STATES,
)
client_order_ref = fields.Char(
string="Customer Reference",
copy=False,
states=READONLY_FIELD_STATES,
)
note = fields.Text(default=_default_note, states=READONLY_FIELD_STATES)
user_id = fields.Many2one(
"res.users",
string="Salesperson",
states=READONLY_FIELD_STATES,
)
team_id = fields.Many2one(
"crm.team",
string="Sales Team",
change_default=True,
default=lambda self: self.env["crm.team"]._get_default_team_id(),
states=READONLY_FIELD_STATES,
)
company_id = fields.Many2one(
comodel_name="res.company",
required=True,
index=True,
default=lambda self: self.env.company,
)
sale_count = fields.Integer(compute="_compute_sale_count")
fiscal_position_id = fields.Many2one(
"account.fiscal.position", string="Fiscal Position"
)
amount_untaxed = fields.Monetary(
string="Untaxed Amount",
store=True,
readonly=True,
compute="_compute_amount_all",
tracking=True,
)
amount_tax = fields.Monetary(
string="Taxes", store=True, readonly=True, compute="_compute_amount_all"
)
amount_total = fields.Monetary(
string="Total", store=True, readonly=True, compute="_compute_amount_all"
)
# Fields use to filter in tree view
original_uom_qty = fields.Float(
string="Original quantity",
compute="_compute_uom_qty",
search="_search_original_uom_qty",
default=0.0,
)
ordered_uom_qty = fields.Float(
string="Ordered quantity",
compute="_compute_uom_qty",
search="_search_ordered_uom_qty",
default=0.0,
)
invoiced_uom_qty = fields.Float(
string="Invoiced quantity",
compute="_compute_uom_qty",
search="_search_invoiced_uom_qty",
default=0.0,
)
remaining_uom_qty = fields.Float(
string="Remaining quantity",
compute="_compute_uom_qty",
search="_search_remaining_uom_qty",
default=0.0,
)
delivered_uom_qty = fields.Float(
string="Delivered quantity",
compute="_compute_uom_qty",
search="_search_delivered_uom_qty",
default=0.0,
)
def _get_sale_orders(self):
return self.mapped("line_ids.sale_lines.order_id")
@api.depends("line_ids")
def _compute_line_count(self):
self.line_count = len(self.mapped("line_ids"))
def _compute_sale_count(self):
for blanket_order in self:
blanket_order.sale_count = len(blanket_order._get_sale_orders())
@api.depends(
"line_ids.remaining_uom_qty",
"validity_date",
"confirmed",
)
def _compute_state(self):
today = fields.Date.today()
precision = self.env["decimal.precision"].precision_get(
"Product Unit of Measure"
)
for order in self:
if not order.confirmed:
order.state = "draft"
elif order.validity_date <= today:
order.state = "expired"
elif float_is_zero(
sum(
order.line_ids.filtered(lambda l: not l.display_type).mapped(
"remaining_uom_qty"
)
),
precision_digits=precision,
):
order.state = "done"
else:
order.state = "open"
def _compute_uom_qty(self):
for bo in self:
bo.original_uom_qty = sum(bo.mapped("line_ids.original_uom_qty"))
bo.ordered_uom_qty = sum(bo.mapped("line_ids.ordered_uom_qty"))
bo.invoiced_uom_qty = sum(bo.mapped("line_ids.invoiced_uom_qty"))
bo.delivered_uom_qty = sum(bo.mapped("line_ids.delivered_uom_qty"))
bo.remaining_uom_qty = sum(bo.mapped("line_ids.remaining_uom_qty"))
@api.onchange("partner_id")
def onchange_partner_id(self):
"""
Update the following fields when the partner is changed:
- Pricelist
- Payment term
- Fiscal position
"""
if not self.partner_id:
self.payment_term_id = False
self.fiscal_position_id = False
return
values = {
"pricelist_id": (
self.partner_id.property_product_pricelist
and self.partner_id.property_product_pricelist.id
or False
),
"payment_term_id": (
self.partner_id.property_payment_term_id
and self.partner_id.property_payment_term_id.id
or False
),
"fiscal_position_id": self.env["account.fiscal.position"]
.with_context(company_id=self.company_id.id)
._get_fiscal_position(self.partner_id),
}
if self.partner_id.user_id:
values["user_id"] = self.partner_id.user_id.id
if self.partner_id.team_id:
values["team_id"] = self.partner_id.team_id.id
self.update(values)
def unlink(self):
for order in self:
if order.state not in ("draft", "expired") or order._check_active_orders():
raise UserError(
_(
"You can not delete an open blanket or "
"with active sale orders! "
"Try to cancel it before."
)
)
return super().unlink()
def _validate(self):
try:
today = fields.Date.today()
for order in self:
assert order.validity_date, _("Validity date is mandatory")
assert order.validity_date > today, _(
"Validity date must be in the future"
)
assert order.partner_id, _("Partner is mandatory")
assert len(order.line_ids) > 0, _("Must have some lines")
order.line_ids._validate()
except AssertionError as e:
raise UserError(e) from e
def set_to_draft(self):
for order in self:
order.write({"state": "draft", "confirmed": False})
return True
def action_confirm(self):
self._validate()
for order in self:
sequence_obj = self.env["ir.sequence"]
if order.company_id:
sequence_obj = sequence_obj.with_company(order.company_id.id)
name = sequence_obj.next_by_code("sale.blanket.order")
order.write({"confirmed": True, "name": name})
return True
def _check_active_orders(self):
for order in self.filtered("sale_count"):
for so in order._get_sale_orders():
if so.state not in ("cancel"):
return True
return False
def action_cancel(self):
for order in self:
if order._check_active_orders():
raise UserError(
_(
"You can not delete a blanket order with opened "
"sale orders! "
"Try to cancel them before."
)
)
order.write({"state": "expired"})
return True
def action_view_sale_orders(self):
sale_orders = self._get_sale_orders()
action = self.env["ir.actions.act_window"]._for_xml_id("sale.action_orders")
if len(sale_orders) > 0:
action["domain"] = [("id", "in", sale_orders.ids)]
action["context"] = [("id", "in", sale_orders.ids)]
else:
action = {"type": "ir.actions.act_window_close"}
return action
def action_view_sale_blanket_order_line(self):
action = self.env["ir.actions.act_window"]._for_xml_id(
"sale_blanket_order.act_open_sale_blanket_order_lines_view_tree"
)
lines = self.mapped("line_ids")
if len(lines) > 0:
action["domain"] = [("id", "in", lines.ids)]
return action
@api.model
def expire_orders(self):
today = fields.Date.today()
expired_orders = self.search(
[("state", "=", "open"), ("validity_date", "<=", today)]
)
expired_orders.modified(["validity_date"])
expired_orders.flush_recordset()
@api.model
def _search_original_uom_qty(self, operator, value):
bo_line_obj = self.env["sale.blanket.order.line"]
res = []
bo_lines = bo_line_obj.search([("original_uom_qty", operator, value)])
order_ids = bo_lines.mapped("order_id")
res.append(("id", "in", order_ids.ids))
return res
@api.model
def _search_ordered_uom_qty(self, operator, value):
bo_line_obj = self.env["sale.blanket.order.line"]
res = []
bo_lines = bo_line_obj.search([("ordered_uom_qty", operator, value)])
order_ids = bo_lines.mapped("order_id")
res.append(("id", "in", order_ids.ids))
return res
@api.model
def _search_invoiced_uom_qty(self, operator, value):
bo_line_obj = self.env["sale.blanket.order.line"]
res = []
bo_lines = bo_line_obj.search([("invoiced_uom_qty", operator, value)])
order_ids = bo_lines.mapped("order_id")
res.append(("id", "in", order_ids.ids))
return res
@api.model
def _search_delivered_uom_qty(self, operator, value):
bo_line_obj = self.env["sale.blanket.order.line"]
res = []
bo_lines = bo_line_obj.search([("delivered_uom_qty", operator, value)])
order_ids = bo_lines.mapped("order_id")
res.append(("id", "in", order_ids.ids))
return res
@api.model
def _search_remaining_uom_qty(self, operator, value):
bo_line_obj = self.env["sale.blanket.order.line"]
res = []
bo_lines = bo_line_obj.search([("remaining_uom_qty", operator, value)])
order_ids = bo_lines.mapped("order_id")
res.append(("id", "in", order_ids.ids))
return res
class BlanketOrderLine(models.Model):
_name = "sale.blanket.order.line"
_description = "Blanket Order Line"
_inherit = ["mail.thread", "mail.activity.mixin", "analytic.mixin"]
@api.depends(
"original_uom_qty",
"price_unit",
"taxes_id",
"order_id.partner_id",
"product_id",
"currency_id",
)
def _compute_amount(self):
for line in self:
price = line.price_unit
taxes = line.taxes_id.compute_all(
price,
line.currency_id,
line.original_uom_qty,
product=line.product_id,
partner=line.order_id.partner_id,
)
line.update(
{
"price_tax": sum(
t.get("amount", 0.0) for t in taxes.get("taxes", [])
),
"price_total": taxes["total_included"],
"price_subtotal": taxes["total_excluded"],
}
)
name = fields.Char("Description", tracking=True)
sequence = fields.Integer()
order_id = fields.Many2one("sale.blanket.order", required=True, ondelete="cascade")
product_id = fields.Many2one(
"product.product",
string="Product",
domain=[("sale_ok", "=", True)],
)
product_uom = fields.Many2one("uom.uom", string="Unit of Measure")
price_unit = fields.Float(string="Price", digits="Product Price")
taxes_id = fields.Many2many(
"account.tax",
string="Taxes",
domain=["|", ("active", "=", False), ("active", "=", True)],
)
date_schedule = fields.Date(string="Scheduled Date")
original_uom_qty = fields.Float(
string="Original quantity", default=1, digits="Product Unit of Measure"
)
ordered_uom_qty = fields.Float(
string="Ordered quantity", compute="_compute_quantities", store=True
)
invoiced_uom_qty = fields.Float(
string="Invoiced quantity", compute="_compute_quantities", store=True
)
remaining_uom_qty = fields.Float(
string="Remaining quantity", compute="_compute_quantities", store=True
)
remaining_qty = fields.Float(
string="Remaining quantity in base UoM",
compute="_compute_quantities",
store=True,
)
delivered_uom_qty = fields.Float(
string="Delivered quantity", compute="_compute_quantities", store=True
)
sale_lines = fields.One2many(
"sale.order.line",
"blanket_order_line",
string="Sale order lines",
readonly=True,
copy=False,
)
company_id = fields.Many2one(
related="order_id.company_id", store=True, index=True, precompute=True
)
currency_id = fields.Many2one("res.currency", related="order_id.currency_id")
partner_id = fields.Many2one(related="order_id.partner_id", string="Customer")
user_id = fields.Many2one(related="order_id.user_id", string="Responsible")
payment_term_id = fields.Many2one(
related="order_id.payment_term_id", string="Payment Terms"
)
pricelist_id = fields.Many2one(related="order_id.pricelist_id", string="Pricelist")
price_subtotal = fields.Monetary(
compute="_compute_amount", string="Subtotal", store=True
)
price_total = fields.Monetary(compute="_compute_amount", string="Total", store=True)
price_tax = fields.Float(compute="_compute_amount", string="Tax", store=True)
display_type = fields.Selection(
[("line_section", "Section"), ("line_note", "Note")],
default=False,
help="Technical field for UX purpose.",
)
def name_get(self):
result = []
if self.env.context.get("from_sale_order"):
for record in self:
res = "[%s]" % record.order_id.name
if record.date_schedule:
formatted_date = format_date(record.env, record.date_schedule)
res += " - {}: {}".format(_("Date Scheduled"), formatted_date)
res += " ({}: {} {})".format(
_("remaining"),
record.remaining_uom_qty,
record.product_uom.name,
)
result.append((record.id, res))
return result
return super().name_get()
def _get_real_price_currency(self, product, rule_id, qty, uom, pricelist_id):
"""Retrieve the price before applying the pricelist
:param obj product: object of current product record
:param float qty: total quentity of product
:param tuple price_and_rule: tuple(price, suitable_rule) coming
from pricelist computation
:param obj uom: unit of measure of current order line
:param integer pricelist_id: pricelist id of sale order"""
# Copied and adapted from the sale module
PricelistItem = self.env["product.pricelist.item"]
field_name = "lst_price"
currency_id = None
product_currency = None
if rule_id:
pricelist_item = PricelistItem.browse(rule_id)
if pricelist_item.pricelist_id.discount_policy == "without_discount":
while (
pricelist_item.base == "pricelist"
and pricelist_item.base_pricelist_id
and pricelist_item.base_pricelist_id.discount_policy
== "without_discount"
):
price, rule_id = pricelist_item.base_pricelist_id.with_context(
uom=uom.id
)._get_product_price_rule(product, qty, uom)
pricelist_item = PricelistItem.browse(rule_id)
if pricelist_item.base == "standard_price":
field_name = "standard_price"
if pricelist_item.base == "pricelist" and pricelist_item.base_pricelist_id:
field_name = "price"
product = product.with_context(
pricelist=pricelist_item.base_pricelist_id.id
)
product_currency = pricelist_item.base_pricelist_id.currency_id
currency_id = pricelist_item.pricelist_id.currency_id
product_currency = (
product_currency
or (product.company_id and product.company_id.currency_id)
or self.env.company.currency_id
)
if not currency_id:
currency_id = product_currency
cur_factor = 1.0
else:
if currency_id.id == product_currency.id:
cur_factor = 1.0
else:
cur_factor = currency_id._get_conversion_rate(
product_currency, currency_id
)
product_uom = product.uom_id.id
if uom and uom.id != product_uom:
# the unit price is in a different uom
uom_factor = uom._compute_price(1.0, product.uom_id)
else:
uom_factor = 1.0
return product[field_name] * uom_factor * cur_factor, currency_id.id
def _get_display_price(self, product):
# Copied and adapted from the sale module
self.ensure_one()
pricelist = self.order_id.pricelist_id
partner = self.order_id.partner_id
if self.order_id.pricelist_id.discount_policy == "with_discount":
return product.with_context(pricelist=pricelist.id).lst_price
final_price, rule_id = pricelist._get_product_price_rule(
self.product_id, self.original_uom_qty or 1.0, self.product_uom
)
context_partner = dict(
self.env.context, partner_id=partner.id, date=fields.Date.today()
)
base_price, currency_id = self.with_context(
**context_partner
)._get_real_price_currency(
self.product_id,
rule_id,
self.original_uom_qty,
self.product_uom,
pricelist.id,
)
if currency_id != pricelist.currency_id.id:
currency = self.env["res.currency"].browse(currency_id)
base_price = currency.with_context(**context_partner).compute(
base_price, pricelist.currency_id
)
# negative discounts (= surcharge) are included in the display price
return max(base_price, final_price)
@api.onchange("product_id", "original_uom_qty")
def onchange_product(self):
precision = self.env["decimal.precision"].precision_get(
"Product Unit of Measure"
)
if self.product_id:
name = self.product_id.name
if not self.product_uom:
self.product_uom = self.product_id.uom_id.id
if self.order_id.partner_id and float_is_zero(
self.price_unit, precision_digits=precision
):
self.price_unit = self._get_display_price(self.product_id)
if self.product_id.code:
name = "[{}] {}".format(name, self.product_id.code)
if self.product_id.description_sale:
name += "\n" + self.product_id.description_sale
self.name = name
fpos = self.order_id.fiscal_position_id
if self.env.uid == SUPERUSER_ID:
company_id = self.env.company.id
self.taxes_id = fpos.map_tax(
self.product_id.taxes_id.filtered(
lambda r: r.company_id.id == company_id
)
)
else:
self.taxes_id = fpos.map_tax(self.product_id.taxes_id)
@api.depends(
"sale_lines.order_id.state",
"sale_lines.blanket_order_line",
"sale_lines.product_uom_qty",
"sale_lines.product_uom",
"sale_lines.qty_delivered",
"sale_lines.qty_invoiced",
"original_uom_qty",
"product_uom",
)
def _compute_quantities(self):
for line in self:
sale_lines = line.sale_lines
line.ordered_uom_qty = sum(
sl.product_uom._compute_quantity(sl.product_uom_qty, line.product_uom)
for sl in sale_lines
if sl.order_id.state != "cancel" and sl.product_id == line.product_id
)
line.invoiced_uom_qty = sum(
sl.product_uom._compute_quantity(sl.qty_invoiced, line.product_uom)
for sl in sale_lines
if sl.order_id.state != "cancel" and sl.product_id == line.product_id
)
line.delivered_uom_qty = sum(
sl.product_uom._compute_quantity(sl.qty_delivered, line.product_uom)
for sl in sale_lines
if sl.order_id.state != "cancel" and sl.product_id == line.product_id
)
line.remaining_uom_qty = line.original_uom_qty - line.ordered_uom_qty
line.remaining_qty = line.product_uom._compute_quantity(
line.remaining_uom_qty, line.product_id.uom_id
)
def _validate(self):
try:
for line in self:
assert (
not line.display_type and line.original_uom_qty > 0.0
) or line.display_type, _("Quantity must be greater than zero")
except AssertionError as e:
raise UserError(e) from e
@api.model_create_multi
def create(self, vals_list):
for values in vals_list:
if values.get(
"display_type", self.default_get(["display_type"])["display_type"]
):
values.update(product_id=False, price_unit=0, product_uom=False)
return super().create(vals_list)
_sql_constraints = [
(
"accountable_required_fields",
"""
CHECK(
display_type IS NOT NULL OR (
product_id IS NOT NULL AND product_uom IS NOT NULL
)
)
""",
"Missing required fields on accountable sale order line.",
),
(
"non_accountable_null_fields",
"""
CHECK(
display_type IS NULL OR (
product_id IS NULL AND price_unit = 0 AND product_uom IS NULL
)
)
""",
"Forbidden values on non-accountable sale order line",
),
]
def write(self, values):
if "display_type" in values and self.filtered(
lambda line: line.display_type != values.get("display_type")
):
raise UserError(
_(
"""
You cannot change the type of a sale order line.
Instead you should delete the current line and create a new line
of the proper type.
"""
)
)
return super(BlanketOrderLine, self).write(values)

View file

@ -0,0 +1,14 @@
# Copyright 2018 ACSONE SA/NV
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import fields, models
class SaleConfigSettings(models.TransientModel):
_inherit = "res.config.settings"
group_blanket_disable_adding_lines = fields.Boolean(
string="Disable adding more lines to SOs",
implied_group="sale_blanket_order.blanket_orders_disable_adding_lines",
)

View file

@ -0,0 +1,204 @@
# Copyright 2018 ACSONE SA/NV
# Copyright 2019 Eficent and IT Consulting Services, S.L.
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from datetime import date, timedelta
from odoo import _, api, fields, models
from odoo.exceptions import ValidationError
class SaleOrder(models.Model):
_inherit = "sale.order"
blanket_order_id = fields.Many2one(
"sale.blanket.order",
string="Origin blanket order",
compute="_compute_blanket_order_id",
)
disable_adding_lines = fields.Boolean(
compute="_compute_disable_adding_lines",
)
@api.model
def _check_exchausted_blanket_order_line(self):
return any(
line.blanket_order_line.remaining_qty < 0.0 for line in self.order_line
)
def action_confirm(self):
res = super().action_confirm()
for order in self:
if order._check_exchausted_blanket_order_line():
raise ValidationError(
_(
"Cannot confirm order %s as one of the lines refers "
"to a blanket order that has no remaining quantity."
)
% order.name
)
return res
@api.constrains("partner_id")
def check_partner_id(self):
for line in self.order_line:
if line.blanket_order_line:
if line.blanket_order_line.partner_id != self.partner_id:
raise ValidationError(
_(
"The customer must be equal to the "
"blanket order lines customer"
)
)
@api.depends("order_line.blanket_order_line.order_id")
def _compute_blanket_order_id(self):
for order in self:
blanket_order = order.order_line.mapped("blanket_order_line.order_id")
order.blanket_order_id = blanket_order[:1]
@api.depends("blanket_order_id")
@api.depends_context("uid")
def _compute_disable_adding_lines(self):
self.disable_adding_lines = False
if self.env.user.has_group(
"sale_blanket_order.blanket_orders_disable_adding_lines"
):
for order in self:
order.disable_adding_lines = order.blanket_order_id
class SaleOrderLine(models.Model):
_inherit = "sale.order.line"
blanket_order_line = fields.Many2one(
"sale.blanket.order.line", string="Blanket Order line", copy=False
)
def _get_assigned_bo_line(self, bo_lines):
# We get the blanket order line with enough quantity and closest
# scheduled date
assigned_bo_line = False
date_planned = date.today()
date_delta = timedelta(days=365)
for line in bo_lines.filtered(lambda l: l.date_schedule):
date_schedule = line.date_schedule
if date_schedule and abs(date_schedule - date_planned) < date_delta:
assigned_bo_line = line
date_delta = abs(date_schedule - date_planned)
if assigned_bo_line:
return assigned_bo_line
non_date_bo_lines = bo_lines.filtered(lambda l: not l.date_schedule)
if non_date_bo_lines:
return non_date_bo_lines[0]
def _get_eligible_bo_lines_domain(self, base_qty):
filters = [
("product_id", "=", self.product_id.id),
("remaining_qty", ">=", base_qty),
("currency_id", "=", self.order_id.currency_id.id),
("order_id.state", "=", "open"),
]
if self.order_id.partner_id:
filters.append(("partner_id", "=", self.order_id.partner_id.id))
return filters
def _get_eligible_bo_lines(self):
base_qty = self.product_uom._compute_quantity(
self.product_uom_qty, self.product_id.uom_id
)
filters = self._get_eligible_bo_lines_domain(base_qty)
return self.env["sale.blanket.order.line"].search(filters)
def get_assigned_bo_line(self):
self.ensure_one()
eligible_bo_lines = self._get_eligible_bo_lines()
if eligible_bo_lines:
if (
not self.blanket_order_line
or self.blanket_order_line not in eligible_bo_lines
):
self.blanket_order_line = self._get_assigned_bo_line(eligible_bo_lines)
else:
self.blanket_order_line = False
self.onchange_blanket_order_line()
return {"domain": {"blanket_order_line": [("id", "in", eligible_bo_lines.ids)]}}
@api.onchange("product_id", "order_partner_id")
def onchange_product_id(self):
# If product has changed remove the relation with blanket order line
if self.product_id:
return self.get_assigned_bo_line()
return
@api.onchange("product_uom", "product_uom_qty")
def product_uom_change(self):
if not self.product_uom or not self.product_id:
self.price_unit = 0.0
return
if self.order_id.pricelist_id and self.order_id.partner_id:
product = self.product_id.with_context(
lang=self.order_id.partner_id.lang,
partner=self.order_id.partner_id,
quantity=self.product_uom_qty,
date=self.order_id.date_order,
pricelist=self.order_id.pricelist_id.id,
uom=self.product_uom.id,
fiscal_position=self.env.context.get("fiscal_position"),
)
self.price_unit = product._get_tax_included_unit_price(
self.company_id or self.order_id.company_id,
self.order_id.currency_id,
self.order_id.date_order,
"sale",
fiscal_position=self.order_id.fiscal_position_id,
product_price_unit=self._get_display_price(),
product_currency=self.order_id.currency_id,
)
if self.product_id and not self.env.context.get("skip_blanket_find", False):
return self.get_assigned_bo_line()
return
@api.onchange("blanket_order_line")
def onchange_blanket_order_line(self):
bol = self.blanket_order_line
if bol:
self.product_id = bol.product_id
if bol.product_uom != self.product_uom:
price_unit = bol.product_uom._compute_price(
bol.price_unit, self.product_uom
)
else:
price_unit = bol.price_unit
self.price_unit = price_unit
if bol.taxes_id:
self.tax_id = bol.taxes_id
else:
if not self.tax_id:
self._compute_tax_id()
self.with_context(skip_blanket_find=True).product_uom_change()
@api.constrains("product_id")
def check_product_id(self):
for line in self:
if (
line.blanket_order_line
and line.product_id != line.blanket_order_line.product_id
):
raise ValidationError(
_(
"The product in the blanket order and in the "
"sales order must match"
)
)
@api.constrains("currency_id")
def check_currency(self):
for line in self:
if line.blanket_order_line:
if line.currency_id != line.blanket_order_line.order_id.currency_id:
raise ValidationError(
_(
"The currency of the blanket order must match with "
"that of the sale order."
)
)

View file

@ -0,0 +1,8 @@
* André Pereira <github@andreparames.com> (https://www.acsone.eu/)
* Adrià Gil Sorribes <adria.gil@eficent.com> (https://www.eficent.com/)
* Jordi Ballester Alomar <jordi.ballester@eficent.com>
* Alex Comba <alex.comba@agilebg.com> (https://www.agilebg.com/)
* Jasper Jumelet <jasper.jumelet@codeforward.nl> (https://www.codeforward.nl/)
* `Trobz <https://trobz.com>`_:
* Nguyễn Minh Chiến <chien@trobz.com>

View file

@ -0,0 +1 @@
The migration of this module from 15.0 to 16.0 was financially supported by Camptocamp

View file

@ -0,0 +1,4 @@
A blanket order is a pre-agreement to sell a certain number of quantities of
products at a specific price. From a confirmed blanket order, the users can
create new sale orders at such price, until the blanket order expires, either
due to reaching the validity date or exhausting all the quantities of products.

View file

@ -0,0 +1,53 @@
A new menu in the Sales area is created, allowing users to create new blanket orders.
To create a new Sale Blanket Order go to the sale menu in the Sales section:
.. figure:: ../static/description/BO_menu.png
:alt: Blanket Orders menu
Hitting the button create will open the form view in which we can introduce the following
information:
* Vendor
* Salesperson
* Payment Terms
* Validity date
* Order lines:
* Product
* Accorded price
* Original, Ordered, Invoiced, Received and Remaining quantities
* Terms and Conditions of the Blanket Order
.. figure:: ../static/description/BO_form.png
:alt: Blanket Orders form
From the form, once the Blanket Order has been confirmed and its state is open, the user can
create a Sale Order, check the Sale Orders associated to the Blanket Order and/or
see the Blanket Order lines associated to the BO.
.. figure:: ../static/description/BO_actions.png
:alt: Actions that can be done from Blanket Order
Hitting the button Create Sale Order will open a wizard that will ask for the amount of each
product in the BO lines for which the Sale Order will be created.
.. figure:: ../static/description/PO_from_BO.png
:alt: Create Sale Order from Blanket Order
Installing this module will add an additional menu which will show all the blanket order lines
currently defined in the system. From this list the user can create customized Sale Orders
selecting the lines for which the PO (or POs if the customers are different) is (are) created.
.. figure:: ../static/description/BO_lines.png
:alt: Blanket Order lines and actions
In the Sale Order form one field is added in the PO lines, the Blanket Order line field. This
field keeps track to which Blanket Order line the PO line is associated. Upon adding a new product
in a newly created Sale Order a blanket order line will be suggested depending on the following
factors:
* Closer Validity date
* Remaining quantity > Quantity introduced in the Sale Order line
.. figure:: ../static/description/PO_BOLine.png
:alt: New field added in Sale Order Line

View file

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<record id="report_blanket_order" model="ir.actions.report">
<field name="name">Blanket Order</field>
<field name="model">sale.blanket.order</field>
<field name="report_type">qweb-pdf</field>
<field name="report_name">sale_blanket_order.report_blanketorder</field>
<field name="report_file">sale_blanket_order.report_blanketorder</field>
<field name="print_report_name">'Blanket Order - %s' % object.name</field>
<field
name="binding_model_id"
ref="sale_blanket_order.model_sale_blanket_order"
/>
<field name="binding_type">report</field>
</record>
</odoo>

View file

@ -0,0 +1,180 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<template id="report_blanketorder_document">
<t t-call="web.external_layout">
<t t-set="doc" t-value="doc.with_context({'lang':doc.partner_id.lang})" />
<div class="page">
<div class="oe_structure" />
<div class="row">
<div class="col-xs-6">
<div
t-field="doc.partner_id"
t-options='{"widget": "contact", "fields": ["address", "name", "phone", "fax"], "no_marker": True, "phone_icons": True}'
/>
<p t-if="doc.partner_id.vat">VAT: <span
t-field="doc.partner_id.vat"
/></p>
</div>
</div>
<h2>
<span>Blanket Order # </span>
<span t-field="doc.name" />
</h2>
<div class="row mt32 mb32" id="informations">
<div t-if="doc.client_order_ref" class="col-3">
<strong>Your Reference:</strong>
<p t-field="doc.client_order_ref" />
</div>
<div class="col-3">
<strong>Validity Date:</strong>
<p t-field="doc.validity_date" />
</div>
<div t-if="doc.user_id.name" class="col-3">
<strong>Salesperson:</strong>
<p t-field="doc.user_id" />
</div>
<div t-if="doc.currency_id" class="col-3">
<strong>Currency:</strong>
<p t-field="doc.currency_id" />
</div>
</div>
<table class="table table-condensed table-borderless">
<thead>
<tr>
<th>Product</th>
<th class="text-end">Unit Price</th>
<th class="text-center">Scheduled Date</th>
<th class="text-end">Original Qty</th>
<th class="text-end">Amount</th>
</tr>
</thead>
<tbody class="sale_tbody">
<t t-set="current_subtotal" t-value="0" />
<t t-foreach="doc.line_ids" t-as="l">
<t
t-set="current_subtotal"
t-value="current_subtotal + l.price_subtotal"
groups="account.group_show_line_subtotals_tax_excluded"
/>
<t
t-set="current_subtotal"
t-value="current_subtotal + l.price_total"
groups="account.group_show_line_subtotals_tax_included"
/>
<tr
t-att-class="'bg-200 fw-bold o_line_section' if l.display_type == 'line_section' else 'fst-italic o_line_note' if l.display_type == 'line_note' else ''"
>
<t t-if="not l.display_type">
<td>
<span t-field="l.product_id" />
</td>
<td class="text-end">
<span t-field="l.price_unit" />
</td>
<td class="text-center">
<span t-field="l.date_schedule" />
</td>
<td class="text-end">
<span t-field="l.original_uom_qty" />
<span
t-field="l.product_uom"
groups="uom.group_uom"
/>
</td>
<td class="text-end">
<span
t-field="l.price_subtotal"
t-options='{"widget": "monetary", "display_currency": l.currency_id}'
/>
</td>
</t>
<t t-if="l.display_type == 'line_section'">
<td name="td_section_line" colspan="99">
<span t-field="l.name" />
</td>
<t t-set="current_section" t-value="l" />
<t t-set="current_subtotal" t-value="0" />
</t>
<t t-if="l.display_type == 'line_note'">
<td name="td_note_line" colspan="99">
<span t-field="l.name" />
</td>
</t>
</tr>
<t
t-if="current_section and (l_last or doc.line_ids[l_index+1].display_type == 'line_section')"
>
<tr class="is-subtotal text-end">
<td name="td_section_subtotal" colspan="99">
<strong class="mr16">Subtotal</strong>
<span
t-esc="current_subtotal"
t-options='{"widget": "monetary", "display_currency": doc.pricelist_id.currency_id}'
/>
</td>
</tr>
</t>
</t>
</tbody>
</table>
<div class="clearfix">
<div class="row">
<div
t-attf-class="#{'col-6' if report_type != 'html' else 'col-sm-7 col-md-6'} ms-auto"
>
<table class="table table-sm">
<tr class="border-black">
<td>
<strong>Subtotal</strong>
</td>
<td class="text-end">
<span
t-field="doc.amount_untaxed"
t-options='{"widget": "monetary", "display_currency": doc.currency_id}'
/>
</td>
</tr>
<tr>
<td>Taxes</td>
<td class="text-end">
<span
t-field="doc.amount_tax"
t-options='{"widget": "monetary", "display_currency": doc.currency_id}'
/>
</td>
</tr>
<tr class="border-black">
<td>
<strong>Total</strong>
</td>
<td class="text-end">
<span
t-field="doc.amount_total"
t-options='{"widget": "monetary", "display_currency": doc.currency_id}'
/>
</td>
</tr>
</table>
</div>
</div>
</div>
<p t-field="doc.note" />
<div class="oe_structure" />
</div>
</t>
</template>
<template id="report_blanketorder">
<t t-call="web.html_container">
<t t-foreach="docs" t-as="doc">
<t
t-call="sale_blanket_order.report_blanketorder_document"
t-lang="doc.partner_id.lang"
/>
</t>
</t>
</template>
</odoo>

View file

@ -0,0 +1,10 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_sale_blanket_order,sale.order,model_sale_blanket_order,sales_team.group_sale_salesman,1,1,1,0
access_sale_blanket_order_line,sale.order.line,model_sale_blanket_order_line,sales_team.group_sale_salesman,1,1,1,1
access_sale_blanket_order_manager,sale.order.manager,model_sale_blanket_order,sales_team.group_sale_manager,1,1,1,1
access_sale_blanket_order_line_manager,sale.order.line.manager,model_sale_blanket_order_line,sales_team.group_sale_manager,1,1,1,1
access_sale_blanket_order_accountant,sale.order.accountant,model_sale_blanket_order,account.group_account_user,1,1,0,0
access_sale_blanket_order_line_accountant,sale.order.line accountant,model_sale_blanket_order_line,account.group_account_user,1,1,0,0
access_sale_blanket_order_line_user,sale.order.line user,model_sale_blanket_order_line,base.group_user,1,0,0,0
access_sale_blanket_order_wizard_user,sale.blanket.order.wizard user,model_sale_blanket_order_wizard,base.group_user,1,1,1,1
access_sale_blanket_order_wizard_line_user,sale.blanket.order.wizard.line user,model_sale_blanket_order_wizard_line,base.group_user,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_sale_blanket_order sale.order model_sale_blanket_order sales_team.group_sale_salesman 1 1 1 0
3 access_sale_blanket_order_line sale.order.line model_sale_blanket_order_line sales_team.group_sale_salesman 1 1 1 1
4 access_sale_blanket_order_manager sale.order.manager model_sale_blanket_order sales_team.group_sale_manager 1 1 1 1
5 access_sale_blanket_order_line_manager sale.order.line.manager model_sale_blanket_order_line sales_team.group_sale_manager 1 1 1 1
6 access_sale_blanket_order_accountant sale.order.accountant model_sale_blanket_order account.group_account_user 1 1 0 0
7 access_sale_blanket_order_line_accountant sale.order.line accountant model_sale_blanket_order_line account.group_account_user 1 1 0 0
8 access_sale_blanket_order_line_user sale.order.line user model_sale_blanket_order_line base.group_user 1 0 0 0
9 access_sale_blanket_order_wizard_user sale.blanket.order.wizard user model_sale_blanket_order_wizard base.group_user 1 1 1 1
10 access_sale_blanket_order_wizard_line_user sale.blanket.order.wizard.line user model_sale_blanket_order_wizard_line base.group_user 1 1 1 1

View file

@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo noupdate="1">
<record model="res.groups" id="blanket_orders_disable_adding_lines">
<field name="name">Disable adding more lines to SOs from Blanket Orders</field>
</record>
<!-- Multi - Company Rules -->
<record model="ir.rule" id="blanket_order_comp_rule">
<field name="name">Blanket Order multi-company</field>
<field name="model_id" ref="model_sale_blanket_order" />
<field name="global" eval="True" />
<field
name="domain_force"
>['|',('company_id','=',False),('company_id', 'in', company_ids)]</field>
</record>
<record model="ir.rule" id="blanket_order_line_comp_rule">
<field name="name">Blanket Order Line multi-company</field>
<field name="model_id" ref="model_sale_blanket_order_line" />
<field name="global" eval="True" />
<field
name="domain_force"
>['|',('company_id','=',False),('company_id', 'in', company_ids)]</field>
</record>
</odoo>

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

View file

@ -0,0 +1,512 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<meta name="generator" content="Docutils: https://docutils.sourceforge.io/" />
<title>README.rst</title>
<style type="text/css">
/*
:Author: David Goodger (goodger@python.org)
:Id: $Id: html4css1.css 9511 2024-01-13 09:50:07Z milde $
:Copyright: This stylesheet has been placed in the public domain.
Default cascading style sheet for the HTML output of Docutils.
Despite the name, some widely supported CSS2 features are used.
See https://docutils.sourceforge.io/docs/howto/html-stylesheets.html for how to
customize this style sheet.
*/
/* used to remove borders from tables and images */
.borderless, table.borderless td, table.borderless th {
border: 0 }
table.borderless td, table.borderless th {
/* Override padding for "table.docutils td" with "! important".
The right padding separates the table cells. */
padding: 0 0.5em 0 0 ! important }
.first {
/* Override more specific margin styles with "! important". */
margin-top: 0 ! important }
.last, .with-subtitle {
margin-bottom: 0 ! important }
.hidden {
display: none }
.subscript {
vertical-align: sub;
font-size: smaller }
.superscript {
vertical-align: super;
font-size: smaller }
a.toc-backref {
text-decoration: none ;
color: black }
blockquote.epigraph {
margin: 2em 5em ; }
dl.docutils dd {
margin-bottom: 0.5em }
object[type="image/svg+xml"], object[type="application/x-shockwave-flash"] {
overflow: hidden;
}
/* Uncomment (and remove this text!) to get bold-faced definition list terms
dl.docutils dt {
font-weight: bold }
*/
div.abstract {
margin: 2em 5em }
div.abstract p.topic-title {
font-weight: bold ;
text-align: center }
div.admonition, div.attention, div.caution, div.danger, div.error,
div.hint, div.important, div.note, div.tip, div.warning {
margin: 2em ;
border: medium outset ;
padding: 1em }
div.admonition p.admonition-title, div.hint p.admonition-title,
div.important p.admonition-title, div.note p.admonition-title,
div.tip p.admonition-title {
font-weight: bold ;
font-family: sans-serif }
div.attention p.admonition-title, div.caution p.admonition-title,
div.danger p.admonition-title, div.error p.admonition-title,
div.warning p.admonition-title, .code .error {
color: red ;
font-weight: bold ;
font-family: sans-serif }
/* Uncomment (and remove this text!) to get reduced vertical space in
compound paragraphs.
div.compound .compound-first, div.compound .compound-middle {
margin-bottom: 0.5em }
div.compound .compound-last, div.compound .compound-middle {
margin-top: 0.5em }
*/
div.dedication {
margin: 2em 5em ;
text-align: center ;
font-style: italic }
div.dedication p.topic-title {
font-weight: bold ;
font-style: normal }
div.figure {
margin-left: 2em ;
margin-right: 2em }
div.footer, div.header {
clear: both;
font-size: smaller }
div.line-block {
display: block ;
margin-top: 1em ;
margin-bottom: 1em }
div.line-block div.line-block {
margin-top: 0 ;
margin-bottom: 0 ;
margin-left: 1.5em }
div.sidebar {
margin: 0 0 0.5em 1em ;
border: medium outset ;
padding: 1em ;
background-color: #ffffee ;
width: 40% ;
float: right ;
clear: right }
div.sidebar p.rubric {
font-family: sans-serif ;
font-size: medium }
div.system-messages {
margin: 5em }
div.system-messages h1 {
color: red }
div.system-message {
border: medium outset ;
padding: 1em }
div.system-message p.system-message-title {
color: red ;
font-weight: bold }
div.topic {
margin: 2em }
h1.section-subtitle, h2.section-subtitle, h3.section-subtitle,
h4.section-subtitle, h5.section-subtitle, h6.section-subtitle {
margin-top: 0.4em }
h1.title {
text-align: center }
h2.subtitle {
text-align: center }
hr.docutils {
width: 75% }
img.align-left, .figure.align-left, object.align-left, table.align-left {
clear: left ;
float: left ;
margin-right: 1em }
img.align-right, .figure.align-right, object.align-right, table.align-right {
clear: right ;
float: right ;
margin-left: 1em }
img.align-center, .figure.align-center, object.align-center {
display: block;
margin-left: auto;
margin-right: auto;
}
table.align-center {
margin-left: auto;
margin-right: auto;
}
.align-left {
text-align: left }
.align-center {
clear: both ;
text-align: center }
.align-right {
text-align: right }
/* reset inner alignment in figures */
div.align-right {
text-align: inherit }
/* div.align-center * { */
/* text-align: left } */
.align-top {
vertical-align: top }
.align-middle {
vertical-align: middle }
.align-bottom {
vertical-align: bottom }
ol.simple, ul.simple {
margin-bottom: 1em }
ol.arabic {
list-style: decimal }
ol.loweralpha {
list-style: lower-alpha }
ol.upperalpha {
list-style: upper-alpha }
ol.lowerroman {
list-style: lower-roman }
ol.upperroman {
list-style: upper-roman }
p.attribution {
text-align: right ;
margin-left: 50% }
p.caption {
font-style: italic }
p.credits {
font-style: italic ;
font-size: smaller }
p.label {
white-space: nowrap }
p.rubric {
font-weight: bold ;
font-size: larger ;
color: maroon ;
text-align: center }
p.sidebar-title {
font-family: sans-serif ;
font-weight: bold ;
font-size: larger }
p.sidebar-subtitle {
font-family: sans-serif ;
font-weight: bold }
p.topic-title {
font-weight: bold }
pre.address {
margin-bottom: 0 ;
margin-top: 0 ;
font: inherit }
pre.literal-block, pre.doctest-block, pre.math, pre.code {
margin-left: 2em ;
margin-right: 2em }
pre.code .ln { color: gray; } /* line numbers */
pre.code, code { background-color: #eeeeee }
pre.code .comment, code .comment { color: #5C6576 }
pre.code .keyword, code .keyword { color: #3B0D06; font-weight: bold }
pre.code .literal.string, code .literal.string { color: #0C5404 }
pre.code .name.builtin, code .name.builtin { color: #352B84 }
pre.code .deleted, code .deleted { background-color: #DEB0A1}
pre.code .inserted, code .inserted { background-color: #A3D289}
span.classifier {
font-family: sans-serif ;
font-style: oblique }
span.classifier-delimiter {
font-family: sans-serif ;
font-weight: bold }
span.interpreted {
font-family: sans-serif }
span.option {
white-space: nowrap }
span.pre {
white-space: pre }
span.problematic, pre.problematic {
color: red }
span.section-subtitle {
/* font-size relative to parent (h1..h6 element) */
font-size: 80% }
table.citation {
border-left: solid 1px gray;
margin-left: 1px }
table.docinfo {
margin: 2em 4em }
table.docutils {
margin-top: 0.5em ;
margin-bottom: 0.5em }
table.footnote {
border-left: solid 1px black;
margin-left: 1px }
table.docutils td, table.docutils th,
table.docinfo td, table.docinfo th {
padding-left: 0.5em ;
padding-right: 0.5em ;
vertical-align: top }
table.docutils th.field-name, table.docinfo th.docinfo-name {
font-weight: bold ;
text-align: left ;
white-space: nowrap ;
padding-left: 0 }
/* "booktabs" style (no vertical lines) */
table.docutils.booktabs {
border: 0px;
border-top: 2px solid;
border-bottom: 2px solid;
border-collapse: collapse;
}
table.docutils.booktabs * {
border: 0px;
}
table.docutils.booktabs th {
border-bottom: thin solid;
text-align: left;
}
h1 tt.docutils, h2 tt.docutils, h3 tt.docutils,
h4 tt.docutils, h5 tt.docutils, h6 tt.docutils {
font-size: 100% }
ul.auto-toc {
list-style-type: none }
</style>
</head>
<body>
<div class="document">
<a class="reference external image-reference" href="https://odoo-community.org/get-involved?utm_source=readme">
<img alt="Odoo Community Association" src="https://odoo-community.org/readme-banner-image" />
</a>
<div class="section" id="sale-blanket-orders">
<h1>Sale Blanket Orders</h1>
<!-- !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! This file is generated by oca-gen-addon-readme !!
!! changes will be overwritten. !!
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! source digest: sha256:2304d65f97e02bd0fec986276cb77baf60ddd090d6bc436ae7004c8276b7c666
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -->
<p><a class="reference external image-reference" href="https://odoo-community.org/page/development-status"><img alt="Beta" src="https://img.shields.io/badge/maturity-Beta-yellow.png" /></a> <a class="reference external image-reference" href="http://www.gnu.org/licenses/agpl-3.0-standalone.html"><img alt="License: AGPL-3" src="https://img.shields.io/badge/license-AGPL--3-blue.png" /></a> <a class="reference external image-reference" href="https://github.com/OCA/sale-workflow/tree/16.0/sale_blanket_order"><img alt="OCA/sale-workflow" src="https://img.shields.io/badge/github-OCA%2Fsale--workflow-lightgray.png?logo=github" /></a> <a class="reference external image-reference" href="https://translation.odoo-community.org/projects/sale-workflow-16-0/sale-workflow-16-0-sale_blanket_order"><img alt="Translate me on Weblate" src="https://img.shields.io/badge/weblate-Translate%20me-F47D42.png" /></a> <a class="reference external image-reference" href="https://runboat.odoo-community.org/builds?repo=OCA/sale-workflow&amp;target_branch=16.0"><img alt="Try me on Runboat" src="https://img.shields.io/badge/runboat-Try%20me-875A7B.png" /></a></p>
<p>A blanket order is a pre-agreement to sell a certain number of quantities of
products at a specific price. From a confirmed blanket order, the users can
create new sale orders at such price, until the blanket order expires, either
due to reaching the validity date or exhausting all the quantities of products.</p>
<p><strong>Table of contents</strong></p>
<div class="contents local topic" id="contents">
<ul class="simple">
<li><a class="reference internal" href="#usage" id="toc-entry-1">Usage</a></li>
<li><a class="reference internal" href="#bug-tracker" id="toc-entry-2">Bug Tracker</a></li>
<li><a class="reference internal" href="#credits" id="toc-entry-3">Credits</a><ul>
<li><a class="reference internal" href="#authors" id="toc-entry-4">Authors</a></li>
<li><a class="reference internal" href="#contributors" id="toc-entry-5">Contributors</a></li>
<li><a class="reference internal" href="#other-credits" id="toc-entry-6">Other credits</a></li>
<li><a class="reference internal" href="#maintainers" id="toc-entry-7">Maintainers</a></li>
</ul>
</li>
</ul>
</div>
<div class="section" id="usage">
<h2><a class="toc-backref" href="#toc-entry-1">Usage</a></h2>
<p>A new menu in the Sales area is created, allowing users to create new blanket orders.</p>
<p>To create a new Sale Blanket Order go to the sale menu in the Sales section:</p>
<div class="figure">
<img alt="Blanket Orders menu" src="https://raw.githubusercontent.com/OCA/sale-workflow/16.0/sale_blanket_order/static/description/BO_menu.png" />
</div>
<p>Hitting the button create will open the form view in which we can introduce the following
information:</p>
<ul class="simple">
<li>Vendor</li>
<li>Salesperson</li>
<li>Payment Terms</li>
<li>Validity date</li>
<li><dl class="first docutils">
<dt>Order lines:</dt>
<dd><ul class="first last">
<li>Product</li>
<li>Accorded price</li>
<li>Original, Ordered, Invoiced, Received and Remaining quantities</li>
</ul>
</dd>
</dl>
</li>
<li>Terms and Conditions of the Blanket Order</li>
</ul>
<div class="figure">
<img alt="Blanket Orders form" src="https://raw.githubusercontent.com/OCA/sale-workflow/16.0/sale_blanket_order/static/description/BO_form.png" />
</div>
<p>From the form, once the Blanket Order has been confirmed and its state is open, the user can
create a Sale Order, check the Sale Orders associated to the Blanket Order and/or
see the Blanket Order lines associated to the BO.</p>
<div class="figure">
<img alt="Actions that can be done from Blanket Order" src="https://raw.githubusercontent.com/OCA/sale-workflow/16.0/sale_blanket_order/static/description/BO_actions.png" />
</div>
<p>Hitting the button Create Sale Order will open a wizard that will ask for the amount of each
product in the BO lines for which the Sale Order will be created.</p>
<div class="figure">
<img alt="Create Sale Order from Blanket Order" src="https://raw.githubusercontent.com/OCA/sale-workflow/16.0/sale_blanket_order/static/description/PO_from_BO.png" />
</div>
<p>Installing this module will add an additional menu which will show all the blanket order lines
currently defined in the system. From this list the user can create customized Sale Orders
selecting the lines for which the PO (or POs if the customers are different) is (are) created.</p>
<div class="figure">
<img alt="Blanket Order lines and actions" src="https://raw.githubusercontent.com/OCA/sale-workflow/16.0/sale_blanket_order/static/description/BO_lines.png" />
</div>
<p>In the Sale Order form one field is added in the PO lines, the Blanket Order line field. This
field keeps track to which Blanket Order line the PO line is associated. Upon adding a new product
in a newly created Sale Order a blanket order line will be suggested depending on the following
factors:</p>
<ul class="simple">
<li>Closer Validity date</li>
<li>Remaining quantity &gt; Quantity introduced in the Sale Order line</li>
</ul>
<div class="figure">
<img alt="New field added in Sale Order Line" src="https://raw.githubusercontent.com/OCA/sale-workflow/16.0/sale_blanket_order/static/description/PO_BOLine.png" />
</div>
</div>
<div class="section" id="bug-tracker">
<h2><a class="toc-backref" href="#toc-entry-2">Bug Tracker</a></h2>
<p>Bugs are tracked on <a class="reference external" href="https://github.com/OCA/sale-workflow/issues">GitHub Issues</a>.
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
<a class="reference external" href="https://github.com/OCA/sale-workflow/issues/new?body=module:%20sale_blanket_order%0Aversion:%2016.0%0A%0A**Steps%20to%20reproduce**%0A-%20...%0A%0A**Current%20behavior**%0A%0A**Expected%20behavior**">feedback</a>.</p>
<p>Do not contact contributors directly about support or help with technical issues.</p>
</div>
<div class="section" id="credits">
<h2><a class="toc-backref" href="#toc-entry-3">Credits</a></h2>
<div class="section" id="authors">
<h3><a class="toc-backref" href="#toc-entry-4">Authors</a></h3>
<ul class="simple">
<li>Acsone SA/NV</li>
</ul>
</div>
<div class="section" id="contributors">
<h3><a class="toc-backref" href="#toc-entry-5">Contributors</a></h3>
<ul>
<li><p class="first">André Pereira &lt;<a class="reference external" href="mailto:github&#64;andreparames.com">github&#64;andreparames.com</a>&gt; (<a class="reference external" href="https://www.acsone.eu/">https://www.acsone.eu/</a>)</p>
</li>
<li><p class="first">Adrià Gil Sorribes &lt;<a class="reference external" href="mailto:adria.gil&#64;eficent.com">adria.gil&#64;eficent.com</a>&gt; (<a class="reference external" href="https://www.eficent.com/">https://www.eficent.com/</a>)</p>
</li>
<li><p class="first">Jordi Ballester Alomar &lt;<a class="reference external" href="mailto:jordi.ballester&#64;eficent.com">jordi.ballester&#64;eficent.com</a>&gt;</p>
</li>
<li><p class="first">Alex Comba &lt;<a class="reference external" href="mailto:alex.comba&#64;agilebg.com">alex.comba&#64;agilebg.com</a>&gt; (<a class="reference external" href="https://www.agilebg.com/">https://www.agilebg.com/</a>)</p>
</li>
<li><p class="first">Jasper Jumelet &lt;<a class="reference external" href="mailto:jasper.jumelet&#64;codeforward.nl">jasper.jumelet&#64;codeforward.nl</a>&gt; (<a class="reference external" href="https://www.codeforward.nl/">https://www.codeforward.nl/</a>)</p>
</li>
<li><p class="first"><a class="reference external" href="https://trobz.com">Trobz</a>:</p>
<blockquote>
<ul class="simple">
<li>Nguyễn Minh Chiến &lt;<a class="reference external" href="mailto:chien&#64;trobz.com">chien&#64;trobz.com</a>&gt;</li>
</ul>
</blockquote>
</li>
</ul>
</div>
<div class="section" id="other-credits">
<h3><a class="toc-backref" href="#toc-entry-6">Other credits</a></h3>
<p>The migration of this module from 15.0 to 16.0 was financially supported by Camptocamp</p>
</div>
<div class="section" id="maintainers">
<h3><a class="toc-backref" href="#toc-entry-7">Maintainers</a></h3>
<p>This module is maintained by the OCA.</p>
<a class="reference external image-reference" href="https://odoo-community.org">
<img alt="Odoo Community Association" src="https://odoo-community.org/logo.png" />
</a>
<p>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.</p>
<p>This module is part of the <a class="reference external" href="https://github.com/OCA/sale-workflow/tree/16.0/sale_blanket_order">OCA/sale-workflow</a> project on GitHub.</p>
<p>You are welcome to contribute. To learn how please visit <a class="reference external" href="https://odoo-community.org/page/Contribute">https://odoo-community.org/page/Contribute</a>.</p>
</div>
</div>
</div>
</div>
</body>
</html>

View file

@ -0,0 +1,4 @@
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
from . import test_blanket_orders
from . import test_sale_order

View file

@ -0,0 +1,426 @@
# Copyright (C) 2018 Eficent Business and IT Consulting Services S.L.
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
from datetime import date, timedelta
from odoo import fields
from odoo.exceptions import UserError
from odoo.tests import common
class TestSaleBlanketOrders(common.TransactionCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.blanket_order_obj = cls.env["sale.blanket.order"]
cls.blanket_order_line_obj = cls.env["sale.blanket.order.line"]
cls.blanket_order_wiz_obj = cls.env["sale.blanket.order.wizard"]
cls.so_obj = cls.env["sale.order"]
cls.payment_term = cls.env.ref("account.account_payment_term_immediate")
cls.sale_pricelist = cls.env["product.pricelist"].create(
{"name": "Test Pricelist", "currency_id": cls.env.ref("base.USD").id}
)
# UoM
cls.categ_unit = cls.env.ref("uom.product_uom_categ_unit")
cls.uom_dozen = cls.env["uom.uom"].create(
{
"name": "Test-DozenA",
"category_id": cls.categ_unit.id,
"factor_inv": 12,
"uom_type": "bigger",
"rounding": 0.001,
}
)
cls.partner = cls.env["res.partner"].create(
{
"name": "TEST CUSTOMER",
"property_product_pricelist": cls.sale_pricelist.id,
}
)
cls.product = cls.env["product.product"].create(
{
"name": "Demo",
"categ_id": cls.env.ref("product.product_category_1").id,
"standard_price": 35.0,
"type": "consu",
"uom_id": cls.env.ref("uom.product_uom_unit").id,
"default_code": "PROD_DEL01",
}
)
cls.product2 = cls.env["product.product"].create(
{
"name": "Demo 2",
"categ_id": cls.env.ref("product.product_category_1").id,
"standard_price": 50.0,
"type": "consu",
"uom_id": cls.env.ref("uom.product_uom_unit").id,
"default_code": "PROD_DEL02",
}
)
cls.yesterday = date.today() - timedelta(days=1)
cls.tomorrow = date.today() + timedelta(days=1)
def test_01_create_blanket_order(self):
"""We create a blanket order and check constrains to confirm BO"""
blanket_order = self.blanket_order_obj.create(
{
"partner_id": self.partner.id,
"validity_date": fields.Date.to_string(self.yesterday),
"payment_term_id": self.payment_term.id,
"pricelist_id": self.sale_pricelist.id,
"line_ids": [
(
0,
0,
{
"product_id": self.product.id,
"product_uom": self.product.uom_id.id,
"original_uom_qty": 20.0,
"price_unit": 0.0, # will be updated later
},
),
(
0,
0,
{
"name": "My section",
"display_type": "line_section",
},
),
],
}
)
blanket_order.sudo().onchange_partner_id()
blanket_order.pricelist_id.discount_policy = "without_discount"
blanket_order.line_ids[0].sudo().onchange_product()
blanket_order.pricelist_id.discount_policy = "with_discount"
blanket_order.line_ids[0].sudo().onchange_product()
blanket_order.line_ids[0].sudo()._get_display_price(self.product)
self.assertEqual(blanket_order.state, "draft")
# date in the past
with self.assertRaises(UserError):
blanket_order.sudo().action_confirm()
blanket_order.validity_date = fields.Date.to_string(self.tomorrow)
blanket_order.sudo().action_confirm()
self.assertEqual(blanket_order.state, "open")
blanket_order.sudo().action_cancel()
self.assertEqual(blanket_order.state, "expired")
blanket_order.sudo().set_to_draft()
self.assertEqual(blanket_order.state, "draft")
blanket_order.sudo().action_confirm()
def test_02_create_sale_orders_from_blanket_order(self):
"""We create a blanket order and create two sale orders"""
blanket_order = self.blanket_order_obj.create(
{
"partner_id": self.partner.id,
"validity_date": fields.Date.to_string(self.tomorrow),
"payment_term_id": self.payment_term.id,
"pricelist_id": self.sale_pricelist.id,
"line_ids": [
(
0,
0,
{
"product_id": False,
"product_uom": False,
"name": "My section",
"display_type": "line_section",
},
),
(
0,
0,
{
"product_id": self.product.id,
"product_uom": self.product.uom_id.id,
"original_uom_qty": 20.0,
"price_unit": 30.0,
},
),
],
}
)
blanket_order.sudo().onchange_partner_id()
blanket_order.sudo().action_confirm()
wizard1 = self.blanket_order_wiz_obj.with_context(
active_id=blanket_order.id, active_model="sale.blanket.order"
).create({})
wizard1.line_ids[0].write({"qty": 10.0})
wizard1.sudo().create_sale_order()
wizard2 = self.blanket_order_wiz_obj.with_context(
active_id=blanket_order.id, active_model="sale.blanket.order"
).create({})
wizard2.line_ids[0].write({"qty": 10.0})
wizard2.sudo().create_sale_order()
self.assertEqual(blanket_order.state, "done")
self.assertEqual(blanket_order.sale_count, 2)
view_action = blanket_order.action_view_sale_orders()
domain_ids = view_action["domain"][0][2]
self.assertEqual(len(domain_ids), 2)
sos = self.so_obj.browse(domain_ids)
for so in sos:
self.assertEqual(so.origin, blanket_order.name)
def test_03_create_sale_orders_from_blanket_order_line(self):
"""We create a blanket order and create two sale orders
from the blanket order lines"""
blanket_order = self.blanket_order_obj.create(
{
"partner_id": self.partner.id,
"validity_date": fields.Date.to_string(self.tomorrow),
"payment_term_id": self.payment_term.id,
"pricelist_id": self.sale_pricelist.id,
"line_ids": [
(
0,
0,
{
"product_id": self.product.id,
"product_uom": self.product.uom_id.id,
"original_uom_qty": 20.0,
"price_unit": 30.0,
},
),
(
0,
0,
{
"product_id": self.product2.id,
"product_uom": self.product2.uom_id.id,
"original_uom_qty": 50.0,
"price_unit": 60.0,
},
),
],
}
)
blanket_order.sudo().onchange_partner_id()
blanket_order.sudo().action_confirm()
bo_lines = blanket_order.line_ids
self.assertEqual(len(bo_lines), 2)
wizard1 = self.blanket_order_wiz_obj.with_context(
active_ids=[bo_lines[0].id, bo_lines[1].id]
).create({})
self.assertEqual(len(wizard1.line_ids), 2)
wizard1.line_ids[0].write({"qty": 10.0})
wizard1.line_ids[1].write({"qty": 20.0})
wizard1.sudo().create_sale_order()
self.assertEqual(bo_lines[0].remaining_uom_qty, 10.0)
self.assertEqual(bo_lines[1].remaining_uom_qty, 30.0)
def test_04_create_sale_order_add_blanket_order_line(self):
"""We create a blanket order and the separately we create
a sale order and see if blanket order lines have been
correctly assigned"""
blanket_order = self.blanket_order_obj.create(
{
"partner_id": self.partner.id,
"validity_date": fields.Date.to_string(self.tomorrow),
"payment_term_id": self.payment_term.id,
"pricelist_id": self.sale_pricelist.id,
"currency_id": self.sale_pricelist.currency_id.id,
"line_ids": [
(
0,
0,
{
"product_id": self.product.id,
"product_uom": self.product.uom_id.id,
"original_uom_qty": 20.0,
"price_unit": 30.0,
},
),
(
0,
0,
{
"product_id": self.product2.id,
"product_uom": self.product2.uom_id.id,
"original_uom_qty": 50.0,
"price_unit": 60.0,
},
),
],
}
)
blanket_order.sudo().onchange_partner_id()
blanket_order.sudo().action_confirm()
bo_lines = blanket_order.line_ids
sale_order = self.so_obj.create(
{
"partner_id": self.partner.id,
"payment_term_id": self.payment_term.id,
"pricelist_id": self.sale_pricelist.id,
"order_line": [
(
0,
0,
{
"product_id": self.product.id,
"product_uom": self.product.uom_id.id,
"product_uom_qty": 10.0,
"price_unit": 30.0,
},
),
(
0,
0,
{
"product_id": self.product2.id,
"product_uom": self.product2.uom_id.id,
"product_uom_qty": 50.0,
"price_unit": 60.0,
},
),
],
}
)
sale_order.order_line[0].onchange_product_id()
self.assertEqual(bo_lines[0].remaining_uom_qty, 10.0)
def test_05_create_sale_order_blanket_order_with_different_uom(self):
"""We create a blanket order and the separately we create
a sale order with different uom and see if blanket order
lines have been correctly assigned"""
blanket_order = self.blanket_order_obj.create(
{
"partner_id": self.partner.id,
"validity_date": fields.Date.to_string(self.tomorrow),
"payment_term_id": self.payment_term.id,
"pricelist_id": self.sale_pricelist.id,
"line_ids": [
(
0,
0,
{
"product_id": self.product.id,
"product_uom": self.uom_dozen.id,
"original_uom_qty": 2.0,
"price_unit": 240.0,
},
)
],
}
)
blanket_order.sudo().onchange_partner_id()
blanket_order.sudo().action_confirm()
sale_order = self.so_obj.create(
{
"partner_id": self.partner.id,
"payment_term_id": self.payment_term.id,
"pricelist_id": self.sale_pricelist.id,
"order_line": [
(
0,
0,
{
"product_id": self.product.id,
"product_uom": self.product.uom_id.id,
"product_uom_qty": 12.0,
"price_unit": 30.0,
},
)
],
}
)
sale_order.order_line[0].onchange_product_id()
sale_order.order_line[0].onchange_blanket_order_line()
self.assertEqual(blanket_order.line_ids[0].remaining_qty, 12.0)
self.assertEqual(sale_order.order_line[0].price_unit, 20.0)
def test_06_create_sale_orders_from_blanket_order(self):
"""We create a blanket order and create three sale orders
where the first two consume the first blanket order line
"""
blanket_order = self.blanket_order_obj.create(
{
"partner_id": self.partner.id,
"validity_date": fields.Date.to_string(self.tomorrow),
"payment_term_id": self.payment_term.id,
"pricelist_id": self.sale_pricelist.id,
"line_ids": [
(
0,
0,
{
"product_id": self.product.id,
"product_uom": self.product.uom_id.id,
"original_uom_qty": 30.0,
"price_unit": 30.0,
},
),
(
0,
0,
{
"product_id": self.product2.id,
"product_uom": self.product2.uom_id.id,
"original_uom_qty": 20.0,
"price_unit": 60.0,
},
),
],
}
)
blanket_order.sudo().onchange_partner_id()
blanket_order.sudo().action_confirm()
wizard1 = self.blanket_order_wiz_obj.with_context(
active_id=blanket_order.id, active_model="sale.blanket.order"
).create({})
wizard1.line_ids.filtered(lambda l: l.product_id == self.product).write(
{"qty": 10.0}
)
wizard1.line_ids.filtered(lambda l: l.product_id == self.product2).write(
{"qty": 10.0}
)
wizard1.sudo().create_sale_order()
wizard2 = self.blanket_order_wiz_obj.with_context(
active_id=blanket_order.id, active_model="sale.blanket.order"
).create({})
wizard2.line_ids.filtered(lambda l: l.product_id == self.product).write(
{"qty": 20.0}
)
wizard2.line_ids.filtered(lambda l: l.product_id == self.product2).write(
{"qty": 0}
)
wizard2.sudo().create_sale_order()
wizard3 = self.blanket_order_wiz_obj.with_context(
active_id=blanket_order.id, active_model="sale.blanket.order"
).create({})
wizard3.line_ids.filtered(lambda l: l.product_id == self.product2).write(
{"qty": 10.0}
)
wizard3.sudo().create_sale_order()
self.assertEqual(blanket_order.state, "done")
self.assertEqual(blanket_order.sale_count, 3)
view_action = blanket_order.action_view_sale_orders()
domain_ids = view_action["domain"][0][2]
self.assertEqual(len(domain_ids), 3)

View file

@ -0,0 +1,255 @@
# Copyright (C) 2018 Eficent Business and IT Consulting Services S.L.
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
from datetime import date, timedelta
from odoo import fields
from odoo.tests import common
class TestSaleOrder(common.TransactionCase):
def setUp(self):
super().setUp()
self.blanket_order_obj = self.env["sale.blanket.order"]
self.blanket_order_line_obj = self.env["sale.blanket.order.line"]
self.sale_order_obj = self.env["sale.order"]
self.sale_order_line_obj = self.env["sale.order.line"]
self.partner = self.env["res.partner"].create({"name": "TEST CUSTOMER"})
self.payment_term = self.env.ref("account.account_payment_term_immediate")
self.sale_pricelist = self.env["product.pricelist"].create(
{"name": "Test Pricelist", "currency_id": self.env.ref("base.USD").id}
)
self.product = self.env["product.product"].create(
{
"name": "Demo",
"categ_id": self.env.ref("product.product_category_1").id,
"standard_price": 40.0,
"type": "consu",
"uom_id": self.env.ref("uom.product_uom_unit").id,
"default_code": "PROD_DEL01",
}
)
self.product_2 = self.env["product.product"].create(
{
"name": "Demo 2",
"categ_id": self.env.ref("product.product_category_1").id,
"standard_price": 35.0,
"type": "consu",
"uom_id": self.env.ref("uom.product_uom_unit").id,
"default_code": "PROD_DEL02",
}
)
self.validity = date.today() + timedelta(days=365)
self.date_schedule_1 = date.today() + timedelta(days=10)
self.date_schedule_2 = date.today() + timedelta(days=20)
def create_blanket_order_01(self):
blanket_order = self.blanket_order_obj.create(
{
"partner_id": self.partner.id,
"validity_date": fields.Date.to_string(self.validity),
"payment_term_id": self.payment_term.id,
"pricelist_id": self.sale_pricelist.id,
"line_ids": [
(
0,
0,
{
"product_id": self.product.id,
"product_uom": self.product.uom_id.id,
"date_schedule": fields.Date.to_string(
self.date_schedule_1
),
"original_uom_qty": 20.0,
"price_unit": 30.0,
},
),
(
0,
0,
{
"product_id": self.product.id,
"product_uom": self.product.uom_id.id,
"date_schedule": fields.Date.to_string(
self.date_schedule_2
),
"original_uom_qty": 20.0,
"price_unit": 30.0,
},
),
],
}
)
blanket_order.sudo().onchange_partner_id()
return blanket_order
def create_blanket_order_02(self):
blanket_order = self.blanket_order_obj.create(
{
"partner_id": self.partner.id,
"validity_date": fields.Date.to_string(self.validity),
"payment_term_id": self.payment_term.id,
"pricelist_id": self.sale_pricelist.id,
"line_ids": [
(
0,
0,
{
"product_id": self.product.id,
"product_uom": self.product.uom_id.id,
"original_uom_qty": 20.0,
"price_unit": 30.0,
},
),
(
0,
0,
{
"product_id": self.product_2.id,
"product_uom": self.product.uom_id.id,
"original_uom_qty": 20.0,
"price_unit": 30.0,
},
),
],
}
)
blanket_order.sudo().onchange_partner_id()
return blanket_order
def test_01_create_sale_order(self):
blanket_order = self.create_blanket_order_01()
blanket_order.sudo().action_confirm()
bo_lines = self.blanket_order_line_obj.search(
[("order_id", "=", blanket_order.id)]
)
self.assertEqual(len(bo_lines), 2)
so = self.sale_order_obj.create(
{
"partner_id": self.partner.id,
"order_line": [
(
0,
0,
{
"name": self.product.name,
"product_id": self.product.id,
"product_uom_qty": 5.0,
"product_uom": self.product.uom_po_id.id,
"price_unit": 10.0,
},
)
],
}
)
so_line = so.order_line[0]
so_line.with_context(from_sale_order=True).name_get()
so_line.onchange_product_id()
self.assertEqual(so_line._get_eligible_bo_lines(), bo_lines)
bo_line_assigned = self.blanket_order_line_obj.search(
[("date_schedule", "=", fields.Date.to_string(self.date_schedule_1))]
)
self.assertEqual(so_line.blanket_order_line, bo_line_assigned)
def test_02_create_sale_order(self):
blanket_order = self.create_blanket_order_02()
blanket_order.sudo().action_confirm()
bo_lines = self.blanket_order_line_obj.search(
[("order_id", "=", blanket_order.id)]
)
self.assertEqual(len(bo_lines), 2)
so = self.sale_order_obj.create(
{
"partner_id": self.partner.id,
"order_line": [
(
0,
0,
{
"name": self.product.name,
"product_id": self.product.id,
"product_uom_qty": 5.0,
"product_uom": self.product.uom_po_id.id,
"price_unit": 10.0,
},
)
],
}
)
so_line = so.order_line[0]
so_line.with_context(from_sale_order=True).name_get()
so_line.onchange_product_id()
self.assertEqual(
so_line._get_eligible_bo_lines(),
bo_lines.filtered(lambda l: l.product_id == self.product),
)
bo_line_assigned = self.blanket_order_line_obj.search(
[
("order_id", "=", blanket_order.id),
("product_id", "=", self.product.id),
("date_schedule", "=", False),
]
)
self.assertEqual(so_line.blanket_order_line, bo_line_assigned)
def test_03_create_sale_order(self):
blanket_order = self.create_blanket_order_01()
blanket_order.sudo().action_confirm()
bo_lines = self.blanket_order_line_obj.search(
[("order_id", "=", blanket_order.id)]
)
self.assertEqual(len(bo_lines), 2)
so = self.sale_order_obj.create(
{
"partner_id": self.partner.id,
"order_line": [
(
0,
0,
{
"name": self.product_2.name,
"product_id": self.product_2.id,
"product_uom_qty": 5.0,
"product_uom": self.product_2.uom_po_id.id,
"price_unit": 10.0,
},
),
(
0,
0,
{
"name": self.product.name,
"product_id": self.product.id,
"product_uom_qty": 5.0,
"product_uom": self.product.uom_po_id.id,
"price_unit": 10.0,
},
),
],
}
)
so_line_1 = so.order_line[0]
so_line_2 = so.order_line[1]
so_line_1.with_context(from_sale_order=True).name_get()
so_line_1.onchange_product_id()
self.assertFalse(so_line_1._get_eligible_bo_lines())
so_line_2.with_context(from_sale_order=True).name_get()
so_line_2.onchange_product_id()
self.assertEqual(
so_line_2._get_eligible_bo_lines(),
bo_lines.filtered(lambda l: l.product_id == self.product),
)
bo_line_assigned = self.blanket_order_line_obj.search(
[
("order_id", "=", blanket_order.id),
("product_id", "=", self.product.id),
("date_schedule", "=", fields.Date.to_string(self.date_schedule_1)),
]
)
self.assertFalse(so_line_1.blanket_order_line)
self.assertEqual(so_line_2.blanket_order_line, bo_line_assigned)
self.assertEqual(so.blanket_order_id, blanket_order)

View file

@ -0,0 +1,180 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<!-- VIEWS -->
<record id="sale_blanket_order_line_tree" model="ir.ui.view">
<field name="name">sale.blanket.order.line.tree</field>
<field name="model">sale.blanket.order.line</field>
<field name="arch" type="xml">
<tree create="false">
<field name="sequence" widget="handle" />
<field name="name" invisible="1" />
<field name="order_id" />
<field
name="product_id"
context="{'partner_id':parent.partner_id, 'quantity':original_uom_qty, 'company_id': company_id}"
/>
<field name="product_uom" invisible="1" />
<field name="price_unit" />
<field name="date_schedule" />
<field
name="original_uom_qty"
string="Original Qty"
context="{'partner_id':parent.partner_id, 'quantity':original_uom_qty, 'company_id': company_id}"
/>
<field name="ordered_uom_qty" />
<field name="invoiced_uom_qty" />
<field name="delivered_uom_qty" />
<field name="remaining_uom_qty" />
<field name="company_id" invisible="1" />
</tree>
</field>
</record>
<record id="sale_blanket_order_line_form" model="ir.ui.view">
<field name="name">sale.blanket.order.line.form</field>
<field name="model">sale.blanket.order.line</field>
<field name="priority" eval="20" />
<field name="arch" type="xml">
<form string="Sale Blanket Order Line" duplicate="false">
<sheet>
<h1>
<field name="order_id" />
</h1>
<group>
<group>
<field name="product_id" readonly="1" />
<field name="price_unit" readonly="1" />
<field name="partner_id" readonly="1" />
<field name="date_schedule" readonly="1" />
<field
name="company_id"
groups="base.group_multi_company"
optional="show"
options="{'no_create': True}"
/>
</group>
<group>
<label for="original_uom_qty" />
<div>
<field
name="original_uom_qty"
class="oe_inline"
readonly="1"
/>
<field
name="product_uom"
groups="uom.group_uom"
class="oe_inline"
readonly="1"
/>
</div>
<label for="ordered_uom_qty" />
<div>
<field
name="ordered_uom_qty"
class="oe_inline"
readonly="1"
/>
<field
name="product_uom"
groups="uom.group_uom"
class="oe_inline"
readonly="1"
/>
</div>
<label for="invoiced_uom_qty" />
<div>
<field
name="invoiced_uom_qty"
class="oe_inline"
readonly="1"
/>
<field
name="product_uom"
groups="uom.group_uom"
class="oe_inline"
readonly="1"
/>
</div>
<label for="remaining_uom_qty" />
<div>
<field
name="remaining_uom_qty"
class="oe_inline"
readonly="1"
/>
<field
name="product_uom"
groups="uom.group_uom"
class="oe_inline"
readonly="1"
/>
</div>
<label for="remaining_qty" />
<div>
<field
name="remaining_qty"
class="oe_inline"
readonly="1"
/>
</div>
</group>
</group>
<notebook>
<page name="sale_lines" string="Sale Order Lines">
<field
name="sale_lines"
mode="tree"
readonly="1"
domain="[('product_id', '=', product_id)]"
/>
</page>
</notebook>
</sheet>
<div class="oe_chatter">
<field name="message_follower_ids" widget="mail_followers" />
<field name="activity_ids" widget="mail_activity" />
<field name="message_ids" widget="mail_thread" />
</div>
</form>
</field>
</record>
<record id="sale_blanket_order_line_search" model="ir.ui.view">
<field name="name">sale.blanket.order.line.search</field>
<field name="model">sale.blanket.order.line</field>
<field name="arch" type="xml">
<search string="Search Sale Blanket Order Line">
<field name="order_id" />
<field name="product_id" />
<field name="date_schedule" />
</search>
</field>
</record>
<!-- ACTIONS (SERVER) -->
<record
id="act_open_sale_blanket_order_lines_view_tree"
model="ir.actions.act_window"
>
<field name="name">Blanket Order Lines</field>
<field name="type">ir.actions.act_window</field>
<field name="res_model">sale.blanket.order.line</field>
<field name="view_mode">tree,form</field>
<field name="search_view_id" ref="sale_blanket_order_line_search" />
</record>
<!-- MENU'S -->
<menuitem
action="act_open_sale_blanket_order_lines_view_tree"
id="menu_sale_blanket_order_line"
groups="sales_team.group_sale_salesman"
sequence="21"
parent="sale.sale_order_menu"
/>
</odoo>

View file

@ -0,0 +1,382 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<!-- VIEWS -->
<record id="view_blanket_order_tree" model="ir.ui.view">
<field name="name">sale.blanket.order.tree</field>
<field name="model">sale.blanket.order</field>
<field name="arch" type="xml">
<tree
decoration-info="state == 'draft'"
decoration-muted="state == 'expired'"
>
<field name="name" />
<field name="user_id" />
<field name="partner_id" />
<field name="validity_date" />
<field name="state" />
<field
name="company_id"
groups="base.group_multi_company"
optional="show"
readonly="1"
/>
</tree>
</field>
</record>
<record id="view_blanket_order_form" model="ir.ui.view">
<field name="name">sale.blanket.order.form</field>
<field name="model">sale.blanket.order</field>
<field name="arch" type="xml">
<form string="Blanket Order" class="o_sale_order">
<header>
<button
name="%(action_create_sale_order)d"
string="Create Sale Order"
type="action"
class="btn-primary"
attrs="{'invisible': [('state', '!=', 'open')]}"
/>
<button
name="set_to_draft"
states="expired,done"
string="To Draft"
class="btn-secondary"
type="object"
/>
<button
name="action_cancel"
states="open"
string="Cancel"
class="btn-secondary"
type="object"
/>
<button
name="action_confirm"
states="draft"
string="Confirm"
class="btn-primary o_sale_confirm"
type="object"
/>
<field
name="state"
widget="statusbar"
statusbar_visible="draft,open,done,expired"
/>
</header>
<sheet>
<div class="oe_button_box" name="button_box">
<button
name="action_view_sale_orders"
type="object"
class="oe_stat_button"
icon="fa-list-alt"
>
<field
name="sale_count"
widget="statinfo"
string="Sale Orders"
/>
</button>
<button
type="object"
name="action_view_sale_blanket_order_line"
class="oe_stat_button"
attrs="{'invisible':[('line_count', '=', 0)]}"
icon="fa-list"
>
<field name="line_count" widget="statinfo" string="Lines" />
</button>
</div>
<div class="oe_title">
<h1>
<field name="name" readonly="1" />
</h1>
</div>
<group>
<group name="group_left">
<field
name="partner_id"
context="{'res_partner_search_mode': 'customer', 'show_address': 1}"
attrs="{'required': [('state', '=', 'draft')]}"
options="{&quot;always_reload&quot;: True}"
/>
<field name="payment_term_id" />
</group>
<group name="group_right">
<field
name="currency_id"
groups="base.group_multi_currency"
options="{'no_create': True}"
/>
<field name="company_id" invisible="1" />
<field
name="validity_date"
attrs="{'required': [('state', '=', 'draft')]}"
/>
</group>
</group>
<notebook>
<page string="Order Lines" name="order_lines">
<field
name="line_ids"
attrs="{'readonly': [('state', 'in', ('open','done','expired'))]}"
widget="section_and_note_one2many"
>
<tree editable="bottom">
<control>
<create
name="add_product_control"
string="Add a product"
/>
<create
name="add_section_control"
string="Add a section"
context="{'default_display_type': 'line_section'}"
/>
<create
name="add_note_control"
string="Add a note"
context="{'default_display_type': 'line_note'}"
/>
</control>
<field name="display_type" invisible="1" />
<field name="sequence" widget="handle" />
<field
name="product_id"
attrs="{
'required': [('display_type', '=', False)],
'invisible': [('display_type', '=', True)],
}"
context="{
'fld': 'product_id',
'partner_id': parent.partner_id,
'quantity': original_uom_qty,
'company_id': parent.company_id
}"
/>
<field
name="name"
widget="section_and_note_text"
optional="show"
/>
<field
name="analytic_distribution"
widget="analytic_distribution"
optional="hide"
groups="analytic.group_analytic_accounting"
options="{
'product_field': 'product_id',
'business_domain': 'sale_order'
}"
/>
<field
name="original_uom_qty"
string="Original Qty"
context="{
'fld': 'original_uom_qty',
'partner_id': parent.partner_id,
'quantity': original_uom_qty,
'company_id': parent.company_id
}"
attrs="{
'required': [('display_type', '=', False)],
'invisible': [('display_type', '=', True)],
}"
/>
<field
name="product_uom"
invisible="1"
groups="!uom.group_uom"
/>
<field
name="product_uom"
groups="uom.group_uom"
optional="show"
/>
<field
name="price_unit"
attrs="{
'required': [('display_type', '=', False)],
'invisible': [('display_type', '=', True)],
}"
/>
<field name="date_schedule" />
<field name="ordered_uom_qty" optional="hide" />
<field name="invoiced_uom_qty" optional="hide" />
<field name="delivered_uom_qty" optional="hide" />
<field name="remaining_uom_qty" optional="hide" />
<field
name="taxes_id"
widget="many2many_tags"
domain="[('type_tax_use','=','sale')]"
context="{'default_type_tax_use': 'sale'}"
options="{'no_create': True}"
attrs="{
'invisible': [('display_type', '=', True)],
}"
/>
<field
name="price_subtotal"
widget="monetary"
attrs="{
'invisible': [('display_type', '=', True)],
}"
/>
<field name="company_id" invisible="1" />
</tree>
</field>
<group class="oe_subtotal_footer oe_right">
<field
name="amount_untaxed"
widget="monetary"
options="{'currency_field': 'currency_id'}"
/>
<field
name="amount_tax"
widget="monetary"
options="{'currency_field': 'currency_id'}"
/>
<label for="amount_total" />
<div class="oe_subtotal_footer_separator">
<field
name="amount_total"
nolabel="1"
class="oe_subtotal_footer_separator"
widget="monetary"
options="{'currency_field': 'currency_id'}"
/>
</div>
</group>
<group string="Terms and Conditions">
<field
name="note"
nolabel="1"
colspan="2"
placeholder="Setup default terms and conditions in your company settings."
/>
</group>
<div class="oe_clear" />
</page>
<page string="Other Information" name="other_information">
<group>
<group string="Sales Information" name="sales_person">
<field
name="pricelist_id"
attrs="{'required': [('state', '=', 'draft')]}"
/>
<field name="user_id" />
<field
name="team_id"
options="{'no_create': True}"
/>
<field name="client_order_ref" />
<field
name="company_id"
options="{'no_create': True}"
groups="base.group_multi_company"
/>
</group>
<group name="invoicing" string="Invoicing">
<t groups="base.group_multi_company">
<field
name="analytic_account_id"
attrs="{'readonly': [('sale_count','!=',0),('state','!=','draft')]}"
groups="analytic.group_analytic_accounting"
force_save="1"
/>
</t>
</group>
</group>
</page>
</notebook>
</sheet>
<div class="oe_chatter">
<field name="message_follower_ids" />
<field name="activity_ids" />
<field name="message_ids" />
</div>
</form>
</field>
</record>
<record id="view_blanket_order_search" model="ir.ui.view">
<field name="name">sale.blanket.order.search</field>
<field name="model">sale.blanket.order</field>
<field name="arch" type="xml">
<search>
<filter
name="state_open"
string="Open"
domain="[('state','=', 'open')]"
/>
<filter
name="original_uom_qty"
string="Original Qty"
domain="[('original_uom_qty','>',0.0)]"
/>
<filter
name="ordered_uom_qty"
string="Ordered Qty"
domain="[('ordered_uom_qty','>',0.0)]"
/>
<filter
name="invoiced_uom_qty"
string="Invoiced Qty"
domain="[('invoiced_uom_qty','>',0.0)]"
/>
<filter
name="received_uom_qty"
string="Delivered Qty"
domain="[('delivered_uom_qty','>',0.0)]"
/>
<filter
name="remaining_uom_qty"
string="Remaining Qty"
domain="[('remaining_uom_qty','>',0.0)]"
/>
<group>
<field name="name" />
<field name="partner_id" />
<field name="product_id" />
<field name="state" />
</group>
</search>
</field>
</record>
<!-- ACTIONS (SERVER) -->
<record model="ir.actions.act_window" id="act_open_blanket_order_view">
<field name="name">Blanket Orders</field>
<field name="type">ir.actions.act_window</field>
<field name="res_model">sale.blanket.order</field>
<field name="view_mode">tree,form</field>
<field name="search_view_id" ref="view_blanket_order_search" />
<field name="domain">[]</field>
<field name="context">{}</field>
<field name="help" type="html">
<p class="oe_view_nocontent_create">
Click to create a blanket order that can be converted into a sale order.
</p>
<p>
Use this menu to search within your blanket orders. For each blanket order,
you can track the related discussion with the customer, control
the products delivered and control the vendor bills.
</p>
</field>
</record>
<!-- MENU'S -->
<menuitem
id="menu_blanket_order_config"
parent="sale.sale_order_menu"
groups="sales_team.group_sale_manager"
sequence="20"
action="act_open_blanket_order_view"
/>
</odoo>

View file

@ -0,0 +1,31 @@
<?xml version="1.0" encoding="utf-8" ?>
<!-- Copyright 2018 ACSONE SA/NV
License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). -->
<odoo>
<record model="ir.ui.view" id="sale_config_settings_form_view">
<field name="name">res.config.settings.form (in sale_blanket_order)</field>
<field name="model">res.config.settings</field>
<field name="inherit_id" ref="sale.res_config_settings_view_form" />
<field name="arch" type="xml">
<xpath
expr="//div[hasclass('row','mt16','o_settings_container')][position() = 3]"
position="after"
>
<h2>Blanket Orders</h2>
<div class="row mt16 o_settings_container">
<div class="col-xs-12 col-md-6 o_setting_box">
<div class="o_setting_left_pane">
<field name="group_blanket_disable_adding_lines" />
</div>
<div class="o_setting_right_pane">
<label for="group_blanket_disable_adding_lines" />
<div class="text-muted" id="sale_config_website_quote">
Disable adding more lines to SOs from Blanket Orders
</div>
</div>
</div>
</div>
</xpath>
</field>
</record>
</odoo>

View file

@ -0,0 +1,39 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<record id="view_order_form" model="ir.ui.view">
<field name="name">sale.order.from.blanket.form</field>
<field name="model">sale.order</field>
<field name="inherit_id" ref="sale.view_order_form" />
<field name="arch" type="xml">
<field name="currency_id" position="after">
<field name="blanket_order_id" invisible="1" />
</field>
<xpath
expr="//field[@name='order_line']//tree/field[@name='product_id']"
position="after"
>
<field
name="blanket_order_line"
context="{'from_sale_order': True}"
attrs="{'column_invisible': [('parent.blanket_order_id', '=', False)]}"
/>
</xpath>
<xpath expr="//field[@name='order_line']" position="attributes">
<attribute name="context">{'from_sale_order': True}</attribute>
</xpath>
</field>
</record>
<record id="view_order_form_disable_adding_lines" model="ir.ui.view">
<field name="name">sale.order.from.blanket.form - disable adding lines</field>
<field name="model">sale.order</field>
<field name="inherit_id" ref="sale.view_order_form" />
<field name="arch" type="xml">
<xpath expr="//field[@name='order_line']" position="before">
<field name="disable_adding_lines" invisible="1" />
</xpath>
<xpath expr="//field[@name='order_line']//tree" position="attributes">
<attribute name="create">not disable_adding_lines</attribute>
</xpath>
</field>
</record>
</odoo>

View file

@ -0,0 +1,3 @@
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
from . import create_sale_orders

View file

@ -0,0 +1,220 @@
# Copyright 2018 ACSONE SA/NV
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from collections import defaultdict
from odoo import _, api, fields, models
from odoo.exceptions import UserError
from odoo.tools import float_is_zero
class BlanketOrderWizard(models.TransientModel):
_name = "sale.blanket.order.wizard"
_description = "Blanket order wizard"
@api.model
def _default_order(self):
# in case the cron hasn't run
self.env["sale.blanket.order"].expire_orders()
if not self.env.context.get("active_id"):
return False
blanket_order = self.env["sale.blanket.order"].search(
[("id", "=", self.env.context["active_id"])], limit=1
)
if blanket_order.state == "expired":
raise UserError(
_("You can't create a sale order from " "an expired blanket order!")
)
return blanket_order
@api.model
def _check_valid_blanket_order_line(self, bo_lines):
precision = self.env["decimal.precision"].precision_get(
"Product Unit of Measure"
)
company_id = False
if all(
float_is_zero(line.remaining_uom_qty, precision_digits=precision)
for line in bo_lines
):
raise UserError(_("The sale has already been completed."))
for line in bo_lines:
if line.order_id.state != "open":
raise UserError(
_("Sale Blanket Order %s is not open") % line.order_id.name
)
line_company_id = line.company_id and line.company_id.id or False
if company_id is not False and line_company_id != company_id:
raise UserError(_("You have to select lines " "from the same company."))
else:
company_id = line_company_id
@api.model
def _default_lines(self):
blanket_order_line_obj = self.env["sale.blanket.order.line"]
blanket_order_line_ids = self.env.context.get("active_ids", False)
active_model = self.env.context.get("active_model", False)
if active_model == "sale.blanket.order":
bo_lines = self._default_order().line_ids
else:
bo_lines = blanket_order_line_obj.browse(blanket_order_line_ids)
self._check_valid_blanket_order_line(bo_lines)
lines = [
(
0,
0,
{
"blanket_line_id": bol.id,
"product_id": bol.product_id.id,
"date_schedule": bol.date_schedule,
"remaining_uom_qty": bol.remaining_uom_qty,
"price_unit": bol.price_unit,
"product_uom": bol.product_uom,
"qty": bol.remaining_uom_qty,
"partner_id": bol.partner_id,
},
)
for bol in bo_lines.filtered(
lambda l: not l.display_type and l.remaining_uom_qty != 0.0
)
]
return lines
blanket_order_id = fields.Many2one(
comodel_name="sale.blanket.order",
readonly=True,
default=lambda self: self._default_order(),
)
sale_order_id = fields.Many2one(
"sale.order", string="Purchase Order", domain=[("state", "=", "draft")]
)
line_ids = fields.One2many(
"sale.blanket.order.wizard.line",
"wizard_id",
string="Lines",
default=_default_lines,
)
def _prepare_so_line_vals(self, line):
return {
"product_id": line.product_id.id,
"name": line.product_id.name,
"product_uom": line.product_uom.id,
"sequence": line.blanket_line_id.sequence,
"price_unit": line.blanket_line_id.price_unit,
"blanket_order_line": line.blanket_line_id.id,
"product_uom_qty": line.qty,
"tax_id": [(6, 0, line.taxes_id.ids)],
}
def _prepare_so_vals(
self,
customer,
user_id,
currency_id,
pricelist_id,
payment_term_id,
order_lines_by_customer,
):
return {
"partner_id": customer,
"origin": self.blanket_order_id.name,
"user_id": user_id,
"currency_id": currency_id,
"pricelist_id": pricelist_id,
"payment_term_id": payment_term_id,
"order_line": order_lines_by_customer[customer],
"analytic_account_id": self.blanket_order_id.analytic_account_id.id,
}
def create_sale_order(self):
order_lines_by_customer = defaultdict(list)
currency_id = 0
pricelist_id = 0
user_id = 0
payment_term_id = 0
for line in self.line_ids.filtered(lambda l: l.qty != 0.0):
if line.qty > line.remaining_uom_qty:
raise UserError(_("You can't order more than the remaining quantities"))
vals = self._prepare_so_line_vals(line)
order_lines_by_customer[line.partner_id.id].append((0, 0, vals))
if currency_id == 0:
currency_id = line.blanket_line_id.order_id.currency_id.id
elif currency_id != line.blanket_line_id.order_id.currency_id.id:
currency_id = False
if pricelist_id == 0:
pricelist_id = line.blanket_line_id.pricelist_id.id
elif pricelist_id != line.blanket_line_id.pricelist_id.id:
pricelist_id = False
if user_id == 0:
user_id = line.blanket_line_id.user_id.id
elif user_id != line.blanket_line_id.user_id.id:
user_id = False
if payment_term_id == 0:
payment_term_id = line.blanket_line_id.payment_term_id.id
elif payment_term_id != line.blanket_line_id.payment_term_id.id:
payment_term_id = False
if not order_lines_by_customer:
raise UserError(_("An order can't be empty"))
if not currency_id:
raise UserError(
_(
"Can not create Sale Order from Blanket "
"Order lines with different currencies"
)
)
res = []
for customer in order_lines_by_customer:
order_vals = self._prepare_so_vals(
customer,
user_id,
currency_id,
pricelist_id,
payment_term_id,
order_lines_by_customer,
)
sale_order = self.env["sale.order"].create(order_vals)
res.append(sale_order.id)
return {
"domain": [("id", "in", res)],
"name": _("Sales Orders"),
"view_type": "form",
"view_mode": "tree,form",
"res_model": "sale.order",
"context": {"from_sale_order": True},
"type": "ir.actions.act_window",
}
class BlanketOrderWizardLine(models.TransientModel):
_name = "sale.blanket.order.wizard.line"
_description = "Blanket order wizard line"
wizard_id = fields.Many2one("sale.blanket.order.wizard")
blanket_line_id = fields.Many2one("sale.blanket.order.line")
product_id = fields.Many2one(
"product.product", related="blanket_line_id.product_id", string="Product"
)
product_uom = fields.Many2one(
"uom.uom", related="blanket_line_id.product_uom", string="Unit of Measure"
)
date_schedule = fields.Date(string="Scheduled Date")
remaining_uom_qty = fields.Float(related="blanket_line_id.remaining_uom_qty")
qty = fields.Float(string="Quantity to Order", required=True)
price_unit = fields.Float(related="blanket_line_id.price_unit")
currency_id = fields.Many2one("res.currency", related="blanket_line_id.currency_id")
partner_id = fields.Many2one(
"res.partner", related="blanket_line_id.partner_id", string="Vendor"
)
taxes_id = fields.Many2many("account.tax", related="blanket_line_id.taxes_id")

View file

@ -0,0 +1,49 @@
<?xml version="1.0" encoding="utf-8" ?>
<odoo>
<record id="view_create_sale_order" model="ir.ui.view">
<field name="name">Create Sale Order</field>
<field name="model">sale.blanket.order.wizard</field>
<field name="arch" type="xml">
<form string="Create Sale Order">
<group>
<field name="line_ids" nolabel="1" colspan="2">
<tree create="false" editable="bottom">
<field
name="blanket_line_id"
force_save="1"
invisible="1"
/>
<field name="product_id" />
<field name="partner_id" />
<field name="date_schedule" />
<field name="price_unit" />
<field name="remaining_uom_qty" />
<field name="product_uom" groups="uom.group_uom" />
<field name="qty" />
</tree>
</field>
</group>
<footer>
<button
name="create_sale_order"
string="Create and View Order"
type="object"
class="btn-primary"
/>
<button string="Cancel" class="btn-default" special="cancel" />
</footer>
</form>
</field>
</record>
<record id="action_create_sale_order" model="ir.actions.act_window">
<field name="name">Create Sale Order</field>
<field name="type">ir.actions.act_window</field>
<field name="res_model">sale.blanket.order.wizard</field>
<field name="view_mode">form</field>
<field name="target">new</field>
<field
name="binding_model_id"
ref="sale_blanket_order.model_sale_blanket_order_line"
/>
</record>
</odoo>