Public release from ruodoo-project: 19.0 - 2026-11-01 22:52:25 UTC

This commit is contained in:
CI Publish Bot
2026-11-01 22:54:30 +00:00
commit 26250aa580
2492 changed files with 664277 additions and 0 deletions

View File

@@ -0,0 +1,4 @@
from . import models
from . import report
from .hooks import post_init_hook

View File

@@ -0,0 +1,27 @@
# -*- coding: utf-8 -*-
{
'name': 'Российская локализация - Договоры счета',
'summary': """
Создание договоров
""",
'description': """
Создание договоров с клиентами и поставщиками. Адаптация под модуль счетов.
""",
'version': '19.0.2026.09.17',
'sequence': 0,
'author': 'MK.Lab, RuOdoo',
'license': 'LGPL-3',
'website': 'https://ruodoo.ru',
'depends': ['l10n_ru_contract', 'account'],
'data': [
'views/contract_customer_account_views.xml',
'views/contract_profile.xml',
'views/account_move_views.xml',
'report/report_contract_invoice.xml',
'security/ir.model.access.csv',
],
'installable': True,
'post_init_hook': 'post_init_hook',
}

View File

@@ -0,0 +1,101 @@
import logging
_logger = logging.getLogger(__name__)
# Стабильный внешний идентификатор профиля «Основной договор».
# По нему на профиль можно ссылаться из данных (в т.ч. демо) через ref,
# не зная реального id записи в БД.
MAIN_PROFILE_XMLID = 'l10n_ru_contract_account.contract_profile_main'
def _find_company_accounting(env, company):
"""Вернуть (journal_sale, receivable, payable, payment_term) для компании.
Счета берём ДЕФОЛТНЫЕ для контрагентов (property_account_receivable_id /
property_account_payable_id) — именно они попадают в строки инвойсов.
Так профиль договора будет согласован со счётом дебиторки инвойса и
проверка invoice_fields_check пройдёт. Любой элемент может быть пустым,
если план счетов ещё не настроен.
"""
journal = env['account.journal'].search(
[('type', '=', 'sale'), ('company_id', '=', company.id)], limit=1)
# Дефолтные счета контрагента в контексте компании.
partner_model = env['res.partner'].with_company(company)
receivable = partner_model.default_get(
['property_account_receivable_id']).get('property_account_receivable_id')
payable = partner_model.default_get(
['property_account_payable_id']).get('property_account_payable_id')
receivable = env['account.account'].browse(receivable) if receivable else \
env['account.account'].search(
[('account_type', '=', 'asset_receivable'),
('company_ids', 'in', company.id)], limit=1)
payable = env['account.account'].browse(payable) if payable else \
env['account.account'].search(
[('account_type', '=', 'liability_payable'),
('company_ids', 'in', company.id)], limit=1)
payment_term = env.ref('account.account_payment_term_immediate',
raise_if_not_found=False)
return journal, receivable, payable, payment_term
def ensure_main_profile(env, company=None):
"""Создать/дозаполнить профиль «Основной договор» и закрепить xmlid.
Возвращает запись профиля или None, если бухгалтерия ещё не настроена
(нет журнала/счетов) — тогда создание откладывается до момента, когда
план счетов будет загружен (напр. pre_init демо-данных).
"""
module, name = MAIN_PROFILE_XMLID.split('.')
profile = env.ref(MAIN_PROFILE_XMLID, raise_if_not_found=False)
if profile and profile.journal_id:
return profile # уже настроен — уважаем существующее
company = company or env.company or env['res.company'].search([], limit=1)
if not company:
return None
journal, receivable, payable, payment_term = _find_company_accounting(
env, company)
if not (journal and receivable and payable and payment_term):
_logger.info(
'l10n_ru_contract_account: профиль «Основной договор» пока не '
'создан — в компании %s не настроен план счетов. Будет создан '
'после настройки бухгалтерии.', company.display_name)
return None
vals = {
'name': 'Основной договор',
'journal_id': journal.id,
'receivable_account_id': receivable.id,
'payable_account_id': payable.id,
'payment_term_id': payment_term.id,
'max_receivable_id': 0.0,
}
if profile:
profile.write(vals)
else:
profile = env['contract.profile'].create(vals)
env['ir.model.data'].create({
'name': name,
'module': module,
'model': 'contract.profile',
'res_id': profile.id,
'noupdate': True,
})
_logger.info(
'l10n_ru_contract_account: профиль «Основной договор» готов '
'(компания %s, журнал %s).', company.display_name, journal.code)
return profile
def post_init_hook(env):
"""При установке модуля пытаемся создать профиль сразу.
В чистой базе журналов ещё нет — тогда профиль создаётся позже
(см. pre_init демо-данных или повторный вызов).
"""
ensure_main_profile(env)

View File

@@ -0,0 +1,4 @@
from . import partner_contract_customer
from . import contract_profile
from . import account_move
from . import account_payment

View File

