fix table
This commit is contained in:
@@ -31,10 +31,12 @@ from app.services.pdf_export import export_pdf
|
|||||||
from app.services.project_service import get_project_full, ingest_zip, project_dir
|
from app.services.project_service import get_project_full, ingest_zip, project_dir
|
||||||
from app.services.project_settings import (
|
from app.services.project_settings import (
|
||||||
TABLE_SETTINGS_META,
|
TABLE_SETTINGS_META,
|
||||||
get_property_names,
|
get_font_stretch,
|
||||||
get_project_params_map,
|
get_project_params_map,
|
||||||
|
get_property_names,
|
||||||
get_table_settings,
|
get_table_settings,
|
||||||
resolve_inscriptions,
|
resolve_inscriptions,
|
||||||
|
save_cell_overflows,
|
||||||
update_table_settings,
|
update_table_settings,
|
||||||
)
|
)
|
||||||
from app.services.table_service import (
|
from app.services.table_service import (
|
||||||
@@ -328,11 +330,13 @@ def export_document(project_id: int, body: ExportRequest, db: Session = Depends(
|
|||||||
)
|
)
|
||||||
if body.format == "pdf":
|
if body.format == "pdf":
|
||||||
try:
|
try:
|
||||||
data = export_pdf(body.table_type, rows, resolved_inscriptions)
|
stretch = get_font_stretch(db, project_id, body.table_type)
|
||||||
|
result = export_pdf(body.table_type, rows, resolved_inscriptions, font_stretch=stretch)
|
||||||
|
save_cell_overflows(db, project_id, body.table_type, result.overflows)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(400, str(e)) from e
|
raise HTTPException(400, str(e)) from e
|
||||||
return Response(
|
return Response(
|
||||||
content=data,
|
content=result.data,
|
||||||
media_type="application/pdf",
|
media_type="application/pdf",
|
||||||
headers={
|
headers={
|
||||||
"Content-Disposition": f'attachment; filename="{project.name}_{body.table_type}.pdf"'
|
"Content-Disposition": f'attachment; filename="{project.name}_{body.table_type}.pdf"'
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from reportlab.pdfbase.ttfonts import TTFont
|
|||||||
from reportlab.pdfgen import canvas
|
from reportlab.pdfgen import canvas
|
||||||
|
|
||||||
from app.core.config import get_settings
|
from app.core.config import get_settings
|
||||||
|
from app.services.pdf_gost_frame import draw_gost_frame
|
||||||
|
|
||||||
_font_registered = False
|
_font_registered = False
|
||||||
FONT_NAME = "GOST_A"
|
FONT_NAME = "GOST_A"
|
||||||
@@ -234,6 +235,80 @@ def _draw_rotated_header(
|
|||||||
c.restoreState()
|
c.restoreState()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PdfExportResult:
|
||||||
|
data: bytes
|
||||||
|
overflows: list[dict[str, Any]]
|
||||||
|
|
||||||
|
|
||||||
|
def _cell_stretch(row: dict[str, Any] | None, default: int) -> int:
|
||||||
|
if not row:
|
||||||
|
return default
|
||||||
|
meta = row.get("cell_stretches") or {}
|
||||||
|
if isinstance(meta, dict) and meta:
|
||||||
|
return default
|
||||||
|
if row.get("stretch"):
|
||||||
|
return min(default, 85)
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def _draw_cell_with_stretch(
|
||||||
|
c: canvas.Canvas,
|
||||||
|
x: float,
|
||||||
|
y: float,
|
||||||
|
w: float,
|
||||||
|
h: float,
|
||||||
|
text: str,
|
||||||
|
font: str,
|
||||||
|
size: float,
|
||||||
|
align: Align,
|
||||||
|
cell_stretch: int,
|
||||||
|
underline: bool,
|
||||||
|
overflow_sink: list[dict[str, Any]] | None,
|
||||||
|
row: dict[str, Any] | None,
|
||||||
|
field: str,
|
||||||
|
global_row_index: int,
|
||||||
|
) -> None:
|
||||||
|
if not text:
|
||||||
|
return
|
||||||
|
pad = 0.8 * mm
|
||||||
|
inner_w = max(w - 2 * pad, 1)
|
||||||
|
scale = cell_stretch / 100.0
|
||||||
|
c.setFont(font, size)
|
||||||
|
text_w = c.stringWidth(text, font, size) * scale
|
||||||
|
display = text
|
||||||
|
is_overflow = False
|
||||||
|
if text_w > inner_w:
|
||||||
|
max_before_scale = inner_w / scale if scale > 0 else inner_w
|
||||||
|
display = _elide(c, text, font, size, max_before_scale)
|
||||||
|
is_overflow = True
|
||||||
|
if overflow_sink is not None and row is not None:
|
||||||
|
overflow_sink.append(
|
||||||
|
{
|
||||||
|
"row_id": row.get("id"),
|
||||||
|
"row_index": row.get("row_index", global_row_index),
|
||||||
|
"field": field,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if scale != 1.0 and not is_overflow:
|
||||||
|
c.saveState()
|
||||||
|
if align == "center":
|
||||||
|
ox = x + w / 2
|
||||||
|
elif align == "right":
|
||||||
|
ox = x + w - pad
|
||||||
|
else:
|
||||||
|
ox = x + pad
|
||||||
|
c.translate(ox, y + h / 2)
|
||||||
|
c.scale(scale, 1)
|
||||||
|
tw = c.stringWidth(display, font, size)
|
||||||
|
tx = -tw / 2 if align == "center" else (-tw if align == "right" else 0)
|
||||||
|
c.drawString(tx, -size * 0.28, display)
|
||||||
|
c.restoreState()
|
||||||
|
else:
|
||||||
|
_draw_text_in_rect(c, x, y, w, h, display, font, size, align, underline=underline)
|
||||||
|
|
||||||
|
|
||||||
def _draw_frame(
|
def _draw_frame(
|
||||||
c: canvas.Canvas,
|
c: canvas.Canvas,
|
||||||
w: float,
|
w: float,
|
||||||
@@ -241,36 +316,10 @@ def _draw_frame(
|
|||||||
page: int,
|
page: int,
|
||||||
total: int,
|
total: int,
|
||||||
inscriptions: dict[int, str],
|
inscriptions: dict[int, str],
|
||||||
doc_type: str,
|
table_type: str,
|
||||||
|
font: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
font = _ensure_font()
|
draw_gost_frame(c, w, h, inscriptions, table_type, page, total, font)
|
||||||
margin_left = 20 * mm
|
|
||||||
margin_right = 5 * mm
|
|
||||||
margin_top = 5 * mm
|
|
||||||
margin_bottom = 5 * mm
|
|
||||||
c.setLineWidth(0.8)
|
|
||||||
c.rect(margin_left, margin_bottom, w - margin_left - margin_right, h - margin_top - margin_bottom)
|
|
||||||
|
|
||||||
block_h = 55 * mm if page == 1 else 15 * mm
|
|
||||||
block_w = 185 * mm
|
|
||||||
x0 = w - margin_right - block_w
|
|
||||||
y0 = margin_bottom
|
|
||||||
c.setLineWidth(0.5)
|
|
||||||
c.rect(x0, y0, block_w, block_h)
|
|
||||||
|
|
||||||
c.setFont(font, 8)
|
|
||||||
name = inscriptions.get(1, "")
|
|
||||||
designation = inscriptions.get(2, "")
|
|
||||||
org = inscriptions.get(9, "")
|
|
||||||
title = DOC_TITLES.get(doc_type, "")
|
|
||||||
c.drawString(x0 + 2 * mm, y0 + block_h - 6 * mm, title)
|
|
||||||
c.drawString(x0 + 2 * mm, y0 + block_h - 12 * mm, (designation or "")[:80])
|
|
||||||
c.drawString(x0 + 2 * mm, y0 + block_h - 18 * mm, (name or "")[:80])
|
|
||||||
if page == 1:
|
|
||||||
c.drawString(x0 + 2 * mm, y0 + 8 * mm, (org or "")[:50])
|
|
||||||
c.drawString(x0 + 2 * mm, y0 + 3 * mm, f"Разраб. {inscriptions.get(111, '')}")
|
|
||||||
c.drawString(x0 + 50 * mm, y0 + 3 * mm, f"Пров. {inscriptions.get(112, '')}")
|
|
||||||
c.drawRightString(x0 + block_w - 2 * mm, y0 + 3 * mm, f"Лист {page}/{total}")
|
|
||||||
|
|
||||||
|
|
||||||
def _cell_value(row: dict[str, Any] | None, field: str, display_index: int) -> str:
|
def _cell_value(row: dict[str, Any] | None, field: str, display_index: int) -> str:
|
||||||
@@ -288,6 +337,9 @@ def _draw_table_page(
|
|||||||
layout: TableLayout,
|
layout: TableLayout,
|
||||||
page_rows: list[dict[str, Any] | None],
|
page_rows: list[dict[str, Any] | None],
|
||||||
page_h: float,
|
page_h: float,
|
||||||
|
font_stretch: int,
|
||||||
|
global_row_start: int,
|
||||||
|
overflows: list[dict[str, Any]],
|
||||||
) -> None:
|
) -> None:
|
||||||
font = _ensure_font()
|
font = _ensure_font()
|
||||||
c.setLineWidth(LINE_WIDTH)
|
c.setLineWidth(LINE_WIDTH)
|
||||||
@@ -324,6 +376,7 @@ def _draw_table_page(
|
|||||||
for display_i, row in enumerate(page_rows):
|
for display_i, row in enumerate(page_rows):
|
||||||
cy = table_top - header_h - (display_i + 1) * row_h
|
cy = table_top - header_h - (display_i + 1) * row_h
|
||||||
cx = table_x
|
cx = table_x
|
||||||
|
global_idx = global_row_start + display_i
|
||||||
for col in layout.columns:
|
for col in layout.columns:
|
||||||
cw = col.width_mm * mm
|
cw = col.width_mm * mm
|
||||||
c.rect(cx, cy, cw, row_h)
|
c.rect(cx, cy, cw, row_h)
|
||||||
@@ -331,10 +384,26 @@ def _draw_table_page(
|
|||||||
is_header = bool(row and row.get("is_header"))
|
is_header = bool(row and row.get("is_header"))
|
||||||
underline = bool(row and row.get("is_underline"))
|
underline = bool(row and row.get("is_underline"))
|
||||||
align: Align = col.align
|
align: Align = col.align
|
||||||
if col.field in ("designation", "name"):
|
if col.field in ("designation", "name", "product_code", "document_code", "supplier", "note", "where_used"):
|
||||||
align = "center" if is_header else "left"
|
align = "center" if is_header else "left"
|
||||||
_draw_text_in_rect(
|
stretch = _cell_stretch(row, font_stretch)
|
||||||
c, cx, cy, cw, row_h, text, font, FONT_SIZE, align, underline=underline or is_header
|
pad_x = cx + (2 * mm if col.field in ("designation", "name") and not is_header else 0)
|
||||||
|
_draw_cell_with_stretch(
|
||||||
|
c,
|
||||||
|
pad_x,
|
||||||
|
cy,
|
||||||
|
cw - (2 * mm if pad_x > cx else 0),
|
||||||
|
row_h,
|
||||||
|
text,
|
||||||
|
font,
|
||||||
|
FONT_SIZE,
|
||||||
|
align,
|
||||||
|
stretch,
|
||||||
|
underline or is_header,
|
||||||
|
overflows,
|
||||||
|
row,
|
||||||
|
col.field,
|
||||||
|
global_idx,
|
||||||
)
|
)
|
||||||
cx += cw
|
cx += cw
|
||||||
|
|
||||||
@@ -343,7 +412,8 @@ def export_pdf(
|
|||||||
table_type: str,
|
table_type: str,
|
||||||
rows: list[dict[str, Any]],
|
rows: list[dict[str, Any]],
|
||||||
inscriptions: dict[int, str] | None = None,
|
inscriptions: dict[int, str] | None = None,
|
||||||
) -> bytes:
|
font_stretch: int = 100,
|
||||||
|
) -> PdfExportResult:
|
||||||
if table_type == "simple_list":
|
if table_type == "simple_list":
|
||||||
raise ValueError("PDF export is not available for simple_list")
|
raise ValueError("PDF export is not available for simple_list")
|
||||||
if table_type not in TABLE_LAYOUTS:
|
if table_type not in TABLE_LAYOUTS:
|
||||||
@@ -353,16 +423,20 @@ def export_pdf(
|
|||||||
inscriptions = inscriptions or {}
|
inscriptions = inscriptions or {}
|
||||||
pages = _paginate_rows(rows, layout.rows_first, layout.rows_other)
|
pages = _paginate_rows(rows, layout.rows_first, layout.rows_other)
|
||||||
total = _total_pages(len(rows), layout.rows_first, layout.rows_other)
|
total = _total_pages(len(rows), layout.rows_first, layout.rows_other)
|
||||||
|
overflows: list[dict[str, Any]] = []
|
||||||
|
font = _ensure_font()
|
||||||
|
|
||||||
buf = BytesIO()
|
buf = BytesIO()
|
||||||
page_w, page_h = layout.page_size
|
page_w, page_h = layout.page_size
|
||||||
c = canvas.Canvas(buf, pagesize=layout.page_size)
|
c = canvas.Canvas(buf, pagesize=layout.page_size)
|
||||||
|
|
||||||
|
row_offset = 0
|
||||||
for page_idx, page_rows in enumerate(pages, start=1):
|
for page_idx, page_rows in enumerate(pages, start=1):
|
||||||
_draw_frame(c, page_w, page_h, page_idx, total, inscriptions, table_type)
|
_draw_frame(c, page_w, page_h, page_idx, total, inscriptions, table_type, font)
|
||||||
_draw_table_page(c, layout, page_rows, page_h)
|
_draw_table_page(c, layout, page_rows, page_h, font_stretch, row_offset, overflows)
|
||||||
|
row_offset += layout.rows_first if page_idx == 1 else layout.rows_other
|
||||||
if page_idx < len(pages):
|
if page_idx < len(pages):
|
||||||
c.showPage()
|
c.showPage()
|
||||||
|
|
||||||
c.save()
|
c.save()
|
||||||
return buf.getvalue()
|
return PdfExportResult(data=buf.getvalue(), overflows=overflows)
|
||||||
|
|||||||
@@ -0,0 +1,342 @@
|
|||||||
|
"""GOST title block / frame drawing (ported from controller/pdfcontroller.cpp)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from reportlab.lib.units import mm
|
||||||
|
from reportlab.pdfgen import canvas
|
||||||
|
|
||||||
|
FRAME_LINE = 0.5 * mm
|
||||||
|
M_LEFT = 20 * mm
|
||||||
|
M_TOP = 5 * mm
|
||||||
|
M_RIGHT = 5 * mm
|
||||||
|
M_BOTTOM = 5 * mm
|
||||||
|
|
||||||
|
DOC_TYPES = {
|
||||||
|
"perechen": 1,
|
||||||
|
"specification_pcb": 2,
|
||||||
|
"specification": 3,
|
||||||
|
"vedomost": 4,
|
||||||
|
}
|
||||||
|
|
||||||
|
PRIMARY_APP_FIELD = {1: 251, 2: 252, 3: 253, 4: 254}
|
||||||
|
|
||||||
|
|
||||||
|
def _ins(inscriptions: dict[int, str], num: int) -> str:
|
||||||
|
return str(inscriptions.get(num, "") or "")
|
||||||
|
|
||||||
|
|
||||||
|
class Inner:
|
||||||
|
"""Inner document area in ReportLab coordinates (origin bottom-left)."""
|
||||||
|
|
||||||
|
def __init__(self, page_w: float, page_h: float):
|
||||||
|
self.page_w = page_w
|
||||||
|
self.page_h = page_h
|
||||||
|
self.left = M_LEFT
|
||||||
|
self.bottom = M_BOTTOM
|
||||||
|
self.right = page_w - M_RIGHT
|
||||||
|
self.top = page_h - M_TOP
|
||||||
|
|
||||||
|
def rect(self, x: float, y: float, w: float, h: float) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _set_frame_pen(c: canvas.Canvas) -> None:
|
||||||
|
c.setLineWidth(FRAME_LINE)
|
||||||
|
c.setStrokeColorRGB(0, 0, 0)
|
||||||
|
|
||||||
|
|
||||||
|
def _txt(
|
||||||
|
c: canvas.Canvas,
|
||||||
|
x: float,
|
||||||
|
y: float,
|
||||||
|
w: float,
|
||||||
|
h: float,
|
||||||
|
text: str,
|
||||||
|
font: str,
|
||||||
|
size: float,
|
||||||
|
align: str = "center",
|
||||||
|
valign: str = "center",
|
||||||
|
) -> None:
|
||||||
|
if not text:
|
||||||
|
return
|
||||||
|
c.setFont(font, size)
|
||||||
|
tw = c.stringWidth(text, font, size)
|
||||||
|
if align == "center":
|
||||||
|
tx = x + (w - tw) / 2
|
||||||
|
elif align == "right":
|
||||||
|
tx = x + w - tw - 0.5 * mm
|
||||||
|
else:
|
||||||
|
tx = x + 0.5 * mm
|
||||||
|
if valign == "bottom":
|
||||||
|
ty = y + 1 * mm
|
||||||
|
elif valign == "top":
|
||||||
|
ty = y + h - size * 0.35 - 0.5 * mm
|
||||||
|
else:
|
||||||
|
ty = y + (h - size * 0.35) / 2
|
||||||
|
c.drawString(tx, ty, text)
|
||||||
|
|
||||||
|
|
||||||
|
def _txt_multiline(
|
||||||
|
c: canvas.Canvas,
|
||||||
|
x: float,
|
||||||
|
y: float,
|
||||||
|
w: float,
|
||||||
|
h: float,
|
||||||
|
text: str,
|
||||||
|
font: str,
|
||||||
|
size: float,
|
||||||
|
max_lines: int = 3,
|
||||||
|
) -> None:
|
||||||
|
if not text:
|
||||||
|
return
|
||||||
|
c.setFont(font, size)
|
||||||
|
words = text.split()
|
||||||
|
lines: list[str] = []
|
||||||
|
current = ""
|
||||||
|
for word in words:
|
||||||
|
test = f"{current} {word}".strip()
|
||||||
|
if c.stringWidth(test, font, size) <= w - mm:
|
||||||
|
current = test
|
||||||
|
else:
|
||||||
|
if current:
|
||||||
|
lines.append(current)
|
||||||
|
current = word
|
||||||
|
if current:
|
||||||
|
lines.append(current)
|
||||||
|
lines = lines[:max_lines]
|
||||||
|
step = size * 1.2
|
||||||
|
start_y = y + h - size * 0.5 - mm
|
||||||
|
for i, line in enumerate(lines):
|
||||||
|
tw = c.stringWidth(line, font, size)
|
||||||
|
c.drawString(x + (w - tw) / 2, start_y - i * step, line)
|
||||||
|
|
||||||
|
|
||||||
|
def _draw_inner_border(c: canvas.Canvas, inner: Inner) -> None:
|
||||||
|
_set_frame_pen(c)
|
||||||
|
c.rect(inner.left, inner.bottom, inner.right - inner.left, inner.top - inner.bottom)
|
||||||
|
|
||||||
|
|
||||||
|
def _draw_left_binding_strip(c: canvas.Canvas, inner: Inner) -> tuple[float, float, float, float]:
|
||||||
|
"""Left vertical strip (12×145 mm) at bottom-left of inner area."""
|
||||||
|
w = 12 * mm
|
||||||
|
h = 145 * mm
|
||||||
|
x = inner.left - w
|
||||||
|
y = inner.bottom
|
||||||
|
c.rect(x, y, w, h)
|
||||||
|
# horizontal dividers (from bottom): 25, 35, 25, 25, 35 mm sections
|
||||||
|
offsets = [25, 60, 85, 110, 145]
|
||||||
|
for off in offsets:
|
||||||
|
yy = y + off * mm
|
||||||
|
c.line(x, yy, inner.left, yy)
|
||||||
|
c.line(x + w + 5 * mm, y, x + w + 5 * mm, y + h)
|
||||||
|
return x, y, w, h
|
||||||
|
|
||||||
|
|
||||||
|
def _draw_left_binding_text(c: canvas.Canvas, strip_x: float, strip_y: float, inscriptions: dict[int, str], font: str) -> None:
|
||||||
|
size = 12
|
||||||
|
c.saveState()
|
||||||
|
c.translate(strip_x + 12 * mm, strip_y + 145 * mm)
|
||||||
|
c.rotate(-90)
|
||||||
|
labels = [
|
||||||
|
(0, 25, "Инв. № подл"),
|
||||||
|
(25, 35, "Подп. и дата"),
|
||||||
|
(60, 25, "Взам. инв. №"),
|
||||||
|
(85, 25, "Инв. № дубл"),
|
||||||
|
(110, 35, "Подп. и дата"),
|
||||||
|
]
|
||||||
|
for ox, ow, label in labels:
|
||||||
|
_txt(c, ox * mm, 0, ow * mm, 5 * mm, label, font, size)
|
||||||
|
vals = [(0, 25, 19), (25, 35, 20), (60, 25, 21), (85, 25, 22), (110, 35, 23)]
|
||||||
|
for ox, ow, field in vals:
|
||||||
|
_txt(c, ox * mm, 5 * mm, ow * mm, 7 * mm, _ins(inscriptions, field), font, size)
|
||||||
|
c.restoreState()
|
||||||
|
|
||||||
|
|
||||||
|
def _draw_top_left_strip(c: canvas.Canvas, inner: Inner, inscriptions: dict[int, str], doc_type: int, font: str) -> None:
|
||||||
|
w = 12 * mm
|
||||||
|
h = 120 * mm
|
||||||
|
x = inner.left - w
|
||||||
|
y = inner.top - h
|
||||||
|
c.rect(x, y, w, h)
|
||||||
|
c.line(x + w + 5 * mm, y, x + w + 5 * mm, y + h)
|
||||||
|
c.line(x, y + 60 * mm, inner.left, y + 60 * mm)
|
||||||
|
|
||||||
|
c.saveState()
|
||||||
|
c.translate(x + w, y)
|
||||||
|
c.rotate(-90)
|
||||||
|
_txt(c, 0, 0, 60 * mm, 5 * mm, "Справ. №", font, 12)
|
||||||
|
_txt(c, 60 * mm, 0, 60 * mm, 5 * mm, "Перв. примен.", font, 12)
|
||||||
|
_txt(c, 0, 5 * mm, 60 * mm, 7 * mm, _ins(inscriptions, 24), font, 12)
|
||||||
|
_txt(
|
||||||
|
c,
|
||||||
|
60 * mm,
|
||||||
|
5 * mm,
|
||||||
|
60 * mm,
|
||||||
|
7 * mm,
|
||||||
|
_ins(inscriptions, PRIMARY_APP_FIELD.get(doc_type, 25)),
|
||||||
|
font,
|
||||||
|
12,
|
||||||
|
)
|
||||||
|
c.restoreState()
|
||||||
|
|
||||||
|
|
||||||
|
def _draw_stamp_first(c: canvas.Canvas, inner: Inner, inscriptions: dict[int, str], doc_type: int, page: int, total: int, font: str) -> None:
|
||||||
|
mw, mh = 185 * mm, 40 * mm
|
||||||
|
x = inner.right - mw
|
||||||
|
y = inner.bottom
|
||||||
|
c.rect(x, y, mw, mh)
|
||||||
|
|
||||||
|
# horizontal lines
|
||||||
|
for dy in (5, 10, 15, 20, 25, 30, 35):
|
||||||
|
if dy <= 10 or dy >= 20:
|
||||||
|
c.line(x, y + dy * mm, x + (7 + 10 + 23 + 15 + 10) * mm, y + dy * mm)
|
||||||
|
else:
|
||||||
|
c.line(x, y + dy * mm, x + mw, y + dy * mm)
|
||||||
|
c.line(x + 65 * mm, y, x + 65 * mm, y + mh)
|
||||||
|
for dx in (7, 17, 40, 55):
|
||||||
|
c.line(x + dx * mm, y, x + dx * mm, y + (15 * mm if dx == 7 else mh))
|
||||||
|
c.line(x + mw - 50 * mm, y + 15 * mm, x + mw - 50 * mm, y + mh)
|
||||||
|
c.line(x + mw - 45 * mm, y + 20 * mm, x + mw - 45 * mm, y + 25 * mm)
|
||||||
|
c.line(x + mw - 40 * mm, y + 20 * mm, x + mw - 40 * mm, y + 25 * mm)
|
||||||
|
c.line(x + mw - 35 * mm, y + 15 * mm, x + mw - 35 * mm, y + 25 * mm)
|
||||||
|
c.line(x + mw - 20 * mm, y + 15 * mm, x + mw - 20 * mm, y + 25 * mm)
|
||||||
|
c.line(x + mw - 50 * mm, y + 20 * mm, x + mw, y + 20 * mm)
|
||||||
|
c.line(x + mw - 50 * mm, y + 25 * mm, x + mw, y + 25 * mm)
|
||||||
|
|
||||||
|
# idk block — 22 mm directly above main stamp top
|
||||||
|
ix = x + mw - 120 * mm
|
||||||
|
iy = y + mh
|
||||||
|
iw, ih = 120 * mm, 22 * mm
|
||||||
|
c.line(ix, iy, ix, iy + ih)
|
||||||
|
c.line(ix + iw, iy, ix + iw, iy + ih)
|
||||||
|
c.line(ix, iy + ih, ix + iw, iy + ih)
|
||||||
|
c.line(ix, iy + 14 * mm, ix + iw, iy + 14 * mm)
|
||||||
|
c.line(ix + 14 * mm, iy, ix + 14 * mm, iy + 14 * mm)
|
||||||
|
c.line(ix + 67 * mm, iy, ix + 67 * mm, iy + 14 * mm)
|
||||||
|
_txt(c, ix, iy, 14 * mm, 14 * mm, _ins(inscriptions, 27), font, 12)
|
||||||
|
_txt(c, ix + 14 * mm, iy, 53 * mm, 14 * mm, _ins(inscriptions, 28), font, 12)
|
||||||
|
_txt(c, ix + 67 * mm, iy, 53 * mm, 14 * mm, _ins(inscriptions, 29), font, 12)
|
||||||
|
_txt(c, ix, iy + 14 * mm, iw, 8 * mm, _ins(inscriptions, 30), font, 12)
|
||||||
|
|
||||||
|
# change block headers
|
||||||
|
_txt(c, x, y + mh - 5 * mm, 7 * mm, 5 * mm, _ins(inscriptions, 14), font, 12)
|
||||||
|
_txt(c, x + 7 * mm, y + mh - 5 * mm, 10 * mm, 5 * mm, _ins(inscriptions, 15), font, 12)
|
||||||
|
_txt(c, x + 17 * mm, y + mh - 5 * mm, 23 * mm, 5 * mm, _ins(inscriptions, 16), font, 12)
|
||||||
|
_txt(c, x + 40 * mm, y + mh - 5 * mm, 15 * mm, 5 * mm, _ins(inscriptions, 17), font, 12)
|
||||||
|
_txt(c, x + 55 * mm, y + mh - 5 * mm, 10 * mm, 5 * mm, _ins(inscriptions, 18), font, 12)
|
||||||
|
_txt(c, x, y + mh - 10 * mm, 7 * mm, 5 * mm, "Изм.", font, 12)
|
||||||
|
_txt(c, x + 7 * mm, y + mh - 10 * mm, 10 * mm, 5 * mm, "Лист", font, 12)
|
||||||
|
_txt(c, x + 17 * mm, y + mh - 10 * mm, 23 * mm, 5 * mm, "№ докум.", font, 12)
|
||||||
|
_txt(c, x + 40 * mm, y + mh - 10 * mm, 15 * mm, 5 * mm, "Подп.", font, 12)
|
||||||
|
_txt(c, x + 55 * mm, y + mh - 10 * mm, 10 * mm, 5 * mm, "Дата", font, 12)
|
||||||
|
|
||||||
|
_txt(c, x + 1 * mm, y + mh - 15 * mm, 16 * mm, 5 * mm, "Разраб.", font, 12, "left")
|
||||||
|
_txt(c, x + 1 * mm, y + mh - 20 * mm, 16 * mm, 5 * mm, "Пров.", font, 12, "left")
|
||||||
|
_txt(c, x + 1 * mm, y + mh - 25 * mm, 16 * mm, 5 * mm, _ins(inscriptions, 10), font, 12, "left")
|
||||||
|
_txt(c, x + 18 * mm, y + mh - 25 * mm, 22 * mm, 5 * mm, _ins(inscriptions, 11), font, 12, "left")
|
||||||
|
_txt(c, x + 18 * mm, y + mh - 15 * mm, 22 * mm, 5 * mm, _ins(inscriptions, 111), font, 12, "left")
|
||||||
|
_txt(c, x + 18 * mm, y + mh - 20 * mm, 22 * mm, 5 * mm, _ins(inscriptions, 112), font, 12, "left")
|
||||||
|
_txt(c, x + 18 * mm, y + mh - 30 * mm, 22 * mm, 5 * mm, _ins(inscriptions, 113), font, 12, "left")
|
||||||
|
_txt(c, x + 18 * mm, y + mh - 35 * mm, 22 * mm, 5 * mm, _ins(inscriptions, 114), font, 12, "left")
|
||||||
|
_txt(c, x + 41 * mm, y + mh - 25 * mm, 14 * mm, 5 * mm, _ins(inscriptions, 12), font, 12, "left")
|
||||||
|
_txt(c, x + 56 * mm, y + mh - 25 * mm, 9 * mm, 5 * mm, _ins(inscriptions, 13), font, 12, "left")
|
||||||
|
_txt(c, x + 1 * mm, y + mh - 30 * mm, 16 * mm, 5 * mm, "Н. контр.", font, 12, "left")
|
||||||
|
_txt(c, x + 1 * mm, y + mh - 35 * mm, 16 * mm, 5 * mm, "Утв.", font, 12, "left")
|
||||||
|
|
||||||
|
# product name / doc title area
|
||||||
|
if doc_type != 4:
|
||||||
|
name_text = _ins(inscriptions, 1002 if doc_type == 3 else 1)
|
||||||
|
_txt_multiline(c, x + 65 * mm, y + mh - 40 * mm, 70 * mm, 25 * mm, name_text, font, 18)
|
||||||
|
if doc_type == 1:
|
||||||
|
_txt(c, x + 65 * mm, y + mh - 40 * mm, 70 * mm, 25 * mm, "Перечень элементов", font, 12, valign="bottom")
|
||||||
|
else:
|
||||||
|
_txt_multiline(c, x + 65 * mm, y + mh - 35 * mm, 70 * mm, 20 * mm, _ins(inscriptions, 1), font, 18)
|
||||||
|
_txt(c, x + 65 * mm, y + mh - 35 * mm, 70 * mm, 5 * mm, _ins(inscriptions, 301), font, 12)
|
||||||
|
_txt(c, x + 65 * mm, y, 70 * mm, 20 * mm, "Ведомость покупных изделий", font, 12, valign="bottom")
|
||||||
|
|
||||||
|
_txt(c, x + 65 * mm, y, 70 * mm, 5 * mm, "Копировал", font, 12, "left")
|
||||||
|
|
||||||
|
title = _ins(inscriptions, 2)
|
||||||
|
if doc_type == 1:
|
||||||
|
title = f"{title} ПЭ3".strip()
|
||||||
|
elif doc_type == 4:
|
||||||
|
title = f"{title} ВП".strip()
|
||||||
|
elif doc_type == 3:
|
||||||
|
title = _ins(inscriptions, 1001)
|
||||||
|
_txt(c, x + 65 * mm, y + mh - 15 * mm, 120 * mm, 15 * mm, title, font, 18)
|
||||||
|
|
||||||
|
_txt(c, x + 135 * mm, y + mh - 15 * mm, 15 * mm, 5 * mm, "Лит.", font, 12)
|
||||||
|
_txt(c, x + 150 * mm, y + mh - 15 * mm, 15 * mm, 5 * mm, "Лист", font, 12)
|
||||||
|
_txt(c, x + 165 * mm, y + mh - 15 * mm, 20 * mm, 5 * mm, "Листов", font, 12)
|
||||||
|
littera = _ins(inscriptions, 4)
|
||||||
|
for i in range(min(3, len(littera))):
|
||||||
|
_txt(c, x + (135 + 5 * i) * mm, y + mh - 20 * mm, 5 * mm, 5 * mm, littera[i], font, 12)
|
||||||
|
if total > 1:
|
||||||
|
_txt(c, x + 150 * mm, y + mh - 20 * mm, 15 * mm, 5 * mm, str(page), font, 12)
|
||||||
|
_txt(c, x + 165 * mm, y + mh - 20 * mm, 20 * mm, 5 * mm, str(total), font, 12)
|
||||||
|
_txt(c, x + 135 * mm, y + mh - 35 * mm, 50 * mm, 15 * mm, _ins(inscriptions, 9), font, 12)
|
||||||
|
|
||||||
|
fmt = "Формат А3" if doc_type == 4 else "Формат А4"
|
||||||
|
_txt(c, x + 135 * mm, y, 50 * mm, 5 * mm, fmt, font, 12, "left")
|
||||||
|
|
||||||
|
|
||||||
|
def _draw_stamp_other(c: canvas.Canvas, inner: Inner, inscriptions: dict[int, str], doc_type: int, page: int, font: str) -> None:
|
||||||
|
mw, mh = 185 * mm, 15 * mm
|
||||||
|
x = inner.right - mw
|
||||||
|
y = inner.bottom
|
||||||
|
c.rect(x, y, mw, mh)
|
||||||
|
for dx in (7, 17, 40, 55, 65, 175):
|
||||||
|
c.line(x + dx * mm, y, x + dx * mm, y + mh)
|
||||||
|
c.line(x, y + 5 * mm, x + 65 * mm, y + 5 * mm)
|
||||||
|
c.line(x, y + 10 * mm, x + 65 * mm, y + 10 * mm)
|
||||||
|
c.line(x + mw - 10 * mm, y + 7 * mm, x + mw, y + 7 * mm)
|
||||||
|
|
||||||
|
_txt(c, x, y, 7 * mm, 5 * mm, "Изм.", font, 12)
|
||||||
|
_txt(c, x + 7 * mm, y, 10 * mm, 5 * mm, "Лист", font, 12)
|
||||||
|
_txt(c, x + 17 * mm, y, 23 * mm, 5 * mm, "№ докум", font, 12)
|
||||||
|
_txt(c, x + 40 * mm, y, 15 * mm, 5 * mm, "Подп.", font, 12)
|
||||||
|
_txt(c, x + 55 * mm, y, 10 * mm, 5 * mm, "Дата", font, 12)
|
||||||
|
_txt(c, x, y + 5 * mm, 7 * mm, 5 * mm, _ins(inscriptions, 14), font, 12)
|
||||||
|
_txt(c, x + 7 * mm, y + 5 * mm, 10 * mm, 5 * mm, _ins(inscriptions, 15), font, 12)
|
||||||
|
_txt(c, x + 17 * mm, y + 5 * mm, 23 * mm, 5 * mm, _ins(inscriptions, 16), font, 12)
|
||||||
|
_txt(c, x + 40 * mm, y + 5 * mm, 15 * mm, 5 * mm, _ins(inscriptions, 17), font, 12)
|
||||||
|
_txt(c, x + 55 * mm, y + 5 * mm, 10 * mm, 5 * mm, _ins(inscriptions, 18), font, 12)
|
||||||
|
|
||||||
|
title = _ins(inscriptions, 2)
|
||||||
|
if doc_type == 1:
|
||||||
|
title = f"{title} ПЭ3".strip()
|
||||||
|
elif doc_type == 4:
|
||||||
|
title = f"{title} ВП".strip()
|
||||||
|
elif doc_type == 3:
|
||||||
|
title = _ins(inscriptions, 1001)
|
||||||
|
_txt(c, x + 65 * mm, y + mh - 15 * mm, 110 * mm, 15 * mm, title, font, 24)
|
||||||
|
_txt(c, x + 65 * mm, y, 110 * mm, 5 * mm, "Копировал", font, 12, "left")
|
||||||
|
fmt = "Формат А3" if doc_type == 4 else "Формат А4"
|
||||||
|
_txt(c, x + 65 * mm, y, 110 * mm, 5 * mm, fmt, font, 12, "right")
|
||||||
|
_txt(c, x + mw - 10 * mm, y + mh - 7 * mm, 10 * mm, 7 * mm, "Лист", font, 12)
|
||||||
|
_txt(c, x + mw - 10 * mm, y, 10 * mm, 8 * mm, str(page), font, 12)
|
||||||
|
|
||||||
|
|
||||||
|
def draw_gost_frame(
|
||||||
|
c: canvas.Canvas,
|
||||||
|
page_w: float,
|
||||||
|
page_h: float,
|
||||||
|
inscriptions: dict[int, str],
|
||||||
|
table_type: str,
|
||||||
|
page: int,
|
||||||
|
total: int,
|
||||||
|
font: str,
|
||||||
|
) -> None:
|
||||||
|
"""Draw GOST A4/A3 frame matching desktop layout."""
|
||||||
|
doc_type = DOC_TYPES.get(table_type, 1)
|
||||||
|
display_total = total + 1 # desktop increments for registration sheet
|
||||||
|
inner = Inner(page_w, page_h)
|
||||||
|
_draw_inner_border(c, inner)
|
||||||
|
sx, sy, sw, sh = _draw_left_binding_strip(c, inner)
|
||||||
|
_draw_left_binding_text(c, sx, sy, inscriptions, font)
|
||||||
|
|
||||||
|
if page == 1:
|
||||||
|
_draw_top_left_strip(c, inner, inscriptions, doc_type, font)
|
||||||
|
_draw_stamp_first(c, inner, inscriptions, doc_type, page, display_total, font)
|
||||||
|
else:
|
||||||
|
_draw_stamp_other(c, inner, inscriptions, doc_type, page, font)
|
||||||
@@ -286,6 +286,54 @@ def resolve_where_used(db: Session, project_id: int, mappings: list[str]) -> str
|
|||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def get_font_stretch(db: Session, project_id: int, table_type: str) -> int:
|
||||||
|
doc = DOC_TYPE_KEYS.get(table_type, table_type)
|
||||||
|
raw = _get_setting(db, project_id, f"fontStretch_{doc}")
|
||||||
|
if not raw:
|
||||||
|
return 100
|
||||||
|
try:
|
||||||
|
return int(raw)
|
||||||
|
except ValueError:
|
||||||
|
return 100
|
||||||
|
|
||||||
|
|
||||||
|
def save_cell_overflows(
|
||||||
|
db: Session, project_id: int, table_type: str, overflows: list[dict[str, Any]]
|
||||||
|
) -> None:
|
||||||
|
merged: dict[str, set[str]] = {}
|
||||||
|
for item in overflows:
|
||||||
|
key = str(item.get("row_id") or item.get("row_index"))
|
||||||
|
merged.setdefault(key, set()).add(item["field"])
|
||||||
|
payload = {k: sorted(v) for k, v in merged.items()}
|
||||||
|
_set_setting(db, project_id, f"cell_overflow_{table_type}", json.dumps(payload, ensure_ascii=False))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def get_cell_overflows(db: Session, project_id: int, table_type: str) -> dict[str, list[str]]:
|
||||||
|
raw = _get_setting(db, project_id, f"cell_overflow_{table_type}")
|
||||||
|
if not raw:
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
data = json.loads(raw)
|
||||||
|
if isinstance(data, dict):
|
||||||
|
return {str(k): list(v) for k, v in data.items()}
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def clear_cell_overflows(db: Session, project_id: int, table_type: str) -> None:
|
||||||
|
row = db.scalar(
|
||||||
|
select(ProjectSetting).where(
|
||||||
|
ProjectSetting.project_id == project_id,
|
||||||
|
ProjectSetting.key == f"cell_overflow_{table_type}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if row:
|
||||||
|
db.delete(row)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
def resolve_decimal_and_board(
|
def resolve_decimal_and_board(
|
||||||
db: Session, project_id: int, project_decimal: str, project_board: str, table_type: str
|
db: Session, project_id: int, project_decimal: str, project_board: str, table_type: str
|
||||||
) -> tuple[str, str]:
|
) -> tuple[str, str]:
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ from app.models import (
|
|||||||
VedomostRow,
|
VedomostRow,
|
||||||
)
|
)
|
||||||
from app.services.project_settings import (
|
from app.services.project_settings import (
|
||||||
|
clear_cell_overflows,
|
||||||
|
get_cell_overflows,
|
||||||
get_column_mappings_list,
|
get_column_mappings_list,
|
||||||
get_project_params_map,
|
get_project_params_map,
|
||||||
get_simple_list_settings,
|
get_simple_list_settings,
|
||||||
@@ -123,7 +125,14 @@ def get_rows(db: Session, project_id: int, table_type: str) -> list[dict[str, An
|
|||||||
rows = db.scalars(
|
rows = db.scalars(
|
||||||
select(model).where(model.project_id == project_id).order_by(model.row_index)
|
select(model).where(model.project_id == project_id).order_by(model.row_index)
|
||||||
).all()
|
).all()
|
||||||
return [row_to_dict(r) for r in rows]
|
overflow_map = get_cell_overflows(db, project_id, table_type)
|
||||||
|
result = []
|
||||||
|
for r in rows:
|
||||||
|
data = row_to_dict(r)
|
||||||
|
key = str(data.get("id") or data.get("row_index"))
|
||||||
|
data["overflow_fields"] = overflow_map.get(key, [])
|
||||||
|
result.append(data)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
def replace_rows(db: Session, project_id: int, table_type: str, rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
def replace_rows(db: Session, project_id: int, table_type: str, rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
@@ -172,6 +181,7 @@ def generate_table(
|
|||||||
if table_type not in TABLE_TYPES:
|
if table_type not in TABLE_TYPES:
|
||||||
raise ValueError(f"Unknown table type: {table_type}")
|
raise ValueError(f"Unknown table type: {table_type}")
|
||||||
|
|
||||||
|
clear_cell_overflows(db, project.id, table_type)
|
||||||
mappings = _mappings_dict(db, project.id)
|
mappings = _mappings_dict(db, project.id)
|
||||||
components = load_components_for_variant(db, project)
|
components = load_components_for_variant(db, project)
|
||||||
col_mappings = get_column_mappings_list(db, project.id, table_type)
|
col_mappings = get_column_mappings_list(db, project.id, table_type)
|
||||||
|
|||||||
@@ -234,6 +234,10 @@ export default function ProjectPage() {
|
|||||||
}, [tableType, project, loadTable, loadTableSettings]);
|
}, [tableType, project, loadTable, loadTableSettings]);
|
||||||
|
|
||||||
const columns = useMemo(() => COLUMNS[tableType] || [], [tableType]);
|
const columns = useMemo(() => COLUMNS[tableType] || [], [tableType]);
|
||||||
|
const overflowCount = useMemo(
|
||||||
|
() => rows.reduce((n, r) => n + (r.overflow_fields?.length || 0), 0),
|
||||||
|
[rows]
|
||||||
|
);
|
||||||
|
|
||||||
async function onUpload(e, method = "POST") {
|
async function onUpload(e, method = "POST") {
|
||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0];
|
||||||
@@ -620,11 +624,30 @@ export default function ProjectPage() {
|
|||||||
Excel
|
Excel
|
||||||
</button>
|
</button>
|
||||||
{tableType !== "simple_list" && (
|
{tableType !== "simple_list" && (
|
||||||
<button disabled={busy} onClick={() => api.exportDoc(projectId, tableType, "pdf")}>
|
<button
|
||||||
|
disabled={busy}
|
||||||
|
onClick={async () => {
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await api.exportDoc(projectId, tableType, "pdf");
|
||||||
|
await loadTable();
|
||||||
|
} catch (e) {
|
||||||
|
setError(e.message);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
PDF
|
PDF
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{overflowCount > 0 && (
|
||||||
|
<div className="overflow-banner">
|
||||||
|
Переполнение ячеек: {overflowCount}. Красным отмечены поля, не влезающие в PDF даже с поджимом.
|
||||||
|
Сократите текст или уменьшите поджим в настройках.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{tableSettings?.fields?.length > 0 && (
|
{tableSettings?.fields?.length > 0 && (
|
||||||
<div className="settings-panel stack">
|
<div className="settings-panel stack">
|
||||||
@@ -714,7 +737,10 @@ export default function ProjectPage() {
|
|||||||
<tr key={row.id || idx} className={row.is_header ? "header-row" : ""}>
|
<tr key={row.id || idx} className={row.is_header ? "header-row" : ""}>
|
||||||
<td className="muted">{row.row_index ?? idx}</td>
|
<td className="muted">{row.row_index ?? idx}</td>
|
||||||
{columns.map(([key]) => (
|
{columns.map(([key]) => (
|
||||||
<td key={key}>
|
<td
|
||||||
|
key={key}
|
||||||
|
className={row.overflow_fields?.includes(key) ? "cell-overflow" : ""}
|
||||||
|
>
|
||||||
<input
|
<input
|
||||||
value={row[key] ?? ""}
|
value={row[key] ?? ""}
|
||||||
disabled={row.is_empty}
|
disabled={row.is_empty}
|
||||||
|
|||||||
@@ -68,6 +68,14 @@ table.data input {
|
|||||||
}
|
}
|
||||||
table.data input:focus { border-color: var(--accent); background: #fff; }
|
table.data input:focus { border-color: var(--accent); background: #fff; }
|
||||||
table.data tr.header-row td { font-weight: 700; text-decoration: underline; }
|
table.data tr.header-row td { font-weight: 700; text-decoration: underline; }
|
||||||
|
table.data td.cell-overflow input { background: #ffd5d5; }
|
||||||
|
.overflow-banner {
|
||||||
|
background: #fff0f0;
|
||||||
|
border: 1px solid #e8a0a0;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
.tabs { display: flex; gap: 0.35rem; flex-wrap: wrap; margin-bottom: 0.75rem; }
|
.tabs { display: flex; gap: 0.35rem; flex-wrap: wrap; margin-bottom: 0.75rem; }
|
||||||
.tab {
|
.tab {
|
||||||
background: #e7e0d2; color: var(--ink); border-radius: 999px; padding: 0.35rem 0.8rem;
|
background: #e7e0d2; color: var(--ink); border-radius: 999px; padding: 0.35rem 0.8rem;
|
||||||
|
|||||||
Reference in New Issue
Block a user