Приём документов → реестр + журнал

Внесение данных из файлов приёма в реестр невостребованных документов
и заполнение журнала. Дата внесения — текущая; журнал заполняется чисто
(без строк-примеров шаблона).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
jze9
2026-06-26 03:11:18 +05:00
commit b5f7060a1f
20 changed files with 1571 additions and 0 deletions

View File

@@ -0,0 +1,6 @@
# -*- coding: utf-8 -*-
"""Запись результатов в реестр и журнал."""
from .journal_writer import JournalWriter
from .register_writer import RegisterWriter
__all__ = ["RegisterWriter", "JournalWriter"]

View File

@@ -0,0 +1,52 @@
# -*- coding: utf-8 -*-
"""Заполнение журнала внесения (.xlsx) через openpyxl.
Пишет строки (Дата внесения, Файл внесения, Количество записей) сразу под
шапкой, сохраняя её оформление. Строки-примеры из шаблона (напр. «Например»)
затираются — в результате остаются только реальные записи.
"""
from __future__ import annotations
from pathlib import Path
from typing import List
import openpyxl
from ..models import JournalEntry
_DATE_FORMAT = "DD.MM.YYYY"
# Заголовок шапки в колонке A, по которому она опознаётся.
_HEADER_MARKER = "дата внесения"
class JournalWriter:
def __init__(self, path: Path):
self.wb = openpyxl.load_workbook(path)
self.ws = self.wb.active
def write(self, entries: List[JournalEntry]) -> None:
row = self._header_row() + 1
self._clear_below(row)
for entry in entries:
date_cell = self.ws.cell(row=row, column=1, value=entry.date)
date_cell.number_format = _DATE_FORMAT
self.ws.cell(row=row, column=2, value=entry.file_name)
self.ws.cell(row=row, column=3, value=entry.count)
row += 1
def save(self, path: Path) -> None:
self.wb.save(path)
def _header_row(self) -> int:
"""Строка шапки (где в колонке A «Дата внесения»); по умолчанию 1."""
for row in range(1, self.ws.max_row + 1):
a = self.ws.cell(row=row, column=1).value
if isinstance(a, str) and a.strip().lower() == _HEADER_MARKER:
return row
return 1
def _clear_below(self, start: int) -> None:
"""Очистить значения колонок A..C во всех строках от start и ниже."""
for row in range(start, self.ws.max_row + 1):
for col in (1, 2, 3):
self.ws.cell(row=row, column=col).value = None

View File