@@ -0,0 +1,127 @@
from odoo import api, fields, models, exceptions, _
class AccountMove(models.Model):
_inherit = 'account.move'
mt_contract_id = fields.Many2one(
'partner.contract.customer', string=_('Номер договора'),
compute='_compute_mt_contract_id', store=True, readonly=False)
sf_number = fields.Char(string=_('Номер с/ф'))
osnovanie = fields.Char(string=_('Основание'))
sec_partner_id = fields.Many2one('res.partner', string=_('Контрагент'), store=True, compute='_compute_get_pid')
stamp = fields.Boolean(string=_('Печать и подпись'), related='mt_contract_id.stamp')
@api.depends('partner_id')
def _compute_get_pid(self):
for s in self:
s.sec_partner_id = s.partner_id.parent_id if s.partner_id.parent_id else s.partner_id
@api.depends('partner_id', 'company_id', 'move_type')
def _compute_mt_contract_id(self):
"""Подставить договор клиента по умолчанию.
Подстановка выполняется для документов продаж (счёт/возврат клиента):
берём действующий (signed) клиентский договор партнёра в рамках
компании. Для документов покупки договор не подставляется — профиль
«Основной договор» ориентирован на журнал продаж. Уже выбранный
вручную договор не перезаписываем.
"""
Contract = self.env['partner.contract.customer']
for move in self:
if move.mt_contract_id:
continue
if move.move_type not in ('out_invoice', 'out_refund'):
move.mt_contract_id = False
continue
if not move.partner_id:
move.mt_contract_id = False
continue
partner = move.partner_id.commercial_partner_id or move.partner_id
domain = [
('partner_id', '=', partner.id),
('type', '=', 'customer'),
('is_template', '=', False),
]
if move.company_id:
domain.append(('company_id', '=', move.company_id.id))
contract = Contract.search(
domain + [('state', '=', 'signed')],
order='date_start desc', limit=1,
) or Contract.search(domain, order='date_start desc', limit=1)
move.mt_contract_id = contract.id if contract else False
@api.onchange('mt_contract_id')
def set_ons(self):
"""Синхронизировать поля инвойса с параметрами договора (в UI).
Заполняем основание, журнал и условие оплаты из профиля договора,
чтобы пользователь сразу видел согласованные значения.
"""
for s in self:
if s.mt_contract_id:
s._apply_contract_fields()
def _apply_contract_fields(self):
"""Привести журнал/условие оплаты/основание в соответствие договору.
Вызывается и в onchange (UI), и перед проведением (_post) — чтобы
согласованность не зависела от способа создания инвойса
(вручную, из заказа продаж или программно из демо-данных).
"""
for s in self:
contract = s.mt_contract_id
if not contract:
continue
s.osnovanie = 'Договор № ' + (contract.name or '') + ' от ' + fields.Datetime.from_string(
contract.date_start).strftime("%d.%m.%Y")
profile = contract.profile_id
# Журнал/условие оплаты из профиля договора применяем только к
# документам продаж; для покупок (in_invoice/in_refund) журнал
# определяется типом документа, профиль клиентского договора его
# не переопределяет.
if s.move_type in ('out_invoice', 'out_refund'):
if profile.journal_id and s.journal_id != profile.journal_id:
s.journal_id = profile.journal_id
if profile.payment_term_id and \
s.invoice_payment_term_id != profile.payment_term_id:
s.invoice_payment_term_id = profile.payment_term_id
def _post(self, soft=True):
# Синхронизировать поля с договором до валидации invoice_fields_check.
for move in self:
if move.mt_contract_id and move.state == 'draft':
move._apply_contract_fields()
return super()._post(soft=soft)
@api.constrains('state')
def invoice_fields_check(self):
for s in self:
if s.state == 'posted':
# Сверку с параметрами договора выполняем только для продаж;
# покупки не обязаны совпадать с профилем клиентского договора.
if s.mt_contract_id and s.move_type in (
'out_invoice', 'out_refund'):
errors_list = []
journal_in_contract = s.mt_contract_id.profile_id.journal_id
payment_term_in_contract = s.mt_contract_id.profile_id.payment_term_id
receivable_in_contract = s.mt_contract_id.profile_id.receivable_account_id
if journal_in_contract != s.journal_id:
errors_list.append(f'Отличается Журнал - [{s.journal_id.name}] '
f'и указанный в договоре №{s.mt_contract_id.name} '
f'Журнал - [{journal_in_contract.name}]\n\n')
if payment_term_in_contract != s.invoice_payment_term_id:
errors_list.append(f'Отличается поле "Условие оплаты" в инвойсе '
f'[Условие оплаты - {s.invoice_payment_term_id.name}] '
f'и указанный в договоре №{s.mt_contract_id.name} '
f'[Условие оплаты - {payment_term_in_contract.name}]\n\n')
if receivable_in_contract not in s.line_ids.account_id:
errors_list.append(f'Отличается поле "Счет дебиторской задолженности" в инвойсе '
f'и указанный в договоре №{s.mt_contract_id.name}')
if errors_list:
raise exceptions.ValidationError(''.join(errors_list))

View File

@@ -0,0 +1,43 @@
from odoo import api, fields, models, _
class AccountPayment(models.Model):
_inherit = 'account.payment'
mt_contract_id = fields.Many2one(
'partner.contract.customer', string=_('Номер договора'),
compute='_compute_mt_contract_id', store=True, readonly=False,
help='Договор-основание платежа. Используется для ведения расчётов '
'с контрагентом в разрезе договора (в т.ч. в РСБУ-слое).')
@api.depends('partner_id', 'company_id', 'partner_type')
def _compute_mt_contract_id(self):
"""Подставить договор контрагента по умолчанию.
Для клиентских платежей — действующий (signed) клиентский договор
партнёра в рамках компании; для платежей поставщикам — договор с
поставщиком. Выбранный вручную договор не перезаписываем.
"""
Contract = self.env['partner.contract.customer']
for payment in self:
if payment.mt_contract_id:
continue
if not payment.partner_id:
payment.mt_contract_id = False
continue
partner = payment.partner_id.commercial_partner_id \
or payment.partner_id
contract_type = 'customer' if payment.partner_type == 'customer' \
else 'supplier'
domain = [
('partner_id', '=', partner.id),
('type', '=', contract_type),
('is_template', '=', False),
]
if payment.company_id:
domain.append(('company_id', '=', payment.company_id.id))
contract = Contract.search(
domain + [('state', '=', 'signed')],
order='date_start desc', limit=1,
) or Contract.search(domain, order='date_start desc', limit=1)
payment.mt_contract_id = contract.id if contract else False

View File

@@ -0,0 +1,11 @@
from odoo import fields, models, _
class ContractProfile(models.Model):
_inherit = 'contract.profile'
payable_account_id = fields.Many2one('account.account', string=_('Счет кредиторской задолженности'), required=True)
receivable_account_id = fields.Many2one('account.account', string=_('Счет дебиторской задолженности'), required=True)
max_receivable_id = fields.Float(string=_('Максимальная деб. задолженность'), required=True)
payment_term_id = fields.Many2one('account.payment.term', string=_('Условие оплаты'), required=True)
journal_id = fields.Many2one('account.journal', string=_('Журнал'), required=True)

View File

