Public release from ruodoo-project: 19.0 - 2026-05-10 21:19:01 UTC

This commit is contained in:
CI Publish Bot
2026-05-10 21:19:11 +00:00
commit cbf9e6e6d6
1213 changed files with 183945 additions and 0 deletions

View File

@ -0,0 +1,8 @@
# -*- coding: utf-8 -*-
from . import sale_order
from . import product
from . import marketing
from . import forecast
from . import mrp
from . import discount

View File

@ -0,0 +1,111 @@
# -*- coding: utf-8 -*-
from odoo import models, fields, api
from datetime import timedelta, datetime
class DiscountMaster(models.TransientModel):
_name = 'mklab.discountmaster'
is_net_check = fields.Boolean(string='Учитывать скидку на полке')
date_from = fields.Date(string='Дата начала', required=True)
date_to = fields.Date(string='Дата окончания', required=True)
line_ids = fields.One2many(
comodel_name='mklab.discountmaster.line',
inverse_name='master_id',
string='Продукты и скидки',
required=True,
)
accuracy = fields.Float(string='Точность', default=5)
def _reopen_form(self):
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'res_model': self._name,
'res_id': self.id,
'view_type': 'form',
'view_mode': 'form',
'target': 'new',
}
class DiscountMasterLine(models.TransientModel):
_name = 'mklab.discountmaster.line'
product_id = fields.Many2one(
comodel_name='product.product',
string='Продукт',
)
our_disc = fields.Float(string='Наша скидка')
net_disc = fields.Float(string='Скидка на полке')
master_id = fields.Many2one(
comodel_name='mklab.discountmaster',
string='Мастер',
)
sale_growth = fields.Float(string='Ожидаемый рост продаж, %')
note = fields.Char(string='Пояснения')
def calc_sale_growth(self):
for s in self:
orderlines = self.env['sale.order.line'].search([
('order_id.date_order', '>=', s.master_id.date_from),
('order_id.date_order', '<=', s.master_id.date_to),
('product_id', '=', s.product_id.id),
])
action_days = 0
non_action_days = 0
summ_action = 0.0
summ_non_action = 0.0
tmp_date = datetime.combine(s.master_id.date_from, datetime.min.time())
end_date = datetime.combine(s.master_id.date_to, datetime.min.time())
action_days_list = []
while tmp_date < end_date:
tmp_date_only = tmp_date.date()
marketline = self.env['mklab.productline'].search([
('product_id', '=', s.product_id.id),
('action_id.start_date', '<=', tmp_date_only),
('action_id.end_date', '>=', tmp_date_only),
])
if marketline:
action_days_list.append(tmp_date_only)
action_days += 1
else:
non_action_days += 1
tmp_date += timedelta(days=1)
for line in orderlines:
order_dt = line.order_id.date_order
order_date = order_dt.date() if isinstance(order_dt, datetime) else order_dt
if order_date in action_days_list:
discount = line.discount
if s.our_disc - s.master_id.accuracy <= discount <= s.our_disc + s.master_id.accuracy:
summ_action += line.price_unit * line.product_uom_qty
else:
summ_non_action += line.price_unit * line.product_uom_qty
if action_days > 0 and non_action_days > 0 and summ_non_action > 0:
s.sale_growth = (
(summ_action / action_days - summ_non_action / non_action_days)
/ summ_non_action * 100
)
s.note = (
'Сумма по акции: ' + str(summ_action) +
', сумма без акции: ' + str(summ_non_action) +
', дней с акцией: ' + str(action_days) +
', дней без акции: ' + str(non_action_days)
)
else:
if action_days == 0:
s.note = 'Невозможно сделать расчет! Не было дней с акцией.'
elif non_action_days == 0:
s.note = 'Невозможно сделать расчет! Не было дней без акции.'
elif summ_non_action == 0:
s.note = 'Невозможно сделать расчет! Сумма без акции равна 0.'
if s.sale_growth <= 0:
s.note = 'Для указанной пары скидок не найдено подходящей статистики продаж'
s.sale_growth = 0
return s.master_id._reopen_form()

View File

