Ниже я прикрепил обновлённые данные по проекту. Мне необходимо связать данные между таблицами
Необходимо написать код и страницы на которых будут связываться данные.
Основная таблица по связыванию hardware. Интегрировать её в выобр в меню. На этой странице необходимо выводить список hardware привязанный к документу по doc_id. Вверху страницы должен быть выпадающий список из существующих Документ:"doc_name от doc_date", фильтрация hardware должна быть по этому документу после нажатия кнопки "выбарть". Предупреждать о несохранённых данных.
Под этими кнопками должна быть кнопки "связать данные из OC" и  "связать данные с Актами". 

По нажатию кнопки "связать данные из OC" должна открываться отдельная страница "Сопоставление данных с OC". 
На странице "Сопоставление данных с OC" необходимо сопоставлять данные из hardware с данными из OS.
Ввреху страницы должна быть надпись Документ:"doc_name от doc_date".
Ниже вывести данные таблицей.
Сопоставление выполнять располагая рядом обозначенные ниже поля, при несовпадении подсвечивать красным. Дать возможность редактирования полей из hardware на странице.
На каждой из строк справа расположить кнопку "Заполнить из ОС", по которой данные по текущей записи по сравниваемым полям из OS переносятся в hardware. Внизу страницы расположить кнопку применить. По нажатию этой кнопки необходимо записать данные со страницы в таблицу hardware.
Поля для соспоставления:
таблица hardware:таблица os
-----------:----------------
inv: inv
quantity: quantity
shifr_time: code_norma_amort
shifr_amo:  отображать, но не сопоставлять
date_build: date_production
date_input: date_input
date_buh: date_expluatation
date_nouse: отображать, но не сопоставлять
first_price: price
last_price: last_price
srok: srok_polezn_use

По нажатию кнопки "связать данные с Актами" должна открываться отдельная страница "Сопоставление данных с Актами". 
На страница "Сопоставление данных с Актами" вывести список с сопоставленными данными. по нажатию на поле необходимо открывать страницу "Просмотр данных HW и AKT". 
Страница аналогична "Просмотр данных HW и AKT" аналогична edit_akt.html, только надо добавить данных из hardware.

Если необходимо задавай дополнительные вопросы.
Обновлённые данные по проекту:
структура:
web_app_hw_out/
├── app/
│   ├── blueprints/
│   │   ├── akt_bp.py
│   │   ├── akt_view_bp.py
│   │   ├── asudoo_bp.py
│   │   ├── hardware_bp.py
│   │   ├── os_bp.py
│   │   ├── reconcile_bp.py
│   │   ├── report_bp.py
│   │   ├── __init__.py
│   ├── hardware_db.sqlite
│   ├── loaders/
│   │   ├── akt_loader.py
│   │   ├── asudoo_loader.py
│   │   ├── hardware_loader.py
│   │   ├── os_loader.py
│   ├── logging_config.py
│   ├── models.py
│   ├── schemas.py
│   ├── static/
│   │   ├── css/
│   │   │   └── bootstrap.min.css
│   │   └── js/
│   │       ├── bootstrap.bundle.min.js
│   │       └── jquery.slim.min.js
│   ├── templates/
│   │   ├── audit_report.html
│   │   ├── base.html
│   │   ├── confirm_hardware.html
│   │   ├── edit_akt.html
│   │   ├── index.html
│   │   ├── inventory_report.html
│   │   ├── inventory_report_form.html
│   │   ├── reconcile_doc.html
│   │   ├── reconcile_hw.html
│   │   ├── reconcile_index.html
│   │   ├── update_asudoo.html
│   │   ├── upload_akt.html
│   │   ├── upload_hardware.html
│   │   ├── upload_os.html
│   │   └── view_akt.html
│   ├── uploads/
│   │   ├── akt_images/
│   ├── utils.py
│   ├── __init__.py
├── config.py
├── create_db.py
├── main.py
Файлы: 
# file:web_app_hw_out\app\__init__.py
# app/__init__.py
from flask import Flask, render_template
from config import Config
from flask_sqlalchemy import SQLAlchemy
from flask_marshmallow import Marshmallow  # Добавляем импорт Marshmallow
from flask_migrate import Migrate
from flask_session import Session  
from flask import send_from_directory
import os
from app.logging_config import setup_logger
from app.utils import parse_date
db = SQLAlchemy()
migrate = Migrate()
ma = Marshmallow()  # Создаем экземпляр Marshmallow
# Создадим объект Session (из Flask-Session):
sess = Session()
#app.config['UPLOAD_FOLDER'] = Config.UPLOAD_FOLDER
def format_datetime(value, format='%Y-%m-%d'):
    if value is None or value == '':
        return ''
    if isinstance(value, str):
        value = parse_date(value)
        if value is None:
            return ''
    return value.strftime(format)
def create_app():
    app = Flask(__name__)
    app.config.from_object(Config)
    db.init_app(app)
    migrate.init_app(app, db)
    ma.init_app(app)  # Инициализируем Marshmallow с приложением
    
    # ВАЖНО: указываем, что SQLAlchemy для сессий это наш db
    # т.е. SESSION_SQLALCHEMY = db (из Config)
    app.config['SESSION_SQLALCHEMY'] = db
    
    # Инициализируем Flask-Session:
    sess.init_app(app)
    
    # Настройка логгера
    setup_logger(app)
    
    # Регистрируем фильтр 'strftime'
    app.jinja_env.filters['strftime'] = format_datetime
    
    # Регистрируем Blueprint'ы
    from app.blueprints import register_blueprints
    register_blueprints(app)
    # Главная страница
    @app.route("/")
    def index():
        return render_template("index.html")
    # Сервис для предоставления доступа к папке uploads
    @app.route('/uploads/<path:filename>')
    def uploaded_file(filename):
        return send_from_directory(os.path.join(app.root_path, 'uploads'), filename)
    return app
# file:web_app_hw_out\app\blueprints\__init__.py
# app/blueprints/__init__.py
from .hardware_bp import hardware_bp
from .os_bp import os_bp
from .akt_bp import akt_bp
from .asudoo_bp import asudoo_bp
from .reconcile_bp import reconcile_bp
from .report_bp import report_bp
from .akt_view_bp import akt_view_bp
def register_blueprints(app):
    app.register_blueprint(hardware_bp)
    app.register_blueprint(os_bp)
    app.register_blueprint(akt_bp)
    app.register_blueprint(asudoo_bp)
    app.register_blueprint(reconcile_bp)
    app.register_blueprint(report_bp)
    app.register_blueprint(akt_view_bp)
# file:web_app_hw_out\app\blueprints\akt_bp.py
# app/blueprints/akt_bp.py
import os
from flask import Blueprint, request, render_template, redirect, url_for, flash
from config import Config
from app.loaders.akt_loader import parse_pdf_for_akt
from app.utils import get_or_create
akt_bp = Blueprint("akt_bp", __name__, template_folder="../../templates")
def allowed_file(filename):
    return "." in filename and filename.rsplit(".", 1)[1].lower() in Config.ALLOWED_EXTENSIONS
@akt_bp.route("/upload_akt", methods=["GET", "POST"])
def upload_akt():
    if request.method == "POST":
        file_pdf = request.files.get("file_pdf")
        if file_pdf and allowed_file(file_pdf.filename):
            filename_pdf = file_pdf.filename
            path_pdf = os.path.join(Config.UPLOAD_FOLDER, filename_pdf)
            file_pdf.save(path_pdf)
            parse_pdf_for_akt(path_pdf)
            flash("Файл PDF (акты) обработан")
        return redirect(url_for("akt_bp.upload_akt"))
    return render_template("upload_akt.html")
# file:web_app_hw_out\app\blueprints\akt_view_bp.py
from flask import Blueprint, request, render_template, redirect, url_for, flash
from app.models import db, Akt
from sqlalchemy import or_
import datetime
akt_view_bp = Blueprint('akt_view_bp', __name__, template_folder="../../templates")
@akt_view_bp.route('/akt', methods=['GET', 'POST'])
def view_akt():
    """
    Страница просмотра всех актов с фильтром.
    """
    filters = {}
    if request.method == 'POST':
        number_akt = request.form.get('number_akt')
        date_akt = request.form.get('date_akt')
        if number_akt:
            filters['number_akt'] = number_akt
        if date_akt:
            filters['date_akt'] = date_akt
    # Фильтрация данных
    query = Akt.query
    if filters.get('number_akt'):
        query = query.filter(Akt.number_akt == filters['number_akt'])
    if filters.get('date_akt'):
        query = query.filter(Akt.date_akt == filters['date_akt'])
    akt_list = query.all()
    # Данные для выпадающих списков
    unique_numbers = [akt.number_akt for akt in Akt.query.distinct(Akt.number_akt).all()]
    unique_dates = [akt.date_akt for akt in Akt.query.distinct(Akt.date_akt).all()]
    return render_template(
        'view_akt.html',
        akt_list=akt_list,
        unique_numbers=unique_numbers,
        unique_dates=unique_dates,
        selected_filters=filters
    )
@akt_view_bp.route('/akt/<int:akt_id>', methods=['GET', 'POST'])
def edit_akt(akt_id):
    """
    Страница редактирования акта.
    """
    akt = Akt.query.get_or_404(akt_id)
    if request.method == 'POST':
        try:
            akt.number_akt = request.form.get('number_akt')
            #akt.date_akt = request.form.get('date_akt')
            # Преобразование даты в объект datetime.date
            date_str = request.form.get('date_akt')
            if date_str:
                akt.date_akt = datetime.datetime.strptime(date_str, '%Y-%m-%d').date()
            
            akt.inv = request.form.get('inv')
            akt.name_hw = request.form.get('name_hw')
            akt.type_hw = request.form.get('type_hw')
            akt.zn = request.form.get('zn')
            akt.condition = request.form.get('condition')
            akt.issue = request.form.get('issue')
            akt.conclusion = request.form.get('conclusion')
            akt.non_compliance = request.form.get('non_compliance')
            #akt.description = request.form.get('description')
            db.session.commit()
            flash('Изменения успешно сохранены!', 'success')
            return redirect(url_for('akt_view_bp.view_akt'))
        except Exception as e:
            db.session.rollback()
            flash(f'Ошибка сохранения данных: {e}', 'danger')
    # Для кнопок перехода к следующему и предыдущему
    next_akt = Akt.query.filter(Akt.akt_id > akt_id).order_by(Akt.akt_id).first()
    prev_akt = Akt.query.filter(Akt.akt_id < akt_id).order_by(Akt.akt_id.desc()).first()
    return render_template(
        'edit_akt.html',
        akt=akt,
        next_akt=next_akt,
        prev_akt=prev_akt
    )
# file:web_app_hw_out\app\blueprints\asudoo_bp.py
# app/blueprints/asudoo_bp.py
from flask import Blueprint, request, render_template, redirect, url_for, flash
from config import Config
from app.loaders.asudoo_loader import parse_csv_for_asudoo
asudoo_bp = Blueprint("asudoo_bp", __name__, template_folder="../../templates")
@asudoo_bp.route("/update_asudoo", methods=["GET", "POST"])
def update_asudoo():
    if request.method == "POST":
        csv_path = request.form.get("csv_path") or Config.ASUDOO_CSV_DEFAULT_PATH
        parse_csv_for_asudoo(csv_path)
        flash("Таблица ASUDOO обновлена")
        return redirect(url_for("asudoo_bp.update_asudoo"))
    else:
        # Передаем путь по умолчанию в шаблон
        return render_template("update_asudoo.html", default_csv_path=Config.ASUDOO_CSV_DEFAULT_PATH)
# file:web_app_hw_out\app\blueprints\hardware_bp.py
# app/blueprints/hardware_bp.py
from flask import (
    Blueprint,
    request,
    render_template,
    redirect,
    url_for,
    flash,
    session
)
from app.models import (
    db,
    Landocs,
    Hardware,
    DescriptionTable,
    KomplektTable,
    OsnovanieTable,
    UESTable
)
from app.loaders.hardware_loader import (
    parse_xlsx_for_hardware_data,
    parse_docx_for_hardware_data
)
from config import Config
import os
import datetime
import json
import logging
from app.utils import get_or_create, parse_date
hardware_bp = Blueprint("hardware_bp", __name__, template_folder="../../templates")
# Создаем логгер
logger = logging.getLogger(__name__)
logger.setLevel(logging.DEBUG)
formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
stream_handler = logging.StreamHandler()
stream_handler.setFormatter(formatter)
def allowed_file(filename):
    return "." in filename and filename.rsplit(".", 1)[1].lower() in Config.ALLOWED_EXTENSIONS
def parse_date_from_string(date_str):
    """
    Преобразует строку даты в объект datetime.date.
    """
    if not date_str or date_str in ('', 'None'):
        return None
    try:
        # Попробуем начать с формата ISO, который использовался при сериализации дат
        #return datetime.fromisoformat(date_str).date()
        return datetime.datetime.fromisoformat(date_str)
    except ValueError:
        # Если не получилось, попробуем другие форматы
        date_formats = [
            "%Y-%m-%d",     # Формат ISO
            "%d.%m.%Y",
            "%d/%m/%Y",
            "%Y/%m/%d",
            "%Y-%m-%d",
            "%d-%m-%Y",
            "%d.%m.%y",
            "%Y",           # Только год
            "%m.%Y",        # Месяц и год
            "%m/%Y",
            "%Y-%m",
            "%Y/%m",
        ]
        for fmt in date_formats:
            try:
                #return datetime.strptime(date_str, fmt).date()
                return datetime.strptime(date_str, fmt)
            except ValueError:
                continue
    return None
