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

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

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,5 @@
# -*- coding: utf-8 -*-
"""Приём документов: внесение в реестр и заполнение журнала."""
from .pipeline import Pipeline, Result
__all__ = ["Pipeline", "Result"]

49
priem_register/config.py Normal file
View File

@@ -0,0 +1,49 @@
# -*- coding: utf-8 -*-
"""Конфигурация: пути, константы, раскладка колонок."""
from __future__ import annotations
import datetime
import re
from pathlib import Path
# Корень проекта (на уровень выше пакета).
BASE_DIR = Path(__file__).resolve().parent.parent
# Исходный реестр и файл-результат (оригинал не перезаписываем).
REGISTER_SRC = BASE_DIR / "2026_ЕДИНЫЙ РЕЕСТР НЕВОСТРЕБОВАННЫХ ДОКУМЕНТОВ..ods"
REGISTER_OUT = BASE_DIR / "2026_ЕДИНЫЙ РЕЕСТР НЕВОСТРЕБОВАННЫХ ДОКУМЕНТОВ._ЗАПОЛНЕНО.ods"
# Журнал внесения.
JOURNAL_SRC = BASE_DIR / "Журнал внесения.xlsx"
JOURNAL_OUT = BASE_DIR / "Журнал внесения_ЗАПОЛНЕНО.xlsx"
# Папка с файлами приёма.
PRIEM_DIR = BASE_DIR / "прием"
# Дата внесения, которая проставляется в журнал (по умолчанию — сегодня).
ENTRY_DATE = datetime.date.today()
# Раскладка листа реестра: 13 колонок A..M.
# 0:A № п/п (нумеруется заново, сквозная)
# 1:B Адрес хранения
# 2:C Рег. номер дела
# 3:D Объект недвижимости
# 4:E Кол-во документов
# 5:F Дата получения
# 6:G Направление (филиал субъекта РФ)
# 7:H Получение (филиал субъекта РФ)
# 8:I Направление (тер. подразделение)
# 9:J Получение (тер. подразделение)
# 10:K Дата выдачи
# 11:L Примечание
# 12:M Номер реестра
REGISTER_COLUMNS = 13
# Колонка № п/п в источнике (пропускается, заменяется сквозной нумерацией).
SOURCE_NUMBER_COL = 0
# Шаблон имени файла приёма: DD.MM.YYYY_74_NN.<ext>
FILENAME_RE = re.compile(r"^(\d{2})\.(\d{2})\.(\d{4})_74_(\d+)\.(ods|xls|xlsx)$", re.I)
# Номер ТО берётся из пути .../ТОk/...
TO_DIR_RE = re.compile(r"[/\\]ТО(\d+)[/\\]")

44
priem_register/mapping.py Normal file
View File

@@ -0,0 +1,44 @@
# -*- coding: utf-8 -*-
"""Поиск файлов приёма и сопоставление их с листами реестра.
Правило: файл DD.MM.YYYY_74_NN.<ext> из папки ТОk -> лист ТОk_NN.
"""
from __future__ import annotations
import datetime
from pathlib import Path
from typing import List
from .config import FILENAME_RE, PRIEM_DIR, TO_DIR_RE
from .models import SourceFile
def discover_sources(priem_dir: Path = PRIEM_DIR) -> List[SourceFile]:
priem_dir = Path(priem_dir)
if not priem_dir.is_dir():
raise FileNotFoundError("Папка приёма не найдена: %s" % priem_dir)
sources: List[SourceFile] = []
for path in sorted(priem_dir.rglob("*")):
if not path.is_file():
continue
match = FILENAME_RE.match(path.name)
if not match:
continue
dd, mm, yyyy, nn, _ext = match.groups()
to_match = TO_DIR_RE.search(str(path))
if not to_match:
raise ValueError("Не удалось определить ТО для файла: %s" % path)
try:
date = datetime.date(int(yyyy), int(mm), int(dd))
except ValueError as exc:
raise ValueError(
"Некорректная дата в имени файла %s: %s" % (path.name, exc)) from exc
sources.append(SourceFile(
path=path,
date=date,
sheet="ТО%s_%s" % (to_match.group(1), nn),
name=path.stem,
))
# порядок внесения: по листу, затем по дате (последующие записи)
sources.sort(key=lambda s: (s.sheet, s.date))
return sources

58
priem_register/models.py Normal file
View File

