[IMP] stock_cycle_count: black, isort

This commit is contained in:
jim.hoefnagels
2020-02-05 11:03:56 +01:00
committed by Mateu Griful
parent 73c7ed377a
commit 1605a9fea4
9 changed files with 563 additions and 465 deletions

View File

@@ -4,12 +4,11 @@
{
"name": "Stock Cycle Count",
"summary": "Adds the capability to schedule cycle counts in a "
"warehouse through different rules defined by the user.",
"warehouse through different rules defined by the user.",
"version": "12.0.1.0.1",
"development_status": "Mature",
"maintainers": ["lreficent"],
"author": "Eficent, "
"Odoo Community Association (OCA)",
"author": "Eficent, " "Odoo Community Association (OCA)",
"website": "https://github.com/OCA/stock-logistics-warehouse",
"category": "Warehouse Management",
"depends": [

View File

@@ -2,66 +2,77 @@
# (http://www.eficent.com)
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
from odoo import api, fields, models, _
from odoo import _, api, fields, models
from odoo.exceptions import UserError
class StockCycleCount(models.Model):
_name = 'stock.cycle.count'
_name = "stock.cycle.count"
_description = "Stock Cycle Counts"
_inherit = 'mail.thread'
_inherit = "mail.thread"
_order = "id desc"
@api.model
def _default_company(self):
company_id = self.env['res.company']._company_default_get(self._name)
company_id = self.env["res.company"]._company_default_get(self._name)
return company_id
name = fields.Char(string='Name', readonly=True)
name = fields.Char(string="Name", readonly=True)
location_id = fields.Many2one(
comodel_name='stock.location', string='Location',
comodel_name="stock.location",
string="Location",
required=True,
readonly=True, states={'draft': [('readonly', False)]},
readonly=True,
states={"draft": [("readonly", False)]},
)
responsible_id = fields.Many2one(
comodel_name='res.users', string='Assigned to',
readonly=True, states={'draft': [('readonly', False)]},
track_visibility='onchange',
comodel_name="res.users",
string="Assigned to",
readonly=True,
states={"draft": [("readonly", False)]},
track_visibility="onchange",
)
date_deadline = fields.Date(
string='Required Date',
readonly=True, states={'draft': [('readonly', False)]},
track_visibility='onchange',
string="Required Date",
readonly=True,
states={"draft": [("readonly", False)]},
track_visibility="onchange",
)
cycle_count_rule_id = fields.Many2one(
comodel_name='stock.cycle.count.rule', string='Cycle count rule',
comodel_name="stock.cycle.count.rule",
string="Cycle count rule",
required=True,
readonly=True, states={'draft': [('readonly', False)]},
track_visibility='onchange',
readonly=True,
states={"draft": [("readonly", False)]},
track_visibility="onchange",
)
state = fields.Selection(selection=[
('draft', 'Planned'),
('open', 'Execution'),
('cancelled', 'Cancelled'),
('done', 'Done')
], string='State', default='draft', track_visibility='onchange',
state = fields.Selection(
selection=[
("draft", "Planned"),
("open", "Execution"),
("cancelled", "Cancelled"),
("done", "Done"),
],
string="State",
default="draft",
track_visibility="onchange",
)
stock_adjustment_ids = fields.One2many(
comodel_name='stock.inventory', inverse_name='cycle_count_id',
string='Inventory Adjustment',
track_visibility='onchange',
)
inventory_adj_count = fields.Integer(
compute='_compute_inventory_adj_count',
comodel_name="stock.inventory",
inverse_name="cycle_count_id",
string="Inventory Adjustment",
track_visibility="onchange",
)
inventory_adj_count = fields.Integer(compute="_compute_inventory_adj_count")
company_id = fields.Many2one(
comodel_name='res.company', string='Company',
comodel_name="res.company",
string="Company",
required=True,
default=_default_company,
readonly=True,
)
@api.depends('stock_adjustment_ids')
@api.depends("stock_adjustment_ids")
@api.multi
def _compute_inventory_adj_count(self):
for rec in self:
@@ -69,46 +80,43 @@ class StockCycleCount(models.Model):
@api.multi
def do_cancel(self):
self.write({'state': 'cancelled'})
self.write({"state": "cancelled"})
@api.multi
def _prepare_inventory_adjustment(self):
self.ensure_one()
return {
'name': 'INV/{}'.format(self.name),
'cycle_count_id': self.id,
'location_id': self.location_id.id,
'exclude_sublocation': True
"name": "INV/{}".format(self.name),
"cycle_count_id": self.id,
"location_id": self.location_id.id,
"exclude_sublocation": True,
}
@api.model
def create(self, vals):
vals['name'] = self.env['ir.sequence'].next_by_code(
'stock.cycle.count') or ''
vals["name"] = self.env["ir.sequence"].next_by_code("stock.cycle.count") or ""
return super(StockCycleCount, self).create(vals)
@api.multi
def action_create_inventory_adjustment(self):
if any([s != 'draft' for s in self.mapped('state')]):
raise UserError(_(
"You can only confirm cycle counts in state 'Planned'."
))
if any([s != "draft" for s in self.mapped("state")]):
raise UserError(_("You can only confirm cycle counts in state 'Planned'."))
for rec in self:
data = rec._prepare_inventory_adjustment()
self.env['stock.inventory'].create(data)
self.write({'state': 'open'})
self.env["stock.inventory"].create(data)
self.write({"state": "open"})
return True
@api.multi
def action_view_inventory(self):
action = self.env.ref('stock.action_inventory_form')
action = self.env.ref("stock.action_inventory_form")
result = action.read()[0]
result['context'] = {}
adjustment_ids = self.mapped('stock_adjustment_ids').ids
result["context"] = {}
adjustment_ids = self.mapped("stock_adjustment_ids").ids
if len(adjustment_ids) > 1:
result['domain'] = [('id', 'in', adjustment_ids)]
result["domain"] = [("id", "in", adjustment_ids)]
elif len(adjustment_ids) == 1:
res = self.env.ref('stock.view_inventory_form', False)
result['views'] = [(res and res.id or False, 'form')]
result['res_id'] = adjustment_ids and adjustment_ids[0] or False
res = self.env.ref("stock.view_inventory_form", False)
result["views"] = [(res and res.id or False, "form")]
result["res_id"] = adjustment_ids and adjustment_ids[0] or False
return result

View File

@@ -2,13 +2,14 @@
# (http://www.eficent.com)
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
from odoo import api, fields, models, _
from datetime import datetime, timedelta
from odoo import _, api, fields, models
from odoo.exceptions import UserError, ValidationError
from datetime import timedelta, datetime
class StockCycleCountRule(models.Model):
_name = 'stock.cycle.count.rule'
_name = "stock.cycle.count.rule"
_description = "Stock Cycle Counts Rules"
@api.multi
@@ -19,131 +20,149 @@ class StockCycleCountRule(models.Model):
@api.model
def _selection_rule_types(self):
return [
('periodic', _('Periodic')),
('turnover', _('Value Turnover')),
('accuracy', _('Minimum Accuracy')),
('zero', _('Zero Confirmation'))]
("periodic", _("Periodic")),
("turnover", _("Value Turnover")),
("accuracy", _("Minimum Accuracy")),
("zero", _("Zero Confirmation")),
]
@api.multi
@api.constrains('rule_type', 'warehouse_ids')
@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:
if rec.rule_type == "zero" and len(rec.warehouse_ids) > 1:
raise ValidationError(
_('Zero confirmation rules can only have one warehouse '
'assigned.')
_(
"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 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.')
_(
"You can only have one zero confirmation rule per "
"warehouse."
)
)
@api.depends('rule_type')
@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.')
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.)')
self.rule_description = _("(No description provided.)")
@api.multi
@api.constrains('periodic_qty_per_period', 'periodic_count_period')
@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.')
_(
"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.')
)
raise ValidationError(_("You cannot define a negative period."))
@api.onchange('location_ids')
@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)
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,
selection="_selection_rule_types", string="Type of rule", required=True
)
rule_description = fields.Char(
string='Rule Description', compute='_compute_rule_description',
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')
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',
string="Turnover Inventory Value Threshold"
)
currency_id = fields.Many2one(
comodel_name='res.currency', string='Currency',
compute='_compute_currency_id',
comodel_name="res.currency", string="Currency", compute="_compute_currency_id"
)
accuracy_threshold = fields.Float(
string='Minimum Accuracy Threshold', digits=(3, 2),
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',
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',
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',
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':
if self.rule_type == "periodic":
proposed_cycle_counts = self._compute_rule_periodic(locs)
elif self.rule_type == 'turnover':
elif self.rule_type == "turnover":
proposed_cycle_counts = self._compute_rule_turnover(locs)
elif self.rule_type == 'accuracy':
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
"date": fields.Datetime.from_string(date),
"location": location,
"rule_type": self,
}
return cycle_count
@@ -151,22 +170,34 @@ class StockCycleCountRule(models.Model):
def _compute_rule_periodic(self, locs):
cycle_counts = []
for loc in locs:
latest_inventory_date = self.env['stock.inventory'].search([
('location_id', '=', loc.id),
('state', 'in', ['confirm', 'done', 'draft'])],
order="date desc", limit=1).date
latest_inventory_date = (
self.env["stock.inventory"]
.search(
[
("location_id", "=", 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
period = self.periodic_count_period / self.periodic_qty_per_period
next_date = fields.Datetime.from_string(
latest_inventory_date) + timedelta(days=period)
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))
_(
"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)
@@ -175,11 +206,15 @@ class StockCycleCountRule(models.Model):
@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')])
moves = self.env["stock.move"].search(
[
"|",
("location_id", "=", location.id),
("location_dest_id", "=", location.id),
("date", ">", date),
("state", "=", "done"),
]
)
return moves
@api.model
@@ -192,9 +227,16 @@ class StockCycleCountRule(models.Model):
def _compute_rule_turnover(self, locs):
cycle_counts = []
for loc in locs:
last_inventories = self.env['stock.inventory'].search([
('location_id', '=', loc.id),
('state', 'in', ['confirm', 'done', 'draft'])]).mapped('date')
last_inventories = (
self.env["stock.inventory"]
.search(
[
("location_id", "=", 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)
@@ -204,16 +246,18 @@ class StockCycleCountRule(models.Model):
turnover = self._compute_turnover(m)
total_turnover += turnover
try:
if total_turnover > \
self.turnover_inventory_value_threshold:
if total_turnover > self.turnover_inventory_value_threshold:
next_date = datetime.today()
cycle_count = self._propose_cycle_count(next_date,
loc)
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))
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)

View File

@@ -2,41 +2,46 @@
# (http://www.eficent.com)
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
from odoo import api, fields, models, _
from odoo import _, api, fields, models
from odoo.exceptions import UserError
PERCENT = 100.0
class StockInventory(models.Model):
_inherit = 'stock.inventory'
_inherit = "stock.inventory"
@api.multi
@api.depends("state", "line_ids")
def _compute_inventory_accuracy(self):
for inv in self:
theoretical = sum(inv.line_ids.mapped(
lambda x: abs(x.theoretical_qty)))
abs_discrepancy = sum(inv.line_ids.mapped(
lambda x: abs(x.discrepancy_qty)))
theoretical = sum(inv.line_ids.mapped(lambda x: abs(x.theoretical_qty)))
abs_discrepancy = sum(inv.line_ids.mapped(lambda x: abs(x.discrepancy_qty)))
if theoretical:
inv.inventory_accuracy = max(
PERCENT * (theoretical - abs_discrepancy) / theoretical,
0.0)
if not inv.line_ids and inv.state == 'done':
PERCENT * (theoretical - abs_discrepancy) / theoretical, 0.0
)
if not inv.line_ids and inv.state == "done":
inv.inventory_accuracy = PERCENT
cycle_count_id = fields.Many2one(
comodel_name='stock.cycle.count', string='Stock Cycle Count',
ondelete='restrict', readonly=True)
comodel_name="stock.cycle.count",
string="Stock Cycle Count",
ondelete="restrict",
readonly=True,
)
inventory_accuracy = fields.Float(
string='Accuracy', compute='_compute_inventory_accuracy',
digits=(3, 2), store=True, group_operator="avg")
string="Accuracy",
compute="_compute_inventory_accuracy",
digits=(3, 2),
store=True,
group_operator="avg",
)
def _update_cycle_state(self):
for inv in self:
if inv.cycle_count_id and inv.state == 'done':
inv.cycle_count_id.state = 'done'
if inv.cycle_count_id and inv.state == "done":
inv.cycle_count_id.state = "done"
return True
@api.multi
@@ -54,9 +59,15 @@ class StockInventory(models.Model):
@api.multi
def write(self, vals):
for inventory in self:
if (inventory.cycle_count_id and 'state' not in vals.keys() and
inventory.state == 'draft'):
if (
inventory.cycle_count_id
and "state" not in vals.keys()
and inventory.state == "draft"
):
raise UserError(
_('You cannot modify the configuration of an Inventory '
'Adjustment related to a Cycle Count.'))
_(
"You cannot modify the configuration of an Inventory "
"Adjustment related to a Cycle Count."
)
)
return super(StockInventory, self).write(vals)

