From 06c7b800fc138423d63fb4dd64755108d421a199 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B1=A4=E5=AD=A6=E4=BC=9A?= Date: Fri, 12 Jun 2026 18:02:06 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8DPDF=E5=BD=92=E6=A1=A3?= =?UTF-8?q?=E9=95=BF=E5=AD=97=E6=AE=B5=E6=8E=92=E7=89=88=E9=94=99=E4=B9=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/services/document_archives.py | 87 ++++++++++++++++--- .../tests/test_document_archive_service.py | 72 +++++++++++++++ 2 files changed, 146 insertions(+), 13 deletions(-) diff --git a/backend/app/services/document_archives.py b/backend/app/services/document_archives.py index 33888cb..5b846e5 100644 --- a/backend/app/services/document_archives.py +++ b/backend/app/services/document_archives.py @@ -59,6 +59,16 @@ ARCHIVE_MIN_ROW_HEIGHT = 24 ARCHIVE_CELL_HORIZONTAL_PADDING = 8 ARCHIVE_CELL_VERTICAL_PADDING = 8 ARCHIVE_CELL_FONT_SIZE = 8 +ARCHIVE_METADATA_FONT_SIZE = 10 +ARCHIVE_METADATA_LEFT_X = 42 +ARCHIVE_METADATA_RIGHT_X = 340 +ARCHIVE_METADATA_COLUMN_GAP = 10 +ARCHIVE_METADATA_LEFT_WIDTH = ARCHIVE_METADATA_RIGHT_X - ARCHIVE_METADATA_LEFT_X - ARCHIVE_METADATA_COLUMN_GAP +ARCHIVE_METADATA_RIGHT_WIDTH = A4[0] - ARCHIVE_TABLE_RIGHT_MARGIN - ARCHIVE_METADATA_RIGHT_X +ARCHIVE_METADATA_TOP_Y_OFFSET = 92 +ARCHIVE_METADATA_MIN_ROW_HEIGHT = 20 +ARCHIVE_METADATA_ROW_PADDING = 7 +ARCHIVE_METADATA_TABLE_GAP = 18 ARCHIVE_STATUS_LABELS = { "ACTIVE": "启用", @@ -1024,6 +1034,59 @@ def _draw_cell( text_y -= line_height +def _archive_metadata_rows(context: ArchiveContext) -> list[tuple[tuple[str, str], tuple[str, str]]]: + contact_text = f"{context.contact_name or ''} 电话: {context.contact_phone or ''}" + tax_text = f"{decimal_text((context.tax_rate or 0) * 100)}%" + return [ + (("单据编号", context.document_no), ("单据日期", date_text(context.document_date))), + ((context.partner_label, context.partner_name), (context.due_date_label, date_text(context.due_date))), + (("联系人", contact_text), ("状态", context.status)), + ((context.address_label or "地址", context.address or ""), ("税率", tax_text)), + ] + + +def _archive_metadata_line_groups( + context: ArchiveContext, + font_name: str, + size: int = ARCHIVE_METADATA_FONT_SIZE, +) -> list[tuple[list[str], list[str]]]: + groups: list[tuple[list[str], list[str]]] = [] + for (left_label, left_value), (right_label, right_value) in _archive_metadata_rows(context): + left_text = f"{left_label}: {left_value or ''}" + right_text = f"{right_label}: {right_value or ''}" + groups.append( + ( + _wrap_text_to_lines(left_text, font_name, size, ARCHIVE_METADATA_LEFT_WIDTH), + _wrap_text_to_lines(right_text, font_name, size, ARCHIVE_METADATA_RIGHT_WIDTH), + ) + ) + return groups + + +def _metadata_row_height(line_count: int, size: int = ARCHIVE_METADATA_FONT_SIZE) -> float: + return max(ARCHIVE_METADATA_MIN_ROW_HEIGHT, line_count * _cell_line_height(size) + ARCHIVE_METADATA_ROW_PADDING) + + +def _archive_table_y_for_context(context: ArchiveContext, font_name: str) -> float: + _, height = A4 + y = height - ARCHIVE_METADATA_TOP_Y_OFFSET + for left_lines, right_lines in _archive_metadata_line_groups(context, font_name): + y -= _metadata_row_height(max(len(left_lines), len(right_lines), 1)) + return min(height - ARCHIVE_TABLE_TOP_Y_OFFSET, y - ARCHIVE_METADATA_TABLE_GAP) + + +def _draw_metadata_lines( + pdf: canvas.Canvas, + x: float, + y: float, + lines: list[str], + line_height: float, +) -> None: + for line in lines: + pdf.drawString(x, y, line) + y -= line_height + + def _archive_table_columns() -> list[tuple[str, int, str]]: return [ ("行", 26, "center"), @@ -1045,7 +1108,7 @@ def _draw_archive_page_frame( bold_font_name: str, page_no: int, total_pages: int, -) -> None: +) -> float: width, height = A4 pdf.setFillColorRGB(0.99, 0.975, 0.92) pdf.rect(0, 0, width, height, stroke=0, fill=1) @@ -1061,15 +1124,13 @@ def _draw_archive_page_frame( pdf.drawRightString(width - 42, height - 62, "PDF归档") pdf.drawRightString(width - 42, height - 80, f"第 {page_no}/{total_pages} 页") - pdf.setFont(font_name, 10) - pdf.drawString(42, height - 92, f"单据编号: {context.document_no}") - pdf.drawString(42, height - 112, f"{context.partner_label}: {context.partner_name}") - pdf.drawString(42, height - 132, f"联系人: {context.contact_name or ''} 电话: {context.contact_phone or ''}") - pdf.drawString(42, height - 152, f"{context.address_label or '地址'}: {context.address or ''}") - pdf.drawString(340, height - 92, f"单据日期: {date_text(context.document_date)}") - pdf.drawString(340, height - 112, f"{context.due_date_label}: {date_text(context.due_date)}") - pdf.drawString(340, height - 132, f"状态: {context.status}") - pdf.drawString(340, height - 152, f"税率: {decimal_text((context.tax_rate or 0) * 100)}%") + pdf.setFont(font_name, ARCHIVE_METADATA_FONT_SIZE) + line_height = _cell_line_height(ARCHIVE_METADATA_FONT_SIZE) + metadata_y = height - ARCHIVE_METADATA_TOP_Y_OFFSET + for left_lines, right_lines in _archive_metadata_line_groups(context, font_name): + _draw_metadata_lines(pdf, ARCHIVE_METADATA_LEFT_X, metadata_y, left_lines, line_height) + _draw_metadata_lines(pdf, ARCHIVE_METADATA_RIGHT_X, metadata_y, right_lines, line_height) + metadata_y -= _metadata_row_height(max(len(left_lines), len(right_lines), 1)) pdf.setFont(bold_font_name, 26) pdf.setStrokeColorRGB(0.55, 0.05, 0.05) @@ -1079,6 +1140,7 @@ def _draw_archive_page_frame( pdf.rotate(-12) pdf.setStrokeColor(colors.black) pdf.setFillColor(colors.black) + return min(height - ARCHIVE_TABLE_TOP_Y_OFFSET, metadata_y - ARCHIVE_METADATA_TABLE_GAP) def _draw_archive_table_header(pdf: canvas.Canvas, y: float, bold_font_name: str) -> None: @@ -1180,14 +1242,13 @@ def render_pdf_archive(context: ArchiveContext, output_path: str | Path) -> None output.parent.mkdir(parents=True, exist_ok=True) font_name, bold_font_name = _register_archive_font() pdf = canvas.Canvas(str(output), pagesize=A4) - height = A4[1] - table_y = height - ARCHIVE_TABLE_TOP_Y_OFFSET + table_y = _archive_table_y_for_context(context, font_name) row_h = ARCHIVE_MIN_ROW_HEIGHT max_body_height = table_y - row_h - ARCHIVE_TABLE_BOTTOM_Y pages = _line_pages(context.lines, max_body_height, font_name) for page_index, page_lines in enumerate(pages, start=1): - _draw_archive_page_frame(pdf, context, font_name, bold_font_name, page_index, len(pages)) + table_y = _draw_archive_page_frame(pdf, context, font_name, bold_font_name, page_index, len(pages)) _draw_archive_table_header(pdf, table_y, bold_font_name) y = _draw_archive_table_rows( pdf, diff --git a/backend/tests/test_document_archive_service.py b/backend/tests/test_document_archive_service.py index cf14318..6acaed4 100644 --- a/backend/tests/test_document_archive_service.py +++ b/backend/tests/test_document_archive_service.py @@ -43,6 +43,7 @@ from app.services.document_archives import ( # noqa: E402 get_latest_archive, render_pdf_archive, _archive_table_columns, + _draw_archive_page_frame, _draw_cell, _draw_archive_footer, ) @@ -732,6 +733,77 @@ class DocumentArchiveServiceTest(unittest.TestCase): rendered_width = pdfmetrics.stringWidth(line, fake_pdf.font_name, fake_pdf.size) self.assertLessEqual(rendered_width, 54) + def test_draw_archive_page_frame_wraps_long_metadata_inside_columns(self) -> None: + class FakePdf: + def __init__(self) -> None: + self.font_name = "Helvetica" + self.size = 10 + self.drawn_texts: list[tuple[float, float, str, str, int]] = [] + + def setFillColorRGB(self, *args, **kwargs) -> None: + _ = args, kwargs + + def rect(self, *args, **kwargs) -> None: + _ = args, kwargs + + def setStrokeColor(self, *args, **kwargs) -> None: + _ = args, kwargs + + def setStrokeColorRGB(self, *args, **kwargs) -> None: + _ = args, kwargs + + def setFillColor(self, *args, **kwargs) -> None: + _ = args, kwargs + + def setFont(self, font_name, size) -> None: + self.font_name = font_name + self.size = size + + def drawRightString(self, x, y, text) -> None: + _ = x, y, text + + def drawCentredString(self, x, y, text) -> None: + _ = x, y, text + + def drawString(self, x, y, text) -> None: + self.drawn_texts.append((x, y, text, self.font_name, self.size)) + + def rotate(self, *args, **kwargs) -> None: + _ = args, kwargs + + fake_pdf = FakePdf() + context = ArchiveContext( + document_type=DOCUMENT_TYPE_SALES_ORDER, + business_id=100, + document_no="生产领料-JH_不锈钢板-用于测试00101_0001-苹果电脑后盖-超长批次-客户特殊版本-返工追溯链路", + title="生产领料出库单归档", + partner_label="生产产品", + partner_name="苹果电脑后盖-测试", + document_date=date(2026, 6, 11), + due_date_label="生产台账", + due_date=2, + status="在生产", + tax_rate=0, + total_amount=636, + contact_name="", + contact_phone="", + address="原材料库", + ) + + table_y = _draw_archive_page_frame(fake_pdf, context, "Helvetica", "Helvetica-Bold", 1, 1) + + metadata_lines = [ + (text, font_name, size) + for x, y, text, font_name, size in fake_pdf.drawn_texts + if x == 42 and y > A4[1] - 190 + ] + document_no_lines = [text for text, _, _ in metadata_lines if "单据编号" in text or "返工追溯" in text] + self.assertGreaterEqual(len(document_no_lines), 2) + self.assertLess(table_y, A4[1] - 190) + for text, font_name, size in metadata_lines: + rendered_width = pdfmetrics.stringWidth(text, font_name, size) + self.assertLessEqual(rendered_width, 288) + def test_generate_archive_failed_row_uses_free_version_after_collision(self) -> None: order = self._seed_sales_order()