@hardware_bp.route("/upload_hardware", methods=["GET", "POST"])
def upload_hardware():
    if request.method == "POST":
        # Check if this is the confirmation step
        #logger.debug(f"[DEBUG] request.form: {request.form}")
        if 'confirm' in request.form:
            # Получаем сериализованные данные из сессии 
            parsed_data_json = session.get('parsed_hardware')
            
            if not parsed_data_json:
                flash("Нет данных для подтверждения.")
                return redirect(url_for("hardware_bp.upload_hardware"))
            
            # Парсим JSON-строку в список словарей (Десериализуем данные)
            parsed_data = json.loads(parsed_data_json)
            for entry in parsed_data:
                # Добавляем поля с None, если они отсутствуют
            
                required_fields = ['no', 'inv','name_txt', 'name', 'quantity', 'sn', 'mac', 'kud', 'boxid', 'shifr_time', 
                                'shifr_amo', 'date_build', 'date_input', 'date_buh', 'date_nouse', 'number_akt', 'date_akt',
                                'first_price', 'last_price', 'srok', 'komplekt_text', 'osnovanie_text', 
                                'description_text', 'ues_description']
                for field in required_fields:
                    entry.setdefault(field, None)
             
            parsed_data = json.loads(parsed_data_json)
            # Преобразуем строки дат обратно в объекты datetime.date
            for entry in parsed_data:
                #entry['date_build'] = parse_date_from_string(entry.get('date_build'))
                #entry['date_input'] = parse_date_from_string(entry.get('date_input'))
                #entry['date_buh'] = parse_date_from_string(entry.get('date_buh'))
                #entry['date_nouse'] = parse_date_from_string(entry.get('date_nouse'))
                entry['date_build'] = parse_date(entry.get('date_build'))
                entry['date_input'] = parse_date(entry.get('date_input'))
                entry['date_buh'] = parse_date(entry.get('date_buh'))
                entry['date_nouse'] = parse_date(entry.get('date_nouse'))
                entry['date_akt'] = parse_date(entry.get('date_akt'))
            accept_entries = request.form.getlist('accept')
            for idx, entry in enumerate(parsed_data):
                if str(idx) in accept_entries:
                    # Получаем обновлённые 'sn', 'mac', 'kud', 'boxid' из формы
                    sn_key = f'sn_{idx}'
                    mac_key = f'mac_{idx}'
                    kud_key = f'kud_{idx}'
                    boxid_key = f'boxid_{idx}'
                    sn_value = request.form.get(sn_key, entry.get('sn'))
                    mac_value = request.form.get(mac_key, entry.get('mac'))
                    kud_value = request.form.get(kud_key, entry.get('kud'))
                    boxid_value = request.form.get(boxid_key, entry.get('boxid'))
                    # Обновляем значения в entry
                    entry['sn'] = sn_value
                    entry['mac'] = mac_value
                    entry['kud'] = kud_value
                    entry['boxid'] = boxid_value
                    # Проверяем, есть ли уже такое оборудование в базе
                    #existing_hw = Hardware.query.filter_by(inv=entry.get("inv")).first()
                    #if existing_hw:
                    #    flash(f"Запись с inv={entry.get('inv')} уже существует. Пропущено сохранение.")
                    #    continue
                    # Save this entry to the database
                    try:
                        # Получаем или создаем связанные записи
                        # Получаем или создаем связанные записи KomplektTable
                        komplekt_text = entry.get("komplekt_text")
                        komplekt = None
                        if komplekt_text:
                            komplekt = get_or_create(db.session, KomplektTable, komplekt_text=komplekt_text)
                        # Получаем или создаем связанные записи OsnovanieTable
                        osnovanie_text = entry.get("osnovanie_text")
                        osnovanie = None
                        if osnovanie_text:
                            osnovanie = get_or_create(db.session, OsnovanieTable, osnovanie_txt=osnovanie_text)
                        # Получаем или создаем связанные записи DescriptionTable
                        description_text = entry.get("description_text")
                        description = None
                        if description_text:
                            description = get_or_create(db.session, DescriptionTable, description_text=description_text)
                        # Получаем или создаем связанные записи UESTable
                        ues_description = entry.get("ues_description")
                        ues = None
                        if ues_description:
                            ues = get_or_create(db.session, UESTable, ues_name=ues_description)
                        # Создаем объект Hardware
                        hardware = Hardware(
                            doc_id=entry.get("doc_id"),
                            no=entry.get("no"),
                            name_txt=entry.get("name_txt"),
                            name=entry.get("name"),
                            inv=entry.get("inv"),
                            quantity=entry.get("quantity"),
                            shifr_time=entry.get("shifr_time"),
                            shifr_amo=entry.get("shifr_amo"),
                            date_build=entry.get("date_build"),
                            date_input=entry.get("date_input"),
                            date_buh=entry.get("date_buh"),
                            date_nouse=entry.get("date_nouse"),
                            first_price=entry.get("first_price"),
                            last_price=entry.get("last_price"),
                            srok=entry.get("srok"),
                            komplekt_id=komplekt.komplekt_id if komplekt else None,
                            osnovanie_id=osnovanie.osnovanie_id if osnovanie else None,
                            description_id=description.description_id if description else None,
                            ues_id=ues.ues_id if ues else None,
                            ues_description=entry.get("ues_description"),
                            sn=entry.get("sn"),
                            mac=entry.get("mac"),
                            kud=entry.get("kud"),
                            boxid=entry.get("boxid"),
                            number_akt=entry.get("number_akt"),
                            date_akt=entry.get("date_akt")
                        )
                        db.session.add(hardware)
                    except Exception as e:
                        db.session.rollback()
                        flash(f"Ошибка при сохранении записи inv={entry.get('inv')}: {e}")
            try:
                db.session.commit()
                flash("Выбранные данные успешно сохранены.")
                session.pop('parsed_hardware', None)
                return redirect(url_for("hardware_bp.upload_hardware"))
            except Exception as e:
                db.session.rollback()
                flash(f"Ошибка при сохранении данных: {e}")
                return redirect(url_for("hardware_bp.upload_hardware"))
        # Handle initial file upload and parsing
        file_xlsx = request.files.get("file_xlsx")
        file_docx = request.files.get("file_docx")
        doc_name = request.form.get("doc_name")
        doc_date = request.form.get("doc_date")
        doc_description = request.form.get("doc_description")
        # Validate document information
        if not doc_name or not doc_date:
            flash("Название и дата документа обязательны.")
            return redirect(url_for("hardware_bp.upload_hardware"))
        try:
            doc_date_parsed = datetime.datetime.strptime(doc_date, '%Y-%m-%d').date()
        except ValueError:
            flash("Неверный формат даты документа. Используйте YYYY-MM-DD.")
            return redirect(url_for("hardware_bp.upload_hardware"))
        # Проверка/создание LANDOCS
        landoc = Landocs.query.filter_by(doc_name=doc_name, doc_date=doc_date_parsed).first()
        if landoc:
            doc_id = landoc.doc_id
            flash(f"Документ уже существует, используем doc_id={doc_id}")
        else:
            landoc = Landocs(doc_name=doc_name, doc_date=doc_date_parsed, doc_description=doc_description)
            db.session.add(landoc)
            db.session.commit()
            doc_id = landoc.doc_id
            flash(f"Создан новый документ doc_id={doc_id}")
        # Parse XLSX and DOCX files
        parsed_hardware = []
        if file_xlsx and allowed_file(file_xlsx.filename):
            filename_xlsx = file_xlsx.filename
            path_xlsx = os.path.join(Config.UPLOAD_FOLDER, filename_xlsx)
            file_xlsx.save(path_xlsx)
            parsed_xlsx = parse_xlsx_for_hardware_data(path_xlsx, doc_id)
            parsed_hardware.extend(parsed_xlsx)
            flash("Файл XLSX обработан")
        if file_docx and allowed_file(file_docx.filename):
            filename_docx = file_docx.filename
            path_docx = os.path.join(Config.UPLOAD_FOLDER, filename_docx)
            file_docx.save(path_docx)
            parsed_docx = parse_docx_for_hardware_data(path_docx, doc_id)
            # Merge parsed_docx into parsed_hardware based on 'inv'
            for docx_entry in parsed_docx:
                matched = False
                for hw_entry in parsed_hardware:
                    if hw_entry['inv'] == docx_entry['inv']:
                        hw_entry['description_text'] = (hw_entry.get('description_text') or "") + "\n" + docx_entry.get('description_text', '')
                        matched = True
                        break
                if not matched:
                    # If no matching inv in XLSX, add as new entry
                    parsed_hardware.append({
                        "doc_id": doc_id,
                        "no": None,
                        "name": None,
                        "inv": docx_entry['inv'],
                        "quantity": None,
                        "shifr_time": None,
                        "shifr_amo": None,
                        "date_build": None,
                        "date_input": None,
                        "date_buh": None,
                        "date_nouse": None,
                        "first_price": None,
                        "last_price": None,
                        "srok": None,
                        "komplekt_text": None,
                        "osnovanie_text": None,
                        "description_text": docx_entry.get('description_text', ''),
                        "ues_text": None,
                        "sn": None,
                        "mac": None,
                        "kud": None
                    })
            flash("Файл DOCX обработан")
        if not parsed_hardware:
            flash("Не найдено данных для обработки.")
            return redirect(url_for("hardware_bp.upload_hardware"))
        #print(parsed_hardware)
        # Store parsed data in session (as JSON)
        session['parsed_hardware'] = json.dumps(parsed_hardware, default=str)
        logging.debug(f'[DEBUG] xlsx session[parsed_hardware] {parsed_hardware}')
        return render_template("confirm_hardware.html", hardware_list=parsed_hardware)
    else:
        # GET request: Render the upload form with existing document names
        doc_names = [doc.doc_name for doc in Landocs.query.distinct(Landocs.doc_name).all()]
        logging.debug(f'[DEBUG]Render the upload form with existing document names {doc_names}')
        return render_template("upload_hardware.html", doc_names=doc_names)
# file:web_app_hw_out\app\blueprints\os_bp.py
# app/blueprints/os_bp.py
import os
from flask import Blueprint, request, render_template, redirect, url_for, flash
from config import Config
from app.loaders.os_loader import parse_csv_for_os
os_bp = Blueprint("os_bp", __name__, template_folder="../../templates")
def allowed_file(filename):
    return "." in filename and filename.rsplit(".", 1)[1].lower() in Config.ALLOWED_EXTENSIONS
@os_bp.route("/upload_os", methods=["GET", "POST"])
def upload_os():
    if request.method == "POST":
        file_csv = request.files.get("file_csv")
        if file_csv and allowed_file(file_csv.filename):
            filename_csv = file_csv.filename
            path_csv = os.path.join(Config.UPLOAD_FOLDER, filename_csv)
            file_csv.save(path_csv)
            parse_csv_for_os(path_csv)
            flash("Файл CSV для OS обработан")
        return redirect(url_for("os_bp.upload_os"))
    return render_template("upload_os.html")
# file:web_app_hw_out\app\blueprints\reconcile_bp.py
# app/blueprints/reconcile_bp.py
from flask import Blueprint, render_template, request, redirect, url_for, flash
from datetime import datetime
from app import db
from app.models import Hardware, OS, Akt, ChekedHW, Landocs
reconcile_bp = Blueprint("reconcile_bp", __name__, template_folder="../../templates")
@reconcile_bp.route("/reconcile", methods=["GET"])
def reconcile_index():
    """
    Главная страница для сверки: выбор документа
    """
    # Получаем все документы из LANDOCS
    landocs = Landocs.query.all()
    return render_template("reconcile_index.html", landocs=landocs)
@reconcile_bp.route("/reconcile/doc/<int:doc_id>", methods=["GET"])
def reconcile_doc(doc_id):
    # Получаем документ LANDOCS
    doc = Landocs.query.get(doc_id)
    if not doc:
        flash("Документ не найден")
        return redirect(url_for("reconcile_bp.reconcile_index"))
    # Получаем список оборудования, связанного с данным документом, включая связанные записи в ChekedHW
    hw_list = db.session.query(Hardware).filter_by(doc_id=doc_id).outerjoin(ChekedHW).add_columns(
        ChekedHW.date_checked_akt,
        ChekedHW.date_checked_os,
        Hardware.chekedhw_id
    ).order_by(Hardware.inv).all()
    return render_template("reconcile_doc.html", doc=doc, hw_list=hw_list)
@reconcile_bp.route("/reconcile/hw/<inv>", methods=["GET"])
def reconcile_hw(inv):
    # Получаем Hardware вместе с ChekedHW
    hw = db.session.query(Hardware).filter_by(inv=inv).outerjoin(ChekedHW).add_columns(
        Hardware,
        ChekedHW.date_checked_akt,
        ChekedHW.date_checked_os
    ).first()
    if not hw:
        flash(f"Hardware не найдено, inv={inv}")
        return redirect(url_for("reconcile_bp.reconcile_index"))
    
    hardware = hw[0]
    date_checked_akt = hw.date_checked_akt
    date_checked_os = hw.date_checked_os
    # Получаем OS
    os_ = OS.query.filter_by(inv=inv).first()
    # Получаем AKT
    akt = Akt.query.get(hardware.akt_id) if hardware.akt_id else None
    # Формируем HTML-блоки
    hw_html = build_hardware_block(hardware, date_checked_akt, date_checked_os)
    os_html = build_os_block(hardware, os_)
    akt_html = build_akt_block(hardware, akt)
    return render_template(
        "reconcile_hw.html",
        inv=inv,
        hw_html=hw_html,
        os_html=os_html,
        akt_html=akt_html
    )
def build_hardware_block(hw, date_checked_akt, date_checked_os):
    html = f"""
    <h2>Данные из Hardware</h2>
    <ul>
      <li>name: {hw.name}</li>
      <li>quantity: {hw.quantity}</li>
      <li>shifr_amo: {hw.shifr_amo}</li>
      <li>date_build: {hw.date_build}</li>
      <li>date_input: {hw.date_input}</li>
      <li>srok: {hw.srok}</li>
      <li>date_checked_os: {date_checked_os or '—'}</li>
      <li>date_checked_akt: {date_checked_akt or '—'}</li>
    </ul>
    """
    return html
def build_os_block(hw, os_):
    if not os_:
        return '<p style="color:red;">Нет данных в OS для данного inv</p>'
    
    def highlight_diff(hw_val, os_val):
        if str(hw_val or "").strip() != str(os_val or "").strip():
            return f'<span style="color:red;">{os_val}</span>'
        else:
            return f'{os_val}'
    
    html = f"""
    <h2>Данные из OS (inv={os_.inv})</h2>
    <ul>
      <li>model: {highlight_diff(hw.name, os_.model)}</li>
      <li>quantity: {highlight_diff(hw.quantity, os_.quantity)}</li>
      <li>code_norma_amort -> shifr_amo: {highlight_diff(hw.shifr_amo, os_.code_norma_amort)}</li>
      <li>date_production -> date_build: {highlight_diff(hw.date_build, os_.date_production)}</li>
      <li>date_input: {highlight_diff(hw.date_input, os_.date_input)}</li>
      <li>srok_polezn_use -> srok: {highlight_diff(hw.srok, os_.srok_polezn_use)}</li>
    </ul>
    <!-- Кнопки -->
    <form method="POST" action="{url_for('reconcile_bp.reconcile_hw_apply_os', inv=hw.inv)}">
        <button type="submit">Применить к Hardware данные из OS</button>
    </form>
    <form method="POST" action="{url_for('reconcile_bp.reconcile_hw_check_os', inv=hw.inv)}">
        <button type="submit">Сверено с ОС</button>
    </form>
    """
    return html
def build_akt_block(hw, akt):
    if not akt:
        return '<p style="color:red;">Нет записи в AKT для данного inv</p>'
    
    def highlight_diff(hw_val, akt_val):
        if str(hw_val or "").strip() != str(akt_val or "").strip():
            return f'<span style="color:red;">{akt_val}</span>'
        else:
            return f'{akt_val}'
    
    html = f"""
    <h2>Данные из AKT (akt_id={akt.akt_id})</h2>
    <ul>
      <li>name_hw: {highlight_diff(hw.name, akt.name_hw)}</li>
      <li>inv: {highlight_diff(hw.inv, akt.inv)}</li>
      <li>number_akt: {akt.number_akt}</li>
      <li>date_akt: {akt.date_akt}</li>
    </ul>
    <form method="POST" action="{url_for('reconcile_bp.reconcile_hw_check_akt', inv=hw.inv)}">
      <button type="submit">Сверено с актом</button>
    </form>
    """
    return html
@reconcile_bp.route("/reconcile/hw/<inv>/apply_os", methods=["POST"])
def reconcile_hw_apply_os(inv):
    hw = Hardware.query.filter_by(inv=inv).first()
    if not hw:
        flash("Hardware не найдено.")
        return redirect(url_for("reconcile_bp.reconcile_index"))
    os_ = OS.query.filter_by(inv=inv).first()
    if not os_:
        flash("OS не найдена, нечего применять.")
        return redirect(url_for("reconcile_bp.reconcile_hw", inv=inv))
    # Обновляем поля Hardware на основе OS
    hw.name = os_.model or hw.name
    hw.quantity = os_.quantity or hw.quantity
    hw.shifr_amo = os_.code_norma_amort or hw.shifr_amo
    hw.date_build = os_.date_production or hw.date_build
    hw.date_input = os_.date_input or hw.date_input
    hw.srok = os_.srok_polezn_use or hw.srok
    try:
        db.session.commit()
        flash("Данные из OS применены к Hardware.")
    except Exception as e:
        db.session.rollback()
        flash(f"Ошибка при сохранении данных: {e}")
    return redirect(url_for("reconcile_bp.reconcile_hw", inv=inv))
@reconcile_bp.route("/reconcile/hw/<inv>/check_os", methods=["POST"])
def reconcile_hw_check_os(inv):
    hw = Hardware.query.filter_by(inv=inv).first()
    if not hw:
        flash("Hardware не найдено.")
        return redirect(url_for("reconcile_bp.reconcile_index"))
    # Получаем или создаем ChekedHW
    if hw.chekedhw_id:
        chekedhw = ChekedHW.query.get(hw.chekedhw_id)
        if not chekedhw:
            chekedhw = ChekedHW()
            db.session.add(chekedhw)
            hw.chekedhw = chekedhw
    else:
        chekedhw = ChekedHW()
        db.session.add(chekedhw)
        hw.chekedhw = chekedhw
    # Обновляем дату сверки с OS
    chekedhw.date_checked_os = datetime.utcnow().date()
    try:
        db.session.commit()
        flash("Сверено с ОС (дата проставлена).")
    except Exception as e:
        db.session.rollback()
        flash(f"Ошибка при сохранении данных: {e}")
    return redirect(url_for("reconcile_bp.reconcile_hw", inv=inv))
@reconcile_bp.route("/reconcile/hw/<inv>/check_akt", methods=["POST"])
def reconcile_hw_check_akt(inv):
    hw = Hardware.query.filter_by(inv=inv).first()
    if not hw:
        flash("Hardware не найдено.")
        return redirect(url_for("reconcile_bp.reconcile_index"))
    if hw.chekedhw_id:
        chekedhw = ChekedHW.query.get(hw.chekedhw_id)
        if not chekedhw:
            chekedhw = ChekedHW()
            db.session.add(chekedhw)
            hw.chekedhw = chekedhw
    else:
        chekedhw = ChekedHW()
        db.session.add(chekedhw)
        hw.chekedhw = chekedhw
    chekedhw.date_checked_akt = datetime.utcnow().date()
    try:
        db.session.commit()
        flash("Сверено с актом (дата проставлена).")
    except Exception as e:
        db.session.rollback()
        flash(f"Ошибка при сохранении данных: {e}")
    return redirect(url_for("reconcile_bp.reconcile_hw", inv=inv))