View File

@@ -3,64 +3,66 @@
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
import logging
from datetime import datetime
from odoo import api, fields, models, tools
from odoo.tools import DEFAULT_SERVER_DATETIME_FORMAT
from datetime import datetime
_logger = logging.getLogger(__name__)
try:
from statistics import mean
STATS_PATH = tools.find_in_path('statistics')
STATS_PATH = tools.find_in_path("statistics")
except (ImportError, IOError) as err:
_logger.debug(err)
class StockLocation(models.Model):
_inherit = 'stock.location'
_inherit = "stock.location"
@api.multi
def _compute_loc_accuracy(self):
for rec in self:
history = self.env['stock.inventory'].search([
('location_id', '=', rec.id), ('state', '=', 'done')])
history = self.env["stock.inventory"].search(
[("location_id", "=", rec.id), ("state", "=", "done")]
)
history = history.sorted(key=lambda r: r.write_date, reverse=True)
if history:
wh = rec.get_warehouse()
if wh.counts_for_accuracy_qty and \
len(history) > wh.counts_for_accuracy_qty:
if (
wh.counts_for_accuracy_qty
and len(history) > wh.counts_for_accuracy_qty
):
rec.loc_accuracy = mean(
history[:wh.counts_for_accuracy_qty].mapped(
'inventory_accuracy'))
history[: wh.counts_for_accuracy_qty].mapped(
"inventory_accuracy"
)
)
else:
rec.loc_accuracy = mean(
history.mapped('inventory_accuracy'))
rec.loc_accuracy = mean(history.mapped("inventory_accuracy"))
zero_confirmation_disabled = fields.Boolean(
string='Disable Zero Confirmations',
help='Define whether this location will trigger a zero-confirmation '
'validation when a rule for its warehouse is defined to perform '
'zero-confirmations.',
string="Disable Zero Confirmations",
help="Define whether this location will trigger a zero-confirmation "
"validation when a rule for its warehouse is defined to perform "
"zero-confirmations.",
)
cycle_count_disabled = fields.Boolean(
string='Exclude from Cycle Count',
help='Define whether the location is going to be cycle counted.',
string="Exclude from Cycle Count",
help="Define whether the location is going to be cycle counted.",
)
qty_variance_inventory_threshold = fields.Float(
string='Acceptable Inventory Quantity Variance Threshold',
string="Acceptable Inventory Quantity Variance Threshold"
)
loc_accuracy = fields.Float(
string='Inventory Accuracy', compute='_compute_loc_accuracy',
digits=(3, 2),
string="Inventory Accuracy", compute="_compute_loc_accuracy", digits=(3, 2)
)
@api.multi
def _get_zero_confirmation_domain(self):
self.ensure_one()
domain = [
('location_id', '=', self.id),
('quantity', '>', 0.0),
]
domain = [("location_id", "=", self.id), ("quantity", ">", 0.0)]
return domain
@api.multi
@@ -68,13 +70,14 @@ class StockLocation(models.Model):
for rec in self:
if not rec.zero_confirmation_disabled:
wh = rec.get_warehouse()
rule_model = self.env['stock.cycle.count.rule']
zero_rule = rule_model.search([
('rule_type', '=', 'zero'),
('warehouse_ids', '=', wh.id)])
rule_model = self.env["stock.cycle.count.rule"]
zero_rule = rule_model.search(
[("rule_type", "=", "zero"), ("warehouse_ids", "=", wh.id)]
)
if zero_rule:
quants = self.env['stock.quant'].search(
rec._get_zero_confirmation_domain())
quants = self.env["stock.quant"].search(
rec._get_zero_confirmation_domain()
)
if not quants:
rec.create_zero_confirmation_cycle_count()
@@ -83,30 +86,41 @@ class StockLocation(models.Model):
self.ensure_one()
date = datetime.today().strftime(DEFAULT_SERVER_DATETIME_FORMAT)
wh_id = self.get_warehouse().id
date_horizon = self.get_warehouse().get_horizon_date().strftime(
DEFAULT_SERVER_DATETIME_FORMAT)
counts_planned = self.env['stock.cycle.count'].search([
('date_deadline', '<', date_horizon), ('state', '=', 'draft'),
('location_id', '=', self.id)])
date_horizon = (
self.get_warehouse()
.get_horizon_date()
.strftime(DEFAULT_SERVER_DATETIME_FORMAT)
)
counts_planned = self.env["stock.cycle.count"].search(
[
("date_deadline", "<", date_horizon),
("state", "=", "draft"),
("location_id", "=", self.id),
]
)
if counts_planned:
counts_planned.write({'state': 'cancelled'})
rule = self.env['stock.cycle.count.rule'].search([
('rule_type', '=', 'zero'), ('warehouse_ids', '=', wh_id)])
self.env['stock.cycle.count'].create({
'date_deadline': date,
'location_id': self.id,
'cycle_count_rule_id': rule.id,
'state': 'draft'
})
counts_planned.write({"state": "cancelled"})
rule = self.env["stock.cycle.count.rule"].search(
[("rule_type", "=", "zero"), ("warehouse_ids", "=", wh_id)]
)
self.env["stock.cycle.count"].create(
{
"date_deadline": date,
"location_id": self.id,
"cycle_count_rule_id": rule.id,
"state": "draft",
}
)
return True
@api.multi
def action_accuracy_stats(self):
self.ensure_one()
action = self.env.ref('stock_cycle_count.act_accuracy_stats')
action = self.env.ref("stock_cycle_count.act_accuracy_stats")
result = action.read()[0]
result['context'] = {"search_default_location_id": self.id}
new_domain = result['domain'][:-1] + \
", ('location_id', 'child_of', active_ids)]"
result['domain'] = new_domain
result["context"] = {"search_default_location_id": self.id}
new_domain = (
result["domain"][:-1] + ", ('location_id', 'child_of', active_ids)]"
)
result["domain"] = new_domain
return result