@ -0,0 +1,236 @@
# -*- coding: utf-8 -*-
from odoo import models, fields, api, exceptions
from datetime import timedelta, datetime
from collections import defaultdict
import logging
_logger = logging.getLogger(__name__)
class MklabForecast(models.Model):
_name = 'mklab.forecast'
name = fields.Char(string='Название', compute='_compute_name')
start_date = fields.Date(string='Дата прогноза', required=True)
forecast_line_ids = fields.One2many(
comodel_name='mklab.forecastline',
inverse_name='forecast_id',
string='Строки',
)
partner_ids = fields.Many2many(
comodel_name='res.partner',
string='Ограничить контрагентами',
)
expected_growth = fields.Float(
string='Ожидаемый рост продаж при акции, %',
required=True,
default=20,
)
is_promo = fields.Boolean(string='Запланирована акция', compute='_compute_is_promo')
is_ready_to_calc = fields.Boolean(string='Отправить на расчет')
total_amount = fields.Float(string='Итого', compute='_compute_total_amount')
state = fields.Selection(
selection=[('draft', 'Черновик'), ('active', 'Утвержден')],
string='Статус',
default='draft',
)
def _compute_name(self):
for s in self:
s.name = str(s.start_date) if s.start_date else ''
def _compute_total_amount(self):
for s in self:
s.total_amount = sum(s.forecast_line_ids.mapped(lambda r: r.total_amount))
def _compute_is_promo(self):
for s in self:
promos = self.env['mklab.marketaction'].search([
('start_date', '<=', s.start_date),
('end_date', '>=', s.start_date),
])
s.is_promo = bool(promos)
@api.constrains('is_ready_to_calc')
def _reset_calculated(self):
for s in self:
s.forecast_line_ids.write({'is_calculated': False})
def action_validate(self):
self.state = 'active'
def action_cancel(self):
self.state = 'draft'
def fill_lines(self):
for s in self:
if s.forecast_line_ids:
raise exceptions.UserError(
'Для автоматического заполнения нужно сначала очистить все строки')
line_obj = self.env['mklab.forecastline']
products = self.env['product.product'].search([('is_forecast_ok', '=', True)])
for prod in products:
line_obj.create({
'forecast_id': s.id,
'product_id': prod.id,
'price': prod.list_price,
})
def create_mrp(self):
for s in self:
lines = s.forecast_line_ids.filtered(
lambda r: r.forecast_value > 0 and not r.mrp_order_id)
for line in lines:
bom = line.bom_id
if not bom:
raise exceptions.UserError(
'Для продукта ' + line.product_id.name + ' не найдена ведомость материалов')
new_order = self.env['mrp.production'].create({
'product_id': line.product_id.id,
'product_qty': line.forecast_value,
'bom_id': bom.id,
'product_uom_id': line.product_id.uom_id.id,
'origin': s.name,
'company_id': self.env.user.company_id.id,
'date_start': s.start_date,
})
line.mrp_order_id = new_order
class MklabForecastLine(models.Model):
_name = 'mklab.forecastline'
product_id = fields.Many2one(
comodel_name='product.product',
string='Товар',
required=True,
)
forecast_value = fields.Float(string='Прогноз/план')
order_value = fields.Float(string='В заказах факт')
invoice_value = fields.Float(string='Отгружено факт')
forecast_id = fields.Many2one(
comodel_name='mklab.forecast',
string='Прогноз',
)
is_calculated = fields.Boolean(string='Пересчитано автоматом')
price = fields.Float(string='Цена')
total_amount = fields.Float(string='Итого', compute='_compute_total_amount')
mrp_order_id = fields.Many2one(
comodel_name='mrp.production',
string='Заказ на производство',
)
notes = fields.Text(string='Комментарий')
bom_id = fields.Many2one(
comodel_name='mrp.bom',
string='Ведомость материалов',
)
def _compute_total_amount(self):
for s in self:
s.total_amount = s.price * s.forecast_value
def calc_bom(self):
for s in self:
s.bom_id, s.notes = s.product_id.start_planning(s.forecast_value)
def get_fact_values(self):
for s in self:
for_start = datetime.combine(s.forecast_id.start_date, datetime.min.time())
for_end = for_start + timedelta(hours=23)
if s.forecast_id.partner_ids:
orders = self.env['sale.order'].search([
('date_order', '<=', for_end),
('date_order', '>=', for_start),
('partner_id', 'in', s.forecast_id.partner_ids.ids),
])
else:
orders = self.env['sale.order'].search([
('date_order', '<=', for_end),
('date_order', '>=', for_start),
])
ord_value = 0
inv_value = 0
for order in orders:
for line in order.order_line.filtered(lambda r: r.product_id == s.product_id):
ord_value += line.product_uom_qty
for inv in order.invoice_ids:
for line in inv.invoice_line_ids.filtered(lambda r: r.product_id == s.product_id):
inv_value += line.quantity
s.invoice_value = inv_value
s.order_value = ord_value
def get_forecast_values(self):
for s in self:
start_date_forforecast = s.forecast_id.start_date - timedelta(days=84)
weekday = s.forecast_id.start_date.weekday()
if s.forecast_id.partner_ids:
orders = self.env['sale.order'].search([
('date_order', '<', s.forecast_id.start_date),
('date_order', '>=', start_date_forforecast),
('dayofweek', '=', weekday),
('partner_id', 'in', s.forecast_id.partner_ids.ids),
])
else:
orders = self.env['sale.order'].search([
('date_order', '<', s.forecast_id.start_date),
('date_order', '>=', start_date_forforecast),
('dayofweek', '=', weekday),
])
f_value = 0
promos_forecast = self.env['mklab.marketaction'].search([
('start_date', '<=', s.forecast_id.start_date),
('end_date', '>=', s.forecast_id.start_date),
])
for order in orders:
promos = self.env['mklab.marketaction'].search([
('start_date', '<=', order.date_order),
('end_date', '>=', order.date_order),
])
koeff = 0
if promos:
mark_lines = self.env['mklab.productline'].search([
('action_id', '=', promos[0].id),
('product_id', '=', s.product_id.id),
])
pids = self.env['mklab.partnerline'].search([
('action_id', '=', promos[0].id),
('partner_id', '=', order.partner_id.id),
])
if mark_lines and (pids or not promos[0].partner_line_ids):
koeff = mark_lines[0].expected_growth
is_promo = False
if promos_forecast:
mark_lines_f = self.env['mklab.productline'].search([
('action_id', '=', promos_forecast[0].id),
('product_id', '=', s.product_id.id),
])
pids_f = self.env['mklab.partnerline'].search([
('action_id', '=', promos_forecast[0].id),
('partner_id', '=', order.partner_id.id),
])
if mark_lines_f and (pids_f or not promos_forecast[0].partner_line_ids):
is_promo = True
order_qty = 0
for line in order.order_line.filtered(lambda r: r.product_id == s.product_id):
line_qty = (line.product_uom_qty * (1 + koeff / 100) * (1 + s.forecast_id.expected_growth / 100)
if is_promo else line.product_uom_qty * (1 + koeff / 100))
f_value += line_qty
order_qty += line_qty
s.forecast_value = round(f_value / 12, 0)
@api.model
def recalc_all(self):
"""Cron: пересчитывает по 5 строк за раз."""
lines = self.env['mklab.forecastline'].search(
[('forecast_id.is_ready_to_calc', '=', True), ('is_calculated', '=', False)],
limit=5,
)
for line in lines:
line.get_forecast_values()
line.get_fact_values()
line.is_calculated = True