# file:web_app_hw_out\app\blueprints\report_bp.py
# app/blueprints/report_bp.py
from flask import Blueprint, jsonify, render_template, request, send_file
from app.models import Hardware, OS
from app.schemas import HardwareSchema
from app import db
import io
import pandas as pd
report_bp = Blueprint("report_bp", __name__)
hardware_schema = HardwareSchema()
hardwares_schema = HardwareSchema(many=True)
@report_bp.route("/inventory_report", methods=["GET", "POST"])
def inventory_report():
    if request.method == "POST":
        # Получаем параметры фильтра из формы
        equipment_type = request.form.get('equipment_type')
        date_from = request.form.get('date_from')
        date_to = request.form.get('date_to')
        export_format = request.form.get('export_format', 'html')
        # Формируем запрос
        query = Hardware.query
        if equipment_type:
            query = query.filter(Hardware.type_hw == equipment_type)
        if date_from:
            query = query.filter(Hardware.date_input >= date_from)
        if date_to:
            query = query.filter(Hardware.date_input <= date_to)
        # Выполняем запрос
        #hardware_list = query.all()
        # Пагинация
        page = request.args.get('page', 1, type=int)
        per_page = 50
        hardware_list = query.paginate(page=page, per_page=per_page)
        if export_format == 'html':
            # Отображаем HTML-шаблон
            return render_template('inventory_report.html', hardware_list=hardware_list)
        elif export_format == 'excel':
            # Экспорт в Excel
            return export_hardware_to_excel(hardware_list)
        elif export_format == 'csv':
            # Экспорт в CSV
            return export_hardware_to_csv(hardware_list)
        else:
            # По умолчанию возвращаем JSON
            data = hardwares_schema.dump(hardware_list)
            return jsonify(data)
        
        #return render_template('inventory_report.html', hardware_list=hardware_list)
    
    else:
        # GET-запрос: отображаем форму для ввода параметров
        return render_template('inventory_report_form.html')
@report_bp.route("/report_spisanie", methods=["GET"])
def report_spisanie():
    rows_zero = Hardware.query.filter(Hardware.last_price == 0).all()
    rows_nonzero = Hardware.query.filter(Hardware.last_price != 0).all()
    
    zero_list = [hardware_to_dict(hw) for hw in rows_zero]
    nonzero_list = [hardware_to_dict(hw) for hw in rows_nonzero]
    
    return jsonify({
        "spisanie_zero_price": zero_list,
        "spisanie_nonzero_price": nonzero_list
    })
def export_hardware_to_excel_old(hardware_list):
    # Преобразуем данные в DataFrame
    df = pd.DataFrame([hardware_to_dict(hw) for hw in hardware_list])
    # Используем буфер памяти для хранения данных Excel
    output = io.BytesIO()
    with pd.ExcelWriter(output, engine='xlsxwriter') as writer:
        df.to_excel(writer, index=False, sheet_name='Inventory')
        writer.save()
        output.seek(0)
    return send_file(output, attachment_filename='inventory_report.xlsx', as_attachment=True)
def export_hardware_to_excel(hardware_list):
    # Преобразуем данные в DataFrame
    df = pd.DataFrame([hardware_to_dict(hw) for hw in hardware_list])
    # Используем буфер памяти для хранения данных Excel
    output = io.BytesIO()
    with pd.ExcelWriter(output, engine='xlsxwriter') as writer:
        df.to_excel(writer, index=False, sheet_name='Inventory')
    output.seek(0)
    return send_file(
        output,
        mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
        as_attachment=True,
        download_name='inventory_report.xlsx'  # Use 'attachment_filename' if using Flask < 2.0
    )
    #return send_file(io.BytesIO(output.getvalue().encode()), attachment_filename='inventory_report.csv', as_attachment=True)
def hardware_to_dict(hw):
    # Преобразуем объект Hardware в словарь
    return {
        'inv': hw.inv,
        'name': hw.name,
        'quantity': hw.quantity,
        'date_input': hw.date_input.isoformat() if hw.date_input else '',
        "hw_id": hw.hw_id,
        "duplicate_hw_id": hw.duplicate_hw_id,
        "doc_id": hw.doc_id,
        "no": hw.no,
        "name": hw.name,
        "inv": hw.inv,
        "sn": hw.sn,
        "mac": hw.mac,
        "kud": hw.kud,
        "quantity": hw.quantity,
        "shifr_time": hw.shifr_time,
        "shifr_amo": hw.shifr_amo,
        "date_build": hw.date_build.isoformat() if hw.date_build else None,
        "date_input": hw.date_input.isoformat() if hw.date_input else None,
        "date_buh": hw.date_buh.isoformat() if hw.date_buh else None,
        "date_nouse": hw.date_nouse.isoformat() if hw.date_nouse else None,
        "first_price": hw.first_price,
        "first_price_date": hw.first_price_date.isoformat() if hw.first_price_date else None,
        "last_price": hw.last_price,
        "last_price_date": hw.last_price_date.isoformat() if hw.last_price_date else None,
        "srok": hw.srok,
        "komplekt_id": hw.komplekt_id,
        "osnovanie_id": hw.osnovanie_id,
        "description_id": hw.description_id,
        "ues_id": hw.ues_id,
        "chekedhw_id": hw.chekedhw_id,
        "os_id": hw.os_id,
        "akt_id": hw.akt_id,
        "asudoolink_id": hw.asudoolink_id,
        "event_id": hw.event_id,
        # Добавьте другие необходимые поля
    }
    
    
@report_bp.route("/audit_report", methods=["GET"])
def audit_report():
    # Объединяем Hardware и OS по инвентарному номеру
    results = db.session.query(
        Hardware,
        OS
    ).outerjoin(OS, Hardware.inv == OS.inv).all()
    discrepancies = []
    for hw, os_ in results:
        if os_ is None:
            # Если нет соответствия в OS
            discrepancies.append({
                'inv': hw.inv,
                'differences': {'notice': 'Нет данных в OS'}
            })
            continue
        diffs = {}
        if hw.name != os_.model:
            diffs['Наименование'] = {'Hardware': hw.name, 'OS': os_.model}
        if hw.quantity != os_.quantity:
            diffs['Количество'] = {'Hardware': hw.quantity, 'OS': os_.quantity}
        if hw.date_input != os_.date_input:
            diffs['Дата ввода'] = {'Hardware': hw.date_input, 'OS': os_.date_input}
        # Проверьте другие поля по необходимости
        if diffs:
            discrepancy = {
                'inv': hw.inv,
                'differences': diffs
            }
            discrepancies.append(discrepancy)
    return render_template('audit_report.html', discrepancies=discrepancies)
# file:web_app_hw_out\app\loaders\akt_loader.py
# app/loaders/akt_loader.py
import os
import pytesseract
from pdf2image import convert_from_path
from PIL import Image
from app.models import db, Akt, Hardware, UESTable
from datetime import datetime
import re
from config import Config
from app.utils import get_or_create, parse_int, parse_date
import pymorphy2
import logging
pytesseract.pytesseract.tesseract_cmd = r'c:\Users\grum77\AppData\Local\Programs\Tesseract-OCR\tesseract.exe'
morph = pymorphy2.MorphAnalyzer()
# Создаем логгер
logger = logging.getLogger(__name__)
logger.setLevel(logging.DEBUG)
formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
stream_handler = logging.StreamHandler()
stream_handler.setFormatter(formatter)
def parse_pdf_for_akt(filepath: str):
    """
    Функция для обработки PDF с актами, используя SQLAlchemy.
    """
    logger.debug(f"[DEBUG] parse_pdf_for_akt: Обрабатываем файл {filepath}")
    if not os.path.exists(filepath):
        logger.error(f"[ERROR] Файл PDF не найден: {filepath}")
        return
    # Конвертация PDF в изображения
    try:
        pages = convert_from_path(filepath, dpi=200)
    except Exception as e:
        logger.error(f"[ERROR] Ошибка при конвертации PDF в изображения: {e}")
        return
    
    # Папка для сохранения изображений актов
    images_folder = os.path.join(Config.UPLOAD_FOLDER, 'akt_images')
    if not os.path.exists(images_folder):
        os.makedirs(images_folder)
    page_count = 0
    akt_count = 0
    for page_index, page_img in enumerate(pages):
        page_count += 1
        # Обработка изображений и OCR
        # Предполагаем, что каждая страница содержит два акта: левый и правый
        # Поворот изображения на 90 градусов влево (против часовой стрелки)
        #page_img = page_img.rotate(90, expand=True)
        # для определения ориентации
        try:
            osd = pytesseract.image_to_osd(page_img)
            rotation = int(re.search('Orientation in degrees: (\d+)', osd).group(1))
            if rotation != 0:
                page_img = page_img.rotate(rotation, expand=True)
        except pytesseract.TesseractError as e:
            page_img = page_img.rotate(90, expand=True)
            logger.warning(f"[WARNING] Ошибка при определении поворота страницы: {e}. Поворачиваем на 90 влево.")
            
        # Размер изображения
        w, h = page_img.size
        # Разделяем изображение на левую и правую половины
        mid_x = w // 2
        left_img = page_img.crop((0, 0, mid_x, h))
        right_img = page_img.crop((mid_x, 0, w, h))
        # сохраняем при тестировании
        #page_img.save(f"page_{page_index+1}_rotated.png")
        #left_img.save(f"page_{page_index+1}_left.png")
        #right_img.save(f"page_{page_index+1}_right.png")
        # Обработка левой и правой частей
        for side, img in [('left', left_img), ('right', right_img)]:
            try:
                text = pytesseract.image_to_string(img, lang="rus+eng")
                logger.debug(f"[DEBUG] Обрабатываем страницу.")
            except pytesseract.TesseractError as e:
                logger.warning(f"[WARNING] Ошибка при обработке страницы: {e}. Пропускаем страницу.")
                continue  
            logger.debug(text)
            number_akt = find_number_akt_in_text(text)
            date_akt = find_date_in_text(text)
            inv = find_inv_in_text(text)
            image_name = save_akt_image(img, number_akt, side, images_folder)
            logger.debug(f"[DEBUG] Имя файла:{image_name}")
            logger.debug(f"[DEBUG] Текст:{text}")
            # Извлекаем дополнительные данные из текста
            name_hw = find_name_hw_in_text(text)
            type_hw = find_type_hw_in_text(text)
            zn = find_zn_in_text(text)
            ues_id = find_ues_id_in_text(text)
            condition, issue, non_compliance, conclusion = find_condition_and_issue_in_text(text)
            #condition, issue = find_condition_and_issue_in_text(text)
            #conclusion = find_conclusion_in_text(text)
            description = find_description_in_text(text)
            logger.debug(f"[DEBUG] Найдены данные: {number_akt}, {date_akt}, {inv}, {name_hw}, {type_hw}, {zn}, {ues_id}, {condition}, {issue}, {non_compliance}, {conclusion}, {description}")
            akt = insert_or_update_akt(
                number_akt=number_akt,
                date_akt=date_akt,
                inv=inv,
                name_hw=name_hw,
                type_hw=type_hw,
                zn=zn,
                ues_id=ues_id,
                condition=condition,
                non_compliance=non_compliance,
                issue=issue,
                conclusion=conclusion,
                description=description,
                image_name=image_name
            )
            akt_count += 1
            # Если inv найден, обновляем hardware.akt_id
            if inv:
                link_hardware_with_akt(inv, akt.akt_id)
    logger.debug(f"[DEBUG] parse_pdf_for_akt: обработка завершена. Найдено страниц: {page_count}, актов: {akt_count}.")
# ------------------------------------------------------------------------------
# Вспомогательные функции
# ------------------------------------------------------------------------------
def save_akt_image(img: Image.Image, number_akt: str, side: str, images_folder: str) -> str:
    """
    Сохраняет изображение акта с именем, содержащим номер акта и сторону.
    Возвращает имя файла изображения.
    """
    if not os.path.exists(images_folder):
        os.makedirs(images_folder)   
        
    if number_akt:
        image_filename = f"akt_{number_akt}_{side}.png"
    else:
        # Используем текущее время, если номер акта неизвестен
        timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
        image_filename = f"akt_{timestamp}_{side}.png"
    image_path = os.path.join(images_folder, image_filename)
    img.save(image_path)
    logger.debug(f"[DEBUG] Изображение сохранено: {image_path}")
    return image_filename  # Сохраняем только имя файла, путь может быть стандартизирован
def find_number_akt_in_text(text: str) -> str:
    """
    Извлекает номер акта из OCR-текста.
    """
    pattern = re.compile(r"\s*№\s*(\d+)\s*от\s*", re.IGNORECASE)
    #pattern = re.compile(r"\s*(?:№|О|N|Ne|Н|НЕ)?\s*(\d+)[^\d]*", re.IGNORECASE)
    match = pattern.search(text)
    
    if match:
        return parse_int(match.group(1).replace('№', '').replace('от', ''))
    return ""
def find_date_in_text(text: str) -> str:
    """
    Извлекает дату из OCR-текста.
    """
    #pattern = re.compile(r"\b\d{1,2}[.\-/]\d{1,2}[.\-/]\d{2,4}\b")
    pattern = re.compile(r"\b(\d{1,2})[.\-/](\d{1,2})[.\-/](\d{2,4})\b")
    match = pattern.search(text)
    
    if match:
        #logger.debug('match дату:','.'.join(match.groups(1)))
        return '.'.join(match.groups(1))
    return ""
def find_inv_in_text(text: str) -> str:
    """
    Извлекает инвентарный номер (inv) из OCR-текста.
    """
    pattern = re.compile(r"Инвентарный\s+номер\s*(\d+\w?/?-?\d+\w?/?-?\d+\w?)", re.IGNORECASE)
    match = pattern.search(text)
    if match:
        
        return '.'.join(match.groups(1)).replace('Инвентарный', '').replace('номер', '').strip()
    return ""
def find_name_hw_in_text(text: str) -> str:
    """
    Извлекает name_hw из текста между "Наименование имущества" и "Тип".
    """
    return find_value_between_keywords(
        text,
        start_keyword="Наименование имущества",
        end_keyword="Тип",
        replacements={"ОМТ": "ONT"},
        remove_non_alnum=True
    )
def find_type_hw_in_text(text: str) -> str:
    """
    Извлекает type_hw из текста между "Тип" и "Заводской".
    """
    return find_value_between_keywords(
        text,
        start_keyword="Тип",
        end_keyword="Заводской",
        replacements={"СЕ": "CE"},
        remove_non_alnum=True
    )
def find_zn_in_text(text: str) -> str:
    """
    Извлекает zn из текста между "Заводской (серийный) номер" и "Год".
    """
    value = find_value_between_keywords(
        text,
        start_keyword="Заводской (серийный) номер",
        end_keyword="Год",
        replacements={"СЕ": "CE"},
        remove_non_alnum=True
    )
    # Заменяем похожие русские буквы на английские
    value = replace_similar_letters(value)
    
    return value
def find_ues_id_in_text(text: str) -> int:
    """
    Извлекает ues_id из текста между "Принадлежность имущества" и "Состояние".
    Учитывает падежи в русском языке для корректного соответствия.
    """
    ues_name = find_value_between_keywords(
        text,
        start_keyword="Принадлежность имущества",
        end_keyword="Состояние",
        remove_non_alnum=True
    )
    ues_name = ues_name.strip()
    if not ues_name:
        return None
    # Приводим извлечённый ues_name к начальной форме
    ues_name_normalized = normalize_text(ues_name)
    logger.debug(f"[DEBUG] ues_name_normalized: {ues_name_normalized}")
    # Ищем соответствие в базе данных среди ues_name, приведённых к начальной форме
    ues_list = UESTable.query.all()
    for ues in ues_list:
        ues_db_normalized = normalize_text(ues.ues_name)
        logger.debug(f"[DEBUG] ues_db_normalized: {ues_db_normalized}")
        if ues_name_normalized == ues_db_normalized:
            return ues.ues_id
    # Если не нашли соответствие, можно вернуть None или создать новую запись
    # Здесь возвращаем None
    return None
def normalize_text(text: str) -> str:
    """
    Приводит текст к нижнему регистру и нормальной форме (лемматизация).
    Удаляет лишние символы.
    :param text: Исходный текст.
    :return: Нормализованный текст.
    """
    # Удаляем нежелательные символы
    text = re.sub(r'[^\w\s]', '', text)
    # Разбиваем на слова
    words = text.split()
    # Лемматизируем каждое слово
    normalized_words = [morph.parse(word)[0].normal_form for word in words]
    # Собираем обратно в строку
    normalized_text = ' '.join(normalized_words)
    return normalized_text.lower()
def find_condition_and_issue_in_text(text: str) -> tuple:
    """
    Извлекает 4 значения из текста: состояние, неисправность, несоответствие и заключение.
    """
    # Инициализация результатов
    condition = ""
    issue = ""
    non_compliance = ""
    conclusion = ""
    # Шаблон для извлечения состояния
    condition_pattern = re.compile(r"Состояние на момент осмотра(.*?)(Выявлено|Заключение|$)", re.DOTALL | re.IGNORECASE)
    condition_match = condition_pattern.search(text)
    if condition_match:
        condition = condition_match.group(1).strip()
    else:
        logger.debug("[DEBUG] Не удалось найти cостояние в тексте")
        
    # Шаблон для извлечения неисправности
    issue_pattern = re.compile(r"Выявлено \(неисправность\)(.*?)(Не соответствует|Заключение|$)", re.DOTALL | re.IGNORECASE)
    issue_match = issue_pattern.search(text)
    if issue_match:
        issue = issue_match.group(1).strip()
    else:
        logger.debug("[DEBUG] Не удалось найти неисправность в тексте")
        
    # Шаблон для извлечения несоответствия
    non_compliance_pattern = re.compile(r"(Не соответствует.*?Заключение\s*\S+)", re.DOTALL | re.IGNORECASE)
    non_compliance_match = non_compliance_pattern.search(text)
    if non_compliance_match:
        non_compliance = non_compliance_match.group(1).strip()
    else:
        logger.debug("[DEBUG] Не удалось найти несоответствие в тексте")
    # Шаблон для извлечения заключения
    conclusion_pattern = re.compile(r"Не подлежит(.*?)(Приложение|\nПриложение|$)", re.DOTALL | re.IGNORECASE)
    conclusion_match = conclusion_pattern.search(text)
    if conclusion_match:
        conclusion = "Не подлежит " + conclusion_match.group(1).strip()
    else:
        logger.debug("[DEBUG] Не удалось найти заключение в тексте")
    # Возвращаем результаты
    return re.sub(r'\s+', ' ', condition),\
           re.sub(r'\s+', ' ', issue), \
           re.sub(r'\s+', ' ', non_compliance.replace('Заключение', '')), \
           re.sub(r'\s+', ' ', conclusion)