View File

@@ -7,7 +7,7 @@ from odoo import api, models
class StockMove(models.Model):
_inherit = 'stock.move'
_inherit = "stock.move"
@api.multi
def _action_done(self):

View File

@@ -2,31 +2,34 @@
# (http://www.eficent.com)
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
from odoo import api, fields, models
from datetime import datetime, timedelta
import logging
from datetime import datetime, timedelta
from odoo import api, fields, models
_logger = logging.getLogger(__name__)
class StockWarehouse(models.Model):
_inherit = 'stock.warehouse'
_inherit = "stock.warehouse"
cycle_count_rule_ids = fields.Many2many(
comodel_name='stock.cycle.count.rule',
relation='warehouse_cycle_count_rule_rel',
column1='warehouse_id',
column2='rule_id',
string='Cycle Count Rules')
comodel_name="stock.cycle.count.rule",
relation="warehouse_cycle_count_rule_rel",
column1="warehouse_id",
column2="rule_id",
string="Cycle Count Rules",
)
cycle_count_planning_horizon = fields.Integer(
string='Cycle Count Planning Horizon (in days)',
help='Cycle Count planning horizon in days. Only the counts inside '
'the horizon will be created.')
string="Cycle Count Planning Horizon (in days)",
help="Cycle Count planning horizon in days. Only the counts inside "
"the horizon will be created.",
)
counts_for_accuracy_qty = fields.Integer(
string='Inventories for location accuracy calculation',
string="Inventories for location accuracy calculation",
default=1,
help='Number of latest inventories used to calculate location '
'accuracy')
help="Number of latest inventories used to calculate location " "accuracy",
)
@api.multi
def get_horizon_date(self):
@@ -37,40 +40,42 @@ class StockWarehouse(models.Model):
return date_horizon
@api.model
def _get_cycle_count_locations_search_domain(
self, parent):
domain = [('parent_path', '=like', parent.parent_path + '%'),
('cycle_count_disabled', '=', False)]
def _get_cycle_count_locations_search_domain(self, parent):
domain = [
("parent_path", "=like", parent.parent_path + "%"),
("cycle_count_disabled", "=", False),
]
return domain
@api.model
def _search_cycle_count_locations(self, rule):
locations = self.env['stock.location']
if rule.apply_in == 'warehouse':
locations = self.env['stock.location'].search(
self._get_cycle_count_locations_search_domain(
self.view_location_id))
elif rule.apply_in == 'location':
locations = self.env["stock.location"]
if rule.apply_in == "warehouse":
locations = self.env["stock.location"].search(
self._get_cycle_count_locations_search_domain(self.view_location_id)
)
elif rule.apply_in == "location":
for loc in rule.location_ids:
locations += self.env['stock.location'].search(
self._get_cycle_count_locations_search_domain(loc))
locations += self.env["stock.location"].search(
self._get_cycle_count_locations_search_domain(loc)
)
return locations
@api.multi
def _cycle_count_rules_to_compute(self):
self.ensure_one()
rules = self.env['stock.cycle.count.rule'].search([
('rule_type', '!=', 'zero'), ('warehouse_ids', 'in', self.ids)])
rules = self.env["stock.cycle.count.rule"].search(
[("rule_type", "!=", "zero"), ("warehouse_ids", "in", self.ids)]
)
return rules
@api.model
def _prepare_cycle_count(self, cycle_count_proposed):
return {
'date_deadline': cycle_count_proposed['date'],
'location_id': cycle_count_proposed['location'].id,
'cycle_count_rule_id': cycle_count_proposed[
'rule_type'].id,
'state': 'draft'
"date_deadline": cycle_count_proposed["date"],
"location_id": cycle_count_proposed["location"].id,
"cycle_count_rule_id": cycle_count_proposed["rule_type"].id,
"state": "draft",
}
@api.multi
@@ -86,43 +91,49 @@ class StockWarehouse(models.Model):
if locations:
proposed_cycle_counts.extend(rule.compute_rule(locations))
if proposed_cycle_counts:
locations = list(set([d['location'] for d in
proposed_cycle_counts]))
locations = list({d["location"] for d in proposed_cycle_counts})
for loc in locations:
proposed_for_loc = list(filter(
lambda x: x['location'] == loc, proposed_cycle_counts))
earliest_date = min([d['date'] for d in proposed_for_loc])
cycle_count_proposed = list(filter(
lambda x: x['date'] == earliest_date,
proposed_for_loc))[0]
domain = [('location_id', '=', loc.id),
('state', 'in', ['draft'])]
existing_cycle_counts = self.env[
'stock.cycle.count'].search(domain)
proposed_for_loc = list(
filter(lambda x: x["location"] == loc, proposed_cycle_counts)
)
earliest_date = min([d["date"] for d in proposed_for_loc])
cycle_count_proposed = list(
filter(lambda x: x["date"] == earliest_date, proposed_for_loc)
)[0]
domain = [("location_id", "=", loc.id), ("state", "in", ["draft"])]
existing_cycle_counts = self.env["stock.cycle.count"].search(domain)
if existing_cycle_counts:
existing_earliest_date = sorted(
existing_cycle_counts.mapped('date_deadline'))[0]
existing_cycle_counts.mapped("date_deadline")
)[0]
existing_earliest_date = fields.Date.from_string(
existing_earliest_date)
existing_earliest_date
)
cycle_count_proposed_date = fields.Date.from_string(
cycle_count_proposed['date'])
if (cycle_count_proposed_date <
existing_earliest_date):
cc_to_update = existing_cycle_counts.search([
('date_deadline', '=', existing_earliest_date)
])
cc_to_update.write({
'date_deadline': cycle_count_proposed_date,
'cycle_count_rule_id': cycle_count_proposed[
'rule_type'].id,
})
delta = (fields.Datetime.from_string(
cycle_count_proposed['date']) - datetime.today())
if not existing_cycle_counts and \
delta.days < rec.cycle_count_planning_horizon:
cc_vals = self._prepare_cycle_count(
cycle_count_proposed)
self.env['stock.cycle.count'].create(cc_vals)
cycle_count_proposed["date"]
)
if cycle_count_proposed_date < existing_earliest_date:
cc_to_update = existing_cycle_counts.search(
[("date_deadline", "=", existing_earliest_date)]
)
cc_to_update.write(
{
"date_deadline": cycle_count_proposed_date,
"cycle_count_rule_id": cycle_count_proposed[
"rule_type"
].id,
}
)
delta = (
fields.Datetime.from_string(cycle_count_proposed["date"])
- datetime.today()
)
if (
not existing_cycle_counts
and delta.days < rec.cycle_count_planning_horizon
):
cc_vals = self._prepare_cycle_count(cycle_count_proposed)
self.env["stock.cycle.count"].create(cc_vals)
@api.model
def cron_cycle_count(self):
@@ -131,8 +142,7 @@ class StockWarehouse(models.Model):
whs = self.search([])
whs.action_compute_cycle_count_rules()
except Exception as e:
_logger.info(
"Error while running stock_cycle_count cron job: %s", str(e))
_logger.info("Error while running stock_cycle_count cron job: %s", str(e))
raise
_logger.info("stock_cycle_count cron job ended.")
return True