@@ -0,0 +1,180 @@
# -*- coding: utf-8 -*-
"""Запись принятых документов в реестр (.ods) через odfpy.
Новые строки дописываются сразу после последней нумерованной строки листа.
Стиль каждой ячейки (границы, формат даты/числа) и стиль строки копируются
из этой последней строки-образца, поэтому оформление реестра сохраняется.
Остальные листы, формулы и стили документа odfpy не трогает.
"""
from __future__ import annotations
import datetime
from pathlib import Path
from typing import Dict, List, Optional, Tuple
from odf.opendocument import load
from odf.table import Table, TableCell, TableRow
from odf.teletype import extractText
from odf.text import P
from ..config import REGISTER_COLUMNS
from ..models import CellValue, Record
# Повторения больше этого — «бесконечный» пустой хвост листа, не данные.
_MAX_REPEAT = 200
class RegisterWriter:
def __init__(self, path: Path):
self.doc = load(str(path))
self._tables: Dict[str, Table] = {
t.getAttribute("name"): t
for t in self.doc.spreadsheet.getElementsByType(Table)
}
def has_sheet(self, name: str) -> bool:
return name in self._tables
def append(self, sheet: str, records: List[Record]) -> int:
"""Дописать записи на лист. Возвращает число добавленных строк."""
table = self._tables[sheet]
template, last_number = self._find_last_numbered_row(table)
col_styles = self._column_styles(template)
row_style = template.getAttribute("stylename")
anchor = template
number = last_number
for record in records:
number += 1
row = self._build_row(record, number, row_style, col_styles)
table.insertBefore(row, anchor.nextSibling)
anchor = row
return len(records)
def save(self, path: Path) -> None:
self.doc.save(str(path))
# --- поиск строки-образца -------------------------------------------
def _find_last_numbered_row(self, table: Table) -> Tuple[TableRow, int]:
last_row: Optional[TableRow] = None
last_number = 0
for tr in table.getElementsByType(TableRow):
if _row_repeat(tr) > 1:
continue # повторяющийся пустой блок
cells = tr.getElementsByType(TableCell)
if not cells:
continue
number = _as_int(cells[0])
if number is not None:
last_row, last_number = tr, number
if last_row is None:
raise ValueError("На листе нет нумерованных строк: %s"
% table.getAttribute("name"))
return last_row, last_number
@staticmethod
def _column_styles(template: TableRow) -> Dict[int, Optional[str]]:
"""Карта {индекс колонки 0..12 -> имя стиля ячейки} из образца."""
styles: Dict[int, Optional[str]] = {}
col = 0
for cell in template.getElementsByType(TableCell):
rep = _col_repeat(cell)
style = cell.getAttribute("stylename")
for _ in range(rep):
if col < REGISTER_COLUMNS:
styles[col] = style
col += 1
if col >= REGISTER_COLUMNS:
break
return styles
# --- построение строки ----------------------------------------------
def _build_row(self, record: Record, number: int,
row_style: Optional[str],
col_styles: Dict[int, Optional[str]]) -> TableRow:
row = TableRow(stylename=row_style) if row_style else TableRow()
for col in range(REGISTER_COLUMNS):
style = col_styles.get(col)
value = number if col == 0 else record.value(col)
row.addElement(_make_cell(value, style))
return row
# --- фабрика ячеек по типу значения -------------------------------------
def _make_cell(value: CellValue, style: Optional[str]) -> TableCell:
if value is None or value == "":
return _cell(style)
if isinstance(value, bool):
return _text_cell(str(value), style)
if isinstance(value, datetime.datetime):
return _date_cell(value, style, with_time=True)
if isinstance(value, datetime.date):
return _date_cell(value, style, with_time=False)
if isinstance(value, (int, float)):
return _number_cell(value, style)
return _text_cell(str(value), style)
def _cell(style: Optional[str]) -> TableCell:
return TableCell(stylename=style) if style else TableCell()
def _text_cell(text: str, style: Optional[str]) -> TableCell:
cell = TableCell(valuetype="string", stylename=style) if style \
else TableCell(valuetype="string")
for line in text.split("\n"):
cell.addElement(P(text=line))
return cell
def _number_cell(num, style: Optional[str]) -> TableCell:
text = _number_text(num)
kw = dict(valuetype="float", value=text)
if style:
kw["stylename"] = style
cell = TableCell(**kw)
cell.addElement(P(text=text))
return cell
def _date_cell(value, style: Optional[str], with_time: bool) -> TableCell:
iso = value.strftime("%Y-%m-%dT%H:%M:%S" if with_time else "%Y-%m-%d")
display = value.strftime("%d.%m.%Y")
kw = dict(valuetype="date", datevalue=iso)
if style:
kw["stylename"] = style
cell = TableCell(**kw)
cell.addElement(P(text=display))
return cell
# --- мелкие помощники ---------------------------------------------------
def _number_text(num) -> str:
if isinstance(num, float) and num.is_integer():
return str(int(num))
return str(num)
def _row_repeat(tr: TableRow) -> int:
raw = tr.getAttribute("numberrowsrepeated")
n = int(raw) if raw else 1
return n if n <= _MAX_REPEAT else _MAX_REPEAT + 1
def _col_repeat(cell: TableCell) -> int:
raw = cell.getAttribute("numbercolumnsrepeated")
n = int(raw) if raw else 1
return n if n <= _MAX_REPEAT else 1
def _as_int(cell: TableCell) -> Optional[int]:
if cell.getAttribute("valuetype") == "float":
raw = cell.getAttribute("value")
if raw:
return int(float(raw))
text = "".join(extractText(p) for p in cell.getElementsByType(P)).strip()
return int(text) if text.isdigit() else None