def find_description_in_text(text: str) -> str:
    """
    Сохраняет весь распознанный текст.
    """
    return text
# Дополнительные вспомогательные функции
def find_value_between_keywords(text: str, start_keyword: str, end_keyword: str, replacements=None, remove_non_alnum=False):
    """
    Извлекает текст между двумя ключевыми словами.
    :param text: Исходный текст.
    :param start_keyword: Начальное ключевое слово.
    :param end_keyword: Конечное ключевое слово.
    :param replacements: Словарь замен {старое: новое}.
    :param remove_non_alnum: Удалять ли не буквенно-цифровые символы в конце.
    :return: Извлеченный и обработанный текст.
    """
    pattern = re.compile(
        re.escape(start_keyword) + r"(.*?)" + re.escape(end_keyword),
        re.DOTALL | re.IGNORECASE
    )
    match = pattern.search(text)
    if match:
        value = match.group(1).strip()
        if replacements:
            value = apply_replacements(value, replacements)
        if remove_non_alnum:
            value = value.rstrip('.,!?:;\'"«»“”’`~@#$%^&*()_+-=[]{}|\\/<>')
        return value.strip()
    return ""
def apply_replacements(text: str, replacements: dict) -> str:
    """
    Применяет замены в тексте по словарю.
    :param text: Исходный текст.
    :param replacements: Словарь замен {старое: новое}.
    :return: Текст после замены.
    """
    for old, new in replacements.items():
        text = text.replace(old, new)
    return text
def clean_line(line: str) -> str:
    """
    Удаляет в конце строки все не буквенные и не цифровые символы.
    :param line: Исходная строка.
    :return: Очищенная строка.
    """
    # Оставляем только буквы и цифры в конце строки
    return re.sub(r'[^a-zA-Zа-яА-Я0-9]+$', '', line).strip()
def replace_similar_letters(text: str) -> str:
    """
    Заменяет схожие по начертанию русские буквы на такие же английские.
    :param text: Исходный текст.
    :return: Текст после замены.
    """
    mapping = {
        'А': 'A',
        'В': 'B',
        'Е': 'E',
        'К': 'K',
        'М': 'M',
        'Н': 'H',
        'О': 'O',
        'Р': 'P',
        'С': 'C',
        'Т': 'T',
        'У': 'Y',
        'Х': 'X',
        'Ь': 'b',
        'І': 'I',
        '’': '\'',
        'я': 'r',
    }
    for cyrillic_char, latin_char in mapping.items():
        text = text.replace(cyrillic_char, latin_char)
        text = text.replace(cyrillic_char.lower(), latin_char.lower())
    return text
def insert_or_update_akt(number_akt: str, date_akt: str, inv: str, name_hw: str = None,
                         type_hw: str = None, zn: str = None, ues_id: int = None,
                         condition: str = None, issue: str = None, non_compliance: str = None, conclusion: str = None,
                         description: str = None, image_name: str = None):
    """
    Создает или обновляет запись в таблице Akt с использованием ORM.
    Возвращает объект Akt.
    """
    date_akt_parsed = parse_date(date_akt)
    akt = Akt(
        number_akt=number_akt,
        date_akt=date_akt_parsed,
        inv=inv,
        name_hw=name_hw,
        type_hw=type_hw,
        zn=zn,
        ues_id=ues_id,
        condition=condition,
        issue=issue,
        conclusion=conclusion,
        description=description,
        image_name=image_name
    )
    db.session.add(akt)
    try:
        db.session.commit()
    except Exception as e:
        db.session.rollback()
        logger.error(f"[ERROR] Ошибка при вставке Akt: {e}")
    return akt
def link_hardware_with_akt(inv: str, akt_id: int):
    """
    Привязать запись Hardware к акту через поле hardware.akt_id.
    """
    hardware = Hardware.query.filter_by(inv=inv).first()
    if hardware:
        hardware.akt_id = akt_id
        try:
            db.session.commit()
        except Exception as e:
            db.session.rollback()
            logger.error(f"[ERROR] Ошибка при обновлении Hardware (inv={inv}): {e}")
    else:
        logger.warning(f"[WARN] Не найдено Hardware с inv={inv} для привязки к Akt.")
# file:web_app_hw_out\app\loaders\asudoo_loader.py
# app/loaders/asudoo_loader.py
import pandas as pd
from app.models import db, Asudoo, BranchNameTable, RuesNameTable, CehNameTable, EquipmentTypeNameTable
from datetime import datetime
import os
def parse_csv_for_asudoo(filepath: str):
    """
    Функция для загрузки/обновления таблицы Asudoo из CSV, используя SQLAlchemy.
    """
    print(f"[DEBUG] parse_csv_for_asudoo: {filepath}")
    # Проверяем, существует ли файл по указанному пути
    if not os.path.exists(filepath):
        print(f"[ERROR] Файл не найден: {filepath}")
        return
    # Предположим, разделитель CSV - ";", кодировка - utf-8
    try:
        df = pd.read_csv(filepath, sep=";", encoding="utf-8", dtype=str)
    except Exception as e:
        print(f"[ERROR] Ошибка при чтении CSV: {e}")
        return
    # Логика вставки/обновления.
    for index, row in df.iterrows():
        inv_num = (row.get("inventory_number") or "").strip()
        if not inv_num:
            continue
        # Получаем/создаем связанные объекты справочников
        branch_name = row.get("branch_name")
        branch = get_or_create(BranchNameTable, branch_name=branch_name)
        rues_name = row.get("rues_name")
        rues = get_or_create(RuesNameTable, rues_name=rues_name)
        ceh_name = row.get("ceh_name")
        ceh = get_or_create(CehNameTable, ceh_name=ceh_name)
        equipment_type_name = row.get("equipment_type_name")
        equipment_type = get_or_create(EquipmentTypeNameTable, equipment_type_name=equipment_type_name)
        # Проверка, есть ли уже такая запись
        asudoo_record = Asudoo.query.filter_by(inventory_number=inv_num).first()
        if asudoo_record:
            # UPDATE
            asudoo_record.equipment_name = row.get("equipment_name")
            asudoo_record.mac = row.get("mac")
            asudoo_record.serial_number = row.get("serial_number")
            asudoo_record.cost = parse_float(row.get("cost"))
            asudoo_record.branch = branch
            asudoo_record.rues = rues
            asudoo_record.ceh = ceh
            asudoo_record.equipment_type = equipment_type
            asudoo_record.last_updated = datetime.utcnow()
            # Обновляем другие поля при необходимости
            try:
                db.session.commit()
            except Exception as e:
                db.session.rollback()
                print(f"[ERROR] Ошибка при обновлении Asudoo (inventory_number={inv_num}): {e}")
        else:
            # INSERT
            new_asudoo = Asudoo(
                inventory_number=inv_num,
                equipment_name=row.get("equipment_name"),
                mac=row.get("mac"),
                serial_number=row.get("serial_number"),
                cost=parse_float(row.get("cost")),
                last_updated=datetime.utcnow(),
                branch=branch,
                rues=rues,
                ceh=ceh,
                equipment_type=equipment_type
                # Добавьте другие поля при необходимости
            )
            db.session.add(new_asudoo)
            try:
                db.session.commit()
            except Exception as e:
                db.session.rollback()
                print(f"[ERROR] Ошибка при вставке Asudoo (inventory_number={inv_num}): {e}")
    print("[DEBUG] parse_csv_for_asudoo: обработка завершена")
def get_or_create(model, **kwargs):
    """
    Получает объект из БД или создает новый, если он не существует.
    """
    instance = model.query.filter_by(**kwargs).first()
    if instance:
        return instance
    else:
        instance = model(**kwargs)
        db.session.add(instance)
        try:
            db.session.commit()
        except Exception as e:
            db.session.rollback()
            print(f"[ERROR] Ошибка при вставке в {model.__tablename__}: {e}")
        return instance
def parse_float(value):
    """
    Конвертирует строку в число с плавающей запятой.
    """
    if not value or pd.isna(value):
        return None
    try:
        return float(value.replace(",", "."))
    except ValueError:
        return None
# file:web_app_hw_out\app\loaders\hardware_loader.py
# app/loaders/hardware_loader.py
import re
import pandas as pd
from typing import Tuple
from app.models import db, Hardware, DescriptionTable, KomplektTable, OsnovanieTable
from datetime import datetime
from docx import Document
import logging
from app.utils import get_or_create, parse_int, parse_date, parse_float, extract_number_from_string, convert_to_string
# Создаем логгер
logger = logging.getLogger(__name__)
logger.setLevel(logging.DEBUG)
formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
stream_handler = logging.StreamHandler()
stream_handler.setFormatter(formatter)
def parse_name_for_sn_mac_kud(name_field: str):
    """
    Извлекает name, sn, boxid и kud из строки.
    :param name_field: Строка, содержащая текст для парсинга.
    :return: ключи 'name', 'sn', 'boxid', 'kud', 'boxid'
    """
    name = ""
    sn = ""
    boxid = ""
    kud = ""
    mac = ""
    # Убираем лишние пробелы и символы
    text = name_field.strip()
    # Поиск name до ключевых слов
    match_name = re.search(r"^(.*?)(?=\s+Заводской номер|Зав№|Заводской\s+номер|Зав№:|Зав№|box_id|BOXID|КУД|kud|KUD|$)", text, re.IGNORECASE)
    if match_name:
        name = match_name.group(1).strip()
    # Поиск SN (заводской номер)
    match_sn = re.search(r"Заводской номер[:\s]*([A-Za-z0-9]+)|Зав№[:\s]*([A-Za-z0-9]+)", text, re.IGNORECASE)
    if match_sn:
        sn = match_sn.group(1) or match_sn.group(2)
    # Поиск boxid
    match_boxid = re.search(r"BOXID[:\s]*([A-Za-z0-9]+)|box_id[:\s]*([A-Za-z0-9]+)", text, re.IGNORECASE)
    if match_boxid:
        boxid = match_boxid.group(1) or match_boxid.group(2)
    # Поиск KUD
    match_kud = re.search(r"KUD[:\s]*([0-9]+)|kud[:\s]*([0-9]+)|КУД[:\s]*([0-9]+)", text, re.IGNORECASE)
    if match_kud:
        kud = match_kud.group(1) or match_kud.group(2) or match_kud.group(3)
    return name, sn, boxid, kud, mac
def parse_akt(text):
    """
    Извлекает номер акта.
    :param text: Строка, содержащая текст для парсинга.
    :return: akt
    """
    akt = None
    # Шаблон для извлечения номера акта (после символа № или #)
    akt_pattern = re.search(r"[№#]\s*(\d+)", text)
    if akt_pattern:
        akt = akt_pattern.group(1)
    return akt
def determine_header_order_with_dates_dynamic_priority(header_rows, reference_dict, exclude_dict=None):
    # Транспонируем заголовки для работы с колонками
    transposed_headers = list(zip(*header_rows))
    def merge_header_cells(cells):
        """Объединяет несколько строк заголовка в один заголовок для столбца."""
        return ' '.join([cell.strip() for cell in cells if cell.strip()])
    # Объединяем заголовки по вертикали
    merged_headers = [merge_header_cells(col) for col in transposed_headers]
    def find_fields(header):
        """
        Ищет поля, соответствующие заголовку, используя регулярные выражения для корней слов.
        """
        header_lower = header.lower()
        logger.debug(f"[DEBUG] Обработка заголовка: '{header}' -> '{header_lower}'")
        matched_fields = set()
        for field, keywords in reference_dict.items():
            logger.debug(f"[DEBUG]  Проверяем поле: '{field}'")
            # Проверяем наличие исключающих слов для данного поля
            exclude_keywords = exclude_dict.get(field, []) if exclude_dict else []
            exclude_matched = False
            for exclude_keyword in exclude_keywords:
                # Используем регулярное выражение для поиска корня слова
                pattern = re.compile(exclude_keyword.lower())
                if pattern.search(header_lower):
                    exclude_matched = True
                    logger.debug(f"[DEBUG]    Найдено исключающее слово '{exclude_keyword}' в заголовке '{header_lower}'")
                    break  # Достаточно первого совпадения
            if exclude_matched:
                logger.debug(f"[DEBUG]    Поле '{field}' исключено из-за исключающего слова.")
                continue  # Пропускаем это поле, так как нашли исключающее слово
            # Поиск включающих ключевых слов
            include_matched = False
            for keyword in keywords:
                # Используем регулярное выражение для поиска корня слова
                pattern = re.compile(keyword.lower())
                if pattern.search(header_lower):
                    include_matched = True
                    logger.debug(f"[DEBUG]    Найдено включающее слово '{keyword}' в заголовке '{header_lower}'")
                    break  # Достаточно первого совпадения
            if include_matched:
                logger.debug(f"[DEBUG]    Поле '{field}' будет добавлено в список совпадений.")
                matched_fields.add(field)
            else:
                logger.debug(f"    Для поля '{field}' не найдено включающее слово в заголовке.")
        logger.debug(f"[DEBUG]  Поля, найденные для заголовка '{header}': {matched_fields}")
        return list(matched_fields)
    def extract_date(header, field_name):
        pattern = r'на\s+(\d{2}\.\d{2}\.\d{4})\s*(?:руб)?'
        match = re.search(pattern, header, re.IGNORECASE)
        if match:
            return match.group(1)
        return None
    field_priority = []
    ordered_fields = []
    for idx, header in enumerate(merged_headers):
        fields = find_fields(header)
        if not fields:
            continue  # Пропускаем этот столбец
        elif len(fields) == 1:
            selected_field = fields[0]
        else:
            # Если несколько полей совпадают, выбираем то, которое появилось первым в field_priority
            for field in fields:
                if field not in field_priority:
                    field_priority.append(field)
            for priority_field in field_priority:
                if priority_field in fields:
                    selected_field = priority_field
                    break
        if selected_field not in field_priority:
            field_priority.append(selected_field)
        extras = {}
        if selected_field == 'first_price':
            date = extract_date(header, 'first_price')
            if date:
                extras['first_price_date'] = date
        elif selected_field == 'last_price':
            date = extract_date(header, 'last_price')
            if date:
                extras['last_price_date'] = date
        ordered_fields.append({'field': selected_field, 'extras': extras})
    return ordered_fields
def extract_field_names(ordered_fields):
    """
    Извлекает только названия полей из списка ordered_fields.
    :param ordered_fields: Список словарей с ключами 'field' и 'extras'.
    :return: Список названий полей в порядке их появления.
    """
    return [col['field'] for col in ordered_fields if col['field'] is not None]
def extract_date_ddmmyyyy(text):
    """
    Извлекает строку даты акта из строки.
    :param text: Строка, содержащая текст для парсинга.
    :return: date_akt.
    """
    date_akt = None
    # Шаблон для извлечения даты (формат DD.MM.YYYY)
    date_pattern = re.search(r"(\d{1,2})[.\-/](\d{1,2})[.\-/](\d{4})", text)
    if date_pattern:
        try:
            date_akt = ".".join(date_pattern.groups())
        except ValueError:
            date_akt = None
    return date_akt
    