View File

@@ -11,9 +11,11 @@ class LocationAccuracyReport(models.AbstractModel):
@api.model
def _get_inventory_domain(self, loc_id, exclude_sublocation=True):
return [('location_id', '=', loc_id),
('exclude_sublocation', '=', exclude_sublocation),
('state', '=', 'done')]
return [
("location_id", "=", loc_id),
("exclude_sublocation", "=", exclude_sublocation),
("state", "=", "done"),
]
@api.model
def _get_location_data(self, locations):
@@ -29,10 +31,5 @@ class LocationAccuracyReport(models.AbstractModel):
report_obj = self.env["report"]
locs = self.env["stock.location"].browse(self._ids)
data = self._get_location_data(locs)
docargs = {
"doc_ids": locs._ids,
"docs": locs,
"data": data,
}
return report_obj.render(
"stock_cycle_count.stock_location_accuracy", docargs)
docargs = {"doc_ids": locs._ids, "docs": locs, "data": data}
return report_obj.render("stock_cycle_count.stock_location_accuracy", docargs)

View File

@@ -1,136 +1,139 @@
# Copyright 2017 Eficent Business and IT Consulting Services S.L.
# (http://www.eficent.com)
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
from odoo.tests import common
from odoo.exceptions import ValidationError
from odoo.exceptions import AccessError
from datetime import datetime, timedelta
from odoo.exceptions import AccessError, ValidationError
from odoo.tests import common
class TestStockCycleCount(common.TransactionCase):
def setUp(self):
super(TestStockCycleCount, self).setUp()
self.res_users_model = self.env['res.users']
self.cycle_count_model = self.env['stock.cycle.count']
self.stock_cycle_count_rule_model = self.env['stock.cycle.count.rule']
self.inventory_model = self.env['stock.inventory']
self.stock_location_model = self.env['stock.location']
self.stock_move_model = self.env['stock.move']
self.stock_warehouse_model = self.env['stock.warehouse']
self.product_model = self.env['product.product']
self.quant_model = self.env['stock.quant']
self.move_model = self.env['stock.move']
self.res_users_model = self.env["res.users"]
self.cycle_count_model = self.env["stock.cycle.count"]
self.stock_cycle_count_rule_model = self.env["stock.cycle.count.rule"]
self.inventory_model = self.env["stock.inventory"]
self.stock_location_model = self.env["stock.location"]
self.stock_move_model = self.env["stock.move"]
self.stock_warehouse_model = self.env["stock.warehouse"]
self.product_model = self.env["product.product"]
self.quant_model = self.env["stock.quant"]
self.move_model = self.env["stock.move"]
self.company = self.env.ref('base.main_company')
self.partner = self.env.ref('base.res_partner_1')
self.g_stock_manager = self.env.ref('stock.group_stock_manager')
self.g_stock_user = self.env.ref('stock.group_stock_user')
self.company = self.env.ref("base.main_company")
self.partner = self.env.ref("base.res_partner_1")
self.g_stock_manager = self.env.ref("stock.group_stock_manager")
self.g_stock_user = self.env.ref("stock.group_stock_user")
# Create users:
self.manager = self._create_user(
'user_1', [self.g_stock_manager], self.company).id
self.user = self._create_user(
'user_2', [self.g_stock_user], self.company).id
"user_1", [self.g_stock_manager], self.company
).id
self.user = self._create_user("user_2", [self.g_stock_user], self.company).id
# Create warehouses:
self.big_wh = self.stock_warehouse_model.create({
'name': 'BIG',
'code': 'B',
'cycle_count_planning_horizon': 30})
self.small_wh = self.stock_warehouse_model.create({
'name': 'SMALL', 'code': 'S'})
self.big_wh = self.stock_warehouse_model.create(
{"name": "BIG", "code": "B", "cycle_count_planning_horizon": 30}
)
self.small_wh = self.stock_warehouse_model.create(
{"name": "SMALL", "code": "S"}
)
# Create rules:
self.rule_periodic = \
self._create_stock_cycle_count_rule_periodic(
self.manager, 'rule_1', [2, 7])
self.rule_turnover = \
self._create_stock_cycle_count_rule_turnover(
self.manager, 'rule_2', [100])
self.rule_accuracy = \
self._create_stock_cycle_count_rule_accuracy(
self.manager, 'rule_3', [5], self.big_wh.view_location_id.ids)
self.rule_periodic = self._create_stock_cycle_count_rule_periodic(
self.manager, "rule_1", [2, 7]
)
self.rule_turnover = self._create_stock_cycle_count_rule_turnover(
self.manager, "rule_2", [100]
)
self.rule_accuracy = self._create_stock_cycle_count_rule_accuracy(
self.manager, "rule_3", [5], self.big_wh.view_location_id.ids
)
self.zero_rule = self._create_stock_cycle_count_rule_zero(
self.manager, 'rule_4')
self.manager, "rule_4"
)
# Configure warehouses:
self.rule_ids = [
self.rule_periodic.id,
self.rule_turnover.id,
self.rule_accuracy.id,
self.zero_rule.id]
self.big_wh.write({
'cycle_count_rule_ids': [(6, 0, self.rule_ids)]
})
self.zero_rule.id,
]
self.big_wh.write({"cycle_count_rule_ids": [(6, 0, self.rule_ids)]})
# Create a location:
self.count_loc = self.stock_location_model.create({
'name': 'Place',
'usage': 'production'
})
self.count_loc = self.stock_location_model.create(
{"name": "Place", "usage": "production"}
)
self.stock_location_model._parent_store_compute()
# Create a cycle count:
self.cycle_count_1 = self.cycle_count_model.sudo(self.manager).create({
'name': 'Test cycle count',
'cycle_count_rule_id': self.rule_periodic.id,
'location_id': self.count_loc.id,
})
self.cycle_count_1 = self.cycle_count_model.sudo(self.manager).create(
{
"name": "Test cycle count",
"cycle_count_rule_id": self.rule_periodic.id,
"location_id": self.count_loc.id,
}
)
# Create a product:
self.product1 = self.product_model.create({
'name': 'Test Product 1',
'type': 'product',
'default_code': 'PROD1',
})
self.product1 = self.product_model.create(
{"name": "Test Product 1", "type": "product", "default_code": "PROD1"}
)
def _create_user(self, login, groups, company):
group_ids = [group.id for group in groups]
user = self.res_users_model.create({
'name': login,
'login': login,
'email': 'example@yourcompany.com',
'company_id': company.id,
'company_ids': [(4, company.id)],
'groups_id': [(6, 0, group_ids)]
})
user = self.res_users_model.create(
{
"name": login,
"login": login,
"email": "example@yourcompany.com",
"company_id": company.id,
"company_ids": [(4, company.id)],
"groups_id": [(6, 0, group_ids)],
}
)
return user
def _create_stock_cycle_count_rule_periodic(self, uid, name, values):
rule = self.stock_cycle_count_rule_model.sudo(uid).create({
'name': name,
'rule_type': 'periodic',
'periodic_qty_per_period': values[0],
'periodic_count_period': values[1],
})
rule = self.stock_cycle_count_rule_model.sudo(uid).create(
{
"name": name,
"rule_type": "periodic",
"periodic_qty_per_period": values[0],
"periodic_count_period": values[1],
}
)
return rule
def _create_stock_cycle_count_rule_turnover(self, uid, name, values):
rule = self.stock_cycle_count_rule_model.sudo(uid).create({
'name': name,
'rule_type': 'turnover',
'turnover_inventory_value_threshold': values[0],
})
rule = self.stock_cycle_count_rule_model.sudo(uid).create(
{
"name": name,
"rule_type": "turnover",
"turnover_inventory_value_threshold": values[0],
}
)
return rule
def _create_stock_cycle_count_rule_accuracy(
self, uid, name, values, zone_ids):
rule = self.stock_cycle_count_rule_model.sudo(uid).create({
'name': name,
'rule_type': 'accuracy',
'accuracy_threshold': values[0],
'apply_in': 'location',
'location_ids': [(6, 0, zone_ids)],
})
def _create_stock_cycle_count_rule_accuracy(self, uid, name, values, zone_ids):
rule = self.stock_cycle_count_rule_model.sudo(uid).create(
{
"name": name,
"rule_type": "accuracy",
"accuracy_threshold": values[0],
"apply_in": "location",
"location_ids": [(6, 0, zone_ids)],
}
)
return rule
def _create_stock_cycle_count_rule_zero(self, uid, name):
rule = self.stock_cycle_count_rule_model.sudo(uid).create({
'name': name,
'rule_type': 'zero',
})
rule = self.stock_cycle_count_rule_model.sudo(uid).create(
{"name": name, "rule_type": "zero"}
)
return rule
def test_cycle_count_planner(self):
@@ -141,113 +144,127 @@ class TestStockCycleCount(common.TransactionCase):
for rule in self.big_wh.cycle_count_rule_ids:
locs += wh._search_cycle_count_locations(rule)
locs = locs.exists() # remove duplicated locations.
counts = self.cycle_count_model.search([
('location_id', 'in', locs.ids)])
self.assertFalse(
counts, 'Existing cycle counts before execute planner.')
counts = self.cycle_count_model.search([("location_id", "in", locs.ids)])
self.assertFalse(counts, "Existing cycle counts before execute planner.")
date_pre_existing_cc = datetime.today() + timedelta(days=30)
loc = locs.filtered(lambda l: l.usage != 'view')[0]
pre_existing_count = self.cycle_count_model.create({
'name': 'To be cancelled when running cron job.',
'cycle_count_rule_id': self.rule_periodic.id,
'location_id': loc.id,
'date_deadline': date_pre_existing_cc
})
self.assertEqual(pre_existing_count.state, 'draft',
'Testing data not generated properly.')
loc = locs.filtered(lambda l: l.usage != "view")[0]
pre_existing_count = self.cycle_count_model.create(
{
"name": "To be cancelled when running cron job.",
"cycle_count_rule_id": self.rule_periodic.id,
"location_id": loc.id,
"date_deadline": date_pre_existing_cc,
}
)
self.assertEqual(
pre_existing_count.state, "draft", "Testing data not generated properly."
)
date = datetime.today() - timedelta(days=1)
self.inventory_model.create({
'name': 'Pre-existing inventory',
'location_id': loc.id,
'date': date
})
self.quant_model.create({
'product_id': self.product1.id,
'location_id': self.count_loc.id,
'quantity': 1.0,
})
move1 = self.stock_move_model.create({
'name': 'Pre-existing move',
'product_id': self.product1.id,
'product_uom_qty': 1.0,
'product_uom': self.product1.uom_id.id,
'location_id': self.count_loc.id,
'location_dest_id': loc.id
})
self.inventory_model.create(
{"name": "Pre-existing inventory", "location_id": loc.id, "date": date}
)
self.quant_model.create(
{
"product_id": self.product1.id,
"location_id": self.count_loc.id,
"quantity": 1.0,
}
)
move1 = self.stock_move_model.create(
{
"name": "Pre-existing move",
"product_id": self.product1.id,
"product_uom_qty": 1.0,
"product_uom": self.product1.uom_id.id,
"location_id": self.count_loc.id,
"location_dest_id": loc.id,
}
)
move1._action_confirm()
move1._action_assign()
move1.move_line_ids[0].qty_done = 1.0
move1._action_done()
wh.cron_cycle_count()
self.assertNotEqual(pre_existing_count.date_deadline,
date_pre_existing_cc,
'Date of pre-existing cycle counts has not been '
'updated.')
counts = self.cycle_count_model.search([
('location_id', 'in', locs.ids)])
self.assertTrue(counts, 'Cycle counts not planned')
self.assertNotEqual(
pre_existing_count.date_deadline,
date_pre_existing_cc,
"Date of pre-existing cycle counts has not been " "updated.",
)
counts = self.cycle_count_model.search([("location_id", "in", locs.ids)])
self.assertTrue(counts, "Cycle counts not planned")
# Zero-confirmations:
count = self.cycle_count_model.search([
('location_id', '=', loc.id),
('cycle_count_rule_id', '=', self.zero_rule.id)])
self.assertFalse(
count, 'Unexpected zero confirmation.')
move2 = self.move_model.create({
'name': 'make the locations to run out of stock.',
'product_id': self.product1.id,
'product_uom_qty': 1.0,
'product_uom': self.product1.uom_id.id,
'location_id': loc.id,
'location_dest_id': self.count_loc.id
})
count = self.cycle_count_model.search(
[
("location_id", "=", loc.id),
("cycle_count_rule_id", "=", self.zero_rule.id),
]
)
self.assertFalse(count, "Unexpected zero confirmation.")
move2 = self.move_model.create(
{
"name": "make the locations to run out of stock.",
"product_id": self.product1.id,
"product_uom_qty": 1.0,
"product_uom": self.product1.uom_id.id,
"location_id": loc.id,
"location_dest_id": self.count_loc.id,
}
)
move2._action_confirm()
move2._action_assign()
move2.move_line_ids[0].qty_done = 1.0
move2._action_done()
count = self.cycle_count_model.search([
('location_id', '=', loc.id),
('cycle_count_rule_id', '=', self.zero_rule.id)])
self.assertTrue(
count, 'Zero confirmation not being created.')
count = self.cycle_count_model.search(
[
("location_id", "=", loc.id),
("cycle_count_rule_id", "=", self.zero_rule.id),
]
)
self.assertTrue(count, "Zero confirmation not being created.")
def test_cycle_count_workflow(self):
"""Tests workflow."""
self.cycle_count_1.action_create_inventory_adjustment()
inventory = self.inventory_model.search([
('cycle_count_id', '=', self.cycle_count_1.id)])
self.assertTrue(inventory, 'Inventory not created.')
inventory = self.inventory_model.search(
[("cycle_count_id", "=", self.cycle_count_1.id)]
)
self.assertTrue(inventory, "Inventory not created.")
inventory.action_start()
inventory.action_validate()
self.assertEqual(self.cycle_count_1.state, 'done',
'Cycle count not set as done.')
self.assertEqual(
self.cycle_count_1.state, "done", "Cycle count not set as done."
)
self.cycle_count_1.do_cancel()
self.assertEqual(self.cycle_count_1.state, 'cancelled',
'Cycle count not set as cancelled.')
self.assertEqual(
self.cycle_count_1.state, "cancelled", "Cycle count not set as cancelled."
)
def test_view_methods(self):
"""Tests the methods used to handle views."""
self.cycle_count_1.action_create_inventory_adjustment()
self.cycle_count_1.action_view_inventory()
inv_count = self.cycle_count_1.inventory_adj_count
self.assertEqual(inv_count, 1,
'View method failing.')
rules = [self.rule_periodic,
self.rule_turnover,
self.rule_accuracy,
self.zero_rule]
self.assertEqual(inv_count, 1, "View method failing.")
rules = [
self.rule_periodic,
self.rule_turnover,
self.rule_accuracy,
self.zero_rule,
]
for r in rules:
r._compute_rule_description()
self.assertTrue(r.rule_description, 'No description provided')
self.assertTrue(r.rule_description, "No description provided")
self.rule_accuracy._onchange_locaton_ids()
self.assertEqual(self.rule_accuracy.warehouse_ids.ids, self.big_wh.ids,
'Rules defined for zones are not getting the right '
'warehouse.')
self.assertEqual(
self.rule_accuracy.warehouse_ids.ids,
self.big_wh.ids,
"Rules defined for zones are not getting the right " "warehouse.",
)
def test_user_security(self):
"""Tests user rights."""
with self.assertRaises(AccessError):
self._create_stock_cycle_count_rule_periodic(
self.user, 'rule_1b', [2, 7])
self._create_stock_cycle_count_rule_periodic(self.user, "rule_1b", [2, 7])
with self.assertRaises(AccessError):
self.cycle_count_1.sudo(self.user).unlink()
@@ -255,22 +272,20 @@ class TestStockCycleCount(common.TransactionCase):
"""Tests the constrains for the periodic rules."""
# constrain: periodic_qty_per_period < 1
with self.assertRaises(ValidationError):
self._create_stock_cycle_count_rule_periodic(
self.manager, 'rule_0', [0, 0])
self._create_stock_cycle_count_rule_periodic(self.manager, "rule_0", [0, 0])
# constrain: periodic_count_period < 0
with self.assertRaises(ValidationError):
self._create_stock_cycle_count_rule_periodic(
self.manager, 'rule_0', [1, -1])
self.manager, "rule_0", [1, -1]
)
def test_rule_zero_constrains(self):
"""Tests the constrains for the zero-confirmation rule: it might
only exist one zero confirmation rule per warehouse and have just
one warehouse assigned.
"""
zero2 = self._create_stock_cycle_count_rule_zero(
self.manager, 'zero_rule_2')
zero2 = self._create_stock_cycle_count_rule_zero(self.manager, "zero_rule_2")
with self.assertRaises(ValidationError):
zero2.warehouse_ids = [(4, self.big_wh.id)]
with self.assertRaises(ValidationError):
self.zero_rule.warehouse_ids = [
(4, self.small_wh.id)]
self.zero_rule.warehouse_ids = [(4, self.small_wh.id)]