from odoo import fields, models # Расчётная доля НДС в сумме по режиму НДС компании (аналог ставки 5/105). _ADVANCE_VAT_FRACTION = {'vat_5': 5.0 / 105.0, 'vat_7': 7.0 / 107.0} class RsbuMove(models.Model): """Расширение операции: перенос правила признания УСН (для КУДиР).""" _inherit = 'rsbu.move' usn_recognition = fields.Selection( selection=[ ('income', 'Доход'), ('expense', 'Расход'), ('refund_customer', 'Возврат клиенту'), ('refund_supplier', 'Возврат поставщику'), ('none', 'Не участвует в УСН'), ], string='Признание в УСН', default='none', copy=False) is_refund = fields.Boolean(string='Это возврат', copy=False) kudir_income_vat = fields.Monetary( string='НДС, исключаемый из дохода УСН', currency_field='currency_id', default=0.0, copy=False, help='Сумма предъявленного НДС по поступлению, которая исключается ' 'из дохода КУДиР (доход = поступление − этот НДС). На УСН+НДС ' 'заполняется из налога документа; для необлагаемых операций 0.') kudir_income_adjust = fields.Monetary( string='Корректировка дохода УСН при отгрузке', currency_field='currency_id', default=0.0, copy=False, help='Отрицательная корректировка дохода КУДиР в момент отгрузки в ' 'счёт ранее полученного аванса, по которому НДС не был исключён ' 'при поступлении (переходящая сделка: аванс получен до перехода ' 'на НДС, отгрузка облагается по ставке). Равна −(зачтённый аванс ' '× расчётная ставка НДС). См. п.1 ст.248, п.1 ст.346.15 НК РФ.') class AccountMove(models.Model): _inherit = 'account.move' def _rsbu_is_calibration(self): """Документ создан калибровкой (несёт ключ 1С). Для таких проводок поля КУДиР-НДС размечает калибровочный слой по ОСВ-эталону, а не онлайн-логика проведения.""" self.ensure_one() return bool(getattr(self, 'calibration_ref_key', False)) def _rsbu_get_move_values(self): vals = super()._rsbu_get_move_values() template = self.rsbu_template_id or self._rsbu_detect_template() if template: vals['usn_recognition'] = template.usn_recognition vals['is_refund'] = template.is_refund # Корректировка дохода КУДиР при отгрузке в счёт аванса, по # которому НДС не был исключён при поступлении (переходящая # сделка УСН→УСН+НДС). Только для облагаемой отгрузки. # Калибровочные отгрузки размечаются mark_kudir_shipment_adjust # (по ОСВ-эталону входящих авансов) — онлайн-расчёт пропускаем. if template.resolver_key == 'shipment_upd' and self.amount_tax \ and not self._rsbu_is_calibration(): adjust = self._rsbu_kudir_shipment_adjust(template) if adjust: vals['kudir_income_adjust'] = adjust return vals def _rsbu_kudir_shipment_adjust(self, template): """Отрицательная корректировка дохода КУДиР при отгрузке. Проходит зачитываемые отгрузкой авансы (FIFO по основанию, как в резолвере shipment_upd). Для авансов, по которым НДС не был исключён при поступлении (origin.kudir_income_vat == 0), начисляет исключение сейчас: −(зачтённая сумма × расчётная доля НДС). Возвращает <= 0. """ self.ensure_one() fraction = _ADVANCE_VAT_FRACTION.get(self.company_id.rsbu_vat_mode, 0.0) if not fraction: return 0.0 advance = template.sudo()._account_or_none('advance_received') partner = self.partner_id if not advance or not partner: return 0.0 subconto = {'partner': partner.id} contract = getattr(self, 'mt_contract_id', False) if contract: subconto['contract'] = contract.id origins = advance.get_origin_balances( date_to=self.date, company_id=self.company_id.id, subconto=subconto) remaining = abs(self.amount_total) corr_base = 0.0 for data in origins: if remaining <= 0: break available = data['balance'] if data['side'] == 'credit' else 0.0 if available <= 0: continue take = round(min(available, remaining), 2) if take <= 0: continue origin_move = data['origin'] # НДС по этому авансу при поступлении не исключался → исключаем # соответствующую долю сейчас, при отгрузке. if not (origin_move.kudir_income_vat or 0.0): corr_base += take remaining -= take if corr_base <= 0: return 0.0 return -round(corr_base * fraction, 2) class AccountPayment(models.Model): _inherit = 'account.payment' def _rsbu_is_calibration(self): """См. AccountMove._rsbu_is_calibration.""" self.ensure_one() return bool(getattr(self, 'calibration_ref_key', False)) def _rsbu_get_move_values(self): vals = super()._rsbu_get_move_values() template = self.rsbu_template_id or self._rsbu_detect_template() if template: vals['usn_recognition'] = template.usn_recognition vals['is_refund'] = template.is_refund # НДС, исключаемый из дохода КУДиР (доход = поступление − НДС). # Только для доходных поступлений от покупателя (кассовый метод # УСН, п.1 ст.248/346.15 НК: доход без предъявленного НДС). # Калибровочные проводки размечаются отдельно (mark_kudir_vat по # ОСВ-эталону) — онлайн-расчёт для них пропускаем. if template.usn_recognition == 'income' \ and not self._rsbu_is_calibration(): vals['kudir_income_vat'] = self._rsbu_kudir_income_vat() return vals def _rsbu_kudir_income_vat(self): """НДС, исключаемый из дохода КУДиР по клиентскому поступлению. Приоритет — явно указанный НДС платежа (rsbu_vat_amount: банковская выписка/оператор). Иначе, если поступление относится к облагаемой НДС реализации (инвойс того же контрагента и договора несёт налог — «инвойс источник правды»), исключаем расчётную долю НДС из суммы. Для необлагаемых операций (лицензии и т.п.) — 0. """ self.ensure_one() vat = abs(self.rsbu_vat_amount or 0.0) if vat: return round(vat, 2) fraction = _ADVANCE_VAT_FRACTION.get(self.company_id.rsbu_vat_mode, 0.0) if fraction and self._rsbu_payment_is_taxable(): return round(abs(self.amount) * fraction, 2) return 0.0 def _rsbu_payment_is_taxable(self): """Облагается ли НДС реализация, к которой относится поступление. Инвойс — источник правды об облагаемости: ищем out_invoice того же контрагента и договора (mt_contract_id) с ненулевым налогом. Договор в смешанной сделке (лицензия без НДС + услуги с НДС) разграничивает облагаемость по конкретной реализации. """ self.ensure_one() partner = self.partner_id if not partner: return False Move = self.env['account.move'].sudo() domain = [ ('move_type', '=', 'out_invoice'), ('company_id', '=', self.company_id.id), ('partner_id', '=', partner.id), ('amount_tax', '>', 0), ] contract = getattr(self, 'mt_contract_id', False) if contract: domain.append(('mt_contract_id', '=', contract.id)) return bool(Move.search(domain, limit=1))