def parse_xlsx_for_hardware_data(filepath: str, doc_id: int) -> list[dict]:    
    """
    Парсинг XLSX файла и сохранение данных в таблицу Hardware с использованием ORM.
    """
    logger.debug(f"[DEBUG] parse_xlsx_for_hardware: filepath={filepath}, doc_id={doc_id}")
    parsed_data = []
    # Считываем файл XLSX
    try:
        df_raw = pd.read_excel(filepath, header=None, dtype=str)
    except Exception as e:
        logger.debug(f"[ERROR] Не удалось открыть xlsx: {e}")
        return parsed_data
    # Ищем строку с заголовками (1..7)
    start_index = None
    zagolovok = []
    for i in range(len(df_raw)):
        
        #row_values = df_raw.iloc[i].dropna().astype(str).tolist()
        row_values = df_raw.iloc[i].astype(str).tolist()
        needed = {"1", "2", "3", "4", "5", "6", "7"}
        if needed.issubset(set(row_values)):
            start_index = i
            break
        else:
            zagolovok.append(row_values) 
    if start_index is None:
        logger.error("[ERROR] Не найдена строка с 1..7, не можем определить начало данных")
        return parsed_data
    # Данные начинаются после строки с заголовками
    data_start = start_index + 1
    df_data = df_raw.iloc[data_start:].copy().reset_index(drop=True)
    # Определяем названия столбцов
    priznak_cols = {
        "no":['п/п'], 
        "name":['Наименование'], 
        "inv":['Инвентарный'], 
        #"quantity":[''], 
        "shifr_time":['временному'], 
        "shifr_amo":['шифр'], 
        "date_build":['постройки'], 
        "date_input":['ввода'], 
        "date_buh":['принятия'], 
        "date_nouse":['используется'], 
        "first_price":['Первоначальная'], 
        #"first_price_date":['Остаточная','стоимость'], 
        "last_price":['Остаточная'], 
        #"last_price_date":['',''], 
        "srok":['полезного'], 
        "komplekt":['Комплектность'], 
        "osnovanie":['Основание'], 
        "description":['Примечание'],
        "ues_description":['Структурное']
    }
    exclude_dict = {
        'shifr_amo': ['временному']#,  # Если в заголовке есть 'амортизации' или 'id', то это не поле 'shifr_amo'
        #'id': ['имя', 'name']     # Если в заголовке есть 'имя' или 'name', то это не поле 'id'
    }
    header_rows = determine_header_order_with_dates_dynamic_priority(zagolovok, priznak_cols, exclude_dict)
    expected_cols = extract_field_names(header_rows)
    
    #expected_cols = [
    #    "no", "name", "inv", "quantity", "shifr_time", "shifr_amo",
    #    "date_build", "date_input", "date_buh", "date_nouse",
    #    "first_price", "first_price_date", "last_price",
    #    "last_price_date", "srok", "komplekt", "osnovanie", "description", "ues_description"
    #]
    logger.debug(f"[DEBUG] Шапка: {zagolovok}")
    logger.debug(f"[DEBUG] Заголовки: {header_rows}")
    logger.debug(f"[DEBUG] Столбцы: {df_data.columns}")
  
    #print(df_data['shifr_time'])
    #print(len(df_data.columns))
    #print(expected_cols) #expected_cols
    #print(len(expected_cols))
    #df_data.columns = expected_cols[: len(df_data.columns)+1]
    # Добавьте отладочные выводы
    logger.debug(f"[DEBUG]Количество столбцов в df_data: {len(df_data.columns)}")
    logger.debug(f"[DEBUG]Длина списка expected_cols: {len(expected_cols)}")
    logger.debug(f"[DEBUG]expected_cols: {expected_cols}")
    
    df_data.columns = expected_cols[: len(df_data.columns)+1]
    # Удаляем записи, где 'no' и 'name' имеют значение NaN
    df_data = df_data.dropna(subset=['no', 'name'], how='all')
    
    
    # Извлечение всех first_price_date
    first_price_dates = [col['extras']['first_price_date'] for col in header_rows if 'first_price_date' in col['extras']]
    # Извлечение всех last_price_date
    last_price_dates = [col['extras']['last_price_date'] for col in header_rows if 'last_price_date' in col['extras']]
    # Парсим и сохраняем данные
    for index, row in df_data.iterrows():
        # Извлекаем поля
        no_ = parse_int(row.get("no"))
        name_txt_ = str(row.get("name", "")).strip()
        if not no_ or not name_txt_:
            logger.warning(f"Пропуск строки: отсутствуют обязательные значения no или name. no={no_}, name='{name_txt_}'")
            continue
        inv_ = str(row.get("inv", "")).strip()
        quantity_ = parse_int(extract_number_from_string(convert_to_string(row.get("komplekt"))))
        shifr_time_ = parse_int(row.get("shifr_time"))
        shifr_amo_ = parse_int(row.get("shifr_amo"))
        date_build_ = parse_date(row.get("date_build"))
        date_input_ = parse_date(row.get("date_input"))
        date_buh_ = parse_date(row.get("date_buh"))
        date_nouse_ = parse_date(row.get("date_nouse"))
        first_price_ = parse_float(row.get("first_price"))
        last_price_ = parse_float(row.get("last_price"))
        srok_ = parse_int(row.get("srok"))
        komplekt_text = convert_to_string(row.get("komplekt"))
        osnovanie_text = convert_to_string(row.get("osnovanie"))
        description_text = convert_to_string(row.get("description"))
        ues_description = convert_to_string(row.get("ues_description"))
        
        # Извлекаем sn, mac, kud из name
        name_,sn_, boxid_, kud_, mac_ = parse_name_for_sn_mac_kud(name_txt_)
        # Извлекаем номер и дату акта
        number_akt_ = parse_akt(osnovanie_text)
        date_akt_ = parse_date(extract_date_ddmmyyyy(osnovanie_text))
        # Добавляем в список
        parsed_data.append({
            "doc_id": doc_id,
            "no": no_,
            "name_txt": name_txt_,
            "inv": inv_,
            "quantity": quantity_,
            "shifr_time": shifr_time_,
            "shifr_amo": shifr_amo_,
            "date_build": date_build_.isoformat() if date_build_ else None,
            "date_input": date_input_.isoformat() if date_input_ else None,
            "date_buh": date_buh_.isoformat() if date_buh_ else None,
            "date_nouse": date_nouse_.isoformat() if date_nouse_ else None,
            "first_price": first_price_,
            "first_price_dates": first_price_dates,
            "last_price": last_price_,
            "last_price_dates": last_price_dates,
            "srok": srok_,
            "komplekt_text": komplekt_text,
            "osnovanie_text": osnovanie_text,
            "description_text": description_text,
            "ues_description": ues_description,
            "number_akt": number_akt_,
            "date_akt": date_akt_.isoformat() if date_akt_ else None,
            "sn": sn_,
            "mac": mac_,
            "kud": kud_,
            "boxid": boxid_,
            "name": name_
        })
    
    logger.debug(f"[DEBUG] parse_xlsx_for_hardware_data: найдено записей: {len(parsed_data)}")
    return parsed_data
def tmp_func():
    if True:
        # Проверяем, существует ли запись с таким inv
        existing_hw = Hardware.query.filter_by(inv=inv_).first()
        if existing_hw:
            logger.warning(f"[WARN] Запись с inv={inv_} уже существует. Пропускаем.")
            #continue
        elif (no_ is None) and (inv_ is None):
            pass
        else:
            # Создаем объект Hardware
            hardware = Hardware(
                doc_id=doc_id,
                no=no_,
                name=name_,
                name_txt=name_txt_,
                inv=inv_,
                sn=sn_,
                mac=mac_,
                kud=kud_,
                number_akt=number_akt_,
                date_akt=date_akt_,
                boxid=boxid_,
                quantity=quantity_,
                shifr_time=shifr_time_,
                shifr_amo=shifr_amo_,
                date_build=date_build_,
                date_input=date_input_,
                date_buh=date_buh_,
                date_nouse=date_nouse_,
                first_price=first_price_,
                first_price_date=first_price_date_,
                last_price=last_price_,
                last_price_date=last_price_date_,
                srok=srok_,
                komplekt=komplekt,
                osnovanie=osnovanie,
                description=description,
                ues_description=ues_text
            )
            db.session.add(hardware)
    # Сохраняем все изменения
    try:
        db.session.commit()
        logger.debug("[DEBUG] parse_xlsx_for_hardware: обработка завершена.")
    except Exception as e:
        db.session.rollback()
        logger.debug(f"[ERROR] Ошибка при сохранении данных: {e}")
def parse_docx_for_hardware2(filepath: str, doc_id: int):
    """
    Читаем DOCX файл и сохраняем данные в базу.
    """
    logger.debug(f"[DEBUG] parse_docx_for_hardware: filepath={filepath}, doc_id={doc_id}")
    
    try:
        doc = Document(filepath)
    except Exception as e:
        logger.debug(f"[ERROR] Не удалось открыть DOCX: {e}")
        return
    for paragraph in doc.paragraphs:
        text_p = paragraph.text.strip()
        if not text_p:
            continue
        # Ищем inv в тексте
        inv_numbers = re.findall(r"\b\d{4,6}\b", text_p)
        for inv_ in inv_numbers:
            hardware = Hardware.query.filter_by(inv=inv_).first()
            if hardware:
                # Обновляем описание
                if hardware.description:
                    hardware.description.description_text += "\n" + text_p
                else:
                    description = DescriptionTable(description_text=text_p)
                    hardware.description = description
                try:
                    db.session.commit()
                except Exception as e:
                    db.session.rollback()
                    logger.debug(f"[ERROR] Ошибка при обновлении Hardware (inv={inv_}): {e}")
def parse_docx_for_hardware_data(filepath: str, doc_id: int):
    """
    Парсинг DOCX файла и возврат данных без сохранения в БД.
    """
    logger.debug(f"[DEBUG] parse_docx_for_hardware_data: filepath={filepath}, doc_id={doc_id}")
    parsed_data = []
    try:
        doc = Document(filepath)
    except Exception as e:
        logger.debug(f"[ERROR] Не удалось открыть DOCX: {e}")
        return parsed_data
    
    for paragraph in doc.paragraphs:
        text_p = paragraph.text.strip()
        if not text_p:
            continue
        # Ищем inv в тексте
        inv_numbers = re.findall(r"\b\d{4,6}\b", text_p)
        for inv_ in inv_numbers:
            parsed_data.append({
                "doc_id": doc_id,
                "inv": inv_,
                "description_text": text_p
            })
    
    logger.debug(f"[DEBUG] parse_docx_for_hardware_data: найдено описаний: {len(parsed_data)}")
    return parsed_data
# file:web_app_hw_out\app\loaders\os_loader.py
# app/loaders/os_loader.py
import re
import pandas as pd
from io import StringIO
from app.models import db, OS
from datetime import datetime
import logging
# Создаем логгер
logger = logging.getLogger(__name__)
logger.setLevel(logging.DEBUG)
formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
stream_handler = logging.StreamHandler()
stream_handler.setFormatter(formatter)
def parse_csv_for_os(filepath: str):
    """
    Функция для загрузки/обновления таблицы OS из CSV с использованием SQLAlchemy.
    """
    logger.debug(f"[DEBUG] parse_csv_for_os: Начинаем обработку файла {filepath}")
    # -------------------------------------------------
    # 1. Считываем весь файл как «сырые» строки
    # -------------------------------------------------
    try:
        with open(filepath, "r", encoding="cp1251") as f:
            lines = f.readlines()
    except UnicodeDecodeError:
        logger.error(f"[ERROR] Не удалось прочитать файл в кодировке cp1251. Попробуйте другую.")
        return
    except FileNotFoundError:
        logger.error(f"[ERROR] Файл не найден: {filepath}")
        return
    # -------------------------------------------------
    # 2. Удаляем  первые строки
    # -------------------------------------------------
    if len(lines) <= 2:
        logger.warning("[WARN] Файл содержит <= 1 строк, после удаления нечего обрабатывать.")
        return
    lines = lines[1:]
    # -------------------------------------------------
    # 3. Удаляем строки по regexp
    # -------------------------------------------------
    cleaned_lines = []
    pattern = re.compile(r"^\s*\d+\s*$")  # если строка содержит только число
    for line in lines:
        if pattern.match(line.strip()):
            continue
        cleaned_lines.append(line)
    cleaned_text = "".join(cleaned_lines)
    # -------------------------------------------------
    # 4. Чтение CSV через pandas
    # -------------------------------------------------
    csv_data = StringIO(cleaned_text)
    field_name = 'name', 'inv', 'passport_number', 'reg_number', 'zn', 'nn', \
        'date_warranty', 'date_production', 'date_expluatation', 'date_input', \
        'date_output', 'date_trash', 'quantity', 'model', 'made_by', \
        'code_party', 'period', 'dop_inv', 'record_number', 'filial_number', \
        'description', 'groups', 'type_doc', 'ed_izm', 'group_os', \
        'vid_os', 'podrazdelenie', 'mol', 'code_norma_amort', 'norma_amort', \
        'uses_os', 'price', 'summ_amort', 'amort_otch_period', 'price_input', \
        'iznos_input', 'fix_summ_amort', 'srok_polezn_use', 'valute', \
        'metod_nach_amort', 'tho_amort', 'type_object', 'code_group_okof', \
        'name_group_okof', 'popravka_koef', 'last_price'
    try:
        df = pd.read_csv(csv_data, header=None, names=field_name ,sep=";", dtype=str)
    except Exception as e:
        logger.error(f"[ERROR] Ошибка при чтении CSV (pandas): {e}")
        return
    # -------------------------------------------------
    # 5. Сопоставление столбцов DataFrame с полями модели OS
    # -------------------------------------------------
    required_columns = [column.name for column in OS.__table__.columns]
    # Проверим наличие нужных столбцов в df
    missing_cols = [c for c in required_columns if c not in df.columns]
    if missing_cols:
        logger.warning(f"[WARN] В CSV отсутствуют столбцы: {missing_cols}. Обработка может быть неполной.")
    # -------------------------------------------------
    # 6. Обработка строк и сохранение в базе данных
    # -------------------------------------------------
    updated_count = 0
    inserted_count = 0
    for _, row in df.iterrows():
        inv = str(row.get("inv") or "").strip()
        if not inv:
            continue
        # Поиск существующей записи
        existing_os = OS.query.filter_by(inv=inv).first()
        # Создание словаря с параметрами
        params = {}
        for col in required_columns:
            val = row.get(col, None)
            # Конвертация данных при необходимости
            if col in ("date_warranty", "date_production", "date_expluatation",
                    "date_input", "date_output", "date_trash"):
                val = parse_date(val)
            elif col in ("price", "last_price"):
                val = parse_float(val)
            elif col == "quantity":
                val = parse_int(val)
            else:
                if val is None or pd.isna(val):
                    val = None
                else:
                    val = str(val).strip()
            params[col] = val
        # Обновление или создание записи
        if existing_os:
            for key, value in params.items():
                if key == "os_id":
                    logger.warning(f"[WARN] Поле os_id не должно обновляться, пропускаем.")
                    continue
                setattr(existing_os, key, value)
            try:
                db.session.commit()
                updated_count += 1
            except Exception as e:
                db.session.rollback()
                logger.warning(f"[WARN] Ошибка при обновлении OS (inv={inv}): {e}")
                logger.debug(f"[DEBUG] Данные, вызвавшие ошибку: {params}")
            logger.debug(f"[DEBUG] OS ID: {existing_os.os_id}, inv: {existing_os.inv}")
        else:
            new_os = OS(**params)
            db.session.add(new_os)
            try:
                db.session.commit()
                inserted_count += 1
            except Exception as e:
                db.session.rollback()
                logger.warning(f"[WARN] Ошибка при вставке OS (inv={inv}): {e}")
    logger.debug(f"[DEBUG] parse_csv_for_os: Обработка завершена. Обновлено: {updated_count}, добавлено: {inserted_count}")
def parse_date(date_str):
    """
    Парсит строку с датой в объект datetime.date.
    """
    if not date_str or pd.isna(date_str):
        return None
    for fmt in ("%d.%m.%Y", "%Y-%m-%d", "%d-%m-%Y"):
        try:
            return datetime.strptime(date_str.strip(), fmt).date()
        except ValueError:
            continue
    return None
def parse_float(value):
    """
    Конвертирует строку в число с плавающей запятой.
    """
    if not value or pd.isna(value):
        return None
    try:
        return float(value.replace(",", "."))
    except ValueError:
        return None
def parse_int(value):
    """
    Конвертирует строку в целое число.
    """
    if not value or pd.isna(value):
        return None
    try:
        return int(value)
    except ValueError:
        return None
# file:web_app_hw_out\app\logging_config.py
import logging
from logging.handlers import RotatingFileHandler
def setup_logger(app):
    """
    Настраивает логгер для приложения Flask с записью логов в файл.
    """
    # Создаем обработчик логов, записывающий в файл
    file_handler = RotatingFileHandler('app_logs.log', maxBytes=10 * 1024 * 1024, backupCount=5)
    file_handler.setLevel(logging.INFO)  # Уровень логирования (можно изменить на DEBUG, WARNING, ERROR)
    file_handler.setFormatter(logging.Formatter(
        '%(asctime)s - %(name)s - %(levelname)s - %(message)s'
    ))
    # Добавляем обработчик к логгеру приложения
    app.logger.addHandler(file_handler)
    # Устанавливаем базовый уровень логирования
    app.logger.setLevel(logging.INFO)
    # Логгируем сообщение об успешной настройке
    app.logger.info('Логгер настроен и пишет только в файл.')
# file:web_app_hw_out\app\models.py
# app/models.py
from app import db
class Events(db.Model):
    __tablename__ = 'events'
    event_id = db.Column(db.Integer, primary_key=True)
    event_name = db.Column(db.String(127))
    event_time = db.Column(db.DateTime)
    def __repr__(self):
        return f'<Event {self.event_name}>'
class UESTable(db.Model):
    __tablename__ = 'ues_table'
    ues_id = db.Column(db.Integer, primary_key=True)
    ues_name = db.Column(db.String(31), nullable=True, unique=True)
    def __repr__(self):
        return f'<UES {self.ues_name}>'