@@ -0,0 +1,58 @@
# -*- coding: utf-8 -*-
"""Доменные модели."""
from __future__ import annotations
import datetime
from dataclasses import dataclass, field
from pathlib import Path
from typing import List, Optional, Union
# Значение ячейки: число, дата, строка либо пусто.
CellValue = Optional[Union[str, int, float, datetime.date]]
@dataclass
class Record:
"""Одна принятая запись — значения колонок B..M (без № п/п)."""
values: List[CellValue] = field(default_factory=list)
def value(self, register_col: int) -> CellValue:
"""Значение для колонки реестра по её индексу (1=B .. 12=M)."""
idx = register_col - 1
if 0 <= idx < len(self.values):
return self.values[idx]
return None
@dataclass
class ReadResult:
"""Результат чтения источника.
records — записи для внесения в реестр;
notes — что было присоединено к записям (информационно, не ошибки);
warnings — данные, которые присоединить не к чему (требуют внимания).
"""
records: List["Record"] = field(default_factory=list)
notes: List[str] = field(default_factory=list)
warnings: List[str] = field(default_factory=list)
@dataclass
class SourceFile:
"""Файл приёма и его привязка к листу реестра."""
path: Path
date: datetime.date
sheet: str # имя листа в реестре, напр. "ТО1_25"
name: str # имя файла без расширения, напр. "09.04.2026_74_25"
@dataclass
class JournalEntry:
"""Строка журнала внесения."""
date: datetime.date
file_name: str
count: int

View File

@@ -0,0 +1,97 @@
# -*- coding: utf-8 -*-
"""Оркестратор: связывает чтение источников, запись реестра и журнала."""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from pathlib import Path
from typing import List
from . import config
from .mapping import discover_sources
from .models import JournalEntry
from .readers import get_reader
from .writers import JournalWriter, RegisterWriter
log = logging.getLogger("priem")
@dataclass
class Result:
entries: List[JournalEntry]
notes: List[str] = field(default_factory=list)
warnings: List[str] = field(default_factory=list)
@property
def total(self) -> int:
return sum(e.count for e in self.entries)
class Pipeline:
def __init__(
self,
register_src: Path = config.REGISTER_SRC,
register_out: Path = config.REGISTER_OUT,
journal_src: Path = config.JOURNAL_SRC,
journal_out: Path = config.JOURNAL_OUT,
priem_dir: Path = config.PRIEM_DIR,
entry_date=config.ENTRY_DATE,
):
self.register_src = Path(register_src)
self.register_out = Path(register_out)
self.journal_src = Path(journal_src)
self.journal_out = Path(journal_out)
self.priem_dir = Path(priem_dir)
self.entry_date = entry_date
if self.register_out == self.register_src:
raise ValueError("register_out совпадает с оригиналом — он был бы перезаписан")
if self.journal_out == self.journal_src:
raise ValueError("journal_out совпадает с оригиналом — он был бы перезаписан")
def run(self) -> Result:
sources = discover_sources(self.priem_dir)
log.info("Найдено файлов приёма: %d", len(sources))
register = RegisterWriter(self.register_src)
entries: List[JournalEntry] = []
notes: List[str] = []
warnings: List[str] = []
for src in sources:
if not register.has_sheet(src.sheet):
raise ValueError(
"В реестре нет листа '%s' (файл %s)" % (src.sheet, src.name))
try:
read = get_reader(src.path).read()
except Exception as exc:
raise RuntimeError(
"Не удалось прочитать файл %s: %s" % (src.path.name, exc)) from exc
count = register.append(src.sheet, read.records)
entries.append(JournalEntry(self.entry_date, src.name, count))
log.info(" %-22s -> %-8s : %3d записей", src.name, src.sheet, count)
for n in read.notes:
notes.append("%s: %s" % (src.name, n))
log.info("%s", n)
for w in read.warnings:
warnings.append("%s: %s" % (src.name, w))
log.warning("%s", w)
register.save(self.register_out)
log.info("Реестр сохранён: %s", self.register_out.name)
journal = JournalWriter(self.journal_src)
journal.write(entries)
journal.save(self.journal_out)
log.info("Журнал сохранён: %s", self.journal_out.name)
result = Result(entries, notes, warnings)
log.info("ИТОГО записей: %d", result.total)
if notes:
log.info("Присоединено заметок: %d (данные не потеряны, см. выше)",
len(notes))
if warnings:
log.warning("ВНИМАНИЕ: %d строк НЕ удалось привязать — проверьте вручную:",
len(warnings))
for w in warnings:
log.warning("%s", w)
return result

