Files
stock-logistics-warehouse/stock_cycle_count/models/stock_cycle_count_rule.py
2020-09-30 12:43:18 +02:00

273 lines
9.9 KiB
Python

# Copyright 2017-18 ForgeFlow S.L.
# (http://www.forgeflow.com)
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
from datetime import datetime, timedelta
from odoo import _, api, fields, models
from odoo.exceptions import UserError, ValidationError
class StockCycleCountRule(models.Model):
_name = "stock.cycle.count.rule"
_description = "Stock Cycle Counts Rules"
def _compute_currency_id(self):
for rec in self:
rec.currency_id = self.env.user.company_id.currency_id
@api.model
def _selection_rule_types(self):
return [
("periodic", _("Periodic")),
("turnover", _("Value Turnover")),
("accuracy", _("Minimum Accuracy")),
("zero", _("Zero Confirmation")),
]
@api.constrains("rule_type", "warehouse_ids")
def _check_zero_rule(self):
for rec in self:
if rec.rule_type == "zero" and len(rec.warehouse_ids) > 1:
raise ValidationError(
_(
"Zero confirmation rules can only have one warehouse "
"assigned."
)
)
if rec.rule_type == "zero":
zero_rule = self.search(
[
("rule_type", "=", "zero"),
("warehouse_ids", "=", rec.warehouse_ids.id),
]
)
if len(zero_rule) > 1:
raise ValidationError(
_(
"You can only have one zero confirmation rule per "
"warehouse."
)
)
@api.depends("rule_type")
def _compute_rule_description(self):
if self.rule_type == "periodic":
self.rule_description = _(
"Ensures that at least a defined number "
"of counts in a given period will "
"be run."
)
elif self.rule_type == "turnover":
self.rule_description = _(
"Schedules a count every time the total "
"turnover of a location exceeds the "
"threshold. This considers every "
"product going into/out of the location"
)
elif self.rule_type == "accuracy":
self.rule_description = _(
"Schedules a count every time the "
"accuracy of a location goes under a "
"given threshold."
)
elif self.rule_type == "zero":
self.rule_description = _(
"Perform an Inventory Adjustment every "
"time a location in the warehouse runs "
"out of stock in order to confirm it is "
"truly empty."
)
else:
self.rule_description = _("(No description provided.)")
@api.constrains("periodic_qty_per_period", "periodic_count_period")
def _check_negative_periodic(self):
for rec in self:
if rec.periodic_qty_per_period < 1:
raise ValidationError(
_(
"You cannot define a negative or null number of counts "
"per period."
)
)
if rec.periodic_count_period < 0:
raise ValidationError(_("You cannot define a negative period."))
@api.onchange("location_ids")
def _onchange_locaton_ids(self):
"""Get the warehouses for the selected locations."""
wh_ids = []
for loc in self.location_ids:
wh_ids.append(loc.get_warehouse().id)
wh_ids = list(set(wh_ids))
self.warehouse_ids = self.env["stock.warehouse"].browse(wh_ids)
name = fields.Char(required=True)
rule_type = fields.Selection(
selection="_selection_rule_types", string="Type of rule", required=True
)
rule_description = fields.Char(
string="Rule Description", compute="_compute_rule_description"
)
active = fields.Boolean(default=True)
periodic_qty_per_period = fields.Integer(string="Counts per period", default=1)
periodic_count_period = fields.Integer(string="Period in days")
turnover_inventory_value_threshold = fields.Float(
string="Turnover Inventory Value Threshold"
)
currency_id = fields.Many2one(
comodel_name="res.currency", string="Currency", compute="_compute_currency_id"
)
accuracy_threshold = fields.Float(
string="Minimum Accuracy Threshold", digits=(3, 2)
)
apply_in = fields.Selection(
string="Apply this rule in:",
selection=[
("warehouse", "Selected warehouses"),
("location", "Selected Location Zones."),
],
default="warehouse",
)
warehouse_ids = fields.Many2many(
comodel_name="stock.warehouse",
relation="warehouse_cycle_count_rule_rel",
column1="rule_id",
column2="warehouse_id",
string="Warehouses where applied",
)
location_ids = fields.Many2many(
comodel_name="stock.location",
relation="location_cycle_count_rule_rel",
column1="rule_id",
column2="location_id",
string="Zones where applied",
)
def compute_rule(self, locs):
if self.rule_type == "periodic":
proposed_cycle_counts = self._compute_rule_periodic(locs)
elif self.rule_type == "turnover":
proposed_cycle_counts = self._compute_rule_turnover(locs)
elif self.rule_type == "accuracy":
proposed_cycle_counts = self._compute_rule_accuracy(locs)
return proposed_cycle_counts
@api.model
def _propose_cycle_count(self, date, location):
cycle_count = {
"date": fields.Datetime.from_string(date),
"location": location,
"rule_type": self,
}
return cycle_count
@api.model
def _compute_rule_periodic(self, locs):
cycle_counts = []
for loc in locs:
latest_inventory_date = (
self.env["stock.inventory"]
.search(
[
("location_ids", "in", [loc.id]),
("state", "in", ["confirm", "done", "draft"]),
],
order="date desc",
limit=1,
)
.date
)
if latest_inventory_date:
try:
period = self.periodic_count_period / self.periodic_qty_per_period
next_date = fields.Datetime.from_string(
latest_inventory_date
) + timedelta(days=period)
if next_date < datetime.today():
next_date = datetime.today()
except Exception as e:
raise UserError(
_(
"Error found determining the frequency of periodic "
"cycle count rule. %s"
)
% str(e)
)
else:
next_date = datetime.today()
cycle_count = self._propose_cycle_count(next_date, loc)
cycle_counts.append(cycle_count)
return cycle_counts
@api.model
def _get_turnover_moves(self, location, date):
moves = self.env["stock.move"].search(
[
"|",
("location_id", "=", location.id),
("location_dest_id", "=", location.id),
("date", ">", date),
("state", "=", "done"),
]
)
return moves
@api.model
def _compute_turnover(self, move):
price = move._get_price_unit()
turnover = move.product_uom_qty * price
return turnover
@api.model
def _compute_rule_turnover(self, locs):
cycle_counts = []
for loc in locs:
last_inventories = (
self.env["stock.inventory"]
.search(
[
("location_ids", "in", [loc.id]),
("state", "in", ["confirm", "done", "draft"]),
]
)
.mapped("date")
)
if last_inventories:
latest_inventory = sorted(last_inventories, reverse=True)[0]
moves = self._get_turnover_moves(loc, latest_inventory)
if moves:
total_turnover = 0.0
for m in moves:
turnover = self._compute_turnover(m)
total_turnover += turnover
try:
if total_turnover > self.turnover_inventory_value_threshold:
next_date = datetime.today()
cycle_count = self._propose_cycle_count(next_date, loc)
cycle_counts.append(cycle_count)
except Exception as e:
raise UserError(
_(
"Error found when comparing turnover with the "
"rule threshold. %s"
)
% str(e)
)
else:
next_date = datetime.today()
cycle_count = self._propose_cycle_count(next_date, loc)
cycle_counts.append(cycle_count)
return cycle_counts
def _compute_rule_accuracy(self, locs):
self.ensure_one()
cycle_counts = []
for loc in locs:
if loc.loc_accuracy < self.accuracy_threshold:
next_date = datetime.today()
cycle_count = self._propose_cycle_count(next_date, loc)
cycle_counts.append(cycle_count)
return cycle_counts