class DescriptionTable(db.Model):
    __tablename__ = 'descriptiontbl'
    description_id = db.Column(db.Integer, primary_key=True)
    description_text = db.Column(db.String(255), unique=True)
    def __repr__(self):
        return f'<Description {self.description_id}>'
class KomplektTable(db.Model):
    __tablename__ = 'komplekttbl'
    komplekt_id = db.Column(db.Integer, primary_key=True)
    komplekt_text = db.Column(db.String(255), unique=True)
    def __repr__(self):
        return f'<Komplekt {self.komplekt_id}>'
class OsnovanieTable(db.Model):
    __tablename__ = 'osnovanietbl'
    osnovanie_id = db.Column(db.Integer, primary_key=True)
    osnovanie_txt = db.Column(db.String(255), unique=True)
    def __repr__(self):
        return f'<Osnovanie {self.osnovanie_id}>'
class Landocs(db.Model):
    __tablename__ = 'landocs'
    doc_id = db.Column(db.Integer, primary_key=True)
    doc_name = db.Column(db.String(255), nullable=False, unique=True)
    doc_date = db.Column(db.Date, nullable=False)
    doc_description = db.Column(db.Text)
    hardware = db.relationship('Hardware', backref='landoc', lazy=True)
    def __repr__(self):
        return f'<Landoc {self.doc_name}>'
class StateHW(db.Model):
    __tablename__ = 'statehw'
    statehw_id = db.Column(db.Integer, primary_key=True)
    statehw_name = db.Column(db.String(31), nullable=False, unique=True)
    def __repr__(self):
        return f'<StateHW {self.statehw_name}>'
class ChekedHW(db.Model):
    __tablename__ = 'chekedhw'
    chekedhw_id = db.Column(db.Integer, primary_key=True)
    statehw_id = db.Column(db.Integer, db.ForeignKey('statehw.statehw_id'))
    date_checked_akt = db.Column(db.Date)
    date_checked_os = db.Column(db.Date)
    doc_id = db.Column(db.Integer, db.ForeignKey('landocs.doc_id'))
    state = db.relationship('StateHW', backref='cheked_hw')
    doc = db.relationship('Landocs', backref='cheked_hw')
    hardware = db.relationship('Hardware', backref='cheked_hw', uselist=False)
    def __repr__(self):
        return f'<ChekedHW {self.chekedhw_id}>'
class Hardware(db.Model):
    __tablename__ = 'hardware'
    hw_id = db.Column(db.Integer, primary_key=True)
    duplicate_hw_id = db.Column(db.Integer, db.ForeignKey('hardware.hw_id'))
    doc_id = db.Column(db.Integer, db.ForeignKey('landocs.doc_id'))
    no = db.Column(db.Integer)
    name_txt = db.Column(db.Text, nullable=False)
    name = db.Column(db.Text, nullable=False)
    inv = db.Column(db.String(31), nullable=False, unique=True)
    sn = db.Column(db.String(64))
    mac = db.Column(db.String(31))
    kud = db.Column(db.String(31))
    boxid = db.Column(db.String(31))
    number_akt = db.Column(db.Integer)
    date_akt = db.Column(db.Date)
    quantity = db.Column(db.Integer)
    shifr_time = db.Column(db.Integer)
    shifr_amo = db.Column(db.Integer)
    date_build = db.Column(db.Date)
    date_input = db.Column(db.Date)
    date_buh = db.Column(db.Date)
    date_nouse = db.Column(db.Date)
    first_price = db.Column(db.Float)
    first_price_date = db.Column(db.Date)
    last_price = db.Column(db.Float)
    last_price_date = db.Column(db.Date)
    srok = db.Column(db.Integer)
    ues_description = db.Column(db.String(31))
    komplekt_id = db.Column(db.Integer, db.ForeignKey('komplekttbl.komplekt_id'))
    osnovanie_id = db.Column(db.Integer, db.ForeignKey('osnovanietbl.osnovanie_id'))
    description_id = db.Column(db.Integer, db.ForeignKey('descriptiontbl.description_id'))
    ues_id = db.Column(db.Integer, db.ForeignKey('ues_table.ues_id'))
    chekedhw_id = db.Column(db.Integer, db.ForeignKey('chekedhw.chekedhw_id'))
    os_id = db.Column(db.Integer, db.ForeignKey('os.os_id'))
    akt_id = db.Column(db.Integer, db.ForeignKey('akt.akt_id'))
    asudoolink_id = db.Column(db.Integer, db.ForeignKey('asudoolink.asudoolink_id'))
    event_id = db.Column(db.Integer, db.ForeignKey('events.event_id'))
    duplicate_hw = db.relationship('Hardware', remote_side=[hw_id])
    komplekt = db.relationship('KomplektTable', backref='hardware')
    osnovanie = db.relationship('OsnovanieTable', backref='hardware')
    description = db.relationship('DescriptionTable', backref='hardware')
    ues = db.relationship('UESTable', backref='hardware')
    os = db.relationship('OS', backref='hardware')
    akt = db.relationship('Akt', backref='hardware')
    event = db.relationship('Events', backref='hardware')
    def __repr__(self):
        return f'<Hardware inv={self.inv}>'
class OS(db.Model):
    __tablename__ = 'os'
    os_id = db.Column(db.Integer, primary_key=True)
    inv = db.Column(db.String(31), unique=True)
    passport_number = db.Column(db.String(31))
    reg_number = db.Column(db.String(31))
    zn = db.Column(db.String(127))
    nn = db.Column(db.String(31))
    date_warranty = db.Column(db.Date)
    date_production = db.Column(db.Date)
    date_expluatation = db.Column(db.Date)
    date_input = db.Column(db.Date)
    date_output = db.Column(db.Date)
    date_trash = db.Column(db.Date)
    quantity = db.Column(db.Integer)
    model = db.Column(db.String(31))
    made_by = db.Column(db.String(31))
    code_party = db.Column(db.String(31))
    period = db.Column(db.String(31))
    dop_inv = db.Column(db.String(31))
    record_number = db.Column(db.String(31))
    filial_number = db.Column(db.String(31))
    description = db.Column(db.String(31))
    groups = db.Column(db.String(31))
    type_doc = db.Column(db.String(31))
    ed_izm = db.Column(db.String(31))
    group_os = db.Column(db.String(127))
    vid_os = db.Column(db.String(127))
    podrazdelenie = db.Column(db.String(127))
    mol = db.Column(db.String(127))
    code_norma_amort = db.Column(db.String(31))
    norma_amort = db.Column(db.String(31))
    uses_os = db.Column(db.String(127))
    price = db.Column(db.Float)
    summ_amort = db.Column(db.String(31))
    amort_otch_period = db.Column(db.String(31))
    price_input = db.Column(db.String(31))
    iznos_input = db.Column(db.String(31))
    fix_summ_amort = db.Column(db.String(31))
    srok_polezn_use = db.Column(db.String(31))
    valute = db.Column(db.String(31))
    metod_nach_amort = db.Column(db.String(255))
    tho_amort = db.Column(db.String(255))
    type_object = db.Column(db.String(255))
    code_group_okof = db.Column(db.String(31))
    name_group_okof = db.Column(db.String(31))
    popravka_koef = db.Column(db.String(31))
    last_price = db.Column(db.Float)
    event_id = db.Column(db.Integer, db.ForeignKey('events.event_id'))
    event = db.relationship('Events', backref='oses')
    def __repr__(self):
        return f'<OS inv={self.inv}>'
class Akt(db.Model):
    __tablename__ = 'akt'
    akt_id = db.Column(db.Integer, primary_key=True)
    number_akt = db.Column(db.Integer)
    date_akt = db.Column(db.Date)
    name_hw = db.Column(db.String(127))
    type_hw = db.Column(db.String(127))
    zn = db.Column(db.String(127))
    inv = db.Column(db.String(31))
    ues_id = db.Column(db.Integer, db.ForeignKey('ues_table.ues_id'))
    condition = db.Column(db.String(127))
    issue = db.Column(db.String(127))
    conclusion = db.Column(db.String(255))
    non_compliance = db.Column(db.String(255))
    description = db.Column(db.Text)
    employee = db.Column(db.String(255))
    #pdf = db.Column(db.LargeBinary)
    image_name = db.Column(db.String(255))  # поле для имени файла изображения
    event_id = db.Column(db.Integer, db.ForeignKey('events.event_id'))
    ues = db.relationship('UESTable', backref='akts')
    event = db.relationship('Events', backref='akts')
    def __repr__(self):
        return f'<Akt number={self.number_akt}>'
class Asudoo(db.Model):
    __tablename__ = 'asudoo'
    asudoo_id = db.Column(db.Integer, primary_key=True)
    branch_name_id = db.Column(db.Integer, db.ForeignKey('branch_name_table.branch_name_id'), nullable=False)
    rues_name_id = db.Column(db.Integer, db.ForeignKey('rues_name_table.rues_name_id'), nullable=False)
    ceh_name_id = db.Column(db.Integer, db.ForeignKey('ceh_name_table.ceh_name_id'), nullable=False)
    equipment_type_name_id = db.Column(db.Integer, db.ForeignKey('equipment_type_name_table.equipment_type_name_id'))
    equipment_name = db.Column(db.Text)
    mac = db.Column(db.String(127))
    inventory_number = db.Column(db.String(127))
    serial_number = db.Column(db.String(255))
    last_updated = db.Column(db.Date)
    cost = db.Column(db.String(31))
    chip_id = db.Column(db.String(127))
    document_date = db.Column(db.Date)
    status = db.Column(db.String(127))
    contractnum = db.Column(db.String(31))
    ttn_number = db.Column(db.String(127))
    ttn_date = db.Column(db.Date)
    number_part = db.Column(db.String(127))
    equipment_code = db.Column(db.String(127))
    branch = db.relationship('BranchNameTable', backref='asudoo')
    rues = db.relationship('RuesNameTable', backref='asudoo')
    ceh = db.relationship('CehNameTable', backref='asudoo')
    equipment_type = db.relationship('EquipmentTypeNameTable', backref='asudoo')
    def __repr__(self):
        return f'<Asudoo inventory_number={self.inventory_number}>'
class BranchNameTable(db.Model):
    __tablename__ = 'branch_name_table'
    branch_name_id = db.Column(db.Integer, primary_key=True)
    branch_name = db.Column(db.String(127), nullable=False, unique=True)
    def __repr__(self):
        return f'<Branch {self.branch_name}>'
class RuesNameTable(db.Model):
    __tablename__ = 'rues_name_table'
    rues_name_id = db.Column(db.Integer, primary_key=True)
    rues_name = db.Column(db.String(127), nullable=False, unique=True)
    def __repr__(self):
        return f'<RUES {self.rues_name}>'
class CehNameTable(db.Model):
    __tablename__ = 'ceh_name_table'
    ceh_name_id = db.Column(db.Integer, primary_key=True)
    ceh_name = db.Column(db.String(127), nullable=False, unique=True)
    def __repr__(self):
        return f'<Ceh {self.ceh_name}>'
class EquipmentTypeNameTable(db.Model):
    __tablename__ = 'equipment_type_name_table'
    equipment_type_name_id = db.Column(db.Integer, primary_key=True)
    equipment_type_name = db.Column(db.String(127), nullable=False, unique=True)
    def __repr__(self):
        return f'<EquipmentType {self.equipment_type_name}>'
class AsudooLink(db.Model):
    __tablename__ = 'asudoolink'
    asudoolink_id = db.Column(db.Integer, primary_key=True)
    asudoo_id = db.Column(db.Integer, db.ForeignKey('asudoo.asudoo_id'))
    mac = db.Column(db.String(255))
    sn = db.Column(db.String(255))
    doc_description = db.Column(db.Text)
    asudoo = db.relationship('Asudoo', backref='asudoo_links')
    hardware = db.relationship('Hardware', backref='asudoo_link')
    def __repr__(self):
        return f'<AsudooLink id={self.asudoolink_id}>'
# file:web_app_hw_out\app\schemas.py
# app/schemas.py
from marshmallow_sqlalchemy import SQLAlchemyAutoSchema
from app.models import Hardware, OS, Akt
from app import ma  # Импортируем экземпляр Marshmallow из app/__init__.py
class HardwareSchema(ma.SQLAlchemyAutoSchema):
    class Meta:
        model = Hardware
        load_instance = True
        include_fk = True  # Включаем внешние ключи, если нужно
class OSSchema(ma.SQLAlchemyAutoSchema):
    class Meta:
        model = OS
        load_instance = True
        include_fk = True
class AktSchema(ma.SQLAlchemyAutoSchema):
    class Meta:
        model = Akt
        load_instance = True
        include_fk = True
# file:web_app_hw_out\app\utils.py
# app/utils.py
from sqlalchemy.exc import SQLAlchemyError
import pandas as pd
from datetime import datetime
import re
def get_or_create(session, model, **kwargs):
    """
    Универсальная функция для получения или создания записи в базе данных.
    :param session: Сессия базы данных SQLAlchemy.
    :param model: Модель SQLAlchemy.
    :param kwargs: Пары "имя поля" - "значение", используемые для поиска или создания записи.
    :return: Экземпляр модели.
    """
    instance = session.query(model).filter_by(**kwargs).first()
    if instance:
        return instance
    else:
        try:
            instance = model(**kwargs)
            session.add(instance)
            session.commit()
            return instance
        except SQLAlchemyError as e:
            session.rollback()
            print(f"[ERROR] Ошибка при сохранении {model.__name__}: {e}")
            return None
       
def parse_int(value):
    """
    Преобразует значение в целое число.
    """
    try:
        return int(value)
    except (TypeError, ValueError):
        return None
def parse_float(value):
    """
    Преобразует значение в число с плавающей точкой.
    """
    try:
        return float(str(value).replace(",", "."))
    except (TypeError, ValueError):
        return None
def parse_date(value):
    """
    Преобразует строку в дату.
    Если передан только год (e.g., '2023'), возвращает дату '2023-01-01'.
    Если передан месяц и год (e.g., '04.2023'), возвращает дату '2023-04-01'.
    Если передана полная дата, возвращает её.
    """
    if not value or pd.isna(value):
        return None
    if isinstance(value, datetime):
        return value.date()
    if isinstance(value, (int, float)):
        # Если значение число, рассматриваем его как год
        try:
            year = int(value)
            return datetime(year, 1, 1).date()
        except (ValueError, TypeError):
            return None
    value_str = str(value).strip()
    # Список возможных форматов дат
    date_formats = [
        "%d.%m.%Y", "%Y-%m-%d", "%Y/%m/%d",       # Полная дата
        "%d.%m.%y", "%d-%m-%Y", "%d/%m/%Y",       # Полная дата с различными разделителями
        "%m.%Y", "%m/%Y", "%Y-%m", "%Y/%m",       # Месяц и год
        "%Y",                                     # Только год
        "%m.%y"                                   # Новый формат ММ.ГГ
    ]
    for fmt in date_formats:
        try:
            parsed_date = datetime.strptime(value_str, fmt)
            # Если исходная строка содержит только год
            if fmt == "%Y":
                return datetime(parsed_date.year, 1, 1).date()
            # Если исходная строка содержит месяц и год
            elif fmt in ("%m.%Y", "%m/%Y", "%Y-%m", "%Y/%m"):
                return datetime(parsed_date.year, parsed_date.month, 1).date()
            else:
                # Полная дата
                return parsed_date.date()
        except (ValueError, TypeError):
            continue
    # Если не удалось распознать дату
    print(f"[WARN] Не удалось распознать дату: {value_str}")
    return None
def extract_number_from_string(text, return_type=int, default=1, find_all=False):
    """
    Универсальная функция для извлечения чисел из строки.
    
    :param text: Входная строка.
    :param return_type: Тип возвращаемого числа (int или float).
    :param default: Значение по умолчанию, если число не найдено.
    :param find_all: Если True, возвращать все найденные числа.
                     Если False, возвращать только первое.
    :return: Число, список чисел или default.
    """
    
    pattern = r'-?\d+\.?\d*'
    #pattern = r'\d+'
    matches = re.findall(pattern, text)
    
    if not matches:
        return default
    
    if find_all:
        numbers = []
        for num_str in matches:
            try:
                number = return_type(num_str)
                numbers.append(number)
            except ValueError:
                continue
        return numbers if numbers else default
    else:
        num_str = matches[0]
        try:
            return return_type(num_str)
        except ValueError:
            return default
def convert_to_string(value, separator=", "):
    """
    Преобразует переданное значение в строку.
    Если значение является списком, объединяет его элементы с помощью разделителя.
    Иначе, просто преобразует значение в строку и удаляет лишние пробелы.
    
    :param value: Значение для преобразования.
    :param separator: Разделитель для объединения элементов списка.
    :return: Строковое представление значения.
    """
    if isinstance(value, list):
        return separator.join(map(str, value)).strip()
    elif isinstance(value, str):
        return value.strip()
    else:
        return str(value).strip() if value else ''