View File

@@ -0,0 +1,6 @@
# -*- coding: utf-8 -*-
"""Читатели исходных файлов приёма."""
from .base import SourceReader
from .factory import get_reader
__all__ = ["SourceReader", "get_reader"]

View File

@@ -0,0 +1,86 @@
# -*- coding: utf-8 -*-
"""Базовый класс читателя источника."""
from __future__ import annotations
import abc
import datetime
from pathlib import Path
from typing import List
from ..config import REGISTER_COLUMNS, SOURCE_NUMBER_COL
from ..models import CellValue, ReadResult, Record
# Позиция колонки «Примечание» (L) в раскладке реестра (A=0 … M=12).
NOTE_COL = 11
class SourceReader(abc.ABC):
"""Читает файл приёма и возвращает записи для внесения.
По заданию: «внести все строки, начиная со второй». Поэтому пропускается
только шапка (строка 1), а каждая последующая непустая строка вносится как
отдельная запись — без фильтрации по № п/п. № п/п в реестре назначается
заново (сквозной), исходный № отбрасывается.
В реестре 13 колонок (A..M). Если в источнике есть данные правее колонки M
(например, безымянная 14-я колонка), переносить их в отдельную колонку
некуда — они добавляются в «Примечание» той же строки, а факт фиксируется
в ``notes`` (информационно).
Подклассы реализуют только :meth:`_rows` — выдачу «сырых» строк таблицы
(список типизированных значений ячеек) первого листа.
"""
def __init__(self, path: Path):
self.path = Path(path)
@abc.abstractmethod
def _rows(self) -> List[List[CellValue]]:
"""Сырые строки первого листа, типизированные значения ячеек."""
def read(self) -> ReadResult:
result = ReadResult()
for index, row in enumerate(self._rows()):
if index == 0:
continue # шапка (строка 1)
if not self._has_content(row):
continue # пустые строки не вносим
record = Record(values=list(row[SOURCE_NUMBER_COL + 1: REGISTER_COLUMNS]))
self._fold_overflow(row, record, result, index + 1)
result.records.append(record)
return result
def _fold_overflow(self, row: List[CellValue], record: Record,
result: ReadResult, human_row: int) -> None:
overflow = [v for i, v in enumerate(row)
if i >= REGISTER_COLUMNS and v not in (None, "")]
for value in overflow:
_merge_cell(record, NOTE_COL, value)
if overflow:
result.notes.append(
"строка %d: данные за колонкой M добавлены в «Примечание»: %s"
% (human_row, ", ".join(_as_text(v) for v in overflow)))
@staticmethod
def _has_content(row: List[CellValue]) -> bool:
return any(v not in (None, "") for v in row)
def _merge_cell(record: Record, position: int, value: CellValue) -> None:
"""Записать значение в колонку записи; занятое поле дополнить через «; »."""
idx = position - 1
while len(record.values) <= idx:
record.values.append(None)
current = record.values[idx]
if current in (None, ""):
record.values[idx] = value
else:
record.values[idx] = "%s; %s" % (_as_text(current), _as_text(value))
def _as_text(value: CellValue) -> str:
if isinstance(value, datetime.date): # покрывает и datetime
return value.strftime("%d.%m.%Y")
if isinstance(value, float) and value.is_integer():
return str(int(value))
return str(value)

View File

@@ -0,0 +1,24 @@
# -*- coding: utf-8 -*-
"""Выбор читателя по расширению файла."""
from __future__ import annotations
from pathlib import Path
from .base import SourceReader
from .ods_reader import OdsReader
from .xls_reader import XlsReader
from .xlsx_reader import XlsxReader
_READERS = {
".ods": OdsReader,
".xls": XlsReader,
".xlsx": XlsxReader,
}
def get_reader(path: Path) -> SourceReader:
ext = Path(path).suffix.lower()
try:
return _READERS[ext](path)
except KeyError:
raise ValueError("Неподдерживаемый формат: %s" % ext)

View File

