Приём документов → реестр + журнал
Внесение данных из файлов приёма в реестр невостребованных документов и заполнение журнала. Дата внесения — текущая; журнал заполняется чисто (без строк-примеров шаблона). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
6
priem_register/writers/__init__.py
Normal file
6
priem_register/writers/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Запись результатов в реестр и журнал."""
|
||||
from .journal_writer import JournalWriter
|
||||
from .register_writer import RegisterWriter
|
||||
|
||||
__all__ = ["RegisterWriter", "JournalWriter"]
|
||||
52
priem_register/writers/journal_writer.py
Normal file
52
priem_register/writers/journal_writer.py
Normal 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
|
||||
180
priem_register/writers/register_writer.py
Normal file
180
priem_register/writers/register_writer.py
Normal 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
|
||||
Reference in New Issue
Block a user