# file:web_app_hw_out\config.py
# config.py
import os
import logging
BASE_DIR = os.path.abspath(os.path.dirname(__file__))
class Config:
    SECRET_KEY = "xcvH&%YHGBk389hklasd9783sdcjnzlq]zxmn"  # Лучше использовать os.environ.get('SECRET_KEY') или секрет из безопасного хранилища
    DEBUG = True
    LOG_LEVEL = logging.DEBUG  # Уровень логгирования
    #HOST = "0.0.0.0"
    HOST = '127.0.0.1'
    PORT = 5000
    # Конфигурация SQLAlchemy
    SQLALCHEMY_DATABASE_URI = 'sqlite:///' + os.path.join(BASE_DIR, 'app', 'hardware_db.sqlite')
    SQLALCHEMY_TRACK_MODIFICATIONS = False
    UPLOAD_FOLDER = os.path.join(BASE_DIR, 'app', 'uploads')
    ALLOWED_EXTENSIONS = {"pdf", "xlsx", "csv", "docx"}
    ASUDOO_CSV_DEFAULT_PATH = os.path.join(BASE_DIR, 'app', 'ASUDOO.csv')
    
    
    # --- НАСТРОЙКИ ДЛЯ SESSION:
    SESSION_TYPE = 'sqlalchemy'
    # Название таблицы для хранения сессий (по умолчанию "sessions"):
    SESSION_SQLALCHEMY_TABLE = 'flask_sessions'
    # Опционально, если вы хотите «принудительно» сериализовать объекты:
    SESSION_USE_SIGNER = True  
    
    MAIL_SERVER = 'smtp.example.com'
    MAIL_PORT = 587
    MAIL_USE_TLS = True
    MAIL_USERNAME = 'your_email@example.com'
    MAIL_PASSWORD = 'your_email_password'
    ADMINS = ['admin@example.com']  # Кому отправлять логи ошибок

# file:web_app_hw_out\create_db.py
# -*- coding: utf-8 -*-
"""
Скрипт для создания базы данных SQLite с необходимыми таблицами.
"""
import sqlite3
def create_database(db_name: str = "hardware_db.sqlite"):
    """Создаёт файл БД (если его нет) и все необходимые таблицы."""
    # Все команды создания таблиц объединены в один SQL-скрипт
    sql_script = """
    BEGIN TRANSACTION;
    -- 1. Таблица EVENTS (с автогенерацией event_id)
    CREATE TABLE IF NOT EXISTS EVENTS (
        event_id INTEGER PRIMARY KEY AUTOINCREMENT,
        event_name TEXT,
        event_time TEXT
    );
    -- 2. Таблица оборудования (hardware)
    CREATE TABLE IF NOT EXISTS hardware (
        hw_id INTEGER PRIMARY KEY AUTOINCREMENT,
        duplicate_hw_id INTEGER,
        doc_id INTEGER,
        no INTEGER,
        name_txt TEXT NOT NULL,
        inv TEXT NOT NULL,
        name TEXT,
        boxid TEXT,
        sn TEXT,
        mac TEXT,
        kud TEXT,
        number_akt INTEGER,
        date_akt TEXT,
        quantity INTEGER,
        shifr_time INTEGER,
        shifr_amo INTEGER,
        date_build TEXT,
        date_input TEXT,
        date_buh TEXT,
        date_nouse TEXT,
        first_price REAL,
        first_price_date TEXT,
        last_price REAL,
        last_price_date TEXT,
        srok INTEGER,
        komplekt_id INTEGER,
        osnovanie_id INTEGER,
        description_id INTEGER,
        ues_description TEXT,
        ues_id INTEGER,
        chekedhw_id INTEGER,
        os_id INTEGER,
        akt_id INTEGER,
        asudoolink_id INTEGER,
        event_id INTEGER,
        FOREIGN KEY (event_id) REFERENCES EVENTS(event_id),
        FOREIGN KEY (ues_id) REFERENCES UES_TABLE(ues_id),
        FOREIGN KEY (doc_id) REFERENCES LANDOCS(doc_id),
        FOREIGN KEY (chekedhw_id) REFERENCES CHEKEDHW(chekedhw_id),
        FOREIGN KEY (os_id) REFERENCES OS(os_id),
        FOREIGN KEY (akt_id) REFERENCES AKT(akt_id),
        FOREIGN KEY (description_id) REFERENCES DESCRIPTIONTBL(description_id),
        FOREIGN KEY (komplekt_id) REFERENCES KOMPLEKTTBL(komplekt_id),
        FOREIGN KEY (asudoolink_id) REFERENCES ASUDOOLINK(asudoolink_id),
        FOREIGN KEY (osnovanie_id) REFERENCES OSNOVANIETBL(osnovanie_id),
        FOREIGN KEY (duplicate_hw_id) REFERENCES hardware(hw_id),
        UNIQUE (inv)
    );
    -- 3. Таблица ОС (OS)
    CREATE TABLE IF NOT EXISTS OS (
        os_id INTEGER PRIMARY KEY AUTOINCREMENT,
        inv TEXT UNIQUE,
        passport_number TEXT,
        reg_number TEXT,
        zn TEXT,
        nn TEXT,
        date_warranty TEXT,
        date_production TEXT,
        date_expluatation TEXT,
        date_input TEXT,
        date_output TEXT,
        date_trash TEXT,
        quantity INTEGER,
        model TEXT,
        made_by TEXT,
        code_party TEXT,
        period TEXT,
        dop_inv TEXT,
        record_number TEXT,
        filial_number TEXT,
        description TEXT,
        groups TEXT,
        type_doc TEXT,
        ed_izm TEXT,
        group_os TEXT,
        vid_os TEXT,
        podrazdelenie TEXT,
        mol TEXT,
        code_norma_amort TEXT,
        norma_amort TEXT,
        uses_os TEXT,
        price REAL,
        summ_amort TEXT,
        amort_otch_period TEXT,
        price_input TEXT,
        iznos_input TEXT,
        fix_summ_amort TEXT,
        srok_polezn_use TEXT,
        valute TEXT,
        metod_nach_amort TEXT,
        tho_amort TEXT,
        type_object TEXT,
        code_group_okof TEXT,
        name_group_okof TEXT,
        popravka_koef TEXT,
        last_price REAL,
        event_id INTEGER,
        FOREIGN KEY (event_id) REFERENCES EVENTS(event_id)
    );
    -- 4. Таблица актов (AKT)
    CREATE TABLE IF NOT EXISTS AKT (
        akt_id INTEGER PRIMARY KEY AUTOINCREMENT,
        number_akt INTEGER,
        date_akt TEXT NOT NULL,
        inv TEXT,
        name_hw TEXT,
        type_hw TEXT,
        zn TEXT,
        ues_id INTEGER,
        condition TEXT,
        issue TEXT,
        conclusion TEXT,
        non_compliance TEXT,
        description TEXT,
        employee TEXT,
        image_name TEXT,
        event_id INTEGER,
        FOREIGN KEY (event_id) REFERENCES EVENTS(event_id),
        FOREIGN KEY (ues_id) REFERENCES UES_TABLE(ues_id)
    );
    -- 5. Таблица UES_TABLE (неизменяемая)
    CREATE TABLE IF NOT EXISTS UES_TABLE (
        ues_id INTEGER PRIMARY KEY AUTOINCREMENT,
        ues_name TEXT NOT NULL
    );
    -- 6. Таблица DESCRIPTIONTBL (неизменяемая)
    CREATE TABLE IF NOT EXISTS DESCRIPTIONTBL (
        description_id INTEGER PRIMARY KEY AUTOINCREMENT,
        description_text TEXT
    );
    -- 7. Таблица KOMPLEKTTBL (неизменяемая)
    CREATE TABLE IF NOT EXISTS KOMPLEKTTBL (
        komplekt_id INTEGER PRIMARY KEY AUTOINCREMENT,
        komplekt_text TEXT
    );
    -- 8. Таблица OSNOVANIETBL (неизменяемая)
    CREATE TABLE IF NOT EXISTS OSNOVANIETBL (
        osnovanie_id INTEGER PRIMARY KEY AUTOINCREMENT,
        osnovanie_txt TEXT
    );
    -- 9. Таблица LANDOCS
    CREATE TABLE IF NOT EXISTS LANDOCS (
        doc_id INTEGER PRIMARY KEY AUTOINCREMENT,
        doc_name TEXT NOT NULL,
        doc_date TEXT NOT NULL,
        doc_description TEXT
    );
    -- 10. Таблица ASUDOOLINK
    CREATE TABLE IF NOT EXISTS ASUDOOLINK (
        asudoolink_id INTEGER PRIMARY KEY AUTOINCREMENT,
        asudoo_id INTEGER,
        mac TEXT,
        sn TEXT,
        doc_description TEXT,
        FOREIGN KEY (asudoo_id) REFERENCES ASUDOO(asudoo_id)
    );
    -- 11. Таблица CHEKEDHW
    CREATE TABLE IF NOT EXISTS CHEKEDHW (
        chekedhw_id INTEGER PRIMARY KEY AUTOINCREMENT,
        statehw_id INTEGER,
        date_checked_akt TEXT,
        date_checked_os TEXT,
        doc_id INTEGER,
        FOREIGN KEY (doc_id) REFERENCES LANDOCS(doc_id),
        FOREIGN KEY (statehw_id) REFERENCES STATEHW(statehw_id)
    );
    -- 12. Таблица STATEHW (неизменяемая)
    CREATE TABLE IF NOT EXISTS STATEHW (
        statehw_id INTEGER PRIMARY KEY AUTOINCREMENT,
        statehw_name TEXT NOT NULL
    );
    -- 13. Таблица ASUDOO
    CREATE TABLE IF NOT EXISTS ASUDOO (
        asudoo_id INTEGER PRIMARY KEY AUTOINCREMENT,
        branch_name_id INTEGER NOT NULL,
        rues_name_id INTEGER NOT NULL,
        ceh_name_id INTEGER NOT NULL,
        equipment_type_name_id INTEGER,
        equipment_name TEXT,
        mac TEXT,
        inventory_number TEXT,
        serial_number TEXT,
        last_updated TEXT,
        cost TEXT,
        chip_id TEXT,
        document_date TEXT,
        status TEXT,
        contractnum TEXT,
        ttn_number TEXT,
        ttn_date TEXT,
        number_part TEXT,
        equipment_code TEXT,
        FOREIGN KEY (branch_name_id) REFERENCES BRANCH_NAME_TABLE(branch_name_id),
        FOREIGN KEY (rues_name_id) REFERENCES RUES_NAME_TABLE(rues_name_id),
        FOREIGN KEY (ceh_name_id) REFERENCES CEH_NAME_TABLE(ceh_name_id),
        FOREIGN KEY (equipment_type_name_id) REFERENCES EQUIPMENT_TYPE_NAME_TABLE(equipment_type_name_id)
    );
    -- 14. Таблицы-справочники для ASUDOO
    CREATE TABLE IF NOT EXISTS BRANCH_NAME_TABLE (
        branch_name_id INTEGER PRIMARY KEY AUTOINCREMENT,
        branch_name TEXT NOT NULL
    );
    CREATE TABLE IF NOT EXISTS RUES_NAME_TABLE (
        rues_name_id INTEGER PRIMARY KEY AUTOINCREMENT,
        rues_name TEXT NOT NULL
    );
    CREATE TABLE IF NOT EXISTS CEH_NAME_TABLE (
        ceh_name_id INTEGER PRIMARY KEY AUTOINCREMENT,
        ceh_name TEXT NOT NULL
    );
    CREATE TABLE IF NOT EXISTS EQUIPMENT_TYPE_NAME_TABLE (
        equipment_type_name_id INTEGER PRIMARY KEY AUTOINCREMENT,
        equipment_type_name TEXT NOT NULL
    );
    CREATE TABLE IF NOT EXISTS flask_sessions (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        session_id VARCHAR(255) NOT NULL UNIQUE,
        data BLOB NOT NULL,
        expiry INTEGER NOT NULL
    );
    COMMIT;
    """
    # Подключаемся к БД (файл создаётся при отсутствии)
    with sqlite3.connect(db_name) as conn:
        # Выполняем скрипт одним блоком
        conn.executescript(sql_script)
    print(f"База данных успешно создана: {db_name}")
if __name__ == "__main__":
    # Можно добавить чтение имени базы из аргументов командной строки
    create_database(r"j:\grum77\2025\web_app_hw_out\app\_hardware_db.sqlite")
# file:web_app_hw_out\main.py
# main.py
from app import create_app
#from config import DEBUG, HOST, PORT
from config import Config
if __name__ == "__main__":
    app = create_app()
    app.run(debug=Config.DEBUG, host=Config.HOST, port=Config.PORT)
# file:web_app_hw_out\app\templates\audit_report.html
{% extends "base.html" %}
{% block title %}Отчет аудита{% endblock %}
{% block content %}
<h1>Отчет аудита</h1>
{% if discrepancies %}
<table class="table table-bordered">
    <thead>
        <tr>
            <th>Инв. номер</th>
            <th>Поле</th>
            <th>Hardware</th>
            <th>OS</th>
        </tr>
    </thead>
    <tbody>
        {% for item in discrepancies %}
            {% if 'notice' in item.differences %}
            <tr>
                <td>{{ item.inv }}</td>
                <td colspan="3">{{ item.differences.notice }}</td>
            </tr>
            {% else %}
                {% for field, values in item.differences.items() %}
                <tr>
                    <td>{{ item.inv }}</td>
                    <td>{{ field }}</td>
                    <td>{{ values.Hardware }}</td>
                    <td>{{ values.OS }}</td>
                </tr>
                {% endfor %}
            {% endif %}
        {% endfor %}
    </tbody>
</table>
{% else %}
<p>Расхождений не обнаружено.</p>
{% endif %}
{% endblock %}
# file:web_app_hw_out\app\templates\base.html
<!-- app/templates/base.html -->
<!DOCTYPE html>
<html lang="ru">
<head>
    <meta charset="UTF-8">
	<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
    <title>{% block title %}Моё приложение{% endblock %}</title>
    <!-- Подключение CSS (например, Bootstrap) -->
    <link rel="stylesheet" href="{{ url_for('static', filename='css/bootstrap.min.css') }}">
    <style>
        body {
            display: flex;
            min-height: 100vh;
            margin: 0;
        }
        .sidebar {
            width: 250px;
            background-color: #f8f9fa;
            padding: 20px;
        }
        .content {
            flex-grow: 1;
            padding: 20px;
        }
        .sidebar a {
            display: block;
            padding: 10px;
            color: #212529;
            text-decoration: none;
        }
        .sidebar a:hover {
            background-color: #e9ecef;
            text-decoration: none;
        }
    </style>
    {% block head_extra %}{% endblock %}
</head>
<body>
    <div class="sidebar">
        <h2>Меню</h2>
        <ul class="nav flex-column">
            <li class="nav-item"><a href="{{ url_for('index') }}" class="nav-link">Главная</a></li>
            <li class="nav-item"><a href="{{ url_for('hardware_bp.upload_hardware') }}" class="nav-link">Загрузка оборудования</a></li>
            <li class="nav-item"><a href="{{ url_for('os_bp.upload_os') }}" class="nav-link">Загрузка данных ОС</a></li>
            <li class="nav-item"><a href="{{ url_for('akt_bp.upload_akt') }}" class="nav-link">Загрузка актов</a></li>
			<li class="nav-item"><a href="{{ url_for('akt_view_bp.view_akt') }}" class="nav-link">Просмотр и редактирование Актов</a></li>
            <li class="nav-item"><a href="{{ url_for('asudoo_bp.update_asudoo') }}" class="nav-link">Обновить ASUDOO</a></li>
            <li class="nav-item"><a href="{{ url_for('reconcile_bp.reconcile_index') }}" class="nav-link">Сверка</a></li>
            <li class="nav-item"><a href="{{ url_for('report_bp.report_spisanie') }}" class="nav-link">Отчёты</a></li>
			<li class="nav-item"><a href="{{ url_for('report_bp.inventory_report') }}" class="nav-link">Отчет 	инвентаризации</a></li>
			<li class="nav-item"><a href="{{ url_for('report_bp.audit_report') }}" class="nav-link">Отчет аудита</a></li>
        </ul>
    </div>
    <div class="content">
        {% with messages = get_flashed_messages() %}
          {% if messages %}
            <div class="alert alert-info">
              {% for message in messages %}
                <p>{{ message }}</p>
              {% endfor %}
            </div>
          {% endif %}
        {% endwith %}
        {% block content %}{% endblock %}
    </div>
    <!-- Подключение JS-библиотек (например, Bootstrap JS, если требуется) -->
    {% block scripts %}
	<script src="{{ url_for('static', filename='js/jquery.slim.min.js') }}"></script>
	<script src="{{ url_for('static', filename='js/bootstrap.bundle.min.js') }}"></script>
	{% endblock %}