View File

@ -0,0 +1,82 @@
# -*- coding: utf-8 -*-
from odoo import models, fields, api, exceptions
class MklabMarketAction(models.Model):
_name = 'mklab.marketaction'
name = fields.Char(
string='Название акции',
compute='_compute_name',
store=True,
)
start_date = fields.Date(string='Дата начала отгрузок', required=True)
end_date = fields.Date(string='Дата окончания отгрузок', required=True)
start_sale_date = fields.Date(string='Дата начала продаж на полке', required=True)
end_sale_date = fields.Date(string='Дата окончания продаж на полке', required=True)
product_line_ids = fields.One2many(
comodel_name='mklab.productline',
inverse_name='action_id',
string='Продукты',
)
partner_line_ids = fields.One2many(
comodel_name='mklab.partnerline',
inverse_name='action_id',
string='Контрагенты',
)
@api.constrains('start_date', 'end_date', 'start_sale_date', 'end_sale_date')
def _check_dates(self):
for s in self:
if s.end_date < s.start_date:
raise exceptions.ValidationError(
'Дата начала отгрузок должна быть меньше даты окончания!')
if s.end_sale_date < s.start_sale_date:
raise exceptions.ValidationError(
'Дата начала продаж на полке должна быть меньше даты окончания!')
@api.depends('start_date', 'end_date')
def _compute_name(self):
for s in self:
s.name = str(s.start_date) if s.start_date and s.end_date else ''
class MklabProductLine(models.Model):
_name = 'mklab.productline'
action_id = fields.Many2one(
comodel_name='mklab.marketaction',
string='Акция',
index=True,
)
product_id = fields.Many2one(
comodel_name='product.product',
string='Товар',
required=True,
index=True,
)
discount_ship = fields.Float(string='Скидка поставки', required=True)
discount_sale = fields.Float(string='Скидка на полке', required=True)
expected_growth = fields.Float(
string='Ожидаемый рост продаж, %',
required=True,
default=20,
)
class MklabPartnerLine(models.Model):
_name = 'mklab.partnerline'
action_id = fields.Many2one(
comodel_name='mklab.marketaction',
string='Акция',
index=True,
)
partner_id = fields.Many2one(
comodel_name='res.partner',
string='Контрагент',
required=True,
index=True,
)
pos_qty = fields.Float(string='Процент точек', required=True)

