# Полная реализация НДС для Конто > **Версия:** 1.0 > **Дата:** 2025-07-15 > **Автор:** Конто Engineering > **Стек:** Python/Quart, SQLAlchemy 2.0 (async), PostgreSQL, Jinja2, Alpine.js, Tailwind CSS --- ## Содержание 1. [Обзор: текущее состояние и план](#first) 2. [Фаза 1 — Книга продаж (исходящий НДС)](#first1) 3. [Фаза 2 — Книга покупок (входящий НДС)](#first2) 4. [Фаза 3 — Расчёт НДС к уплате](#first3) 5. [Фаза 4 — Декларация по НДС (XML)](#first4) 6. [Фаза 5 — Особенности для 5%/7% (без вычета)](#first5) 7. [Миграции Alembic](#first6) 8. [Тесты](#first7) 9. [Roadmap и трудозатраты](#first8) --- ## 1. Обзор {#first} ### 1.1. Что уже реализовано | Компонент | Статус | Файл | |-----------|--------|------| | Поля `vat_amount`, `vat_rate` в `Document` | ✅ | `app/models/documents.py` | | Поля `vat_rate`, `vat_amount` в `DocumentItem` | ✅ | `app/models/document_items.py` | | Таблица `vat_rate_periods` (ставки НДС в учётной политике) | ✅ | `app/models/vat_rate_periods.py` | | Свойства `Organization.is_vat_payer`, `current_vat_rate_code` | ✅ | `app/models/organizations.py` | | Утилиты расчёта НДС (`resolve_item_vat_rate`, `calculate_item_vat`) | ✅ | `app/utils/vat_utils.py` | | Сервис `VATRatePeriodService` (CRUD, автоопределение ставки) | ✅ | `app/services/vat_rate_period_service.py` | | Допустимые ставки: `none/5/7/20/22` | ✅ | `app/services/vat_rate_period_service.py` | | Счёт 68.02 (НДС к уплате) в плане счетов | ✅ | `scripts/init_chart_of_accounts.py` | | Счёт 90.03 (НДС в продажах) | ✅ | `app/services/accounting_service.py` | | Базовая проводка `Дт 90.03 Кт 68.02` | ✅ | `app/services/accounting_service.py` | | УПД-сервис (`UPDService`) | ✅ | `app/services/upd_service.py` | | Шаблоны УПД (PDF in/out) | ✅ | `app/utils/upd_in_pdf.py`, `upd_out_pdf.py` | | XML-генератор УПД | ✅ | `app/utils/upd_xml_generator.py` | | Интеграция с Астрал.Отчётность (модели) | ✅ | `app/models/astral_reports.py` | ### 1.2. Что нужно реализовать | Компонент | Приоритет | Фаза | |-----------|-----------|------| | Книга продаж (`SalesBookEntry`) | 🔴 Критично | 1 | | Автоматическое заполнение из УПД | 🔴 Критично | 1 | | Книга покупок (`PurchaseBookEntry`) | 🔴 Критично | 2 | | Счёт 19 (входящий НДС) + субсчета 19.01–19.03 | 🔴 Критично | 2 | | Проводки: `Дт 19 Кт 60`, `Дт 68.02 Кт 19` | 🔴 Критично | 2 | | `VATCalculationService` (расчёт к уплате) | 🔴 Критично | 3 | | Страница «НДС» в интерфейсе | 🟡 Важно | 3 | | Декларация по НДС (XML, разделы 1,3,8,9) | 🟡 Важно | 4 | | Отправка через Астрал.Отчётность | 🟡 Важно | 4 | | Упрощённый режим для 5%/7% (без вычета) | 🔴 Критично | 5 | | Проводка `Дт 68.02 Кт 51` (уплата НДС) | 🟢 Средне | 3 | ### 1.3. Нормативная база - **Федеральный закон от 12.07.2024 № 176-ФЗ** — налоговая реформа 2025–2026 - **НК РФ, глава 21** — НДС - **НК РФ, ст. 171.1** — запрет вычета НДС при ставках 5%/7% - **НК РФ, ст. 174** — порядок и сроки уплаты (ежемесячно ⅓, до 28-го числа) - **Постановление Правительства РФ от 26.12.2011 № 1137** — формы книг покупок/продаж, журналов счетов-фактур - **Приказ ФНС от 29.10.2014 № ММВ-7-3/558@** — форма декларации по НДС (КНД 1151001) - **XSD-формат NO_NDS** — актуальная версия на format.nalog.ru ### 1.4. Ключевые пороги (с 2026 года) | Доход за предыдущий год | Ставка НДС | Право на вычет | |--------------------------|------------|----------------| | до 60 млн ₽ | Освобождение от НДС | — | | 60–250 млн ₽ | **5%** | ❌ Нет | | 250–450 млн ₽ | **7%** | ❌ Нет | | свыше 450 млн ₽ | **22%** (общая) | ✅ Да | > **Важно:** ставки 5%/7% — единые на все товары/услуги, без права вычета входящего НДС (ст. 171.1 НК РФ). > Ставка 22% заменяет 20% с 01.01.2025 (ФЗ-176). --- ## 2. Фаза 1 — Книга продаж {#first1} Книга продаж — обязательный регистр налогового учёта, в который вносятся выставленные счета-фактуры (и УПД со статусом «1»). Ведётся каждым плательщиком НДС. ### 2.1. Модель `SalesBookEntry` ```python # app/models/vat_books.py """ Модели книги продаж и книги покупок для учёта НДС. Книга продаж — регистр исходящего НДС (Постановление №1137). Книга покупок — регистр входящего НДС для вычета. """ from sqlalchemy import ( Column, Integer, String, Date, DateTime, Numeric, Text, ForeignKey, Boolean, Index, UniqueConstraint ) from sqlalchemy.sql import func from sqlalchemy.orm import relationship from app.models.base import Base class SalesBookEntry(Base): """ Запись книги продаж (исходящий НДС). Каждая запись соответствует одному счёту-фактуре / УПД, выставленному покупателю в отчётном квартале. Основание: Постановление Правительства РФ от 26.12.2011 № 1137, Приложение 5 «Книга продаж». Графы книги продаж (по номерам из Постановления): 1 — Порядковый номер записи 2 — Код вида операции 3 — Номер и дата счёта-фактуры продавца 3а — Номер и дата исправления СФ 3б — Номер и дата корректировочного СФ 4–6 — не используются (агентские) 7 — Наименование покупателя 8 — ИНН/КПП покупателя 9–11 — Посредник (не используются) 12 — Номер и дата документа об оплате 13а — Стоимость продаж с НДС (по ставке 22%) 13б — Стоимость продаж с НДС (по ставке 10%) 14 — Стоимость продаж без НДС 15 — Стоимость продаж по ставке 0% 16 — Сумма НДС по ставке 22% 17 — Сумма НДС по ставке 10% 17а — Сумма НДС по ставке 5% 17б — Сумма НДС по ставке 7% 18 — Стоимость продаж освобождённых от НДС """ __tablename__ = 'sales_book_entries' id = Column(Integer, primary_key=True) organization_id = Column( Integer, ForeignKey('organizations.id', ondelete='CASCADE'), nullable=False, index=True ) # Период (квартал) quarter = Column(Integer, nullable=False, comment='Квартал (1-4)') year = Column(Integer, nullable=False, comment='Год') # Порядковый номер в книге (графа 1) sequence_number = Column(Integer, nullable=False) # Код вида операции (графа 2) — Приложение к Приказу ФНС ММВ-7-3/136@ operation_type_code = Column( String(2), nullable=False, default='01', comment='01=реализация, 02=аванс, 06=аванс зачтённый, 21=возврат, 26=0%' ) # Счёт-фактура продавца (графа 3) invoice_number = Column(String(100), nullable=False, comment='Номер счёта-фактуры/УПД') invoice_date = Column(Date, nullable=False, comment='Дата счёта-фактуры/УПД') # Исправление (графа 3а) correction_number = Column(String(50), nullable=True) correction_date = Column(Date, nullable=True) # Корректировочный СФ (графа 3б) adjustment_number = Column(String(50), nullable=True) adjustment_date = Column(Date, nullable=True) # Покупатель (графы 7, 8) buyer_name = Column(String(500), nullable=False, comment='Наименование покупателя') buyer_inn = Column(String(12), nullable=True, comment='ИНН покупателя') buyer_kpp = Column(String(9), nullable=True, comment='КПП покупателя') # Документ об оплате (графа 12) — для авансов payment_document_number = Column(String(100), nullable=True) payment_document_date = Column(Date, nullable=True) # Стоимость продаж с НДС по ставкам (графы 13а, 13б) total_with_vat_22 = Column(Numeric(15, 2), default=0, comment='Стоимость с НДС по ставке 22%') total_with_vat_10 = Column(Numeric(15, 2), default=0, comment='Стоимость с НДС по ставке 10%') total_with_vat_5 = Column(Numeric(15, 2), default=0, comment='Стоимость с НДС по ставке 5%') total_with_vat_7 = Column(Numeric(15, 2), default=0, comment='Стоимость с НДС по ставке 7%') # Стоимость без НДС и по 0% (графы 14, 15) total_without_vat = Column(Numeric(15, 2), default=0, comment='Без НДС (графа 14)') total_zero_rate = Column(Numeric(15, 2), default=0, comment='По ставке 0% (графа 15)') # Суммы НДС по ставкам (графы 16, 17, 17а, 17б) vat_amount_22 = Column(Numeric(15, 2), default=0, comment='НДС по ставке 22%') vat_amount_10 = Column(Numeric(15, 2), default=0, comment='НДС по ставке 10%') vat_amount_5 = Column(Numeric(15, 2), default=0, comment='НДС по ставке 5%') vat_amount_7 = Column(Numeric(15, 2), default=0, comment='НДС по ставке 7%') # Освобождённые (графа 18) total_exempt = Column(Numeric(15, 2), default=0, comment='Освобождённые от НДС') # Связь с документом в системе document_id = Column( Integer, ForeignKey('documents.id', ondelete='SET NULL'), nullable=True, comment='ID документа (УПД/СФ) в системе' ) # Связь с контрагентом contractor_id = Column( Integer, ForeignKey('contractors.id', ondelete='SET NULL'), nullable=True ) # Служебные поля is_auto = Column(Boolean, default=True, comment='Автоматически заполнена из документа') notes = Column(Text, nullable=True) created_at = Column(DateTime(timezone=True), server_default=func.now()) updated_at = Column(DateTime(timezone=True), onupdate=func.now()) # Relationships organization = relationship("Organization", lazy="select") document = relationship("Document", lazy="select") contractor = relationship("Contractor", lazy="select") __table_args__ = ( Index('idx_sales_book_org_period', 'organization_id', 'year', 'quarter'), Index('idx_sales_book_document', 'document_id'), UniqueConstraint( 'organization_id', 'document_id', 'operation_type_code', name='uq_sales_book_org_doc_op' ), ) @property def total_vat(self): """Общая сумма НДС по записи""" from decimal import Decimal return ( (self.vat_amount_22 or Decimal('0')) + (self.vat_amount_10 or Decimal('0')) + (self.vat_amount_5 or Decimal('0')) + (self.vat_amount_7 or Decimal('0')) ) @property def total_sales(self): """Общая стоимость продаж с НДС""" from decimal import Decimal return ( (self.total_with_vat_22 or Decimal('0')) + (self.total_with_vat_10 or Decimal('0')) + (self.total_with_vat_5 or Decimal('0')) + (self.total_with_vat_7 or Decimal('0')) + (self.total_without_vat or Decimal('0')) + (self.total_zero_rate or Decimal('0')) + (self.total_exempt or Decimal('0')) ) ``` ### 2.2. Сервис `SalesBookService` ```python # app/services/sales_book_service.py """ Сервис книги продаж. Отвечает за: - Автоматическое создание записей из УПД/СФ - CRUD операции - Формирование итогов за квартал - Нумерацию записей """ from decimal import Decimal from datetime import date from typing import List, Optional, Dict from sqlalchemy import select, and_, func, delete from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from app.models.vat_books import SalesBookEntry from app.models.documents import Document from app.models.document_items import DocumentItem from app.models.contractors import Contractor from app.models.organizations import Organization def _quarter_of(d: date) -> int: """Определить квартал по дате""" return (d.month - 1) // 3 + 1 def _quarter_date_range(year: int, quarter: int): """Получить диапазон дат квартала""" start_month = (quarter - 1) * 3 + 1 end_month = start_month + 2 start_date = date(year, start_month, 1) if end_month == 12: end_date = date(year, 12, 31) else: end_date = date(year, end_month + 1, 1) from datetime import timedelta end_date = end_date - timedelta(days=1) return start_date, end_date class SalesBookService: """Сервис для работы с книгой продаж""" # Типы документов, которые попадают в книгу продаж SALES_DOCUMENT_TYPES = ('UPD_OUT', 'upd', 'invoice_factura') @staticmethod async def create_entry_from_document( db: AsyncSession, document: Document, contractor: Optional[Contractor] = None, operation_type_code: str = '01' ) -> Optional[SalesBookEntry]: """ Создать запись книги продаж из документа (УПД/СФ). Вызывается автоматически при проведении исходящего УПД. Args: db: Сессия БД document: Документ (УПД или счёт-фактура) contractor: Контрагент-покупатель operation_type_code: Код вида операции (по умолчанию '01' — реализация) Returns: SalesBookEntry или None если документ не подлежит включению """ # Проверяем тип документа if document.document_type not in SalesBookService.SALES_DOCUMENT_TYPES: return None # Проверяем, нет ли уже записи для этого документа existing = await db.execute( select(SalesBookEntry).where(and_( SalesBookEntry.organization_id == document.organization_id, SalesBookEntry.document_id == document.id, SalesBookEntry.operation_type_code == operation_type_code )) ) if existing.scalar_one_or_none(): return None # Уже есть запись # Определяем квартал q = _quarter_of(document.document_date) year = document.document_date.year # Получаем следующий порядковый номер seq = await SalesBookService._next_sequence( db, document.organization_id, year, q ) # Загружаем контрагента если не передан if contractor is None and document.contractor_id: result = await db.execute( select(Contractor).where(Contractor.id == document.contractor_id) ) contractor = result.scalar_one_or_none() # Загружаем позиции для разбивки по ставкам result = await db.execute( select(DocumentItem).where(DocumentItem.document_id == document.id) ) items = result.scalars().all() # Разбивка сумм по ставкам НДС amounts = SalesBookService._split_by_vat_rate(items) entry = SalesBookEntry( organization_id=document.organization_id, quarter=q, year=year, sequence_number=seq, operation_type_code=operation_type_code, invoice_number=document.document_number, invoice_date=document.document_date, buyer_name=contractor.full_name if contractor else 'Не указан', buyer_inn=contractor.inn if contractor else None, buyer_kpp=getattr(contractor, 'kpp', None), document_id=document.id, contractor_id=document.contractor_id, # Суммы по ставкам total_with_vat_22=amounts.get('total_with_vat_22', Decimal('0')), total_with_vat_10=amounts.get('total_with_vat_10', Decimal('0')), total_with_vat_5=amounts.get('total_with_vat_5', Decimal('0')), total_with_vat_7=amounts.get('total_with_vat_7', Decimal('0')), total_without_vat=amounts.get('total_without_vat', Decimal('0')), total_zero_rate=amounts.get('total_zero_rate', Decimal('0')), vat_amount_22=amounts.get('vat_22', Decimal('0')), vat_amount_10=amounts.get('vat_10', Decimal('0')), vat_amount_5=amounts.get('vat_5', Decimal('0')), vat_amount_7=amounts.get('vat_7', Decimal('0')), total_exempt=amounts.get('total_exempt', Decimal('0')), is_auto=True, ) db.add(entry) await db.flush() return entry @staticmethod def _split_by_vat_rate(items: List[DocumentItem]) -> Dict[str, Decimal]: """ Разбить позиции документа по ставкам НДС. Возвращает dict с суммами по каждой графе книги продаж. """ result = { 'total_with_vat_22': Decimal('0'), 'total_with_vat_10': Decimal('0'), 'total_with_vat_5': Decimal('0'), 'total_with_vat_7': Decimal('0'), 'total_without_vat': Decimal('0'), 'total_zero_rate': Decimal('0'), 'vat_22': Decimal('0'), 'vat_10': Decimal('0'), 'vat_5': Decimal('0'), 'vat_7': Decimal('0'), 'total_exempt': Decimal('0'), } for item in items: rate = int(item.vat_rate) if item.vat_rate else 0 amount = Decimal(str(item.amount or 0)) vat = Decimal(str(item.vat_amount or 0)) total = Decimal(str(item.total_amount or 0)) if rate == 22 or rate == 20: result['total_with_vat_22'] += total result['vat_22'] += vat elif rate == 10: result['total_with_vat_10'] += total result['vat_10'] += vat elif rate == 5: result['total_with_vat_5'] += total result['vat_5'] += vat elif rate == 7: result['total_with_vat_7'] += total result['vat_7'] += vat elif rate == 0: result['total_zero_rate'] += total else: # Без НДС result['total_exempt'] += amount return result @staticmethod async def _next_sequence( db: AsyncSession, org_id: int, year: int, quarter: int ) -> int: """Следующий порядковый номер в книге продаж за квартал""" result = await db.execute( select(func.coalesce(func.max(SalesBookEntry.sequence_number), 0)) .where(and_( SalesBookEntry.organization_id == org_id, SalesBookEntry.year == year, SalesBookEntry.quarter == quarter, )) ) return result.scalar() + 1 @staticmethod async def get_entries( db: AsyncSession, organization_id: int, year: int, quarter: int ) -> List[SalesBookEntry]: """Получить все записи книги продаж за квартал""" result = await db.execute( select(SalesBookEntry) .where(and_( SalesBookEntry.organization_id == organization_id, SalesBookEntry.year == year, SalesBookEntry.quarter == quarter, )) .order_by(SalesBookEntry.sequence_number) ) return list(result.scalars().all()) @staticmethod async def get_quarter_totals( db: AsyncSession, organization_id: int, year: int, quarter: int ) -> Dict[str, Decimal]: """ Итоги книги продаж за квартал. Возвращает суммы по всем графам для итоговой строки. """ entries = await SalesBookService.get_entries(db, organization_id, year, quarter) totals = { 'total_with_vat_22': Decimal('0'), 'total_with_vat_10': Decimal('0'), 'total_with_vat_5': Decimal('0'), 'total_with_vat_7': Decimal('0'), 'total_without_vat': Decimal('0'), 'total_zero_rate': Decimal('0'), 'vat_amount_22': Decimal('0'), 'vat_amount_10': Decimal('0'), 'vat_amount_5': Decimal('0'), 'vat_amount_7': Decimal('0'), 'total_exempt': Decimal('0'), 'total_vat': Decimal('0'), 'total_sales': Decimal('0'), 'entries_count': len(entries), } for e in entries: totals['total_with_vat_22'] += e.total_with_vat_22 or Decimal('0') totals['total_with_vat_10'] += e.total_with_vat_10 or Decimal('0') totals['total_with_vat_5'] += e.total_with_vat_5 or Decimal('0') totals['total_with_vat_7'] += e.total_with_vat_7 or Decimal('0') totals['total_without_vat'] += e.total_without_vat or Decimal('0') totals['total_zero_rate'] += e.total_zero_rate or Decimal('0') totals['vat_amount_22'] += e.vat_amount_22 or Decimal('0') totals['vat_amount_10'] += e.vat_amount_10 or Decimal('0') totals['vat_amount_5'] += e.vat_amount_5 or Decimal('0') totals['vat_amount_7'] += e.vat_amount_7 or Decimal('0') totals['total_exempt'] += e.total_exempt or Decimal('0') totals['total_vat'] = ( totals['vat_amount_22'] + totals['vat_amount_10'] + totals['vat_amount_5'] + totals['vat_amount_7'] ) totals['total_sales'] = ( totals['total_with_vat_22'] + totals['total_with_vat_10'] + totals['total_with_vat_5'] + totals['total_with_vat_7'] + totals['total_without_vat'] + totals['total_zero_rate'] + totals['total_exempt'] ) return totals @staticmethod async def rebuild_from_documents( db: AsyncSession, organization_id: int, year: int, quarter: int ) -> int: """ Полная перестройка книги продаж из документов за квартал. Удаляет все автоматические записи и создаёт заново. Ручные записи (is_auto=False) сохраняются. Returns: Количество созданных записей """ # Удаляем автоматические записи await db.execute( delete(SalesBookEntry).where(and_( SalesBookEntry.organization_id == organization_id, SalesBookEntry.year == year, SalesBookEntry.quarter == quarter, SalesBookEntry.is_auto == True, )) ) # Находим исходящие УПД/СФ за квартал start_date, end_date = _quarter_date_range(year, quarter) result = await db.execute( select(Document) .where(and_( Document.organization_id == organization_id, Document.document_type.in_(SalesBookService.SALES_DOCUMENT_TYPES), Document.document_date >= start_date, Document.document_date <= end_date, Document.status.in_(['issued', 'paid']), # Только проведённые )) .order_by(Document.document_date, Document.document_number) ) documents = result.scalars().all() count = 0 for doc in documents: entry = await SalesBookService.create_entry_from_document(db, doc) if entry: count += 1 await db.flush() return count @staticmethod async def delete_entry( db: AsyncSession, entry_id: int, organization_id: int ) -> bool: """Удалить запись из книги продаж""" result = await db.execute( select(SalesBookEntry).where(and_( SalesBookEntry.id == entry_id, SalesBookEntry.organization_id == organization_id, )) ) entry = result.scalar_one_or_none() if not entry: return False await db.delete(entry) await db.flush() return True ``` ### 2.3. Интеграция с проведением УПД Добавляем вызов в существующий процесс проведения документа: ```python # Добавить в app/views/documents_extended.py # В обработчик смены статуса УПД на 'issued': from app.services.sales_book_service import SalesBookService # При проведении исходящего УПД (status='issued'): async def on_document_issued(db, document): """Хук проведения документа — создание записей книги продаж и проводок""" if document.document_type in ('UPD_OUT', 'upd', 'invoice_factura'): # Создаём запись в книге продаж await SalesBookService.create_entry_from_document(db, document) # Создаём проводки НДС (если организация — плательщик) org = await db.get(Organization, document.organization_id) if org and org.is_vat_payer and document.vat_amount and document.vat_amount > 0: from app.services.accounting_service import AccountingService # Дт 90.03 Кт 68.02 — Начислен НДС с реализации await AccountingService.create_entry( db=db, organization_id=document.organization_id, entry_date=document.document_date, debit_account='90.03', credit_account='68.02', amount=document.vat_amount, description=f'НДС с реализации по {document.document_type} №{document.document_number}', document_type='document', document_id=document.id, ) ``` ### 2.4. Эндпоинты книги продаж ```python # app/views/vat.py """ Маршруты для НДС: книга продаж, книга покупок, расчёт, декларация. """ from quart import Blueprint, render_template, request, redirect, url_for, session, flash, jsonify, Response from sqlalchemy import select from datetime import date, datetime from decimal import Decimal from app.database import AsyncSessionLocal from app.auth_utils import organization_required, module_permission_required from app.org_utils import get_current_organization from app.services.sales_book_service import SalesBookService from app.logging import get_logger logger = get_logger(__name__) vat_bp = Blueprint("vat", __name__, url_prefix="/vat") @vat_bp.route("/sales-book") @module_permission_required("taxes", "view") @organization_required async def sales_book(): """Книга продаж — список записей за квартал""" user_id = session.get("user_id") current_org = await get_current_organization(user_id) if not current_org: return redirect(url_for("organizations.create")) # Параметры периода now = date.today() year = int(request.args.get("year", now.year)) quarter = int(request.args.get("quarter", (now.month - 1) // 3 + 1)) async with AsyncSessionLocal() as db: entries = await SalesBookService.get_entries(db, current_org.id, year, quarter) totals = await SalesBookService.get_quarter_totals(db, current_org.id, year, quarter) return await render_template( "vat/sales_book.html", entries=entries, totals=totals, year=year, quarter=quarter, organization=current_org, ) @vat_bp.route("/sales-book/rebuild", methods=["POST"]) @module_permission_required("taxes", "edit") @organization_required async def sales_book_rebuild(): """Перестроить книгу продаж из документов""" user_id = session.get("user_id") current_org = await get_current_organization(user_id) form = await request.form year = int(form.get("year", date.today().year)) quarter = int(form.get("quarter", (date.today().month - 1) // 3 + 1)) async with AsyncSessionLocal() as db: count = await SalesBookService.rebuild_from_documents( db, current_org.id, year, quarter ) await db.commit() await flash(f"Книга продаж перестроена: {count} записей", "success") return redirect(url_for("vat.sales_book", year=year, quarter=quarter)) @vat_bp.route("/sales-book//delete", methods=["POST"]) @module_permission_required("taxes", "edit") @organization_required async def sales_book_delete(entry_id: int): """Удалить запись из книги продаж""" user_id = session.get("user_id") current_org = await get_current_organization(user_id) async with AsyncSessionLocal() as db: deleted = await SalesBookService.delete_entry(db, entry_id, current_org.id) await db.commit() if deleted: await flash("Запись удалена", "success") else: await flash("Запись не найдена", "error") year = request.args.get("year", date.today().year) quarter = request.args.get("quarter", (date.today().month - 1) // 3 + 1) return redirect(url_for("vat.sales_book", year=year, quarter=quarter)) ``` ### 2.5. Шаблон книги продаж ```html {# app/templates/vat/sales_book.html #} {% extends "base.html" %} {% block title %}Книга продаж — {{ quarter }} кв. {{ year }}{% endblock %} {% block content %}
{# Заголовок с навигацией по кварталам #}

Книга продаж

{{ organization.short_name }} — {{ quarter }} квартал {{ year }} г.

{# Навигация по кварталам #}
{# Кнопка перестройки #}
{# Сводка #}

Записей

{{ totals.entries_count }}

Реализация

{{ "{:,.2f}".format(totals.total_sales) }} ₽

НДС начислен

{{ "{:,.2f}".format(totals.total_vat) }} ₽

Без НДС / 0%

{{ "{:,.2f}".format(totals.total_exempt + totals.total_zero_rate) }} ₽

{# Таблица записей #}
{% for entry in entries %} {% else %} {% endfor %} {% if entries %} {% endif %}
Код СФ / УПД Покупатель Стоимость с НДС Сумма НДС Действия
{{ entry.sequence_number }} {{ entry.operation_type_code }}
№ {{ entry.invoice_number }}
от {{ entry.invoice_date.strftime('%d.%m.%Y') }}
{{ entry.buyer_name[:40] }}{% if entry.buyer_name|length > 40 %}...{% endif %}
{{ entry.buyer_inn or '' }}
{{ "{:,.2f}".format(entry.total_sales) }} {{ "{:,.2f}".format(entry.total_vat) }} {% if entry.document_id %} Документ {% endif %}
Нет записей за {{ quarter }} квартал {{ year }} г.
Нажмите «Перестроить из документов» для автоматического заполнения
Итого за квартал {{ "{:,.2f}".format(totals.total_sales) }} {{ "{:,.2f}".format(totals.total_vat) }}
{% endblock %} ``` --- ## 3. Фаза 2 — Книга покупок {#first2} ### 3.1. Счёт 19 и субсчета Необходимо добавить в план счетов: ```sql -- Добавление счёта 19 и субсчетов (в миграции Alembic) INSERT INTO chart_of_accounts (account_code, parent_code, account_name, account_type, description, is_active, is_synthetic) VALUES ('19', NULL, 'НДС по приобретённым ценностям', 'active', 'Суммы НДС, предъявленные поставщиками, подлежащие вычету', true, true), ('19.01', '19', 'НДС при приобретении основных средств', 'active', 'НДС по приобретённым ОС', true, false), ('19.02', '19', 'НДС по приобретённым нематериальным активам', 'active', 'НДС по приобретённым НМА', true, false), ('19.03', '19', 'НДС по приобретённым МПЗ', 'active', 'НДС по приобретённым материально-производственным запасам, работам, услугам', true, false) ON CONFLICT (account_code) DO NOTHING; ``` ### 3.2. Модель `PurchaseBookEntry` ```python # Добавить в app/models/vat_books.py class PurchaseBookEntry(Base): """ Запись книги покупок (входящий НДС). Каждая запись соответствует одному счёту-фактуре / УПД, полученному от поставщика, по которому принимается вычет НДС. Основание: Постановление Правительства РФ от 26.12.2011 № 1137, Приложение 4 «Книга покупок». Важно: при ставках 5% и 7% книга покупок НЕ ВЕДЁТСЯ (нет права на вычет). Книга покупок актуальна только при ставке 22% (общая). Графы книги покупок: 1 — Порядковый номер 2 — Код вида операции 3 — Номер и дата СФ продавца 4–6 — Исправление, корректировочный СФ 7 — Номер и дата документа об оплате 8 — Дата принятия на учёт 9 — Наименование продавца 10 — ИНН/КПП продавца 11–12 — Посредник (не используются) 13 — Номер ГТД 14 — Валюта (не используется, RUB) 15 — Стоимость покупок с НДС 16 — Сумма НДС к вычету """ __tablename__ = 'purchase_book_entries' id = Column(Integer, primary_key=True) organization_id = Column( Integer, ForeignKey('organizations.id', ondelete='CASCADE'), nullable=False, index=True ) # Период quarter = Column(Integer, nullable=False, comment='Квартал (1-4)') year = Column(Integer, nullable=False, comment='Год') # Порядковый номер (графа 1) sequence_number = Column(Integer, nullable=False) # Код вида операции (графа 2) operation_type_code = Column( String(2), nullable=False, default='01', comment='01=приобретение, 02=аванс, 06=аванс зачтённый, 22=возврат' ) # Счёт-фактура продавца (графа 3) invoice_number = Column(String(100), nullable=False, comment='Номер СФ/УПД поставщика') invoice_date = Column(Date, nullable=False, comment='Дата СФ/УПД поставщика') # Исправление СФ (графа 4) correction_number = Column(String(50), nullable=True) correction_date = Column(Date, nullable=True) # Корректировочный СФ (графа 5) adjustment_number = Column(String(50), nullable=True) adjustment_date = Column(Date, nullable=True) # Документ об оплате (графа 7) payment_document_number = Column(String(100), nullable=True) payment_document_date = Column(Date, nullable=True) # Дата принятия на учёт (графа 8) acceptance_date = Column(Date, nullable=True, comment='Дата оприходования') # Продавец (графы 9, 10) seller_name = Column(String(500), nullable=False, comment='Наименование продавца') seller_inn = Column(String(12), nullable=True, comment='ИНН продавца') seller_kpp = Column(String(9), nullable=True, comment='КПП продавца') # ГТД (графа 13) — для импортных товаров customs_declaration = Column(String(100), nullable=True) # Стоимость покупок с НДС (графа 15) total_with_vat = Column(Numeric(15, 2), nullable=False, default=0) # Сумма НДС к вычету (графа 16) vat_deductible = Column(Numeric(15, 2), nullable=False, default=0) # Связь с документом document_id = Column( Integer, ForeignKey('documents.id', ondelete='SET NULL'), nullable=True, comment='ID входящего документа (УПД_IN/СФ) в системе' ) # Связь с контрагентом contractor_id = Column( Integer, ForeignKey('contractors.id', ondelete='SET NULL'), nullable=True ) # Статус вычета is_deducted = Column( Boolean, default=False, comment='Вычет принят (проводка Дт 68.02 Кт 19 выполнена)' ) deduction_date = Column(Date, nullable=True, comment='Дата применения вычета') # Служебные is_auto = Column(Boolean, default=True) notes = Column(Text, nullable=True) created_at = Column(DateTime(timezone=True), server_default=func.now()) updated_at = Column(DateTime(timezone=True), onupdate=func.now()) # Relationships organization = relationship("Organization", lazy="select") document = relationship("Document", lazy="select") contractor = relationship("Contractor", lazy="select") __table_args__ = ( Index('idx_purchase_book_org_period', 'organization_id', 'year', 'quarter'), Index('idx_purchase_book_document', 'document_id'), UniqueConstraint( 'organization_id', 'document_id', 'operation_type_code', name='uq_purchase_book_org_doc_op' ), ) ``` ### 3.3. Сервис `PurchaseBookService` ```python # app/services/purchase_book_service.py """ Сервис книги покупок. Отвечает за: - Автоматическое создание записей из входящих УПД/СФ - Создание проводок входящего НДС (Дт 19 Кт 60) - Применение вычета НДС (Дт 68.02 Кт 19) - Формирование итогов за квартал Важно: книга покупок ведётся ТОЛЬКО при ставке 22%. При 5%/7% вычет запрещён (ст. 171.1 НК РФ). """ from decimal import Decimal from datetime import date from typing import List, Optional, Dict from sqlalchemy import select, and_, func, delete from sqlalchemy.ext.asyncio import AsyncSession from app.models.vat_books import PurchaseBookEntry from app.models.documents import Document from app.models.document_items import DocumentItem from app.models.contractors import Contractor from app.models.organizations import Organization from app.services.accounting_service import AccountingService def _quarter_of(d: date) -> int: return (d.month - 1) // 3 + 1 def _quarter_date_range(year: int, quarter: int): start_month = (quarter - 1) * 3 + 1 end_month = start_month + 2 start_date = date(year, start_month, 1) if end_month == 12: end_date = date(year, 12, 31) else: from datetime import timedelta end_date = date(year, end_month + 1, 1) - timedelta(days=1) return start_date, end_date class PurchaseBookService: """Сервис для работы с книгой покупок""" # Типы входящих документов PURCHASE_DOCUMENT_TYPES = ('UPD_IN',) @staticmethod async def can_deduct_vat( db: AsyncSession, organization_id: int, target_date: Optional[date] = None ) -> bool: """ Проверяет, имеет ли организация право на вычет НДС. Вычет разрешён ТОЛЬКО при ставке 22%. При 5%/7% вычет запрещён (ст. 171.1 НК РФ). """ from app.services.vat_rate_period_service import VATRatePeriodService period = await VATRatePeriodService.get_current_period( db, organization_id, target_date ) if not period: return False return period.vat_rate_code in ('20', '22') @staticmethod async def create_entry_from_document( db: AsyncSession, document: Document, contractor: Optional[Contractor] = None, operation_type_code: str = '01' ) -> Optional[PurchaseBookEntry]: """ Создать запись книги покупок из входящего документа. Также создаёт проводку: Дт 19.03 Кт 60 (входящий НДС). Args: document: Входящий УПД contractor: Контрагент-поставщик operation_type_code: Код вида операции Returns: PurchaseBookEntry или None """ if document.document_type not in PurchaseBookService.PURCHASE_DOCUMENT_TYPES: return None # Проверяем право на вычет can_deduct = await PurchaseBookService.can_deduct_vat( db, document.organization_id, document.document_date ) if not can_deduct: return None # Нет права на вычет (5%/7%) # Проверяем, нет ли записи existing = await db.execute( select(PurchaseBookEntry).where(and_( PurchaseBookEntry.organization_id == document.organization_id, PurchaseBookEntry.document_id == document.id, PurchaseBookEntry.operation_type_code == operation_type_code )) ) if existing.scalar_one_or_none(): return None q = _quarter_of(document.document_date) year = document.document_date.year seq = await PurchaseBookService._next_sequence( db, document.organization_id, year, q ) # Загружаем контрагента if contractor is None and document.contractor_id: result = await db.execute( select(Contractor).where(Contractor.id == document.contractor_id) ) contractor = result.scalar_one_or_none() vat_amount = Decimal(str(document.vat_amount or 0)) total_amount = Decimal(str(document.total_amount or 0)) entry = PurchaseBookEntry( organization_id=document.organization_id, quarter=q, year=year, sequence_number=seq, operation_type_code=operation_type_code, invoice_number=document.document_number, invoice_date=document.document_date, acceptance_date=document.document_date, seller_name=contractor.full_name if contractor else 'Не указан', seller_inn=contractor.inn if contractor else None, seller_kpp=getattr(contractor, 'kpp', None), total_with_vat=total_amount, vat_deductible=vat_amount, document_id=document.id, contractor_id=document.contractor_id, is_auto=True, ) db.add(entry) await db.flush() # Создаём проводку: Дт 19.03 Кт 60 — Входящий НДС if vat_amount > 0: await AccountingService.create_entry( db=db, organization_id=document.organization_id, entry_date=document.document_date, debit_account='19.03', credit_account='60', amount=vat_amount, description=f'Входящий НДС по УПД №{document.document_number} от {contractor.short_name if contractor else "поставщик"}', document_type='document', document_id=document.id, ) return entry @staticmethod async def apply_deduction( db: AsyncSession, entry_id: int, organization_id: int, deduction_date: Optional[date] = None ) -> Optional[PurchaseBookEntry]: """ Применить вычет НДС по записи книги покупок. Создаёт проводку: Дт 68.02 Кт 19.03 — НДС принят к вычету. Вычет можно применить в течение 3 лет с даты оприходования (п. 1.1 ст. 172 НК РФ). """ result = await db.execute( select(PurchaseBookEntry).where(and_( PurchaseBookEntry.id == entry_id, PurchaseBookEntry.organization_id == organization_id, )) ) entry = result.scalar_one_or_none() if not entry or entry.is_deducted: return None deduction_date = deduction_date or date.today() # Проверяем право на вычет can_deduct = await PurchaseBookService.can_deduct_vat( db, organization_id, deduction_date ) if not can_deduct: return None # Проводка: Дт 68.02 Кт 19.03 await AccountingService.create_entry( db=db, organization_id=organization_id, entry_date=deduction_date, debit_account='68.02', credit_account='19.03', amount=entry.vat_deductible, description=f'Вычет НДС по СФ №{entry.invoice_number} от {entry.seller_name[:30]}', document_type='purchase_book', document_id=entry.id, ) entry.is_deducted = True entry.deduction_date = deduction_date await db.flush() return entry @staticmethod async def apply_all_deductions( db: AsyncSession, organization_id: int, year: int, quarter: int, deduction_date: Optional[date] = None ) -> int: """ Массовое применение вычетов по всем непринятым записям за квартал. Returns: Количество применённых вычетов """ result = await db.execute( select(PurchaseBookEntry).where(and_( PurchaseBookEntry.organization_id == organization_id, PurchaseBookEntry.year == year, PurchaseBookEntry.quarter == quarter, PurchaseBookEntry.is_deducted == False, )) ) entries = result.scalars().all() count = 0 for entry in entries: applied = await PurchaseBookService.apply_deduction( db, entry.id, organization_id, deduction_date ) if applied: count += 1 return count @staticmethod async def get_entries( db: AsyncSession, organization_id: int, year: int, quarter: int ) -> List[PurchaseBookEntry]: """Получить записи книги покупок за квартал""" result = await db.execute( select(PurchaseBookEntry) .where(and_( PurchaseBookEntry.organization_id == organization_id, PurchaseBookEntry.year == year, PurchaseBookEntry.quarter == quarter, )) .order_by(PurchaseBookEntry.sequence_number) ) return list(result.scalars().all()) @staticmethod async def get_quarter_totals( db: AsyncSession, organization_id: int, year: int, quarter: int ) -> Dict[str, Decimal]: """Итоги книги покупок за квартал""" entries = await PurchaseBookService.get_entries( db, organization_id, year, quarter ) totals = { 'total_with_vat': Decimal('0'), 'vat_deductible': Decimal('0'), 'vat_deducted': Decimal('0'), 'vat_pending': Decimal('0'), 'entries_count': len(entries), } for e in entries: totals['total_with_vat'] += e.total_with_vat or Decimal('0') totals['vat_deductible'] += e.vat_deductible or Decimal('0') if e.is_deducted: totals['vat_deducted'] += e.vat_deductible or Decimal('0') else: totals['vat_pending'] += e.vat_deductible or Decimal('0') return totals @staticmethod async def _next_sequence( db: AsyncSession, org_id: int, year: int, quarter: int ) -> int: result = await db.execute( select(func.coalesce(func.max(PurchaseBookEntry.sequence_number), 0)) .where(and_( PurchaseBookEntry.organization_id == org_id, PurchaseBookEntry.year == year, PurchaseBookEntry.quarter == quarter, )) ) return result.scalar() + 1 @staticmethod async def rebuild_from_documents( db: AsyncSession, organization_id: int, year: int, quarter: int ) -> int: """ Перестройка книги покупок из входящих документов. Ручные записи сохраняются. """ # Удаляем автоматические await db.execute( delete(PurchaseBookEntry).where(and_( PurchaseBookEntry.organization_id == organization_id, PurchaseBookEntry.year == year, PurchaseBookEntry.quarter == quarter, PurchaseBookEntry.is_auto == True, )) ) start_date, end_date = _quarter_date_range(year, quarter) result = await db.execute( select(Document) .where(and_( Document.organization_id == organization_id, Document.document_type.in_(PurchaseBookService.PURCHASE_DOCUMENT_TYPES), Document.document_date >= start_date, Document.document_date <= end_date, Document.status.in_(['issued', 'paid']), )) .order_by(Document.document_date, Document.document_number) ) documents = result.scalars().all() count = 0 for doc in documents: entry = await PurchaseBookService.create_entry_from_document(db, doc) if entry: count += 1 await db.flush() return count ``` ### 3.4. Эндпоинты книги покупок ```python # Добавить в app/views/vat.py from app.services.purchase_book_service import PurchaseBookService @vat_bp.route("/purchase-book") @module_permission_required("taxes", "view") @organization_required async def purchase_book(): """Книга покупок — список записей за квартал""" user_id = session.get("user_id") current_org = await get_current_organization(user_id) if not current_org: return redirect(url_for("organizations.create")) now = date.today() year = int(request.args.get("year", now.year)) quarter = int(request.args.get("quarter", (now.month - 1) // 3 + 1)) async with AsyncSessionLocal() as db: # Проверяем право на вычет can_deduct = await PurchaseBookService.can_deduct_vat( db, current_org.id, now ) entries = await PurchaseBookService.get_entries(db, current_org.id, year, quarter) totals = await PurchaseBookService.get_quarter_totals(db, current_org.id, year, quarter) return await render_template( "vat/purchase_book.html", entries=entries, totals=totals, year=year, quarter=quarter, organization=current_org, can_deduct=can_deduct, ) @vat_bp.route("/purchase-book/rebuild", methods=["POST"]) @module_permission_required("taxes", "edit") @organization_required async def purchase_book_rebuild(): """Перестроить книгу покупок из документов""" user_id = session.get("user_id") current_org = await get_current_organization(user_id) form = await request.form year = int(form.get("year", date.today().year)) quarter = int(form.get("quarter", (date.today().month - 1) // 3 + 1)) async with AsyncSessionLocal() as db: count = await PurchaseBookService.rebuild_from_documents( db, current_org.id, year, quarter ) await db.commit() await flash(f"Книга покупок перестроена: {count} записей", "success") return redirect(url_for("vat.purchase_book", year=year, quarter=quarter)) @vat_bp.route("/purchase-book//deduct", methods=["POST"]) @module_permission_required("taxes", "edit") @organization_required async def purchase_book_deduct(entry_id: int): """Применить вычет НДС по записи""" user_id = session.get("user_id") current_org = await get_current_organization(user_id) async with AsyncSessionLocal() as db: entry = await PurchaseBookService.apply_deduction( db, entry_id, current_org.id ) await db.commit() if entry: await flash("Вычет НДС применён", "success") else: await flash("Невозможно применить вычет", "error") return redirect(url_for("vat.purchase_book", year=request.args.get("year"), quarter=request.args.get("quarter"))) @vat_bp.route("/purchase-book/deduct-all", methods=["POST"]) @module_permission_required("taxes", "edit") @organization_required async def purchase_book_deduct_all(): """Массовое применение вычетов""" user_id = session.get("user_id") current_org = await get_current_organization(user_id) form = await request.form year = int(form.get("year", date.today().year)) quarter = int(form.get("quarter", (date.today().month - 1) // 3 + 1)) async with AsyncSessionLocal() as db: count = await PurchaseBookService.apply_all_deductions( db, current_org.id, year, quarter ) await db.commit() await flash(f"Применено вычетов: {count}", "success") return redirect(url_for("vat.purchase_book", year=year, quarter=quarter)) ``` ### 3.5. Шаблон книги покупок ```html {# app/templates/vat/purchase_book.html #} {% extends "base.html" %} {% block title %}Книга покупок — {{ quarter }} кв. {{ year }}{% endblock %} {% block content %}

Книга покупок

{{ organization.short_name }} — {{ quarter }} квартал {{ year }} г.

{% if not can_deduct %}

⚠️ Вычет НДС недоступен при ставках 5%/7% (ст. 171.1 НК РФ)

{% endif %}
{% if can_deduct %}
{% endif %}
{# Сводка #}

Записей

{{ totals.entries_count }}

Покупки с НДС

{{ "{:,.2f}".format(totals.total_with_vat) }} ₽

НДС к вычету (принято)

{{ "{:,.2f}".format(totals.vat_deducted) }} ₽

НДС ожидает вычета

{{ "{:,.2f}".format(totals.vat_pending) }} ₽

{# Таблица #}
{% for entry in entries %} {% else %} {% endfor %}
СФ / УПД Продавец Стоимость с НДС НДС к вычету Статус Действия
{{ entry.sequence_number }}
№ {{ entry.invoice_number }}
от {{ entry.invoice_date.strftime('%d.%m.%Y') }}
{{ entry.seller_name[:40] }}{% if entry.seller_name|length > 40 %}...{% endif %}
{{ entry.seller_inn or '' }}
{{ "{:,.2f}".format(entry.total_with_vat) }} {{ "{:,.2f}".format(entry.vat_deductible) }} {% if entry.is_deducted %} Принят {% else %} Ожидает {% endif %} {% if not entry.is_deducted and can_deduct %}
{% endif %}
{% if not can_deduct %} Книга покупок не ведётся при ставках 5%/7% (нет права на вычет) {% else %} Нет записей за {{ quarter }} квартал {{ year }} г. {% endif %}
{% endblock %} ``` ### 3.6. Хук для входящих УПД ```python # Добавить в обработчик проведения входящего УПД # в app/views/documents_extended.py from app.services.purchase_book_service import PurchaseBookService async def on_incoming_document_issued(db, document): """Хук проведения входящего документа""" if document.document_type == 'UPD_IN': # Попытка создать запись в книге покупок (если 22%) await PurchaseBookService.create_entry_from_document(db, document) # Проводка оприходования (Дт 41/10/20 Кт 60) — без НДС # НДС выделен в отдельную проводку (Дт 19 Кт 60) внутри сервиса ``` --- ## 4. Фаза 3 — Расчёт НДС {#first3} ### 4.1. `VATCalculationService` ```python # app/services/vat_calculation_service.py """ Сервис расчёта НДС к уплате. Формула: НДС к уплате = Исходящий НДС (Кт 68.02) − Входящий НДС (Дт 68.02) Исходящий НДС = итого из книги продаж за квартал Входящий НДС = итого принятых вычетов из книги покупок за квартал При ставках 5%/7% входящий НДС = 0 (нет вычетов). """ from decimal import Decimal from datetime import date from typing import Dict from sqlalchemy.ext.asyncio import AsyncSession from app.services.sales_book_service import SalesBookService from app.services.purchase_book_service import PurchaseBookService from app.services.vat_rate_period_service import VATRatePeriodService class VATCalculationService: """Расчёт НДС к уплате за квартал""" @staticmethod async def calculate_quarter( db: AsyncSession, organization_id: int, year: int, quarter: int ) -> Dict: """ Рассчитать НДС к уплате за квартал. Returns: { 'year': int, 'quarter': int, 'vat_rate_code': str, # Текущая ставка 'can_deduct': bool, # Есть ли право на вычет # Исходящий НДС (книга продаж) 'output_vat_22': Decimal, 'output_vat_10': Decimal, 'output_vat_5': Decimal, 'output_vat_7': Decimal, 'output_vat_total': Decimal, # Итого исходящий НДС # Входящий НДС (книга покупок, только при 22%) 'input_vat_deducted': Decimal, # Принятый к вычету # Итого 'vat_payable': Decimal, # К уплате (положительное) или к возмещению (отрицательное) 'monthly_payment': Decimal, # Ежемесячный платёж (1/3) # Сроки уплаты 'payment_dates': list, # 3 даты уплаты (28-е число) # Доп. информация 'sales_entries_count': int, 'purchase_entries_count': int, } """ # Определяем ставку period = await VATRatePeriodService.get_current_period( db, organization_id, date(year, (quarter - 1) * 3 + 1, 1) ) vat_rate_code = period.vat_rate_code if period else 'none' can_deduct = vat_rate_code in ('20', '22') # Книга продаж sales_totals = await SalesBookService.get_quarter_totals( db, organization_id, year, quarter ) # Книга покупок (только при 22%) purchase_totals = {'vat_deducted': Decimal('0'), 'entries_count': 0} if can_deduct: purchase_totals = await PurchaseBookService.get_quarter_totals( db, organization_id, year, quarter ) output_vat_total = ( sales_totals['vat_amount_22'] + sales_totals['vat_amount_10'] + sales_totals['vat_amount_5'] + sales_totals['vat_amount_7'] ) input_vat = purchase_totals.get('vat_deducted', Decimal('0')) vat_payable = output_vat_total - input_vat # Ежемесячный платёж (1/3, округление вверх до копейки) if vat_payable > 0: monthly = (vat_payable / 3).quantize(Decimal('0.01')) # Первые два — по 1/3, третий — остаток monthly_1 = monthly monthly_2 = monthly monthly_3 = vat_payable - monthly_1 - monthly_2 else: monthly_1 = monthly_2 = monthly_3 = Decimal('0') # Сроки уплаты (28-е число следующих 3 месяцев после квартала) payment_dates = VATCalculationService._payment_dates(year, quarter) return { 'year': year, 'quarter': quarter, 'vat_rate_code': vat_rate_code, 'can_deduct': can_deduct, 'output_vat_22': sales_totals['vat_amount_22'], 'output_vat_10': sales_totals['vat_amount_10'], 'output_vat_5': sales_totals['vat_amount_5'], 'output_vat_7': sales_totals['vat_amount_7'], 'output_vat_total': output_vat_total, 'input_vat_deducted': input_vat, 'vat_payable': vat_payable, 'monthly_payment_1': monthly_1, 'monthly_payment_2': monthly_2, 'monthly_payment_3': monthly_3, 'payment_dates': payment_dates, 'sales_entries_count': sales_totals['entries_count'], 'purchase_entries_count': purchase_totals.get('entries_count', 0), } @staticmethod def _payment_dates(year: int, quarter: int): """ Сроки уплаты НДС — 28-е число каждого из 3 месяцев следующего квартала. Пример: Q1 2026 → 28.04.2026, 28.05.2026, 28.06.2026 """ from datetime import date as d base_month = quarter * 3 + 1 # Первый месяц следующего квартала base_year = year dates = [] for i in range(3): m = base_month + i y = base_year if m > 12: m -= 12 y += 1 dates.append(d(y, m, 28)) return dates @staticmethod async def calculate_year( db: AsyncSession, organization_id: int, year: int ) -> Dict: """ Расчёт НДС за год (все 4 квартала). Returns: { 'year': int, 'quarters': [Q1_result, Q2_result, Q3_result, Q4_result], 'annual_output_vat': Decimal, 'annual_input_vat': Decimal, 'annual_vat_payable': Decimal, } """ quarters = [] annual_output = Decimal('0') annual_input = Decimal('0') annual_payable = Decimal('0') for q in range(1, 5): result = await VATCalculationService.calculate_quarter( db, organization_id, year, q ) quarters.append(result) annual_output += result['output_vat_total'] annual_input += result['input_vat_deducted'] annual_payable += result['vat_payable'] return { 'year': year, 'quarters': quarters, 'annual_output_vat': annual_output, 'annual_input_vat': annual_input, 'annual_vat_payable': annual_payable, } ``` ### 4.2. Страница «НДС» в интерфейсе ```python # Добавить в app/views/vat.py from app.services.vat_calculation_service import VATCalculationService @vat_bp.route("/") @module_permission_required("taxes", "view") @organization_required async def vat_overview(): """Главная страница НДС — обзор за квартал""" user_id = session.get("user_id") current_org = await get_current_organization(user_id) if not current_org: return redirect(url_for("organizations.create")) # Если организация не плательщик НДС — уведомление if not current_org.is_vat_payer: return await render_template( "vat/not_payer.html", organization=current_org, ) now = date.today() year = int(request.args.get("year", now.year)) quarter = int(request.args.get("quarter", (now.month - 1) // 3 + 1)) async with AsyncSessionLocal() as db: calc = await VATCalculationService.calculate_quarter( db, current_org.id, year, quarter ) return await render_template( "vat/overview.html", calc=calc, year=year, quarter=quarter, organization=current_org, ) @vat_bp.route("/annual") @module_permission_required("taxes", "view") @organization_required async def vat_annual(): """Годовой обзор НДС""" user_id = session.get("user_id") current_org = await get_current_organization(user_id) if not current_org: return redirect(url_for("organizations.create")) year = int(request.args.get("year", date.today().year)) async with AsyncSessionLocal() as db: annual = await VATCalculationService.calculate_year( db, current_org.id, year ) return await render_template( "vat/annual.html", annual=annual, year=year, organization=current_org, ) ``` ### 4.3. Шаблон обзора НДС ```html {# app/templates/vat/overview.html #} {% extends "base.html" %} {% block title %}НДС — {{ quarter }} кв. {{ year }}{% endblock %} {% block content %}
{# Заголовок #}

НДС

{{ organization.short_name }} — {{ quarter }} квартал {{ year }} г. Ставка {{ calc.vat_rate_code }}% {% if not calc.can_deduct %} (без вычета){% endif %}

{# Главная карточка — НДС к уплате #}

НДС к уплате за квартал

{{ "{:,.2f}".format(calc.vat_payable) }} ₽

{% if calc.vat_payable < 0 %}

К возмещению из бюджета

{% endif %}
{# Разбивка #}

Исходящий НДС

{{ "{:,.2f}".format(calc.output_vat_total) }} ₽

{{ calc.sales_entries_count }} записей в книге продаж

Входящий НДС (вычеты)

{{ "{:,.2f}".format(calc.input_vat_deducted) }} ₽

{% if not calc.can_deduct %}

❌ Вычет недоступен при {{ calc.vat_rate_code }}%

{% else %}

{{ calc.purchase_entries_count }} записей в книге покупок

{% endif %}
{# Разбивка по ставкам #}

Исходящий НДС по ставкам

{% if calc.output_vat_22 > 0 %} {% endif %} {% if calc.output_vat_10 > 0 %} {% endif %} {% if calc.output_vat_5 > 0 %} {% endif %} {% if calc.output_vat_7 > 0 %} {% endif %}
22%{{ "{:,.2f}".format(calc.output_vat_22) }} ₽
10%{{ "{:,.2f}".format(calc.output_vat_10) }} ₽
5%{{ "{:,.2f}".format(calc.output_vat_5) }} ₽
7%{{ "{:,.2f}".format(calc.output_vat_7) }} ₽
Итого {{ "{:,.2f}".format(calc.output_vat_total) }} ₽

График уплаты (1/3 ежемесячно)

{% for d in calc.payment_dates %} {% endfor %}
до {{ d.strftime('%d.%m.%Y') }} {% if loop.index == 1 %}{{ "{:,.2f}".format(calc.monthly_payment_1) }} {% elif loop.index == 2 %}{{ "{:,.2f}".format(calc.monthly_payment_2) }} {% else %}{{ "{:,.2f}".format(calc.monthly_payment_3) }}{% endif %} ₽
{# Быстрые ссылки #}
{% endblock %} ``` --- ## 5. Фаза 4 — Декларация НДС {#first4} ### 5.1. Структура XML декларации Декларация по НДС (КНД 1151001) — **обязательно электронная** подача по ТКС. **Разделы, необходимые для УСН-плательщиков:** | Раздел | Название | Описание | |--------|----------|----------| | 1 | Сумма налога к уплате | Итоговая сумма НДС к уплате / возмещению | | 3 | Расчёт суммы налога | Налоговая база и НДС по ставкам | | 8 | Сведения из книги покупок | Построчно из книги покупок (только при 22%) | | 9 | Сведения из книги продаж | Построчно из книги продаж | > **Разделы 2, 4–7, 10–12** — для налоговых агентов, экспортёров, посредников. Не нужны на начальном этапе. ### 5.2. Генератор XML ```python # app/utils/xml/nds_declaration_xml_generator.py """ Генератор XML декларации по НДС (КНД 1151001). Формат: NO_NDS версия 5.08 (актуальная на 2025-2026) Кодировка: windows-1251 Файл: NO_NDS.A_K_O_GGGGMMDD_N.xml Применимые разделы: - Раздел 1: Сумма налога к уплате - Раздел 3: Расчёт суммы налога - Раздел 8: Сведения из книги покупок - Раздел 9: Сведения из книги продаж Основание: Приказ ФНС от 29.10.2014 № ММВ-7-3/558@ (с изменениями) """ from xml.etree.ElementTree import Element, SubElement, tostring from xml.dom import minidom from datetime import date from decimal import Decimal from typing import Dict, List, Optional from io import BytesIO import uuid class NDSDeclarationXMLGenerator: """Генератор XML декларации НДС""" VERSION_FORMAT = "5.08" VERSION_PROG = "Конто 1.0" KND = "1151001" @staticmethod def generate( inn: str, kpp: Optional[str], organization_name: str, oktmo: str, tax_office_code: str, year: int, quarter: int, is_ip: bool, signatory: Dict, # {'surname': '', 'name': '', 'patronymic': ''} vat_rate_code: str, # '5', '7', '22' calculation: Dict, # Результат VATCalculationService.calculate_quarter sales_entries: List, # SalesBookEntry[] purchase_entries: List, # PurchaseBookEntry[] (может быть пустым) correction_number: int = 0, phone: Optional[str] = None, ) -> BytesIO: """ Сгенерировать XML файл декларации НДС. Returns: BytesIO с XML-содержимым (кодировка windows-1251) """ # Корневой элемент root = Element('Файл') root.set('xmlns:xsi', 'http://www.w3.org/2001/XMLSchema-instance') # Атрибуты файла file_id = f"NO_NDS.A_{tax_office_code}_{inn}{'_' + kpp if kpp else ''}_{date.today().strftime('%Y%m%d')}_{uuid.uuid4().hex[:36]}" root.set('ИдФайл', file_id) root.set('ВерсФорм', NDSDeclarationXMLGenerator.VERSION_FORMAT) root.set('ВерсПрог', NDSDeclarationXMLGenerator.VERSION_PROG) # Документ doc = SubElement(root, 'Документ') doc.set('КНД', NDSDeclarationXMLGenerator.KND) # Период: квартал period_code = str(20 + quarter) # 21=Q1, 22=Q2, 23=Q3, 24=Q4 doc.set('Период', period_code) doc.set('ОтчГод', str(year)) doc.set('КорНомер', str(correction_number)) doc.set('КодНО', tax_office_code) # Сведения о налогоплательщике sv_np = SubElement(doc, 'СвНП') sv_np.set('ОКТМО', oktmo) if phone: sv_np.set('Тлф', phone) if is_ip: np_fl = SubElement(sv_np, 'НПФЛ') np_fl.set('ИННФЛ', inn) fio = SubElement(np_fl, 'ФИО') fio.set('Фамилия', signatory.get('surname', '')) fio.set('Имя', signatory.get('name', '')) if signatory.get('patronymic'): fio.set('Отчество', signatory['patronymic']) else: np_ul = SubElement(sv_np, 'НПЮЛ') np_ul.set('НаимОрг', organization_name) np_ul.set('ИННЮЛ', inn) np_ul.set('КПП', kpp or '') # Подписант sign = SubElement(doc, 'Подписант') sign.set('ПрПодworthy', '1') # 1 = руководитель fio_sign = SubElement(sign, 'ФИО') fio_sign.set('Фамилия', signatory.get('surname', '')) fio_sign.set('Имя', signatory.get('name', '')) if signatory.get('patronymic'): fio_sign.set('Отчество', signatory['patronymic']) # === Раздел 1: Сумма налога к уплате === section1 = SubElement(doc, 'НДС') r1 = SubElement(section1, 'СумУплНП') r1.set('ОКТМО', oktmo) r1.set('КБК', '18210301000011000110') # КБК НДС vat_payable = calculation.get('vat_payable', Decimal('0')) if vat_payable >= 0: r1.set('НалУпл', str(int(vat_payable))) else: r1.set('НалВозworthy', str(int(abs(vat_payable)))) # === Раздел 3: Расчёт суммы налога === r3 = SubElement(section1, 'РасчНалworthy') # Налоговая база и НДС по ставкам # Ставка 22% if calculation.get('output_vat_22', Decimal('0')) > 0: row_22 = SubElement(r3, 'НалБаworthy22') base_22 = calculation['output_vat_22'] * Decimal('100') / Decimal('22') row_22.set('НалБаза', str(int(base_22))) row_22.set('НалИсworthy', str(int(calculation['output_vat_22']))) # Ставка 10% if calculation.get('output_vat_10', Decimal('0')) > 0: row_10 = SubElement(r3, 'НалБаworthy10') base_10 = calculation['output_vat_10'] * Decimal('100') / Decimal('10') row_10.set('НалБаза', str(int(base_10))) row_10.set('НалИсworthy', str(int(calculation['output_vat_10']))) # Ставка 5% if calculation.get('output_vat_5', Decimal('0')) > 0: row_5 = SubElement(r3, 'НалБаworthy5') base_5 = calculation['output_vat_5'] * Decimal('100') / Decimal('5') row_5.set('НалБаза', str(int(base_5))) row_5.set('НалИсworthy', str(int(calculation['output_vat_5']))) # Ставка 7% if calculation.get('output_vat_7', Decimal('0')) > 0: row_7 = SubElement(r3, 'НалБаworthy7') base_7 = calculation['output_vat_7'] * Decimal('100') / Decimal('7') row_7.set('НалБаза', str(int(base_7))) row_7.set('НалИсworthy', str(int(calculation['output_vat_7']))) # Итого начислено total_base = SubElement(r3, 'ВсегоИсworthy') total_base.set('СумНал', str(int(calculation.get('output_vat_total', 0)))) # Вычеты (Раздел 3, строка 120-210) input_vat = calculation.get('input_vat_deducted', Decimal('0')) if input_vat > 0: deductions = SubElement(r3, 'ВычНДС') deductions.set('СумНалВычет', str(int(input_vat))) # Итого к уплате / возмещению r3_total = SubElement(r3, 'ИтогНДС') if vat_payable >= 0: r3_total.set('НалУпл', str(int(vat_payable))) else: r3_total.set('НалВозworthy', str(int(abs(vat_payable)))) # === Раздел 9: Сведения из книги продаж === if sales_entries: r9 = SubElement(section1, 'КнПрод') r9.set('КолСтр', str(len(sales_entries))) for entry in sales_entries: row = SubElement(r9, 'КнПродСтр') row.set('НомерПор', str(entry.sequence_number)) row.set('КодВидОпер', entry.operation_type_code) row.set('НомСчФПрод', entry.invoice_number) row.set('ДатаСчФПрод', entry.invoice_date.strftime('%d.%m.%Y')) # Покупатель row.set('НаимПок', entry.buyer_name or '') row.set('ИННПок', entry.buyer_inn or '') if entry.buyer_kpp: row.set('КПППок', entry.buyer_kpp) # Суммы total = ( (entry.total_with_vat_22 or Decimal('0')) + (entry.total_with_vat_10 or Decimal('0')) + (entry.total_with_vat_5 or Decimal('0')) + (entry.total_with_vat_7 or Decimal('0')) ) row.set('СтоимПродСНДС', str(int(total))) vat_total = ( (entry.vat_amount_22 or Decimal('0')) + (entry.vat_amount_10 or Decimal('0')) + (entry.vat_amount_5 or Decimal('0')) + (entry.vat_amount_7 or Decimal('0')) ) row.set('СумНДС', str(int(vat_total))) # Итого по разделу 9 r9_total = SubElement(r9, 'КнПродИтг') r9_total.set('ВсегоСтоимПродСНДС', str(int( sum((e.total_with_vat_22 or 0) + (e.total_with_vat_10 or 0) + (e.total_with_vat_5 or 0) + (e.total_with_vat_7 or 0) for e in sales_entries) ))) r9_total.set('ВсегоНДС', str(int( sum((e.vat_amount_22 or 0) + (e.vat_amount_10 or 0) + (e.vat_amount_5 or 0) + (e.vat_amount_7 or 0) for e in sales_entries) ))) # === Раздел 8: Сведения из книги покупок === if purchase_entries: r8 = SubElement(section1, 'КнПок') r8.set('КолСтр', str(len(purchase_entries))) for entry in purchase_entries: row = SubElement(r8, 'КнПокСтр') row.set('НомерПор', str(entry.sequence_number)) row.set('КодВидОпер', entry.operation_type_code) row.set('НомСчФПрод', entry.invoice_number) row.set('ДатаСчФПрод', entry.invoice_date.strftime('%d.%m.%Y')) # Продавец row.set('НаимПрод', entry.seller_name or '') row.set('ИННПрод', entry.seller_inn or '') if entry.seller_kpp: row.set('КПППрод', entry.seller_kpp) # Суммы row.set('СтоимПокСНДС', str(int(entry.total_with_vat or 0))) row.set('СумНДС', str(int(entry.vat_deductible or 0))) # Итого по разделу 8 r8_total = SubElement(r8, 'КнПокИтг') r8_total.set('ВсегоСтоимПокСНДС', str(int( sum(e.total_with_vat or 0 for e in purchase_entries) ))) r8_total.set('ВсегоНДС', str(int( sum(e.vat_deductible or 0 for e in purchase_entries) ))) # Формируем XML xml_str = tostring(root, encoding='unicode') dom = minidom.parseString(xml_str) pretty_xml = dom.toprettyxml(indent=' ', encoding='windows-1251') # Убираем двойное объявление xml lines = pretty_xml.decode('windows-1251').split('\n') result_xml = '\n'.join(lines[1:]) # Убираем от minidom final_xml = '\n' + result_xml buffer = BytesIO() buffer.write(final_xml.encode('windows-1251')) buffer.seek(0) return buffer @staticmethod def generate_filename( inn: str, kpp: Optional[str], tax_office_code: str, ) -> str: """Сформировать имя файла по требованиям ФНС""" today = date.today().strftime('%Y%m%d') guid = uuid.uuid4().hex[:36] if kpp: return f"NO_NDS.A_{tax_office_code}_{inn}_{kpp}_{today}_{guid}.xml" else: return f"NO_NDS.A_{tax_office_code}_{inn}_{today}_{guid}.xml" ``` > **Примечание:** Это упрощённая версия генератора. Для production необходимо: > 1. Скачать актуальную XSD-схему с https://format.nalog.ru/ (раздел NO_NDS) > 2. Привести имена XML-элементов в точное соответствие с XSD > 3. Добавить валидацию XML по XSD перед отправкой > 4. Тестировать через программу «Тестер» ФНС ### 5.3. Эндпоинт генерации декларации ```python # Добавить в app/views/vat.py from app.utils.xml.nds_declaration_xml_generator import NDSDeclarationXMLGenerator @vat_bp.route("/declaration") @module_permission_required("taxes", "view") @organization_required async def declaration(): """Страница декларации по НДС""" user_id = session.get("user_id") current_org = await get_current_organization(user_id) if not current_org: return redirect(url_for("organizations.create")) now = date.today() year = int(request.args.get("year", now.year)) quarter = int(request.args.get("quarter", (now.month - 1) // 3 + 1)) async with AsyncSessionLocal() as db: calc = await VATCalculationService.calculate_quarter( db, current_org.id, year, quarter ) sales_entries = await SalesBookService.get_entries(db, current_org.id, year, quarter) purchase_entries = [] if calc['can_deduct']: purchase_entries = await PurchaseBookService.get_entries(db, current_org.id, year, quarter) return await render_template( "vat/declaration.html", calc=calc, year=year, quarter=quarter, organization=current_org, sales_count=len(sales_entries), purchase_count=len(purchase_entries), ) @vat_bp.route("/declaration/xml") @module_permission_required("taxes", "edit") @organization_required async def declaration_xml(): """Скачать XML файл декларации НДС""" user_id = session.get("user_id") current_org = await get_current_organization(user_id) if not current_org: return redirect(url_for("organizations.create")) year = int(request.args.get("year", date.today().year)) quarter = int(request.args.get("quarter", 1)) async with AsyncSessionLocal() as db: calc = await VATCalculationService.calculate_quarter( db, current_org.id, year, quarter ) sales_entries = await SalesBookService.get_entries(db, current_org.id, year, quarter) purchase_entries = [] if calc['can_deduct']: purchase_entries = await PurchaseBookService.get_entries(db, current_org.id, year, quarter) # Парсим ФИО руководителя leader_parts = (current_org.leader_name or '').split() signatory = { 'surname': leader_parts[0] if len(leader_parts) > 0 else '', 'name': leader_parts[1] if len(leader_parts) > 1 else '', 'patronymic': leader_parts[2] if len(leader_parts) > 2 else '', } is_ip = str(current_org.organization_type) == 'OrganizationType.IP' tax_office_code = (current_org.inn or '0000')[:4] xml_buffer = NDSDeclarationXMLGenerator.generate( inn=current_org.inn, kpp=current_org.kpp, organization_name=current_org.full_name, oktmo=current_org.oktmo or '00000000', tax_office_code=tax_office_code, year=year, quarter=quarter, is_ip=is_ip, signatory=signatory, vat_rate_code=calc['vat_rate_code'], calculation=calc, sales_entries=sales_entries, purchase_entries=purchase_entries, phone=current_org.phone, ) filename = NDSDeclarationXMLGenerator.generate_filename( inn=current_org.inn, kpp=current_org.kpp, tax_office_code=tax_office_code, ) return Response( xml_buffer.getvalue(), mimetype='application/xml', headers={ 'Content-Disposition': f'attachment; filename="{filename}"' } ) ``` ### 5.4. Интеграция с Астрал.Отчётность Для отправки декларации по НДС через Астрал API используем существующую инфраструктуру (`AstralReport`): ```python # app/services/nds_report_service.py """ Сервис отправки декларации НДС через Астрал.Отчётность. Использует существующую интеграцию с Астрал.Платформа API. Декларация НДС обязательно сдаётся в электронном виде по ТКС. """ from datetime import date from typing import Optional from sqlalchemy.ext.asyncio import AsyncSession from app.models.astral_reports import AstralReport, ReportType, ReportDirection, ReportStatus from app.utils.xml.nds_declaration_xml_generator import NDSDeclarationXMLGenerator class NDSReportService: """Сервис отправки декларации НДС""" @staticmethod async def create_nds_report( db: AsyncSession, organization_id: int, xml_content: bytes, filename: str, year: int, quarter: int, correction_number: int = 0 ) -> AstralReport: """ Создать запись отчёта НДС для отправки через Астрал. Args: organization_id: ID организации xml_content: Содержимое XML файла filename: Имя файла year: Отчётный год quarter: Отчётный квартал Returns: AstralReport """ period_code = f"Q{quarter}-{year}" report = AstralReport( organization_id=organization_id, report_type=ReportType.NDS_DECLARATION, direction=ReportDirection.FNS, status=ReportStatus.DRAFT, period=period_code, year=year, correction_number=correction_number, xml_content=xml_content, filename=filename, ) db.add(report) await db.flush() return report @staticmethod async def send_report( db: AsyncSession, report_id: int, organization_id: int ) -> bool: """ Отправить декларацию НДС через Астрал API. Требует наличие подписки ЭДО и сертификата КЭП. """ # TODO: Интеграция с Астрал.Платформа API # 1. Получить сертификат КЭП из EdoSubscriber # 2. Подписать XML # 3. Отправить через API Астрал # 4. Обновить статус отчёта raise NotImplementedError( "Отправка через Астрал API будет реализована после получения " "тестового доступа к среде Астрал.Отчётность" ) ``` --- ## 6. Фаза 5 — Особенности для 5%/7% {#first5} ### 6.1. Принципиальные отличия При ставках 5% и 7% действует **упрощённый режим НДС** (ст. 164 п. 8-9, ст. 171.1 НК РФ): | Аспект | 5%/7% (упрощённый) | 22% (общий) | |--------|--------------------|-------------| | Единая ставка на все товары | ✅ Да | ❌ Нет (22%, 10%, 0%) | | Книга продаж | ✅ Ведётся | ✅ Ведётся | | Книга покупок | ❌ Не ведётся | ✅ Ведётся | | Входящий НДС (вычет) | ❌ Запрещён | ✅ Разрешён | | Счёт 19 | ❌ Не используется | ✅ Используется | | Декларация НДС | ✅ Упрощённая (р.1,3,9) | ✅ Полная (р.1,3,8,9) | | Льготные ставки (10%, 0%) | ❌ Не применяются | ✅ Применяются | ### 6.2. Логика определения режима ```python # app/services/vat_mode_service.py """ Сервис определения режима НДС организации. Центральная точка для определения доступных функций НДС в зависимости от ставки организации. """ from dataclasses import dataclass from typing import Optional from datetime import date from sqlalchemy.ext.asyncio import AsyncSession from app.services.vat_rate_period_service import VATRatePeriodService @dataclass class VATMode: """Режим НДС организации""" is_vat_payer: bool # Является плательщиком НДС rate_code: str # Код ставки: 'none', '5', '7', '22' rate_percent: Optional[int] # Числовая ставка (5, 7, 22) или None can_deduct: bool # Право на вычет входящего НДС use_purchase_book: bool # Ведётся ли книга покупок use_account_19: bool # Используется ли счёт 19 uniform_rate: bool # Единая ставка на все товары declaration_sections: list # Разделы декларации @property def label(self) -> str: if not self.is_vat_payer: return 'Без НДС' return f'НДС {self.rate_code}%' class VATModeService: """Определение режима НДС""" @staticmethod async def get_mode( db: AsyncSession, organization_id: int, target_date: Optional[date] = None ) -> VATMode: """ Определить режим НДС организации на дату. Returns: VATMode с полным описанием доступных функций """ period = await VATRatePeriodService.get_current_period( db, organization_id, target_date ) if not period or period.vat_rate_code == 'none': return VATMode( is_vat_payer=False, rate_code='none', rate_percent=None, can_deduct=False, use_purchase_book=False, use_account_19=False, uniform_rate=False, declaration_sections=[], ) rate_code = period.vat_rate_code if rate_code in ('5', '7'): return VATMode( is_vat_payer=True, rate_code=rate_code, rate_percent=int(rate_code), can_deduct=False, use_purchase_book=False, use_account_19=False, uniform_rate=True, # Единая ставка на всё declaration_sections=[1, 3, 9], # Без раздела 8 (нет книги покупок) ) # 20% или 22% — общий режим return VATMode( is_vat_payer=True, rate_code=rate_code, rate_percent=int(rate_code), can_deduct=True, use_purchase_book=True, use_account_19=True, uniform_rate=False, # Могут быть разные ставки (22%, 10%, 0%) declaration_sections=[1, 3, 8, 9], ) ``` ### 6.3. Обработка при 5%/7% в документах При создании позиций исходящего документа (УПД), если организация на 5%/7%: ```python # Модификация в app/utils/vat_utils.py def resolve_item_vat_rate( org_vat_rate_code: str, nomenclature_vat_exemption: Optional[Decimal] ) -> Optional[Decimal]: """ Определить ставку НДС для позиции документа. При ставках 5%/7%: - ВСЕГДА применяется единая ставка организации - Льготы номенклатуры ИГНОРИРУЮТСЯ - Все товары/услуги облагаются по единой ставке При ставке 22%: - Льготы номенклатуры применяются (10%, 0%, без НДС) - Без льготы → ставка 22% """ if org_vat_rate_code == 'none': return None if org_vat_rate_code in ('5', '7'): # ЕДИНАЯ СТАВКА — льготы не применяются return Decimal(org_vat_rate_code) # Режим 22% (или 20%) — льготы применяются if nomenclature_vat_exemption is None: return Decimal('22') if org_vat_rate_code == '22' else Decimal('20') if nomenclature_vat_exemption == Decimal('-1'): return None # Без НДС return nomenclature_vat_exemption # 10 или 0 ``` ### 6.4. Проводки при 5%/7% При ставках 5%/7% НДС включается в себестоимость (счёт 19 не используется): ```python # Проводки при ПРОДАЖЕ (5%/7%): # Дт 62 Кт 90.01 — Выручка с НДС (total_amount) # Дт 90.03 Кт 68.02 — Начислен НДС (vat_amount) # Проводки при ПОКУПКЕ (5%/7%): # Дт 41 Кт 60 — Оприходование с НДС (total_amount, включая НДС поставщика!) # Входящий НДС НЕ выделяется на счёт 19 # НДС поставщика включается в себестоимость товара # Пример: # Организация на 5%. Купила товар за 120,000 (в т.ч. НДС 22% = 20,000) # Проводка: Дт 41 Кт 60 — 120,000 руб. (НДС поставщика включён в себестоимость) # # Продала товар за 200,000 + НДС 5% = 210,000 # Проводки: # Дт 62 Кт 90.01 — 210,000 (выручка с НДС) # Дт 90.03 Кт 68.02 — 10,000 (НДС 5% от 200,000) ``` ### 6.5. Предупреждения в интерфейсе ```html {# Компонент предупреждения для страниц НДС при 5%/7% #} {% macro vat_no_deduction_warning(vat_mode) %} {% if vat_mode.is_vat_payer and not vat_mode.can_deduct %}

Ставка {{ vat_mode.rate_code }}% — без права на вычет

При ставке НДС {{ vat_mode.rate_code }}% входящий НДС от поставщиков не принимается к вычету (ст. 171.1 НК РФ). НДС поставщика включается в себестоимость товаров/услуг. Книга покупок не ведётся.

{% endif %} {% endmacro %} ``` --- ## 7. Миграции Alembic ### 7.1. Миграция: создание таблиц книг НДС ```python # alembic/versions/xxx_create_vat_books.py """ Создание таблиц книги продаж и книги покупок. Revision ID: create_vat_books Create Date: 2025-07-15 """ from alembic import op import sqlalchemy as sa revision = 'create_vat_books' down_revision = None # Указать реальную предыдущую ревизию branch_labels = None depends_on = None def upgrade() -> None: # === Книга продаж === op.create_table( 'sales_book_entries', sa.Column('id', sa.Integer(), primary_key=True), sa.Column('organization_id', sa.Integer(), sa.ForeignKey('organizations.id', ondelete='CASCADE'), nullable=False), sa.Column('quarter', sa.Integer(), nullable=False, comment='Квартал (1-4)'), sa.Column('year', sa.Integer(), nullable=False, comment='Год'), sa.Column('sequence_number', sa.Integer(), nullable=False), # Код вида операции sa.Column('operation_type_code', sa.String(2), nullable=False, server_default='01', comment='01=реализация, 02=аванс, 06=зачёт аванса'), # Счёт-фактура sa.Column('invoice_number', sa.String(100), nullable=False), sa.Column('invoice_date', sa.Date(), nullable=False), # Исправление / корректировка sa.Column('correction_number', sa.String(50), nullable=True), sa.Column('correction_date', sa.Date(), nullable=True), sa.Column('adjustment_number', sa.String(50), nullable=True), sa.Column('adjustment_date', sa.Date(), nullable=True), # Покупатель sa.Column('buyer_name', sa.String(500), nullable=False), sa.Column('buyer_inn', sa.String(12), nullable=True), sa.Column('buyer_kpp', sa.String(9), nullable=True), # Документ об оплате sa.Column('payment_document_number', sa.String(100), nullable=True), sa.Column('payment_document_date', sa.Date(), nullable=True), # Стоимость по ставкам sa.Column('total_with_vat_22', sa.Numeric(15, 2), server_default='0'), sa.Column('total_with_vat_10', sa.Numeric(15, 2), server_default='0'), sa.Column('total_with_vat_5', sa.Numeric(15, 2), server_default='0'), sa.Column('total_with_vat_7', sa.Numeric(15, 2), server_default='0'), sa.Column('total_without_vat', sa.Numeric(15, 2), server_default='0'), sa.Column('total_zero_rate', sa.Numeric(15, 2), server_default='0'), # НДС по ставкам sa.Column('vat_amount_22', sa.Numeric(15, 2), server_default='0'), sa.Column('vat_amount_10', sa.Numeric(15, 2), server_default='0'), sa.Column('vat_amount_5', sa.Numeric(15, 2), server_default='0'), sa.Column('vat_amount_7', sa.Numeric(15, 2), server_default='0'), # Освобождённые sa.Column('total_exempt', sa.Numeric(15, 2), server_default='0'), # Связи sa.Column('document_id', sa.Integer(), sa.ForeignKey('documents.id', ondelete='SET NULL'), nullable=True), sa.Column('contractor_id', sa.Integer(), sa.ForeignKey('contractors.id', ondelete='SET NULL'), nullable=True), # Служебные sa.Column('is_auto', sa.Boolean(), server_default='true'), sa.Column('notes', sa.Text(), nullable=True), sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()), sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True), ) op.create_index('idx_sales_book_org_period', 'sales_book_entries', ['organization_id', 'year', 'quarter']) op.create_index('idx_sales_book_document', 'sales_book_entries', ['document_id']) op.create_unique_constraint( 'uq_sales_book_org_doc_op', 'sales_book_entries', ['organization_id', 'document_id', 'operation_type_code'] ) # === Книга покупок === op.create_table( 'purchase_book_entries', sa.Column('id', sa.Integer(), primary_key=True), sa.Column('organization_id', sa.Integer(), sa.ForeignKey('organizations.id', ondelete='CASCADE'), nullable=False), sa.Column('quarter', sa.Integer(), nullable=False), sa.Column('year', sa.Integer(), nullable=False), sa.Column('sequence_number', sa.Integer(), nullable=False), sa.Column('operation_type_code', sa.String(2), nullable=False, server_default='01'), # Счёт-фактура продавца sa.Column('invoice_number', sa.String(100), nullable=False), sa.Column('invoice_date', sa.Date(), nullable=False), # Исправление / корректировка sa.Column('correction_number', sa.String(50), nullable=True), sa.Column('correction_date', sa.Date(), nullable=True), sa.Column('adjustment_number', sa.String(50), nullable=True), sa.Column('adjustment_date', sa.Date(), nullable=True), # Документ об оплате sa.Column('payment_document_number', sa.String(100), nullable=True), sa.Column('payment_document_date', sa.Date(), nullable=True), # Дата принятия на учёт sa.Column('acceptance_date', sa.Date(), nullable=True), # Продавец sa.Column('seller_name', sa.String(500), nullable=False), sa.Column('seller_inn', sa.String(12), nullable=True), sa.Column('seller_kpp', sa.String(9), nullable=True), # ГТД sa.Column('customs_declaration', sa.String(100), nullable=True), # Суммы sa.Column('total_with_vat', sa.Numeric(15, 2), nullable=False, server_default='0'), sa.Column('vat_deductible', sa.Numeric(15, 2), nullable=False, server_default='0'), # Связи sa.Column('document_id', sa.Integer(), sa.ForeignKey('documents.id', ondelete='SET NULL'), nullable=True), sa.Column('contractor_id', sa.Integer(), sa.ForeignKey('contractors.id', ondelete='SET NULL'), nullable=True), # Статус вычета sa.Column('is_deducted', sa.Boolean(), server_default='false'), sa.Column('deduction_date', sa.Date(), nullable=True), # Служебные sa.Column('is_auto', sa.Boolean(), server_default='true'), sa.Column('notes', sa.Text(), nullable=True), sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()), sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True), ) op.create_index('idx_purchase_book_org_period', 'purchase_book_entries', ['organization_id', 'year', 'quarter']) op.create_index('idx_purchase_book_document', 'purchase_book_entries', ['document_id']) op.create_unique_constraint( 'uq_purchase_book_org_doc_op', 'purchase_book_entries', ['organization_id', 'document_id', 'operation_type_code'] ) def downgrade() -> None: op.drop_table('purchase_book_entries') op.drop_table('sales_book_entries') ``` ### 7.2. Миграция: добавление счёта 19 в план счетов ```python # alembic/versions/xxx_add_account_19.py """ Добавление счёта 19 (НДС по приобретённым ценностям) и субсчетов. Revision ID: add_account_19 """ from alembic import op import sqlalchemy as sa revision = 'add_account_19' down_revision = 'create_vat_books' # Указать реальную предыдущую ревизию def upgrade() -> None: # Используем raw SQL для INSERT с ON CONFLICT op.execute(""" INSERT INTO chart_of_accounts (account_code, parent_code, account_name, account_type, description, is_active, is_synthetic) VALUES ('19', NULL, 'НДС по приобретённым ценностям', 'active', 'Обобщение информации об уплаченных (причитающихся к уплате) суммах НДС по приобретённым ценностям. Дебетовое сальдо — НДС, ещё не принятый к вычету.', true, true), ('19.01', '19', 'НДС при приобретении ОС', 'active', 'НДС по приобретённым основным средствам', true, false), ('19.02', '19', 'НДС по приобретённым НМА', 'active', 'НДС по приобретённым нематериальным активам', true, false), ('19.03', '19', 'НДС по приобретённым МПЗ', 'active', 'НДС по приобретённым материально-производственным запасам, работам, услугам', true, false) ON CONFLICT (account_code) DO NOTHING; """) def downgrade() -> None: op.execute(""" DELETE FROM chart_of_accounts WHERE account_code IN ('19', '19.01', '19.02', '19.03'); """) ``` --- ## 8. Тесты ### 8.1. Тесты книги продаж ```python # tests/test_vat/test_sales_book.py """ Тесты книги продаж. """ import pytest from decimal import Decimal from datetime import date from app.models.documents import Document from app.models.document_items import DocumentItem from app.models.vat_books import SalesBookEntry from app.services.sales_book_service import SalesBookService @pytest.fixture def sample_upd_out(db_session, organization): """Создаём тестовый исходящий УПД""" doc = Document( organization_id=organization.id, document_type='UPD_OUT', document_number='УПД-001', document_date=date(2026, 2, 15), contractor_id=1, amount=Decimal('100000.00'), vat_amount=Decimal('5000.00'), total_amount=Decimal('105000.00'), status='issued', ) db_session.add(doc) db_session.flush() item = DocumentItem( document_id=doc.id, sequence=1, name='Консультационные услуги', unit='шт', quantity=Decimal('1'), price=Decimal('100000.00'), amount=Decimal('100000.00'), vat_rate=Decimal('5'), vat_amount=Decimal('5000.00'), total_amount=Decimal('105000.00'), ) db_session.add(item) db_session.flush() return doc @pytest.mark.asyncio async def test_create_sales_entry_from_upd(db_session, sample_upd_out, contractor): """Тест создания записи книги продаж из УПД""" entry = await SalesBookService.create_entry_from_document( db_session, sample_upd_out, contractor ) assert entry is not None assert entry.invoice_number == 'УПД-001' assert entry.quarter == 1 # Февраль → Q1 assert entry.year == 2026 assert entry.vat_amount_5 == Decimal('5000.00') assert entry.total_with_vat_5 == Decimal('105000.00') assert entry.total_vat == Decimal('5000.00') @pytest.mark.asyncio async def test_no_duplicate_sales_entry(db_session, sample_upd_out, contractor): """Тест защиты от дублирования""" entry1 = await SalesBookService.create_entry_from_document( db_session, sample_upd_out, contractor ) entry2 = await SalesBookService.create_entry_from_document( db_session, sample_upd_out, contractor ) assert entry1 is not None assert entry2 is None # Дубль не создан @pytest.mark.asyncio async def test_quarter_totals(db_session, organization, sample_upd_out, contractor): """Тест итогов за квартал""" await SalesBookService.create_entry_from_document( db_session, sample_upd_out, contractor ) totals = await SalesBookService.get_quarter_totals( db_session, organization.id, 2026, 1 ) assert totals['entries_count'] == 1 assert totals['vat_amount_5'] == Decimal('5000.00') assert totals['total_vat'] == Decimal('5000.00') @pytest.mark.asyncio async def test_split_by_vat_rate_22(db_session, organization): """Тест разбивки по ставке 22%""" # Создаём документ со ставкой 22% doc = Document( organization_id=organization.id, document_type='UPD_OUT', document_number='УПД-002', document_date=date(2026, 4, 10), amount=Decimal('500000.00'), vat_amount=Decimal('110000.00'), total_amount=Decimal('610000.00'), status='issued', ) db_session.add(doc) db_session.flush() item = DocumentItem( document_id=doc.id, sequence=1, name='Оборудование', unit='шт', quantity=Decimal('1'), price=Decimal('500000.00'), amount=Decimal('500000.00'), vat_rate=Decimal('22'), vat_amount=Decimal('110000.00'), total_amount=Decimal('610000.00'), ) db_session.add(item) db_session.flush() amounts = SalesBookService._split_by_vat_rate([item]) assert amounts['total_with_vat_22'] == Decimal('610000.00') assert amounts['vat_22'] == Decimal('110000.00') assert amounts['total_with_vat_5'] == Decimal('0') @pytest.mark.asyncio async def test_rebuild_from_documents(db_session, organization, sample_upd_out): """Тест полной перестройки книги""" count = await SalesBookService.rebuild_from_documents( db_session, organization.id, 2026, 1 ) assert count == 1 entries = await SalesBookService.get_entries( db_session, organization.id, 2026, 1 ) assert len(entries) == 1 ``` ### 8.2. Тесты книги покупок и вычета ```python # tests/test_vat/test_purchase_book.py """ Тесты книги покупок и механизма вычета НДС. """ import pytest from decimal import Decimal from datetime import date from app.models.documents import Document from app.models.vat_rate_periods import VATRatePeriod from app.services.purchase_book_service import PurchaseBookService @pytest.fixture def org_on_22(db_session, organization): """Организация на ставке 22% (с правом вычета)""" period = VATRatePeriod( organization_id=organization.id, start_date=date(2026, 1, 1), vat_rate_code='22', ) db_session.add(period) db_session.flush() return organization @pytest.fixture def org_on_5(db_session, organization): """Организация на ставке 5% (без права вычета)""" period = VATRatePeriod( organization_id=organization.id, start_date=date(2026, 1, 1), vat_rate_code='5', ) db_session.add(period) db_session.flush() return organization @pytest.fixture def sample_upd_in(db_session, organization): """Входящий УПД от поставщика""" doc = Document( organization_id=organization.id, document_type='UPD_IN', document_number='ВХ-042', document_date=date(2026, 3, 20), contractor_id=1, amount=Decimal('200000.00'), vat_amount=Decimal('44000.00'), # 22% от 200k total_amount=Decimal('244000.00'), status='issued', ) db_session.add(doc) db_session.flush() return doc @pytest.mark.asyncio async def test_purchase_entry_with_22(db_session, org_on_22, sample_upd_in): """При 22% — запись создаётся, вычет доступен""" entry = await PurchaseBookService.create_entry_from_document( db_session, sample_upd_in ) assert entry is not None assert entry.vat_deductible == Decimal('44000.00') assert entry.is_deducted is False @pytest.mark.asyncio async def test_purchase_entry_blocked_at_5(db_session, org_on_5, sample_upd_in): """При 5% — запись НЕ создаётся (нет права на вычет)""" entry = await PurchaseBookService.create_entry_from_document( db_session, sample_upd_in ) assert entry is None @pytest.mark.asyncio async def test_can_deduct_vat(db_session, org_on_22, org_on_5): """Тест проверки права на вычет""" can_22 = await PurchaseBookService.can_deduct_vat(db_session, org_on_22.id) can_5 = await PurchaseBookService.can_deduct_vat(db_session, org_on_5.id) assert can_22 is True assert can_5 is False @pytest.mark.asyncio async def test_apply_deduction(db_session, org_on_22, sample_upd_in): """Тест применения вычета""" entry = await PurchaseBookService.create_entry_from_document( db_session, sample_upd_in ) assert entry.is_deducted is False applied = await PurchaseBookService.apply_deduction( db_session, entry.id, org_on_22.id ) assert applied is not None assert applied.is_deducted is True assert applied.deduction_date is not None @pytest.mark.asyncio async def test_no_double_deduction(db_session, org_on_22, sample_upd_in): """Нельзя применить вычет дважды""" entry = await PurchaseBookService.create_entry_from_document( db_session, sample_upd_in ) await PurchaseBookService.apply_deduction(db_session, entry.id, org_on_22.id) # Повторная попытка result = await PurchaseBookService.apply_deduction( db_session, entry.id, org_on_22.id ) assert result is None # Уже вычтен ``` ### 8.3. Тесты расчёта НДС ```python # tests/test_vat/test_vat_calculation.py """ Тесты расчёта НДС к уплате. """ import pytest from decimal import Decimal from datetime import date from app.services.vat_calculation_service import VATCalculationService @pytest.mark.asyncio async def test_calculate_quarter_5_percent(db_session, org_on_5_with_sales): """Расчёт при 5%: только исходящий, без вычетов""" calc = await VATCalculationService.calculate_quarter( db_session, org_on_5_with_sales.id, 2026, 1 ) assert calc['vat_rate_code'] == '5' assert calc['can_deduct'] is False assert calc['input_vat_deducted'] == Decimal('0') assert calc['vat_payable'] == calc['output_vat_total'] @pytest.mark.asyncio async def test_calculate_quarter_22_with_deductions( db_session, org_on_22_with_sales_and_purchases ): """Расчёт при 22%: исходящий минус входящий""" calc = await VATCalculationService.calculate_quarter( db_session, org_on_22_with_sales_and_purchases.id, 2026, 1 ) assert calc['vat_rate_code'] == '22' assert calc['can_deduct'] is True assert calc['vat_payable'] == ( calc['output_vat_total'] - calc['input_vat_deducted'] ) @pytest.mark.asyncio async def test_monthly_payment_split(db_session, org_with_vat): """Тест разбивки на 3 ежемесячных платежа""" calc = await VATCalculationService.calculate_quarter( db_session, org_with_vat.id, 2026, 1 ) total = calc['monthly_payment_1'] + calc['monthly_payment_2'] + calc['monthly_payment_3'] assert total == calc['vat_payable'] @pytest.mark.asyncio async def test_payment_dates(db_session): """Тест сроков уплаты""" dates = VATCalculationService._payment_dates(2026, 1) assert dates[0] == date(2026, 4, 28) assert dates[1] == date(2026, 5, 28) assert dates[2] == date(2026, 6, 28) # Q4 → следующий год dates_q4 = VATCalculationService._payment_dates(2026, 4) assert dates_q4[0] == date(2027, 1, 28) ``` ### 8.4. Тесты vat_utils ```python # tests/test_vat/test_vat_utils.py """ Тесты утилит расчёта НДС. """ import pytest from decimal import Decimal from app.utils.vat_utils import resolve_item_vat_rate, calculate_item_vat class TestResolveItemVatRate: """Тесты определения ставки НДС позиции""" def test_none_rate(self): """Без НДС → None""" assert resolve_item_vat_rate('none', None) is None def test_5_percent_ignores_exemption(self): """5% — игнорирует льготу номенклатуры""" assert resolve_item_vat_rate('5', None) == Decimal('5') assert resolve_item_vat_rate('5', Decimal('10')) == Decimal('5') assert resolve_item_vat_rate('5', Decimal('-1')) == Decimal('5') def test_7_percent_ignores_exemption(self): """7% — игнорирует льготу номенклатуры""" assert resolve_item_vat_rate('7', None) == Decimal('7') assert resolve_item_vat_rate('7', Decimal('10')) == Decimal('7') def test_22_percent_no_exemption(self): """22% без льготы → 22%""" assert resolve_item_vat_rate('22', None) == Decimal('22') def test_22_percent_with_10_exemption(self): """22% с льготой 10% → 10%""" assert resolve_item_vat_rate('22', Decimal('10')) == Decimal('10') def test_22_percent_without_vat_exemption(self): """22% с льготой -1 (без НДС) → None""" assert resolve_item_vat_rate('22', Decimal('-1')) is None def test_20_percent_legacy(self): """20% (старая ставка) → 20%""" assert resolve_item_vat_rate('20', None) == Decimal('20') class TestCalculateItemVat: """Тесты расчёта суммы НДС""" def test_5_percent(self): vat, total = calculate_item_vat(Decimal('100000'), Decimal('5')) assert vat == Decimal('5000.00') assert total == Decimal('105000.00') def test_22_percent(self): vat, total = calculate_item_vat(Decimal('500000'), Decimal('22')) assert vat == Decimal('110000.00') assert total == Decimal('610000.00') def test_none_rate(self): vat, total = calculate_item_vat(Decimal('100000'), None) assert vat == Decimal('0') assert total == Decimal('100000') ``` --- ## 9. Roadmap ### 9.1. План реализации ``` ┌─────────────────────────────────────────────────────────────────────┐ │ ROADMAP реализации НДС │ ├─────────────┬──────────────┬────────────────────────────────────────┤ │ Фаза │ Срок │ Описание │ ├─────────────┼──────────────┼────────────────────────────────────────┤ │ Фаза 0 │ 1 неделя │ Подготовка инфраструктуры │ │ (Sprint 0) │ │ - Миграции Alembic (таблицы) │ │ │ │ - Модели SQLAlchemy │ │ │ │ - Счёт 19 + субсчета │ │ │ │ - Регистрация blueprint vat_bp │ │ │ │ - VATModeService │ ├─────────────┼──────────────┼────────────────────────────────────────┤ │ Фаза 1 │ 2 недели │ Книга продаж │ │ │ │ - SalesBookService │ │ │ │ - Автозаполнение из УПД │ │ │ │ - Эндпоинты + шаблон │ │ │ │ - Интеграция с проведением документов │ │ │ │ - Тесты │ ├─────────────┼──────────────┼────────────────────────────────────────┤ │ Фаза 2 │ 2 недели │ Книга покупок │ │ │ │ - PurchaseBookService │ │ │ │ - Проводки Дт 19 Кт 60 │ │ │ │ - Вычет НДС (Дт 68.02 Кт 19) │ │ │ │ - Блокировка вычетов при 5%/7% │ │ │ │ - Эндпоинты + шаблон │ │ │ │ - Тесты │ ├─────────────┼──────────────┼────────────────────────────────────────┤ │ Фаза 3 │ 1.5 недели │ Расчёт НДС и интерфейс │ │ │ │ - VATCalculationService │ │ │ │ - Страница «НДС» (overview) │ │ │ │ - Годовой обзор │ │ │ │ - Разбивка 1/3 ежемесячно │ │ │ │ - Проводка Дт 68.02 Кт 51 (уплата) │ ├─────────────┼──────────────┼────────────────────────────────────────┤ │ Фаза 4 │ 3 недели │ Декларация НДС │ │ │ │ - XML-генератор (разделы 1,3,8,9) │ │ │ │ - Валидация по XSD │ │ │ │ - Страница просмотра декларации │ │ │ │ - Скачивание XML │ │ │ │ - Интеграция с Астрал.Отчётность │ │ │ │ - Тестирование через «Тестер» ФНС │ ├─────────────┼──────────────┼────────────────────────────────────────┤ │ Фаза 5 │ 1 неделя │ Особенности 5%/7% │ │ │ │ - Блокировка вычетов в UI │ │ │ │ - Предупреждения │ │ │ │ - Включение НДС в себестоимость │ │ │ │ - Е2Е тесты упрощённого режима │ ├─────────────┼──────────────┼────────────────────────────────────────┤ │ Фаза 6 │ 1 неделя │ Финализация │ │ (Polishing) │ │ - Навигация (меню, хлебные крошки) │ │ │ │ - Налоговый календарь (сроки НДС) │ │ │ │ - Документация пользователя │ │ │ │ - QA, edge-cases │ ├─────────────┼──────────────┼────────────────────────────────────────┤ │ ИТОГО │ ~11 недель │ ≈ 2.5 месяца (1 разработчик) │ └─────────────┴──────────────┴────────────────────────────────────────┘ ``` ### 9.2. Оценка трудозатрат (в человеко-днях) | Задача | Оценка | Примечание | |--------|--------|------------| | **Фаза 0: Инфраструктура** | **3 дня** | | | Миграции Alembic | 1 день | 2 таблицы + счёт 19 | | Модели SQLAlchemy | 0.5 дня | SalesBookEntry, PurchaseBookEntry | | VATModeService | 0.5 дня | Определение режима НДС | | Регистрация blueprint | 0.5 дня | Маршруты, навигация | | Тесты моделей | 0.5 дня | | | **Фаза 1: Книга продаж** | **7 дней** | | | SalesBookService | 2 дня | Создание, разбивка по ставкам | | Автозаполнение из УПД | 1.5 дня | Хук проведения, rebuild | | Эндпоинты (3 шт) | 1 день | list, rebuild, delete | | Шаблон Jinja2 | 1 день | Таблица с фильтрами | | Тесты | 1.5 дня | 8-10 тест-кейсов | | **Фаза 2: Книга покупок** | **8 дней** | | | PurchaseBookService | 2 дня | С проверкой права на вычет | | Проводки (Дт 19 Кт 60, Дт 68.02 Кт 19) | 2 дня | Интеграция с AccountingService | | Эндпоинты (5 шт) | 1.5 дня | list, rebuild, deduct, deduct_all, delete | | Шаблон Jinja2 | 1 день | С кнопками вычета | | Тесты | 1.5 дня | 10-12 тест-кейсов | | **Фаза 3: Расчёт НДС** | **6 дней** | | | VATCalculationService | 2 дня | Квартал + год | | Страница «НДС» (overview) | 2 дня | Карточки, графики | | Проводка уплаты (Дт 68.02 Кт 51) | 0.5 дня | | | Тесты | 1.5 дня | | | **Фаза 4: Декларация** | **12 дней** | | | Изучение XSD-схемы ФНС | 2 дня | format.nalog.ru | | XML-генератор | 4 дня | Разделы 1, 3, 8, 9 | | Валидация по XSD | 1.5 дня | lxml + xsd | | Страница просмотра | 1 день | Предпросмотр разделов | | Интеграция с Астрал | 2 дня | Отправка по API | | Тесты | 1.5 дня | | | **Фаза 5: Особенности 5%/7%** | **4 дня** | | | Блокировка вычетов | 1 день | В UI и сервисах | | Единая ставка в документах | 1 день | vat_utils | | Предупреждения / UX | 1 день | Компоненты | | E2E тесты | 1 день | | | **Фаза 6: Финализация** | **5 дней** | | | Навигация, хлебные крошки | 1 день | breadcrumbs.py | | Налоговый календарь НДС | 1.5 дня | Автоматические события | | Документация | 1 день | | | QA, edge-cases | 1.5 дня | | | **ИТОГО** | **45 дней** | **~9 недель (1 разработчик)** | ### 9.3. Приоритеты для MVP (минимальный продукт) Если нужен MVP к **Q1 2026** (первый квартал, когда НДС вступает в силу): **MVP (4 недели):** 1. ✅ Фаза 0: Миграции + модели 2. ✅ Фаза 1: Книга продаж (упрощённая) 3. ✅ Фаза 3: Расчёт НДС (только для 5%/7%) 4. ✅ Фаза 5: Особенности 5%/7% **Расширение (после MVP):** 5. Фаза 2: Книга покупок (для 22%) 6. Фаза 4: Декларация XML + Астрал > **Обоснование:** 80%+ целевой аудитории будут на ставках 5%/7% (без вычета). > Им не нужна книга покупок и счёт 19. Для MVP достаточно книги продаж и расчёта. ### 9.4. Файловая структура ``` app/ ├── models/ │ └── vat_books.py # SalesBookEntry, PurchaseBookEntry (НОВЫЙ) ├── services/ │ ├── sales_book_service.py # Сервис книги продаж (НОВЫЙ) │ ├── purchase_book_service.py # Сервис книги покупок (НОВЫЙ) │ ├── vat_calculation_service.py # Расчёт НДС (НОВЫЙ) │ ├── vat_mode_service.py # Определение режима НДС (НОВЫЙ) │ ├── nds_report_service.py # Отправка декларации (НОВЫЙ) │ └── vat_rate_period_service.py # (СУЩЕСТВУЕТ) ├── utils/ │ ├── vat_utils.py # (СУЩЕСТВУЕТ, дополнить) │ └── xml/ │ └── nds_declaration_xml_generator.py # XML декларации (НОВЫЙ) ├── views/ │ └── vat.py # Все эндпоинты НДС (НОВЫЙ) ├── templates/ │ └── vat/ │ ├── overview.html # Главная страница НДС (НОВЫЙ) │ ├── not_payer.html # Заглушка для неплательщиков (НОВЫЙ) │ ├── sales_book.html # Книга продаж (НОВЫЙ) │ ├── purchase_book.html # Книга покупок (НОВЫЙ) │ ├── declaration.html # Декларация (НОВЫЙ) │ └── annual.html # Годовой обзор (НОВЫЙ) alembic/versions/ ├── xxx_create_vat_books.py # Миграция: таблицы книг (НОВЫЙ) └── xxx_add_account_19.py # Миграция: счёт 19 (НОВЫЙ) tests/test_vat/ ├── test_sales_book.py # (НОВЫЙ) ├── test_purchase_book.py # (НОВЫЙ) ├── test_vat_calculation.py # (НОВЫЙ) └── test_vat_utils.py # (НОВЫЙ) ``` ### 9.5. Регистрация Blueprint ```python # Добавить в app/__init__.py (или app/config/routes.py) from app.views.vat import vat_bp app.register_blueprint(vat_bp) ``` ```python # Добавить в навигацию (sidebar menu) # app/templates/components/sidebar.html # В разделе "Налоги": { 'label': 'НДС', 'url': '/vat/', 'icon': 'receipt-tax', 'visible': organization.is_vat_payer, # Показывать только плательщикам 'children': [ {'label': 'Обзор', 'url': '/vat/'}, {'label': 'Книга продаж', 'url': '/vat/sales-book'}, {'label': 'Книга покупок', 'url': '/vat/purchase-book'}, {'label': 'Декларация', 'url': '/vat/declaration'}, ] } ``` ### 9.6. Зависимости (дополнительные пакеты) Для валидации XML по XSD: ``` # requirements.txt (дополнить) lxml>=5.0.0 # Для валидации XML по XSD-схемам ФНС ``` --- ## Приложение A: Коды видов операций (НДС) | Код | Описание | |-----|----------| | 01 | Реализация товаров, работ, услуг | | 02 | Оплата, частичная оплата (аванс) | | 06 | Зачёт аванса при отгрузке | | 10 | Безвозмездная передача | | 13 | СМР для собственного потребления | | 16 | Возврат товаров | | 18 | Корректировочный счёт-фактура | | 21 | Возврат аванса | | 22 | Реализация при взаимозачёте | | 26 | Экспорт (0%) | --- ## Приложение B: Схема проводок НДС ``` ПРОДАЖА (исходящий НДС) ======================== Дт 62 Кт 90.01 — Выручка (total_amount) Дт 90.03 Кт 68.02 — Начислен НДС (vat_amount) Дт 90.02 Кт 41 — Списана себестоимость (cost) Дт 51 Кт 62 — Получена оплата (при оплате) ПОКУПКА (входящий НДС) — только при 22%! ========================================== Дт 41 Кт 60 — Оприходование (amount без НДС) Дт 19.03 Кт 60 — Входящий НДС (vat_amount) Дт 68.02 Кт 19.03 — Вычет НДС (принятие к вычету) Дт 60 Кт 51 — Оплата поставщику (при оплате) ПОКУПКА при 5%/7% — БЕЗ ВЫЧЕТА ================================ Дт 41 Кт 60 — Оприходование (total_amount включая НДС!) Дт 60 Кт 51 — Оплата поставщику ❌ Счёт 19 НЕ используется УПЛАТА НДС ========== Дт 68.02 Кт 51 — Уплата НДС в бюджет (1/3 ежемесячно) ``` --- ## Приложение C: Сроки отчётности НДС | Квартал | Подача декларации (25-е число) | Уплата 1/3 | Уплата 2/3 | Уплата 3/3 | |---------|-------------------------------|------------|------------|------------| | Q1 | 25 апреля | 28 апреля | 28 мая | 28 июня | | Q2 | 25 июля | 28 июля | 28 августа | 28 сентября | | Q3 | 25 октября | 28 октября | 28 ноября | 28 декабря | | Q4 | 25 января (след. год) | 28 января | 28 февраля | 28 марта | > **Важно:** Декларация по НДС сдаётся **ТОЛЬКО в электронном виде** по ТКС (телекоммуникационным каналам связи). Бумажная форма не принимается. --- *Документ подготовлен для проекта Конто. При реализации необходимо сверяться с актуальными XSD-схемами ФНС на https://format.nalog.ru/ и тестировать XML через программу «Тестер» ФНС.*