@@ -0,0 +1,86 @@
# -*- coding: utf-8 -*-
"""Читатель .ods (odfpy). Разворачивает повторяющиеся ячейки/строки."""
from __future__ import annotations
import datetime
from typing import List
from odf.opendocument import load
from odf.table import Table, TableCell, TableRow
from odf.teletype import extractText
from odf.text import P
from ..models import CellValue
from .base import SourceReader
# Пределы на «бесконечные» повторения пустого хвоста таблицы.
_MAX_COL_REPEAT = 200
_MAX_ROW_REPEAT = 200
class OdsReader(SourceReader):
def _rows(self) -> List[List[CellValue]]:
doc = load(str(self.path))
table = doc.spreadsheet.getElementsByType(Table)[0]
rows: List[List[CellValue]] = []
for tr in table.getElementsByType(TableRow):
row = self._row_values(tr)
row_rep = _repeat(tr, "numberrowsrepeated", _MAX_ROW_REPEAT)
for _ in range(row_rep):
rows.append(list(row))
_trim_trailing_empty(rows)
return rows
@staticmethod
def _row_values(tr: TableRow) -> List[CellValue]:
values: List[CellValue] = []
for cell in tr.getElementsByType(TableCell):
col_rep = _repeat(cell, "numbercolumnsrepeated", _MAX_COL_REPEAT)
value = _cell_value(cell)
values.extend([value] * col_rep)
return values
def _repeat(element, attr: str, limit: int) -> int:
raw = element.getAttribute(attr)
n = int(raw) if raw else 1
# огромные повторения — это пустой хвост листа, не размножаем
return n if n <= limit else 1
def _cell_value(cell: TableCell) -> CellValue:
vtype = cell.getAttribute("valuetype")
if vtype in ("float", "currency", "percentage"):
raw = cell.getAttribute("value")
if raw is not None:
num = float(raw)
return int(num) if num.is_integer() else num
if vtype == "date":
raw = cell.getAttribute("datevalue")
if raw:
return _parse_date(raw)
text = _cell_text(cell).strip()
return text or None
def _cell_text(cell: TableCell) -> str:
# extractText корректно собирает текст из <text:span>, <text:s> (кратные
# пробелы), <text:tab> и <text:line-break> — ручной обход childNodes их терял.
return "\n".join(extractText(p) for p in cell.getElementsByType(P))
def _parse_date(raw: str):
for fmt in ("%Y-%m-%dT%H:%M:%S", "%Y-%m-%d"):
try:
dt = datetime.datetime.strptime(raw, fmt)
if dt.hour == dt.minute == dt.second == 0:
return dt.date()
return dt
except ValueError:
continue
return raw
def _trim_trailing_empty(rows: List[List[CellValue]]) -> None:
while rows and all(v in (None, "") for v in rows[-1]):
rows.pop()

View File

@@ -0,0 +1,44 @@
# -*- coding: utf-8 -*-
"""Читатель .xls (xlrd). Корректно разбирает даты-сериалы Excel."""
from __future__ import annotations
from typing import List
import xlrd
from ..models import CellValue
from .base import SourceReader
class XlsReader(SourceReader):
def _rows(self) -> List[List[CellValue]]:
book = xlrd.open_workbook(self.path)
try:
sheet = book.sheet_by_index(0)
rows: List[List[CellValue]] = []
for r in range(sheet.nrows):
row: List[CellValue] = []
for c in range(sheet.ncols):
row.append(self._cell(book, sheet.cell(r, c)))
rows.append(row)
return rows
finally:
book.release_resources()
@staticmethod
def _cell(book, cell) -> CellValue:
t = cell.ctype
if t == xlrd.XL_CELL_EMPTY or t == xlrd.XL_CELL_BLANK:
return None
if t == xlrd.XL_CELL_DATE:
dt = xlrd.xldate_as_datetime(cell.value, book.datemode)
# дата без времени -> date
if dt.hour == dt.minute == dt.second == 0:
return dt.date()
return dt
if t == xlrd.XL_CELL_NUMBER:
return cell.value
if t == xlrd.XL_CELL_BOOLEAN:
return bool(cell.value)
text = str(cell.value).strip()
return text or None

View File

@@ -0,0 +1,30 @@
# -*- coding: utf-8 -*-
"""Читатель .xlsx (openpyxl)."""
from __future__ import annotations
from typing import List
import openpyxl
from ..models import CellValue
from .base import SourceReader
class XlsxReader(SourceReader):
def _rows(self) -> List[List[CellValue]]:
wb = openpyxl.load_workbook(self.path, data_only=True, read_only=True)
try:
ws = wb.worksheets[0]
rows: List[List[CellValue]] = []
for row in ws.iter_rows(values_only=True):
rows.append([_norm(v) for v in row])
return rows
finally:
wb.close()
def _norm(value: CellValue) -> CellValue:
if isinstance(value, str):
value = value.strip()
return value or None
return value

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