View File

@ -0,0 +1,18 @@
# -*- coding: utf-8 -*-
from odoo import models, fields
class VirtualBom(models.Model):
_inherit = 'mrp.bom'
is_virtual = fields.Boolean(string='Виртуальная (сгенерированная)')
def bom_available(self, qty=1):
"""Проверяет, что все компоненты BOM есть на складе в нужном количестве."""
for s in self:
for line in s.bom_line_ids:
if line.product_id.qty_available < line.product_qty * qty:
return False
return True

View File

@ -0,0 +1,26 @@
# -*- coding: utf-8 -*-
from odoo import models, fields
class ProductProduct(models.Model):
_inherit = 'product.product'
is_forecast_ok = fields.Boolean(string='Участвует в прогнозах')
def find_available_bom(self, qty=1):
"""Возвращает первый не-виртуальный BOM, для которого все компоненты есть на складе.
Возвращает (bom, notes) или (False, причина).
"""
for s in self:
boms = s.bom_ids.filtered(lambda b: not b.is_virtual)
if not boms:
return False, 'Нет спецификаций для продукта ' + s.name
for bom in boms:
if bom.bom_available(qty):
return bom, 'Найдена доступная спецификация'
return False, 'Нет спецификации, для которой все компоненты есть на складе (' + s.name + ')'
def start_planning(self, qty):
"""Точка входа для подбора BOM из строки прогноза."""
return self.find_available_bom(qty)

View File

@ -0,0 +1,34 @@
# -*- coding: utf-8 -*-
from odoo import models, fields, api
from datetime import datetime
class SaleOrderForecast(models.Model):
_inherit = 'sale.order'
dayofweek = fields.Integer(
string='День недели',
compute='_compute_dayofweek',
store=True,
)
@api.depends('date_order')
def _compute_dayofweek(self):
for s in self:
if s.date_order:
order_dt = s.date_order if isinstance(s.date_order, datetime) else datetime.combine(s.date_order, datetime.min.time())
s.dayofweek = order_dt.weekday()
else:
s.dayofweek = 0
@api.model
def conf_mass(self, date_start=False, date_end=False):
if date_start and date_end:
orders = self.env['sale.order'].search([
('date_order', '<=', date_end),
('date_order', '>=', date_start),
('state', '=', 'draft'),
], limit=15)
for order in orders:
order.action_confirm()