</body>
</html>
# file:web_app_hw_out\app\templates\confirm_hardware.html
{% extends "base.html" %}
{% block title %}Подтверждение данных оборудования{% endblock %}
{% block content %}
    <h1>Подтверждение данных оборудования</h1>
    <form method="POST">
        <input type="hidden" name="confirm" value="true">
        <table class="table table-bordered table-sm">
            <thead>
                <tr>
					<th>Принять</th>
                    <th>Индекс</th>
                    <th>№</th>
                    <th>Инв. номер</th>
                    <th>Название</th>
                    <th>Количество</th>
                    <th>Серийный номер (SN)</th>
                    <th>MAC адрес</th>
                    <th>BOXID</th>
                    <th>KUD</th>
                    <th>Шифр времени</th>
                    <th>Шифр амортизации</th>
                    <th>Год постройки</th>
                    <th>Дата ввода (ММ.ГГГГ)</th>
                    <th>Дата бух. учета</th>
                    <th>Дата неиспользования (ММ.ГГГГ)</th>
                    <th>Первоначальная стоимость</th>
                    <th>Остаточная стоимость</th>
                    <th>Срок полезного использования</th>
                    <th>Комплектность</th>
                    <th>Основание</th>
					<th>Акт</th>
					<th>Дата акта</th>
                    <th>Описание</th>
                    <th>Описание УЭС</th>
                </tr>
            </thead>
            <tbody>
                {% for hw in hardware_list %}
                <tr>
                    <td>
                        <input type="checkbox" name="accept" value="{{ loop.index0 }}" checked>
                    </td>
					<td>{{ loop.index }}</td>
                    <td>{{ hw.get('no', '') }}</td>
                    <td>{{ hw.get('inv', '') }}</td>
                    <td>{{ hw.get('name_txt', '') }}</td>
                    <td>{{ hw.get('quantity', '') }}</td>
                    <td>
                        <input type="text" name="sn_{{ loop.index0 }}" value="{{ hw.get('sn', '') }}" class="form-control">
                    </td>
                    <td>
                        <input type="text" name="mac_{{ loop.index0 }}" value="{{ hw.get('mac', '') }}" class="form-control">
                    </td>
                    <td>
                        <input type="text" name="boxid_{{ loop.index0 }}" value="{{ hw.get('boxid', '') }}" class="form-control">
                    </td>
                    <td>
                        <input type="text" name="kud_{{ loop.index0 }}" value="{{ hw.get('kud', '') }}" class="form-control">
                    </td>
                    <td>{{ hw.get('shifr_time', '') }}</td>
                    <td>{{ hw.get('shifr_amo', '') }}</td>
                    <td>
                        {% if hw.get('date_build') %}
                            {{ hw.get('date_build')|strftime('%Y') }}
                        {% else %}
                            ''
                        {% endif %}
                    </td>
                    <td>
                        {% if hw.get('date_input') %}
                            {{ hw.get('date_input')|strftime('%m.%Y') }}
                        {% else %}
                            ''
                        {% endif %}
                    </td>
                    <td>
                        {% if hw.get('date_buh') %}
                            {{ hw.get('date_buh')|strftime('%d.%m.%Y') }}
                        {% else %}
                            ''
                        {% endif %}
                    </td>
                    <td>
                        {% if hw.get('date_nouse') %}
                            {{ hw.get('date_nouse')|strftime('%m.%Y') }}
                        {% else %}
                            ''
                        {% endif %}
                    </td>
                    <td>{{ hw.get('first_price', '') }}</td>
                    <td>{{ hw.get('last_price', '') }}</td>
                    <td>{{ hw.get('srok', '') }}</td>
                    <td>{{ hw.get('komplekt_text', '') }}</td>
                    <td>{{ hw.get('osnovanie_text', '') }}</td>
                    <td>{{ hw.get('number_akt', '') }}</td>
					<td>
                        {% if hw.get('date_akt') %}
                            {{ hw.get('date_akt')|strftime('%d.%m.%Y') }}
                        {% else %}
                            ''
                        {% endif %}
                    </td>
                    <td>{{ hw.get('description_text', '') }}</td>
                    <td>{{ hw.get('ues_description', '') }}</td>
                </tr>
                {% endfor %}
            </tbody>
        </table>
        <button type="submit" class="btn btn-success">Сохранить выбранное</button>
        <a href="{{ url_for('hardware_bp.upload_hardware') }}" class="btn btn-danger">Отмена</a>
    </form>
{% endblock %}
# file:web_app_hw_out\app\templates\edit_akt.html
{% extends "base.html" %}
{% block title %}Редактирование акта{% endblock %}
{% block content %}
<h1>Редактирование акта: ID {{ akt.akt_id }}</h1>
<form method="POST" class="form-row">
    <div class="col-md-6">
        <div class="form-group">
            <label>Номер акта:</label>
            <input type="text" name="number_akt" value="{{ akt.number_akt }}" class="form-control">
        </div>
        <div class="form-group">
            <label>Дата акта:</label>
            <input type="date" name="date_akt" value="{{ akt.date_akt }}" class="form-control">
        </div>
        <div class="form-group">
            <label>Инвентарный номер:</label>
            <input type="text" name="inv" value="{{ akt.inv }}" class="form-control">
        </div>
        <div class="form-group">
            <label>Наименование:</label>
            <input type="text" name="name_hw" value="{{ akt.name_hw }}" class="form-control">
        </div>
        <div class="form-group">
            <label>Тип:</label>
            <input type="text" name="type_hw" value="{{ akt.type_hw }}" class="form-control">
        </div>
        <div class="form-group">
            <label>Серийный номер:</label>
            <input type="text" name="zn" value="{{ akt.zn }}" class="form-control">
        </div>
        <div class="form-group">
            <label>Состояние на момент осмотра:</label>
            <textarea name="condition" class="form-control">{{ akt.condition }}</textarea>
        </div>
        <div class="form-group">
            <label>Выявлено (неисправность):</label>
            <textarea name="issue" class="form-control">{{ akt.issue }}</textarea>
        </div>
        <div class="form-group">
            <label>Заключение:</label>
            <textarea name="non_compliance" class="form-control">{{ akt.non_compliance }}</textarea>
        </div>
        <div class="form-group">
            <label>Приложение:</label>
            <textarea name="conclusion" class="form-control">{{ akt.conclusion }}</textarea>
        </div>
        <button type="submit" class="btn btn-success">Сохранить</button>
        <a href="{{ url_for('akt_view_bp.view_akt') }}" class="btn btn-secondary">Вернуться</a>
    </div>
		<div class="col-md-6">
			{% if akt.image_name %}
				<img src="{{ url_for('uploaded_file', filename='akt_images/' ~ akt.image_name) }}" class="img-fluid" alt="Изображение акта">
			{% else %}
				<p>Изображение недоступно</p>
			{% endif %}
        <div class="mt-3">
            {% if prev_akt %}
            <a href="{{ url_for('akt_view_bp.edit_akt', akt_id=prev_akt.akt_id) }}" class="btn btn-primary">Предыдущий</a>
            {% endif %}
            {% if next_akt %}
            <a href="{{ url_for('akt_view_bp.edit_akt', akt_id=next_akt.akt_id) }}" class="btn btn-primary">Следующий</a>
            {% endif %}
        </div>
    </div>
</form>
{% endblock %}
# file:web_app_hw_out\app\templates\index.html
<!-- app/templates/index.html -->
{% extends "base.html" %}
{% block title %}Главная{% endblock %}
{% block content %}
<h1>Движение по списанию оборудования</h1>
<p>Выберите действие из меню слева.</p>
{% endblock %}
# file:web_app_hw_out\app\templates\inventory_report.html
{% extends "base.html" %}
{% block title %}Отчет инвентаризации{% endblock %}
{% block content %}
<h1>Отчет инвентаризации</h1>
<table class="table table-bordered">
    <thead>
        <tr>
            <th>Инв. номер</th>
            <th>Наименование</th>
            <th>Количество</th>
            <th>Дата ввода</th>
            <!-- Добавьте другие заголовки -->
        </tr>
    </thead>
    <tbody>
        {% for hw in hardware_list %}
        <tr>
            <td>{{ hw.inv }}</td>
            <td>{{ hw.name }}</td>
            <td>{{ hw.quantity }}</td>
            <td>{{ hw.date_input }}</td>
            <!-- Добавьте другие поля -->
        </tr>
        {% endfor %}
    </tbody>
</table>
{% endblock %}
# file:web_app_hw_out\app\templates\inventory_report_form.html
{% extends "base.html" %}
{% block title %}Отчет инвентаризации{% endblock %}
{% block content %}
<h1>Отчет инвентаризации</h1>
<form method="POST">
    <div class="form-group">
        <label for="equipment_type">Тип оборудования:</label>
        <input type="text" id="equipment_type" name="equipment_type" class="form-control">
    </div>
    <div class="form-group">
        <label for="date_from">Дата с:</label>
        <input type="date" id="date_from" name="date_from" class="form-control">
    </div>
    <div class="form-group">
        <label for="date_to">Дата по:</label>
        <input type="date" id="date_to" name="date_to" class="form-control">
    </div>
    <div class="form-group">
        <label for="export_format">Формат экспорта:</label>
        <select id="export_format" name="export_format" class="form-control">
            <option value="html">HTML</option>
            <option value="excel">Excel</option>
            <option value="csv">CSV</option>
            <option value="json">JSON</option>
        </select>
    </div>
    <button type="submit" class="btn btn-primary">Сформировать отчет</button>
</form>
{% endblock %}
# file:web_app_hw_out\app\templates\reconcile_doc.html
<!-- app/templates/reconcile_doc.html -->
{% extends "base.html" %}
{% block title %}Сверка: документ {{ doc['doc_name'] }} ({{ doc['doc_date'] }}){% endblock %}
{% block content %}
    <h1>Документ: {{ doc['doc_name'] }} ({{ doc['doc_date'] }})</h1>
    <table class="table table-bordered">
        <thead>
            <tr>
                <th>inv</th>
                <th>name</th>
                <th>quantity</th>
                <th>date_checked_akt</th>
                <th>date_checked_os</th>
            </tr>
        </thead>
        <tbody>
            {% for hw in hw_list %}
            <tr>
                <td>
                    <a href="{{ url_for('reconcile_bp.reconcile_hw', inv=hw['inv']) }}">
                        {{ hw['inv'] }}
                    </a>
                </td>
                <td>{{ hw['name'] }}</td>
                <td>{{ hw['quantity'] }}</td>
                <td>{{ hw['date_checked_akt'] }}</td>
                <td>{{ hw['date_checked_os'] }}</td>
            </tr>
            {% endfor %}
        </tbody>
    </table>
    <p><a href="{{ url_for('reconcile_bp.reconcile_index') }}" class="btn btn-secondary">Вернуться к списку документов</a></p>
{% endblock %}
# file:web_app_hw_out\app\templates\reconcile_hw.html
<!-- app/templates/reconcile_hw.html -->
{% extends "base.html" %}
{% block title %}Сверка инв.№{{ inv }}{% endblock %}
{% block content %}
    <h1>Сверка для инв.№{{ inv }}</h1>
    <!-- Блок hardware -->
    <div>
        {{ hw_html|safe }}
    </div>
    <!-- Блок OS -->
    <div>
        {{ os_html|safe }}
    </div>
    <!-- Блок AKT -->
    <div>
        {{ akt_html|safe }}
    </div>
    <p><a href="{{ url_for('reconcile_bp.reconcile_index') }}" class="btn btn-secondary">Вернуться к списку документов</a></p>
{% endblock %}
# file:web_app_hw_out\app\templates\reconcile_index.html
<!-- app/templates/reconcile_index.html -->
{% extends "base.html" %}
{% block title %}Сверка: выбор документа{% endblock %}
{% block content %}
    <h1>Сверка: выбор документа LANDOCS</h1>
    <ul class="list-group">
        {% for doc in landocs %}
        <li class="list-group-item">
            <a href="{{ url_for('reconcile_bp.reconcile_doc', doc_id=doc.doc_id) }}">
                doc_id={{ doc.doc_id }} | {{ doc.doc_name }} ({{ doc.doc_date }})
            </a>
        </li>
        {% endfor %}
    </ul>
{% endblock %}
# file:web_app_hw_out\app\templates\update_asudoo.html
<!-- app/templates/update_asudoo.html -->
{% extends "base.html" %}
{% block title %}Обновление таблицы ASUDOO{% endblock %}
{% block content %}
    <h1>Обновить таблицу ASUDOO (CSV)</h1>
    <form method="POST">
        <div class="form-group">
            <label for="csv_path">Путь к CSV-файлу:</label>
            <input type="text" id="csv_path" name="csv_path" value="{{ default_csv_path }}" class="form-control">
        </div>
        <button type="submit" class="btn btn-primary">Обновить</button>
    </form>
{% endblock %}
# file:web_app_hw_out\app\templates\upload_akt.html
<!-- app/templates/upload_akt.html -->
{% extends "base.html" %}
{% block title %}Загрузка актов (AKT) из PDF{% endblock %}
{% block content %}
    <h1>Загрузка актов (AKT) из PDF</h1>
    <form method="POST" enctype="multipart/form-data">
        <div class="form-group">
            <label for="file_pdf">Файл PDF:</label>
            <input type="file" id="file_pdf" name="file_pdf" accept=".pdf" class="form-control-file">
        </div>
        <button type="submit" class="btn btn-primary">Загрузить</button>
    </form>
{% endblock %}
# file:web_app_hw_out\app\templates\upload_hardware.html
<!-- app/templates/upload_hardware.html -->
{% extends "base.html" %}
{% block title %}Загрузка оборудования{% endblock %}
{% block content %}
    <h1>Загрузка данных об оборудовании (hardware)</h1>
    <form method="POST" enctype="multipart/form-data">
        <fieldset class="form-group">
            <legend>Информация о документе:</legend>
            <div class="form-group">
                <label for="doc_name">Название документа:</label>
                <input type="text" id="doc_name" name="doc_name" list="doc_name_list" required class="form-control">
                <datalist id="doc_name_list">
                    {% for name in doc_names %}
                        <option value="{{ name }}">
                    {% endfor %}
                </datalist>
            </div>
            <div class="form-group">
                <label for="doc_date">Дата документа (YYYY-MM-DD):</label>
                <input type="date" id="doc_date" name="doc_date" required class="form-control">
            </div>
            <div class="form-group">
                <label for="doc_description">Описание документа:</label>
                <input type="text" id="doc_description" name="doc_description" class="form-control">
            </div>
        </fieldset>
        <fieldset class="form-group">
            <legend>Файлы для загрузки:</legend>
            <div class="form-group">
                <label for="file_xlsx">Файл XLSX (оборудование):</label>
                <input type="file" id="file_xlsx" name="file_xlsx" accept=".xlsx" class="form-control-file">
            </div>
            <div class="form-group">
                <label for="file_docx">Файл DOCX (описательная часть):</label>
                <input type="file" id="file_docx" name="file_docx" accept=".docx" class="form-control-file">
            </div>
        </fieldset>
        <button type="submit" class="btn btn-primary">Загрузить</button>
    </form>
{% endblock %}
# file:web_app_hw_out\app\templates\upload_os.html
<!-- app/templates/upload_os.html -->
{% extends "base.html" %}
{% block title %}Загрузка данных ОС{% endblock %}
{% block content %}
    <h1>Загрузка данных ОС</h1>
    <form method="POST" enctype="multipart/form-data">
        <div class="form-group">
            <label for="file_csv">Файл CSV для OS:</label>
            <input type="file" id="file_csv" name="file_csv" accept=".csv" class="form-control-file">
        </div>
        <button type="submit" class="btn btn-primary">Загрузить</button>
    </form>
{% endblock %}
# file:web_app_hw_out\app\templates\view_akt.html
{% extends "base.html" %}
{% block title %}Просмотр всех актов{% endblock %}
{% block content %}
<h1>Просмотр актов</h1>
<form method="POST" class="form-inline mb-3">
    <select name="number_akt" class="form-control mr-2">
        <option value="">Выберите номер акта</option>
        {% for num in unique_numbers %}
        <option value="{{ num }}" {% if selected_filters.get('number_akt') == num %}selected{% endif %}>
            {{ num }}
        </option>
        {% endfor %}
    </select>
    <select name="date_akt" class="form-control mr-2">
        <option value="">Выберите дату акта</option>
        {% for date in unique_dates %}
        <option value="{{ date }}" {% if selected_filters.get('date_akt') == date %}selected{% endif %}>
            {{ date }}
        </option>
        {% endfor %}
    </select>
    <button type="submit" class="btn btn-primary">Применить</button>
</form>
<table class="table table-bordered">
    <thead>
        <tr>
            <th>ID</th>
            <th>Номер акта</th>
            <th>Дата акта</th>
            <th>Наименование</th>
        </tr>
    </thead>
    <tbody>
        {% for akt in akt_list %}
        <tr onclick="window.location='{{ url_for('akt_view_bp.edit_akt', akt_id=akt.akt_id) }}'">
            <td>{{ akt.akt_id }}</td>
            <td>{{ akt.number_akt }}</td>
            <td>{{ akt.date_akt }}</td>
            <td>{{ akt.name_hw }}</td>
        </tr>
        {% endfor %}
    </tbody>
</table>
{% endblock %}