@@ -0,0 +1,22 @@
from odoo import fields, models, _
class PartnerContractCustomer(models.Model):
_inherit = "partner.contract.customer"
sec_partner_id = fields.Many2one(
"res.partner",
string=_("Контрагент как в заказе"),
)
accountant_id = fields.Many2one(
"res.users",
string=_("Бухгалтер по взаиморасчетам"),
)
buh_code = fields.Char(
string=_("Код договора из бухгалтерии"),
)
payment_term_id = fields.Many2one(
"account.payment.term",
string=_("Условие оплаты"),
related="profile_id.payment_term_id",
readonly=False,
)

View File

@@ -0,0 +1 @@
from . import report_contract_invoice

View File

@@ -0,0 +1,14 @@
from odoo import models
class ContractCustomerReportInvoice(models.AbstractModel):
_name = 'contract.customer.report_invoice'
def get_report_values(self, docids, data=None):
docs = self.env['account.move'].browse(docids)
return {
'doc_ids': docs.ids,
'doc_model': 'account.move',
'docs': docs,
}

View File

@@ -0,0 +1,596 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<template id="report_contract_customer_invoice">
<t t-call="web.basic_layout">
<t t-foreach="docs" t-as="o">
<t t-if="o and 'company_id' in o">
<t t-set="company" t-value="o.company_id"/>
</t>
<t t-if="not o or not 'company_id' in o">
<t t-set="company" t-value="res_company"/>
</t>
<t t-set="context" t-value="o._context"/>
<div class="header">
<STYLE TYPE="text/css">
body {background: #ffffff; margin: 0; font-family: Times new roman; font-size: 12pt; font-style:
normal;}
tr.R0{height: 15px;}
tr.R0 td.R0C0{ font-family: Times new roman; font-size: 11pt; font-style: normal; text-align:
left; vertical-align: medium; font-weight: bold;}
tr.R0 td.R0C1{ font-family: Times new roman; font-size: 11pt; font-style: normal; text-align:
right; vertical-align: medium; font-weight: bold;}
</STYLE>
<table border="0" cellpadding="0" cellspacing="0" style="width:100%">
<tbody>
<tr class="R0">
<td class="R0C0"><t t-esc="o.mt_contract_id.company_id.name or ''"/></td>
<td class="R0C1">Договор <t t-esc="o.mt_contract_id.name or ''"/> от <t
t-esc="o.mt_contract_id.date_start or ''"/></td>
</tr>
</tbody>
</table>
</div>
<div class="page">
<STYLE TYPE="text/css">
body {background: #ffffff; margin: 0; font-family: Times new roman; font-size: 12pt; font-style:
normal;}
tr.R0{height: 15px;}
tr.R0 td.R0C0{ font-family: Times new roman; font-size: 11pt; font-style: normal; font-weight:
bold; text-align: center; vertical-align: medium;}
tr.R0 td.R0C1{ font-family: Times new roman; font-size: 11pt; font-style: normal; text-align:
left; vertical-align: medium;}
tr.R0 td.R0C2{ font-family: Times new roman; font-size: 11pt; font-style: normal; text-align:
right; vertical-align: medium;}
tr.R0 td.R0C3{ font-family: Times new roman; font-size: 12pt; font-style: normal; text-align:
justify; vertical-align: medium; margin: 40px;}
tr.R0 td.R0C4{ font-family: Times new roman; font-size: 12pt; font-style: normal; text-align:
center; vertical-align: medium; font-weight: bold;}
tr.R0 td.R0C5{ font-family: Times new roman; font-size: 12pt; font-style: normal; text-align:
start; vertical-align: medium;}
p.R0C4{ font-family: Times new roman; font-size: 12pt; font-style: normal; text-align: center;
vertical-align: medium; font-weight: bold;}
div.R0C5{ font-family: Times new roman; font-size: 12pt; font-style: normal; text-align:
justify; vertical-align: medium;}
tr.R0 td.R0C6rtl{ font-family: Times new roman; font-size: 11pt; font-style: normal;
font-weight: bold; text-align: left; vertical-align: medium; border-left: #000000 1px solid;
border-top: #000000 1px solid; border-right: #000000 1px solid;}
tr.R0 td.R0C6rl{ font-family: Times new roman; font-size: 11pt; font-style: normal; font-weight:
bold; text-align: left; vertical-align: medium; border-left: #000000 1px solid; border-right:
#000000 1px solid}
tr.R0 td.R0C6r{ font-family: Times new roman; font-size: 11pt; font-style: normal; font-weight:
bold; text-align: left; vertical-align: medium; border-right: #000000 1px solid}
tr.R0 td.R0C6l{ font-family: Times new roman; font-size: 11pt; font-style: normal;
text-align: left; vertical-align: medium; border-left: #000000 1px solid;}
tr.R0 td.R0C6b{ font-family: Times new roman; font-size: 11pt; font-style: normal; font-weight:
bold; text-align: left; vertical-align: medium; border-bottom: #000000 1px solid;}
tr.R0 td.R0C6t{ font-family: Times new roman; font-size: 11pt; font-style: normal; font-weight:
bold; text-align: left; vertical-align: medium; border-top: #000000 1px solid;}
tr.R0 td.R0C6rbl{ font-family: Times new roman; font-size: 11pt; font-style: normal;
font-weight: bold; text-align: left; vertical-align: medium; border-left: #000000 1px solid;
border-right: #000000 1px solid; border-bottom: #000000 1px solid;}
tr.R0 td.R0C7rtl{ font-family: Times new roman; font-size: 11pt; font-style: normal; text-align:
left; vertical-align: medium; border-left: #000000 1px solid; border-top: #000000 1px solid;
border-right: #000000 1px solid;}
tr.R0 td.R0C7rl{ font-family: Times new roman; font-size: 11pt; font-style: normal; text-align:
left; vertical-align: medium; border-left: #000000 1px solid; border-right: #000000 1px solid;}
tr.R0 td.R0C7rbl{ font-family: Times new roman; font-size: 11pt; font-style: normal; text-align:
left; vertical-align: medium; border-left: #000000 1px solid; border-right: #000000 1px solid;
border-bottom: #000000 1px solid;}
tr.R0 td.R0C6tI{ font-family: Times new roman; font-size: 11pt; font-style: normal; font-weight:
bold; text-align: left; vertical-align: medium; border-bottom: #000000 1px solid;}
tr.R0 td.R0C6tI { overflow: visible; }
tr.R0 td.R0C6tI span { position: relative; }
tr.R0 td.R0C6tI span img { position: absolute; width: 100px; top: -30px; left: 80px; }
tr.R0 td.R0C6tS { overflow: visible; }
tr.R0 td.R0C6tS span { position: relative; }
tr.R0 td.R0C6tS span img { position: absolute; width: 140px; top: -40px; left: -200px; }
tr.R0 td.R0C8{ font-family: Times new roman; font-size: 11pt; font-style: normal; text-align:
right; vertical-align: medium; font-weight: bold;}
tr.R0 td.R0C9box{ font-family: Times new roman; font-size: 12pt; font-style: normal; text-align:
center; vertical-align: medium; border-left: #000000 1px solid; border-right: #000000 1px solid;
border-bottom: #000000 1px solid; border-top: #000000 1px solid;}
</STYLE>
<table border="0" cellpadding="0" cellspacing="0" style="width:100%; page-break-inside: auto;">
<tbody>
<tr class="R0">
<td class="R0C0" colspan="2">Договор № <t t-esc="o.mt_contract_id.name or ''"/></td>
</tr>
<tr class="R0">
<td class="R0C0" colspan="2">на поставку продукции</td>
</tr>
<tr class="R0">
<td class="R0C1"></td>
<td class="R0C2"></td>
</tr>
<tr class="R0">
<td class="R0C1"><t t-esc="o.mt_contract_id.company_id.partner_id.city or ''"/></td>
<td class="R0C2"><t
t-esc="o.mt_contract_id.get_date_text(o.mt_contract_id.date_start) or ''"/></td>
</tr>
<tr class="R0">
<td class="R0C1"></td>
<td class="R0C2"></td>
</tr>
<tr class="R0">
<td class="R0C3" colspan="2">
<span>
<t t-raw="o.mt_contract_id.contract_header or ''"/>
</span>
</td>
<tr class="R0">
<td class="R0C1"></td>
<td class="R0C2"></td>
</tr>
</tr>
<!--t t-foreach="o.mt_contract_id.lines.sorted(key=lambda r: r.sequence, reverse=False)" t-as="line">
<tr class="R0">
<td class="R0C4" colspan="2"><t t-esc="line.name"/></td>
</tr>
<tr class="R0">
<td class="R0C5" colspan="2"><t t-raw="line.punct"/></td>
</tr>
<tr class="R0">
<td class="R0C5" colspan="2"></td>
</tr>
</t-->
<!--tr class="R0">
<td class="R0C4" colspan="2">Реквизиты и подписи сторон.</td>
</tr>
<tr class="R0">
<td class="R0C1"></td>
<td class="R0C2"></td>
</tr-->
</tbody>
</table>
<t t-foreach="o.mt_contract_id.lines_ids.sorted(key=lambda r: r.sequence, reverse=False)" t-as="line">
<p class="R0C4"><t t-esc="line.name or ''"/></p>
<div class="R0C5"><span><t t-raw="line.punct or ''"/></span></div>
</t>
<table border="0" cellpadding="0" cellspacing="0" style="width:100%; page-break-inside: avoid;">
<COL WIDTH="5%"/>
<COL WIDTH="30%"/>
<COL WIDTH="15%"/>
<COL WIDTH="5%"/>
<COL WIDTH="30%"/>
<COL WIDTH="15%"/>
<tbody>
<tr class="R0">
<td class="R0C4" colspan="6">Реквизиты и подписи сторон.</td>
</tr>
<tr class="R0">
<td class="R0C1" colspan="6"></td>
</tr>
<tr class="R0">
<td class="R0C6rtl" colspan="3">Поставщик:</td>
<td class="R0C6rtl" colspan="3">Покупатель:</td>
</tr>
<tr class="R0">
<td class="R0C6rl" colspan="3"><t t-esc="o.mt_contract_id.company_id.name or ''"/></td>
<td class="R0C6rl" colspan="3"><t t-esc="o.mt_contract_id.partner_id.name or ''"/></td>
</tr>
<tr class="R0">
<td class="R0C6l" colspan="3">ИНН
<t t-esc="o.mt_contract_id.company_id.inn or ''"/>
</td>
<td class="R0C7rl" colspan="3">ИНН <t
t-esc="o.mt_contract_id.partner_id.inn or ''"/></td>
</tr>
<tr class="R0">
<td class="R0C6l" colspan="2">ОГРН
<t t-esc="o.company_id.company_registry or ''"/>
</td>
<td class="R0C6r" rowspan="2">
<t t-esc="o.mt_contract_id.director_name_company or ''"/>
</td>
<t t-if="o.mt_contract_id.partner_type in ['company', 'company_ip']">
<td class="R0C7rl" colspan="3">ОГРН
<t t-esc="o.partner_id.ogrn or ''"/>
</td>
</t>
<t t-else="">
<td class="R0C7rl" colspan="3">Паспорт
<t t-esc="o.partner_id.passport or ''"/>
</td>
</t>
</tr>
<tr class="R0">
<td class="R0C6l" colspan="3">КПП
<t t-esc="o.mt_contract_id.company_id.kpp or ''"/>
</td>
<t t-if="o.mt_contract_id.partner_type == 'company'">
<td class="R0C7rl" colspan="3">КПП
<t t-esc="o.partner_id.kpp or ''"/>
</td>
</t>
<t t-else="">
<td class="R0C7rl" colspan="3"></td>
</t>
</tr>
<tr class="R0">
<td class="R0C6rl" colspan="3">Юридический адрес:
</td>
<td class="R0C6rl" colspan="3">Юридический адрес:
</td>
</tr>
<tr class="R0">
<td class="R0C6l" colspan="3">
<t t-esc="o.mt_contract_id.address(o.company_id) or ''"/>
</td>
<td class="R0C7rl" colspan="3">
<t t-esc="o.mt_contract_id.address(o.partner_id) or ''"/>
</td>
</tr>
<tr class="R0">
<td class="R0C6rl" colspan="3"></td>
<td class="R0C6rl" colspan="3"></td>
</tr>
<tr class="R0">
<td class="R0C6rl" colspan="3"></td>
<td class="R0C6rl" colspan="3"></td>
</tr>
<tr class="R0">
<td class="R0C6rl" colspan="3"></td>
<td class="R0C6rl" colspan="3"></td>
</tr>
<tr class="R0">
<td class="R0C6l"></td>
<td class="R0C6tI">
<SPAN>
<t t-raw="o.stamp and o.mt_contract_id.img(o.company_id.chief_id.facsimile) or ''"/>
</SPAN>
</td>
<td class="R0C6tS">
<SPAN>
<t t-raw="o.stamp and o.mt_contract_id.img(o.company_id.stamp) or ''"/>
</SPAN>
</td>
<td class="R0C6rl" colspan="3"></td>
</tr>
<tr class="R0">
<td class="R0C6rl" colspan="3"></td>
<td class="R0C6l"></td>
<td class="R0C6b"></td>
<td class="R0C6r">
<t t-esc="''"/>
</td>
</tr>
<tr class="R0">
<td class="R0C6rl" colspan="3"></td>
<td class="R0C6rl" colspan="3"></td>
</tr>
<tr class="R0">
<td class="R0C6rl" colspan="3"></td>
<td class="R0C6rl" colspan="3"></td>
</tr>
<tr class="R0">
<td class="R0C6rl" colspan="3"></td>
<td class="R0C6rl" colspan="3"></td>
</tr>
<tr class="R0">
<td class="R0C6rbl" colspan="3"></td>
<td class="R0C6rbl" colspan="3"></td>
</tr>
</tbody>
</table>
<p style="page-break-before:always;"></p>
<!--p style="page-break-after:always"></p>
<div style="page-break-after: auto;"><span style="display: none;"> </span></div>
<p style="page-break-inside: avoid">
<div style="page-break-inside: auto"></div-->
<table border="0" cellpadding="0" cellspacing="0" style="width:100%">
<tbody>
<tr class="R0">
<td class="R0C8" colspan="8">Приложение<br/>к договору № <t
t-esc="o.mt_contract_id.name"/><br/>на поставку продукции<br/>от <t
t-esc="o.mt_contract_id.get_date_text(o.mt_contract_id.date_start) or ''"/></td>
</tr>
<tr class="R0">
<td class="R0C0" colspan="8"></td>
</tr>
<tr class="R0">
<td class="R0C0" colspan="8">Спецификация № 1</td>
</tr>
<tr class="R0">
<td class="R0C0" colspan="8"></td>
</tr>
<tr class="R0">
<td class="R0C1" colspan="4"><t
t-esc="o.mt_contract_id.company_id.partner_id.city or ''"/></td>
<td class="R0C2" colspan="4"><t
t-esc="o.mt_contract_id.get_date_text(o.mt_contract_id.date_start) or ''"/></td>
</tr>
<tr class="R0">
<td class="R0C0" colspan="8"></td>
</tr>
<tr class="R0">
<td class="R0C3" colspan="8">
<span>
<t t-raw="o.mt_contract_id.contract_header or ''"/>
</span>
</td>
</tr>
<tr class="R0">
<td class="R0C0" colspan="8"></td>
</tr>
<tr class="R0">
<td class="R0C5" colspan="8">1. По договору на поставку продукции <t
t-esc="o.mt_contract_id.name or ''"/> от <t
t-esc="o.mt_contract_id.get_date_text(o.mt_contract_id.date_start) or ''"/>.
Поставщик обязуется поставить, а Покупатель оплатить в полном объеме следующее:</td>
</tr>
<tr class="R0">
<td class="R0C0" colspan="8"></td>
</tr>
<tr class="R0">
<td class="R0C9box">Номенклатура</td>
<!-- <td class="R0C9box">*ГОСТ</td>-->
<td class="R0C9box">Ед.изм.</td>
<td class="R0C9box">Кол-во</td>
<td class="R0C9box">Цена/руб.<br/>без НДС</td>
<td class="R0C9box">Стоимость/руб.<br/>без НДС</td>
<!-- <td class="R0C9box">НДС</td>-->
<!-- <td class="R0C9box">Стоимость/руб.<br/>c НДС</td>-->
</tr>
<t t-set="invoice_line_ids" t-value="o.invoice_line_ids.filtered(lambda s: 'аванс' not in s.name.lower() and 'депозит' not in s.name.lower())"/>
<t t-set="quantity" t-value="sum([line.quantity for line in invoice_line_ids])"/>
<t t-set="sum_full"
t-value="sum([line.quantity*line.price_unit for line in invoice_line_ids])"/>
<t t-set="sum_nds"
t-value="sum([line.quantity*line.price_unit*1.2 for line in invoice_line_ids])"/>
<tr class="R0" t-foreach="invoice_line_ids" t-as="line">
<td class="R0C9box"><t t-esc="line.product_id.name or ''"/></td>
<!-- <td class="R0C9box">&lt;!&ndash;ГОСТ&ndash;&gt;</td>-->
<td class="R0C9box"><t t-esc="line.product_id.uom_id.name or ''"/></td>
<td class="R0C9box"><t t-esc="line.quantity or ''"/></td>
<td class="R0C9box"><t t-esc="line.price_unit or ''"/></td>
<td class="R0C9box"><t t-esc="line.price_unit*line.quantity or ''"/></td>
<!-- <td class="R0C9box">20%</td>-->
<!-- <td class="R0C9box"><t t-esc="line.price_unit*line.quantity*1.2 or ''"/></td>-->
</tr>
<tr class="R0">
<td></td>
<!-- <td></td>-->
<td>ИТОГО</td>
<td class="R0C9box"><t t-esc="quantity or ''"/></td>
<td class="R0C9box">x</td>
<td class="R0C9box"><t t-esc="sum_full or ''"/></td>
<!-- <td class="R0C9box">x</td>-->
<!-- <td class="R0C9box"><t t-esc="sum_nds or ''"/></td>-->
</tr>
<tr class="R0">
<td class="R0C0" colspan="8"></td>
</tr>
<tr class="R0">
<td class="R0C5" colspan="8">Итого: <t t-esc="sum_full or ''"/> рублей (<t
t-esc="o.mt_contract_id.rubles(sum_full) or ''"/>). В стоимость продукции входит стоимость
тары и стоимость транспортных расходов, связанных с доставкой продукции до станции
назначения.<br/>
2. Базис поставки (пункт назначения) по отгрузочным реквизитам:<br/>
<t t-esc="o.mt_contract_id.address_delivery(o.mt_contract_id.partner_id.id) or ''"/><br/>
Грузополучатель: <t t-esc="o.mt_contract_id.partner_id.name or ''"/>, код <t
t-esc="o.mt_contract_id.partner_id.id or ''"/>, ОКПО <t
t-esc="o.mt_contract_id.partner_id.okpo or ''"/>.<br/>
3. Условия оплаты: <t t-esc="o.invoice_payment_term_id.name or ''"/> согласно
выставленному счету Поставщика.<br/>
4. Срок отгрузки: <t
t-esc="o.mt_contract_id.get_date_text(o.mt_contract_id.date_end) or ''"/><br/>
5. Срок действия настоящей спецификации ограничен и равен сроку отгрузки по условиям
данной спецификации.
</td>
</tr>
<tr class="R0">
<td class="R0C0" colspan="8"></td>
</tr>
</tbody>
</table>
<table border="0" cellpadding="0" cellspacing="0" style="width:100%; page-break-inside: avoid;">
<COL WIDTH="5%"/>
<COL WIDTH="30%"/>
<COL WIDTH="15%"/>
<COL WIDTH="5%"/>
<COL WIDTH="30%"/>
<COL WIDTH="15%"/>
<tbody>
<tr class="R0">
<td class="R0C4" colspan="6">Реквизиты и подписи сторон.</td>
</tr>
<tr class="R0">
<td class="R0C1" colspan="6"></td>
</tr>
<tr class="R0">
<td class="R0C6rtl" colspan="3">Поставщик:</td>
<td class="R0C6rtl" colspan="3">Покупатель:</td>
</tr>
<tr class="R0">
<td class="R0C6rl" colspan="3"><t t-esc="o.mt_contract_id.company_id.name or ''"/></td>
<td class="R0C6rl" colspan="3"><t t-esc="o.mt_contract_id.partner_id.name or ''"/></td>
</tr>
<tr class="R0">
<td class="R0C6l" colspan="3">ИНН
<t t-esc="o.mt_contract_id.company_id.inn or ''"/>
</td>
<td class="R0C7rl" colspan="3">ИНН <t
t-esc="o.mt_contract_id.partner_id.inn or ''"/></td>
</tr>
<tr class="R0">
<td class="R0C6l" colspan="2">ОГРН
<t t-esc="o.company_id.company_registry or ''"/>
</td>
<td class="R0C6r" rowspan="2">
<t t-esc="o.mt_contract_id.director_name_company or ''"/>
</td>
<t t-if="o.mt_contract_id.partner_type in ['company', 'company_ip']">
<td class="R0C7rl" colspan="3">ОГРН
<t t-esc="o.partner_id.ogrn or ''"/>
</td>
</t>
<t t-else="">
<td class="R0C7rl" colspan="3">Паспорт
<t t-esc="o.partner_id.passport or ''"/>
</td>
</t>
</tr>
<tr class="R0">
<td class="R0C6l" colspan="3">КПП
<t t-esc="o.mt_contract_id.company_id.kpp or ''"/>
</td>
<t t-if="o.mt_contract_id.partner_type == 'company'">
<td class="R0C7rl" colspan="3">КПП
<t t-esc="o.partner_id.kpp or ''"/>
</td>
</t>
<t t-else="">
<td class="R0C7rl" colspan="3"></td>
</t>
</tr>
<tr class="R0">
<td class="R0C6rl" colspan="3">Юридический адрес:
</td>
<td class="R0C6rl" colspan="3">Юридический адрес:
</td>
</tr>
<tr class="R0">
<td class="R0C6l" colspan="3">
<t t-esc="o.mt_contract_id.address(o.company_id) or ''"/>
</td>
<td class="R0C7rl" colspan="3">
<t t-esc="o.mt_contract_id.address(o.partner_id) or ''"/>
</td>
</tr>
<tr class="R0">
<td class="R0C6rl" colspan="3"></td>
<td class="R0C6rl" colspan="3"></td>
</tr>
<tr class="R0">
<td class="R0C6rl" colspan="3"></td>
<td class="R0C6rl" colspan="3"></td>
</tr>
<tr class="R0">
<td class="R0C6rl" colspan="3"></td>
<td class="R0C6rl" colspan="3"></td>
</tr>
<tr class="R0">
<td class="R0C6l"></td>
<td class="R0C6tI">
<SPAN>
<t t-raw="o.stamp and o.mt_contract_id.img(o.company_id.chief_id.facsimile) or ''"/>
</SPAN>
</td>
<td class="R0C6tS">
<SPAN>
<t t-raw="o.stamp and o.mt_contract_id.img(o.company_id.stamp) or ''"/>
</SPAN>
</td>
<td class="R0C6rl" colspan="3"></td>
</tr>
<tr class="R0">
<td class="R0C6rl" colspan="3"></td>
<td class="R0C6l"></td>
<td class="R0C6b"></td>
<td class="R0C6r">
<t t-esc="''"/>
</td>
</tr>
<tr class="R0">
<td class="R0C6rl" colspan="3"></td>
<td class="R0C6rl" colspan="3"></td>
</tr>
<tr class="R0">
<td class="R0C6rl" colspan="3"></td>
<td class="R0C6rl" colspan="3"></td>
</tr>
<tr class="R0">
<td class="R0C6rl" colspan="3"></td>
<td class="R0C6rl" colspan="3"></td>
</tr>
<tr class="R0">
<td class="R0C6rbl" colspan="3"></td>
<td class="R0C6rbl" colspan="3"></td>
</tr>
</tbody>
</table>
</div>
<div class="footer">
<STYLE TYPE="text/css">
body {background: #ffffff; margin: 0; font-family: Times new roman; font-size: 12pt; font-style:
normal;}
tr.R0{height: 30px;}
tr.R0 td.R0C0{ font-family: Times new roman; font-size: 11pt; font-style: normal; font-weight:
bold; text-align: left; vertical-align: bottom;}
tr.R0 td.R0C1{ font-family: Times new roman; font-size: 11pt; font-style: normal; font-weight:
bold; text-align: right; vertical-align: bottom;}
tr.R0 td.R0C6tI{ font-family: Times new roman; font-size: 11pt; font-style: normal; font-weight:
bold; text-align: left; vertical-align: top; border-bottom: #000000 1px solid;}
tr.R0 td.R0C6tI { overflow: visible; }
tr.R0 td.R0C6tI span { position: relative; }
tr.R0 td.R0C6tI span img { position: absolute; width: 100px; top: -5px; left: 5px;}
</STYLE>
<table border="0" cellpadding="0" cellspacing="0" style="width:100%">
<COL WIDTH="20%"/>
<COL WIDTH="20%"/>
<COL WIDTH="20%"/>
<COL WIDTH="20%"/>
<COL WIDTH="20%"/>
<tbody>
<tr class="R0">
<td class="R0C0">Подпись Исполнителя</td>
<td class="R0C6tI">
<SPAN>
<t
t-raw="o.stamp and o.mt_contract_id.img(company.chief_id.facsimile) or ''"/>
</SPAN>
</td>
<td class="R0C0"></td>
<td class="R0C1">Подпись Заказчика</td>
<td class="R0C6tI"></td>
</tr>
</tbody>
</table>
<div class="row">
<div class="col-xs-3 pull-right">
<small style="font-size:18px;">
<span>Страница</span>
<span class="page"/>
из
<span class="topage"/>
</small>
</div>
</div>
</div>
</t>
</t>
</template>
<record id="paperformat_a4" model="report.paperformat">
<field name="name">A4</field>
<field name="default" eval="True"/>
<field name="format">A4</field>
<field name="page_height">0</field>
<field name="page_width">0</field>
<field name="orientation">Portrait</field>
<field name="margin_top">15</field>
<field name="margin_bottom">30</field>
<field name="margin_left">7</field>
<field name="margin_right">7</field>
<field name="header_line" eval="False"/>
<field name="header_spacing">10</field>
<field name="dpi">90</field>
</record>
<record id="action_report_contract_customer_invoice" model="ir.actions.report">
<field name="name">Договор со спецификацией</field>
<field name="model">account.move</field>
<field name="report_type">qweb-pdf</field>
<!-- report_name/report_file указывали на namespace чужого модуля
(l10n_ru_contract.*), где шаблон не загружается -> рендер ломался.
Исправлено на собственный namespace l10n_ru_contract_account.* -->
<field name="report_name">l10n_ru_contract_account.report_contract_customer_invoice</field>
<field name="report_file">l10n_ru_contract_account.report_contract_customer_invoice</field>
<field name="print_report_name">'Договор со спецификацией - %s' % (object.name)</field>
<!-- Deprecated, будет удалено в 20 версии: печать переведена на DOCX.
Форма скрыта из UI: binding сброшен (eval=False), код сохранён. -->
<field name="binding_model_id" eval="False"/>
<field name="paperformat_id" ref="paperformat_a4" />
</record>
</odoo>

View File

@@ -0,0 +1,2 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_contract_profile,access_contract_profile,model_contract_profile,,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_contract_profile access_contract_profile model_contract_profile 1 1 1 1

View File

@@ -0,0 +1 @@
from . import test_contract_account

View File

@@ -0,0 +1,227 @@
"""
Tests for l10n_ru_contract_account — invoice field validation against contract.
Validates: Requirements 15.1, 15.2, 15.3, 15.4
"""
from odoo.tests.common import TransactionCase
from odoo.exceptions import ValidationError
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _get_or_create_partner(env, parent=None):
"""Return or create a company partner, optionally with a parent."""
partner = env['res.partner'].create({
'name': 'Test Partner CA',
'is_company': True,
'parent_id': parent.id if parent else False,
})
return partner
def _get_or_create_accounts(env):
"""Return a receivable and payable account for the current company."""
receivable = env['account.account'].search([
('account_type', '=', 'asset_receivable'),
('company_ids', 'in', env.company.id),
], limit=1)
payable = env['account.account'].search([
('account_type', '=', 'liability_payable'),
('company_ids', 'in', env.company.id),
], limit=1)
return receivable, payable
def _get_or_create_journal(env, name='Test Sales Journal CA'):
"""Return or create a sales journal."""
journal = env['account.journal'].search([
('type', '=', 'sale'),
('company_id', '=', env.company.id),
], limit=1)
if not journal:
journal = env['account.journal'].create({
'name': name,
'type': 'sale',
'code': 'TSCA',
'company_id': env.company.id,
})
return journal
def _get_or_create_payment_term(env):
"""Return or create a payment term."""
term = env['account.payment.term'].search([], limit=1)
if not term:
term = env['account.payment.term'].create({'name': 'Immediate CA'})
return term
def _make_profile(env, journal, payment_term, receivable, payable):
"""Create a contract.profile with all required account fields."""
return env['contract.profile'].create({
'name': 'Test Profile CA',
'journal_id': journal.id,
'payment_term_id': payment_term.id,
'receivable_account_id': receivable.id,
'payable_account_id': payable.id,
'max_receivable_id': 100000.0,
})
def _make_contract(env, partner, profile):
"""Create a minimal partner.contract.customer."""
return env['partner.contract.customer'].create({
'date_start': '2024-01-01',
'date_end': '2024-12-31',
'type': 'customer',
'profile_id': profile.id,
'partner_id': partner.id,
'partner_type': 'company',
'company_id': env.company.id,
})
def _make_invoice(env, partner, journal, payment_term, contract, receivable_account):
"""Create a draft out_invoice linked to a contract."""
product = env['product.product'].search([], limit=1)
income_account = env['account.account'].search([
('account_type', '=', 'income'),
('company_ids', 'in', env.company.id),
], limit=1)
if not income_account:
income_account = receivable_account # fallback
move = env['account.move'].create({
'move_type': 'out_invoice',
'partner_id': partner.id,
'journal_id': journal.id,
'invoice_payment_term_id': payment_term.id,
'mt_contract_id': contract.id,
'invoice_line_ids': [(0, 0, {
'name': 'Test line',
'quantity': 1.0,
'price_unit': 100.0,
'account_id': income_account.id,
})],
})
return move
# ---------------------------------------------------------------------------
# TestContractAccountMove
# ---------------------------------------------------------------------------
class TestContractAccountMove(TransactionCase):
"""
Tests for invoice field validation against contract profile.
Validates: Requirements 15.1, 15.2, 15.3, 15.4
"""
def setUp(self):
super().setUp()
self.receivable, self.payable = _get_or_create_accounts(self.env)
self.journal = _get_or_create_journal(self.env)
self.payment_term = _get_or_create_payment_term(self.env)
self.partner = _get_or_create_partner(self.env)
self.profile = _make_profile(
self.env, self.journal, self.payment_term,
self.receivable, self.payable,
)
self.contract = _make_contract(self.env, self.partner, self.profile)
# ------------------------------------------------------------------
# Requirement 15.1 — journal mismatch raises ValidationError on post
# ------------------------------------------------------------------
def test_invoice_posted_journal_mismatch_raises(self):
"""
Req 15.1 — posting an invoice whose journal differs from the contract's
profile journal raises ValidationError.
"""
other_journal = self.env['account.journal'].create({
'name': 'Other Journal CA',
'type': 'sale',
'code': 'OJCA',
'company_id': self.env.company.id,
})
invoice = _make_invoice(
self.env, self.partner, other_journal,
self.payment_term, self.contract, self.receivable,
)
with self.assertRaises(ValidationError):
invoice.action_post()
# ------------------------------------------------------------------
# Requirement 15.2 — payment term mismatch raises ValidationError on post
# ------------------------------------------------------------------
def test_invoice_posted_payment_term_mismatch_raises(self):
"""
Req 15.2 — posting an invoice whose payment term differs from the
contract's profile payment term raises ValidationError.
"""
other_term = self.env['account.payment.term'].create({
'name': 'Other Term CA',
})
invoice = _make_invoice(
self.env, self.partner, self.journal,
other_term, self.contract, self.receivable,
)
with self.assertRaises(ValidationError):
invoice.action_post()
# ------------------------------------------------------------------
# Requirement 15.3 — matching fields post without errors
# ------------------------------------------------------------------
def test_invoice_posted_matching_contract_ok(self):
"""
Req 15.3 — posting an invoice whose journal and payment term match
the contract profile succeeds without errors.
"""
invoice = _make_invoice(
self.env, self.partner, self.journal,
self.payment_term, self.contract, self.receivable,
)
# Should not raise
invoice.action_post()
self.assertEqual(invoice.state, 'posted',
"Invoice should be in posted state after action_post")
# ------------------------------------------------------------------
# Requirement 15.4 — sec_partner_id computed field
# ------------------------------------------------------------------
def test_compute_sec_partner_id_with_parent(self):
"""
Req 15.4 — sec_partner_id equals partner_id.parent_id when parent exists.
"""
parent = _get_or_create_partner(self.env)
child = _get_or_create_partner(self.env, parent=parent)
invoice = self.env['account.move'].create({
'move_type': 'out_invoice',
'partner_id': child.id,
'journal_id': self.journal.id,
})
self.assertEqual(
invoice.sec_partner_id, parent,
"sec_partner_id should be partner_id.parent_id when parent exists",
)
def test_compute_sec_partner_id_without_parent(self):
"""
Req 15.4 — sec_partner_id equals partner_id when partner has no parent.
"""
invoice = self.env['account.move'].create({
'move_type': 'out_invoice',
'partner_id': self.partner.id,
'journal_id': self.journal.id,
})
self.assertEqual(
invoice.sec_partner_id, self.partner,
"sec_partner_id should equal partner_id when no parent exists",
)

View File

@@ -0,0 +1,20 @@
<odoo>
<record id="view_invoice_form" model="ir.ui.view">
<field name="name">view_invoice_form</field>
<field name="model">account.move</field>
<field name="inherit_id" ref="account.view_move_form"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='payment_reference']" position="after">
<field name="mt_contract_id"
domain="[('partner_id','=',sec_partner_id),('company_id','=',company_id)]"
context="{'default_sec_partner_id':partner_id,'default_company_id':company_id,'default_type':'customer'}"/>
<field name="sec_partner_id" invisible="1"/>
<field name="sf_number"/>
<field name="osnovanie" invisible="1"/>
</xpath>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="contract_customer_form_account" model="ir.ui.view">
<field name="name">partner.contract.customer.form.account</field>
<field name="model">partner.contract.customer</field>
<field name="inherit_id" ref="l10n_ru_contract.contract_customer_form"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='partner_type']" position="after">
<field name="sec_partner_id" invisible="1"/>
</xpath>
<xpath expr="//notebook" position="inside">
<page string="Бухгалтерия" name="accounting">
<group>
<group>
<field name="accountant_id"/>
<field name="buh_code"/>
</group>
<group>
<field name="stamp"/>
<field name="signed"/>
</group>
</group>
</page>
</xpath>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,22 @@
<odoo>
<record id="contract_profile_view_account" model="ir.ui.view">
<field name="name">contract.profile.form.account</field>
<field name="model">contract.profile</field>
<field name="inherit_id" ref="l10n_ru_contract.contract_profile_view"/>
<field name="arch" type="xml">
<xpath expr="//form/sheet/group[1]" position="after">
<group>
<field name="receivable_account_id"
domain="[('account_type', '=', 'asset_receivable')]"/>
<field name="max_receivable_id"/>
</group>
<group>
<field name="payable_account_id"
domain="[('account_type', '=', 'liability_payable')]"/>
<field name="payment_term_id"/>
<field name="journal_id"/>
</group>
</xpath>
</field>
</record>
</odoo>