Public release from ruodoo-project: 19.0 - 2026-05-10 21:19:01 UTC
This commit is contained in:
96
mklab_forecast_mrp/README.md
Normal file
96
mklab_forecast_mrp/README.md
Normal file
@ -0,0 +1,96 @@
|
||||
# Номенклатурный производственный план на основе прогнозов (mklab_forecast_mrp)
|
||||
|
||||
---
|
||||
|
||||
## Описание
|
||||
|
||||
Модуль автоматизирует цикл планирования производства на основе анализа истории продаж:
|
||||
|
||||
- **Прогнозирование спроса** — анализирует заказы продаж за последние 12 месяцев с учётом дня недели и месяца года (сезонность). Нормализует исторические данные по прошедшим акциям и применяет ожидаемый рост, если на прогнозируемую дату запланирована маркетинговая акция.
|
||||
- **Планирование производства** — по готовому прогнозу одним действием создаёт заказы на производство (`mrp.production`) для всех позиций.
|
||||
- **Автоподбор ведомости материалов** — для каждого продукта автоматически находит спецификацию (BOM), для которой все компоненты есть на складе в нужном количестве.
|
||||
- **Маркетинговые акции** — ведёт реестр акций с датами отгрузки и продаж на полке, привязывает к акции продукты (со скидками и ожидаемым ростом) и контрагентов.
|
||||
- **Анализ влияния скидок** — мастер-форма для расчёта фактического роста продаж при заданной паре скидок (наша скидка + скидка на полке) на основе реальной истории заказов.
|
||||
|
||||
---
|
||||
|
||||
## Установка
|
||||
|
||||
### Требования
|
||||
|
||||
- Odoo 19.0
|
||||
- PostgreSQL 16+
|
||||
- Python 3.10+
|
||||
|
||||
### Ручная установка
|
||||
|
||||
1. Скопировать папку `mklab_forecast_mrp` в директорию `addons` вашего Odoo.
|
||||
2. В `odoo.conf` добавить путь к папке в `addons_path`.
|
||||
3. Перезапустить Odoo.
|
||||
4. Настройки → Приложения → убрать фильтр "Приложения" → найти **mklab_forecast_mrp** → Установить.
|
||||
|
||||
---
|
||||
|
||||
## Как пользоваться
|
||||
|
||||
После установки в главном меню появится раздел **План** с тремя подразделами.
|
||||
|
||||
### Стимулирующие мероприятия
|
||||
|
||||
Перед работой с прогнозами заведите акции.
|
||||
|
||||
**План → Стимулирующие мероприятия → Мероприятия → Создать**
|
||||
|
||||
| Поле | Описание |
|
||||
|---|---|
|
||||
| Дата начала/окончания отгрузок | Период, когда товар отгружается по акции |
|
||||
| Дата начала/окончания продаж на полке | Период продаж у покупателя |
|
||||
| Продукты | Список товаров с размером скидки и ожидаемым ростом продаж (%) |
|
||||
| Контрагенты | Если заполнено — акция действует только для указанных контрагентов |
|
||||
|
||||
---
|
||||
|
||||
### Прогнозирование и план производства
|
||||
|
||||
**План → Планирование → Прогнозы → Создать**
|
||||
|
||||
**Шаг 1.** Указать дату прогноза и при необходимости ограничить список контрагентов.
|
||||
|
||||
**Шаг 2.** Нажать **Заполнить строки** — автоматически добавятся все продукты с флагом "Участвует в прогнозах" (настраивается на карточке продукта).
|
||||
|
||||
**Шаг 3.** Для каждой строки доступны действия:
|
||||
- **Прогноз** — рассчитать прогнозное количество на основе истории продаж
|
||||
- **Факт** — подтянуть фактические данные по заказам и отгрузкам за эту дату
|
||||
- **Подобрать ВМ** — найти ведомость материалов, для которой все компоненты есть на складе
|
||||
|
||||
**Шаг 4.** Включить флаг **Отправить на расчёт** — фоновый cron пересчитает строки автоматически (по 5 штук за итерацию).
|
||||
|
||||
**Шаг 5.** Нажать **Создать заказы на производство** — для всех строк с заполненным прогнозом и ведомостью материалов будут созданы заказы на производство.
|
||||
|
||||
**Шаг 6.** Нажать **Утвердить** для фиксации прогноза.
|
||||
|
||||
> Поле **Ожидаемый рост при акции, %** — применяется как коэффициент роста, если на прогнозируемую дату найдена акция, но продукт не указан в её составе явно.
|
||||
|
||||
---
|
||||
|
||||
### Анализ влияния скидок
|
||||
|
||||
**План → Анализ данных → Мастер анализа данных**
|
||||
|
||||
Позволяет оценить, насколько реально выросли продажи при заданной паре скидок в указанный период.
|
||||
|
||||
1. Указать период анализа и точность сопоставления скидок (±%).
|
||||
2. Добавить строки: продукт + наша скидка + скидка на полке.
|
||||
3. Нажать **Расчёт** — система сравнит средние продажи в дни акции и без акции и выведет процент роста.
|
||||
|
||||
---
|
||||
|
||||
### Настройка продуктов
|
||||
|
||||
На карточке продукта (Инвентарь → Продукты) доступны дополнительные поля:
|
||||
|
||||
- **Участвует в прогнозах** — продукт будет включён при автозаполнении строк прогноза
|
||||
|
||||
На карточке ведомости материалов:
|
||||
|
||||
- **Виртуальная** — пометить BOM как сгенерированный автоматически (такие BOM пропускаются при автоподборе)
|
||||
3
mklab_forecast_mrp/__init__.py
Normal file
3
mklab_forecast_mrp/__init__.py
Normal file
@ -0,0 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from . import models
|
||||
32
mklab_forecast_mrp/__manifest__.py
Normal file
32
mklab_forecast_mrp/__manifest__.py
Normal file
@ -0,0 +1,32 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
{
|
||||
'name': "Номенклатурный производственный план на основе прогнозов",
|
||||
|
||||
'summary': "Прогнозирование и планирование производства по продажам",
|
||||
|
||||
'description': """
|
||||
Прогнозирование продаж с учётом дня недели, месяца и маркетинговых акций.
|
||||
Автоматическое создание заказов на производство из прогноза.
|
||||
Подбор ведомости материалов по наличию на складе.
|
||||
""",
|
||||
|
||||
'author': "Mikhail Skvortsov Lab",
|
||||
'website': "https://www.inf-centre.ru",
|
||||
|
||||
'category': 'Manufacturing',
|
||||
'version': '19.0.1.0.0',
|
||||
|
||||
'depends': ['base', 'sale', 'mrp'],
|
||||
|
||||
'data': [
|
||||
'security/ir.model.access.csv',
|
||||
'views/product_views.xml',
|
||||
'views/mrp_bom_views.xml',
|
||||
'views/marketing.xml',
|
||||
'views/forecast.xml',
|
||||
],
|
||||
|
||||
'demo': [
|
||||
'demo/demo.xml',
|
||||
],
|
||||
}
|
||||
4
mklab_forecast_mrp/demo/demo.xml
Normal file
4
mklab_forecast_mrp/demo/demo.xml
Normal file
@ -0,0 +1,4 @@
|
||||
<odoo>
|
||||
<data>
|
||||
</data>
|
||||
</odoo>
|
||||
8
mklab_forecast_mrp/models/__init__.py
Normal file
8
mklab_forecast_mrp/models/__init__.py
Normal 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
|
||||
111
mklab_forecast_mrp/models/discount.py
Normal file
111
mklab_forecast_mrp/models/discount.py
Normal 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()
|
||||
236
mklab_forecast_mrp/models/forecast.py
Normal file
236
mklab_forecast_mrp/models/forecast.py
Normal 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
|
||||
82
mklab_forecast_mrp/models/marketing.py
Normal file
82
mklab_forecast_mrp/models/marketing.py
Normal 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)
|
||||
18
mklab_forecast_mrp/models/mrp.py
Normal file
18
mklab_forecast_mrp/models/mrp.py
Normal 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
|
||||
|
||||
26
mklab_forecast_mrp/models/product.py
Normal file
26
mklab_forecast_mrp/models/product.py
Normal 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)
|
||||
34
mklab_forecast_mrp/models/sale_order.py
Normal file
34
mklab_forecast_mrp/models/sale_order.py
Normal 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()
|
||||
8
mklab_forecast_mrp/security/ir.model.access.csv
Normal file
8
mklab_forecast_mrp/security/ir.model.access.csv
Normal file
@ -0,0 +1,8 @@
|
||||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
access_mklab_forecastline,access_mklab_forecastline,model_mklab_forecastline,base.group_user,1,1,1,1
|
||||
access_mklab_forecast,access_mklab_forecast,model_mklab_forecast,base.group_user,1,1,1,1
|
||||
access_mklab_marketaction,access_mklab_marketaction,model_mklab_marketaction,base.group_user,1,1,1,1
|
||||
access_mklab_productline,access_mklab_productline,model_mklab_productline,base.group_user,1,1,1,1
|
||||
access_mklab_partnerline,access_mklab_partnerline,model_mklab_partnerline,base.group_user,1,1,1,1
|
||||
access_mklab_discountmaster,access_mklab_discountmaster,model_mklab_discountmaster,base.group_user,1,1,1,1
|
||||
access_mklab_discountmaster_line,access_mklab_discountmaster_line,model_mklab_discountmaster_line,base.group_user,1,1,1,1
|
||||
|
114
mklab_forecast_mrp/views/forecast.xml
Normal file
114
mklab_forecast_mrp/views/forecast.xml
Normal file
@ -0,0 +1,114 @@
|
||||
<odoo>
|
||||
|
||||
<record model="ir.ui.view" id="mklab_forecast_mrp.listforecast">
|
||||
<field name="name">Прогноз и план</field>
|
||||
<field name="model">mklab.forecast</field>
|
||||
<field name="arch" type="xml">
|
||||
<list>
|
||||
<field name="name"/>
|
||||
<field name="start_date"/>
|
||||
<field name="state"/>
|
||||
<field name="is_promo"/>
|
||||
<field name="total_amount"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record model="ir.ui.view" id="mklab_forecast_mrp.formforecast">
|
||||
<field name="name">Прогноз и план</field>
|
||||
<field name="model">mklab.forecast</field>
|
||||
<field name="arch" type="xml">
|
||||
<form>
|
||||
<header>
|
||||
<field name="state" widget="statusbar" statusbar_visible="draft,active"/>
|
||||
<button name="fill_lines" type="object" string="Заполнить строки" class="oe_highlight"/>
|
||||
<button name="create_mrp" type="object" string="Создать заказы на производство" class="oe_highlight"/>
|
||||
<button name="action_validate" string="Утвердить" type="object" class="oe_highlight"/>
|
||||
<button name="action_cancel" string="Отменить" type="object"/>
|
||||
</header>
|
||||
<sheet>
|
||||
<group>
|
||||
<group>
|
||||
<field name="start_date"/>
|
||||
<field name="is_promo"/>
|
||||
<field name="expected_growth"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="is_ready_to_calc"/>
|
||||
<field name="total_amount"/>
|
||||
</group>
|
||||
</group>
|
||||
<group>
|
||||
<field name="partner_ids" widget="many2many_tags"/>
|
||||
</group>
|
||||
<field name="forecast_line_ids">
|
||||
<list editable="top">
|
||||
<field name="product_id"/>
|
||||
<field name="forecast_value"/>
|
||||
<field name="order_value"/>
|
||||
<field name="invoice_value"/>
|
||||
<field name="price"/>
|
||||
<field name="total_amount"/>
|
||||
<field name="bom_id"/>
|
||||
<field name="mrp_order_id"/>
|
||||
<field name="notes"/>
|
||||
<field name="is_calculated" readonly="True"/>
|
||||
<button name="get_forecast_values" type="object" string="Прогноз"/>
|
||||
<button name="get_fact_values" type="object" string="Факт"/>
|
||||
<button name="calc_bom" type="object" string="Подобрать ВМ"/>
|
||||
</list>
|
||||
</field>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record model="ir.ui.view" id="mklab_forecast_mrp.discmaster">
|
||||
<field name="name">Влияние скидок на рост продаж</field>
|
||||
<field name="model">mklab.discountmaster</field>
|
||||
<field name="arch" type="xml">
|
||||
<form>
|
||||
<sheet>
|
||||
<group>
|
||||
<field name="date_from"/>
|
||||
<field name="date_to"/>
|
||||
<field name="accuracy"/>
|
||||
<field name="is_net_check"/>
|
||||
</group>
|
||||
<field name="line_ids">
|
||||
<list editable="top">
|
||||
<field name="product_id"/>
|
||||
<field name="our_disc"/>
|
||||
<field name="net_disc"/>
|
||||
<field name="sale_growth"/>
|
||||
<field name="note"/>
|
||||
<button name="calc_sale_growth" type="object" string="Расчет" class="oe_highlight"/>
|
||||
</list>
|
||||
</field>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record model="ir.actions.act_window" id="mklab_forecast_mrp.action_windowforecast">
|
||||
<field name="name">Прогноз-план</field>
|
||||
<field name="res_model">mklab.forecast</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
</record>
|
||||
|
||||
<record model="ir.actions.act_window" id="mklab_forecast_mrp.action_window_master">
|
||||
<field name="name">Влияние скидок на рост продаж</field>
|
||||
<field name="res_model">mklab.discountmaster</field>
|
||||
<field name="view_mode">form</field>
|
||||
<field name="target">new</field>
|
||||
</record>
|
||||
|
||||
<menuitem name="Прогнозы" id="mklab_forecast_mrp.menu_2_list"
|
||||
parent="mklab_forecast_mrp.menu_2"
|
||||
action="mklab_forecast_mrp.action_windowforecast"/>
|
||||
|
||||
<menuitem name="Мастер анализа данных" id="mklab_forecast_mrp.menu_3_list"
|
||||
parent="mklab_forecast_mrp.menu_3"
|
||||
action="mklab_forecast_mrp.action_window_master"/>
|
||||
|
||||
</odoo>
|
||||
71
mklab_forecast_mrp/views/marketing.xml
Normal file
71
mklab_forecast_mrp/views/marketing.xml
Normal file
@ -0,0 +1,71 @@
|
||||
<odoo>
|
||||
|
||||
<record model="ir.ui.view" id="mklab_forecast_mrp.marketinglist">
|
||||
<field name="name">Промо акции</field>
|
||||
<field name="model">mklab.marketaction</field>
|
||||
<field name="arch" type="xml">
|
||||
<list>
|
||||
<field name="name"/>
|
||||
<field name="start_date"/>
|
||||
<field name="end_date"/>
|
||||
<field name="start_sale_date"/>
|
||||
<field name="end_sale_date"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record model="ir.ui.view" id="mklab_forecast_mrp.marketingform">
|
||||
<field name="name">Промо акции</field>
|
||||
<field name="model">mklab.marketaction</field>
|
||||
<field name="arch" type="xml">
|
||||
<form>
|
||||
<sheet>
|
||||
<group>
|
||||
<field name="name"/>
|
||||
<field name="start_date"/>
|
||||
<field name="end_date"/>
|
||||
<field name="start_sale_date"/>
|
||||
<field name="end_sale_date"/>
|
||||
</group>
|
||||
<field name="product_line_ids">
|
||||
<list editable="top">
|
||||
<field name="product_id"/>
|
||||
<field name="discount_ship"/>
|
||||
<field name="discount_sale"/>
|
||||
<field name="expected_growth"/>
|
||||
</list>
|
||||
</field>
|
||||
<field name="partner_line_ids">
|
||||
<list editable="top">
|
||||
<field name="partner_id"/>
|
||||
<field name="pos_qty"/>
|
||||
</list>
|
||||
</field>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record model="ir.actions.act_window" id="mklab_forecast_mrp.action_window">
|
||||
<field name="name">Маркетинговые мероприятия</field>
|
||||
<field name="res_model">mklab.marketaction</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
</record>
|
||||
|
||||
<!-- Корневое меню -->
|
||||
<menuitem name="План" id="mklab_forecast_mrp.menu_root"/>
|
||||
|
||||
<!-- Разделы -->
|
||||
<menuitem name="Планирование" id="mklab_forecast_mrp.menu_2"
|
||||
parent="mklab_forecast_mrp.menu_root" sequence="1"/>
|
||||
<menuitem name="Стимулирующие мероприятия" id="mklab_forecast_mrp.menu_1"
|
||||
parent="mklab_forecast_mrp.menu_root" sequence="2"/>
|
||||
<menuitem name="Анализ данных" id="mklab_forecast_mrp.menu_3"
|
||||
parent="mklab_forecast_mrp.menu_root" sequence="3"/>
|
||||
|
||||
<!-- Пункты меню -->
|
||||
<menuitem name="Мероприятия" id="mklab_forecast_mrp.menu_1_list"
|
||||
parent="mklab_forecast_mrp.menu_1"
|
||||
action="mklab_forecast_mrp.action_window"/>
|
||||
|
||||
</odoo>
|
||||
14
mklab_forecast_mrp/views/mrp_bom_views.xml
Normal file
14
mklab_forecast_mrp/views/mrp_bom_views.xml
Normal file
@ -0,0 +1,14 @@
|
||||
<odoo>
|
||||
|
||||
<record id="mklab_bom" model="ir.ui.view">
|
||||
<field name="name">mklab bom</field>
|
||||
<field name="model">mrp.bom</field>
|
||||
<field name="inherit_id" ref="mrp.mrp_bom_form_view"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//field[@name='product_id']" position="after">
|
||||
<field name="is_virtual"/>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
14
mklab_forecast_mrp/views/product_views.xml
Normal file
14
mklab_forecast_mrp/views/product_views.xml
Normal file
@ -0,0 +1,14 @@
|
||||
<odoo>
|
||||
|
||||
<record id="mklab_product" model="ir.ui.view">
|
||||
<field name="name">mklab product</field>
|
||||
<field name="model">product.product</field>
|
||||
<field name="inherit_id" ref="product.product_normal_form_view"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//field[@name='default_code']" position="after">
|
||||
<field name="is_forecast_ok"/>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
Reference in New Issue
Block a user