# -*- coding: utf-8 -*- """按原 Excel 模板版式生成采购单 / 委外生产订单 PDF。""" import math import os import io from datetime import datetime from xml.sax.saxutils import escape from reportlab.lib import colors from reportlab.lib.enums import TA_CENTER, TA_LEFT from reportlab.lib.pagesizes import A4 from reportlab.lib.styles import ParagraphStyle from reportlab.lib.units import mm from reportlab.pdfbase import pdfmetrics from reportlab.pdfbase.ttfonts import TTFont from reportlab.platypus import (BaseDocTemplate, Flowable, Frame, KeepTogether, LongTable, PageTemplate, Paragraph, Spacer, Table, TableStyle) from amount_cn import amount_to_cn from money import ZERO, format_price, money from models import COMPANY_NAME, DOC_TYPES FONT_DIRS = [r"C:\Windows\Fonts", "/usr/share/fonts"] BODY_CANDIDATES = ["simsun.ttc", "STSONG.TTF", "simfang.ttf", "simkai.ttf", "Deng.ttf"] BOLD_CANDIDATES = ["simhei.ttf", "msyh.ttc", "msyhbd.ttc", "Dengb.ttf", "STXIHEI.TTF"] SEAL_CANDIDATES = ["simfang.ttf", "STFANGSO.TTF", "simsun.ttc", "STSONG.TTF"] SEAL_NUMBER_CANDIDATES = ["arial.ttf", "LiberationSans-Regular.ttf", "DejaVuSans.ttf"] FONT_BODY = "CN-Body" FONT_BOLD = "CN-Bold" FONT_SEAL = "CN-Seal" FONT_SEAL_NUMBER = "Seal-Number" # 延续两种模板比例;委外单将过宽的单位列让给规格列。 COL_RATIO = { "purchase": [1184, 3104, 6528, 3232, 2400, 2976, 2400, 3712, 1728], "outsourcing": [1184, 5440, 7264, 1536, 2400, 2976, 3840, 3712, 4736], } TERMS = [ u"1、因供方交期延误,品质不良,规格不符或数量不准确等情况造成我公司损失,概由供应方承担。", u"2、供方所交材料均要求符合欧盟环境有害物质管理标准。", u"3、随货请附出货检验报告。", u"4、运输与保险:供方负责。", u"5、违约责任:按经济合同法等相关法律法规处置,供方延期交货,需方有权延期付款,延期付款时间为延期交货时间的2倍。", u"6、保密协定:供需双方保证对方的商业秘密。", u"7、合同生效:本合同一式两份,自双方签字盖章起生效,传真件签字盖章亦有效,订单手写无效。", u"8、售后服务与保修:如产品使用过程中发现质量问题,供方负责调换或退货,并赔偿相应损失,包含但不限于物料和返工费用。", ] STATUS_WATERMARK = { "draft": u"草稿 · 未提交", "pending_review": u"待审批", "pending_approval": u"待审批", "rejected": u"已驳回 · 未生效", "voided": u"已作废 · 禁止使用", } SEAL_COLOR = colors.HexColor("#C8102E") SEAL_NUMBER = "3205070994140" PDF_TEMPLATE_VERSION = "po-v5" PDF_SEAL_SIZE = 32 * 1.3 * mm def _seal_arc(text, radius, start, span, font, size, bottom=False, scale_x=1, scale_y=1): """Lay out both renderers in a 100 x 100 Cartesian coordinate system. Fit by glyph width, so seal-number characters do not get the same angular spacing as Chinese characters. The lower arc remains upright. """ length = radius * math.radians(span) width = pdfmetrics.stringWidth(text, font, size) * scale_x size *= min(1, length * 0.94 / width) if width else 1 widths = [pdfmetrics.stringWidth(ch, font, size) * scale_x for ch in text] gap = (length - sum(widths)) / max(len(text), 1) cursor = gap / 2 letters = [] for ch, width in zip(text, widths): offset = math.degrees((cursor + width / 2) / radius) angle = start + offset if bottom else start - offset rad = math.radians(angle) letters.append({'text': ch, 'x': 50 + radius * math.cos(rad), 'y': 50 + radius * math.sin(rad), 'rotation': angle + 90 if bottom else angle - 90}) cursor += width + gap return {'font': font, 'size': size, 'letters': letters, 'scale_x': scale_x, 'scale_y': scale_y} def seal_layout(company, seal_number=SEAL_NUMBER): """Shared geometry for the PDF seal and the inline SVG approval preview.""" register_fonts() company = (company or COMPANY_NAME).strip() seal_number = (seal_number or SEAL_NUMBER).strip() star = [] for i in range(10): radius = 9.2 if i % 2 == 0 else 9.2 * 0.382 angle = math.radians(90 + i * 36) star.append((50 + radius * math.cos(angle), 58 + radius * math.sin(angle))) separators = [(50 + 38 * math.cos(math.radians(a)), 50 + 38 * math.sin(math.radians(a))) for a in (205, 335)] return {'company': company, 'number': seal_number, 'arcs': [_seal_arc(company, 32.2, 200, 220, FONT_SEAL, 10.4, scale_x=0.8, scale_y=1.18), _seal_arc(seal_number, 41.5, 210, 120, FONT_SEAL_NUMBER, 5.8, bottom=True, scale_x=0.9)], 'title': {'font': FONT_SEAL, 'size': 9.2, 'scale_x': 0.94, 'scale_y': 1.12}, 'star': star, 'separators': separators} class SealFlowable(Flowable): """公司名和印章号码环绕一圈的采购章,不含个人及日期。""" def __init__(self, company, title, seal_number=SEAL_NUMBER, size=PDF_SEAL_SIZE): Flowable.__init__(self) self.layout = seal_layout(company, seal_number) self.title = title or u"采购专用章" self.width = size self.height = size def draw(self): c = self.canv c.saveState() c.scale(self.width / 100, self.height / 100) c.setFillColor(SEAL_COLOR) c.setStrokeColor(SEAL_COLOR) c.setLineWidth(1.5) c.circle(50, 50, 46, stroke=1, fill=0) c.setLineWidth(0.5) c.circle(50, 50, 44.6, stroke=1, fill=0) for arc in self.layout['arcs']: c.setFont(arc['font'], arc['size']) for letter in arc['letters']: c.saveState() c.translate(letter['x'], letter['y']) c.rotate(letter['rotation']) c.scale(arc['scale_x'], arc['scale_y']) c.drawCentredString(0, 0, letter['text']) c.restoreState() for x, y in self.layout['separators']: c.circle(x, y, 0.65, stroke=0, fill=1) star = c.beginPath() for i, (x, y) in enumerate(self.layout['star']): (star.moveTo if i == 0 else star.lineTo)(x, y) star.close() c.drawPath(star, stroke=0, fill=1) title_style = self.layout['title'] c.setFont(title_style['font'], title_style['size']) c.translate(50, 34) c.scale(title_style['scale_x'], title_style['scale_y']) c.drawCentredString(0, 0, self.title) c.restoreState() class ConfirmationFlowable(Flowable): """Keep each party's label and signing space in the same fixed-size cell.""" def __init__(self, party, width, seal=None): Flowable.__init__(self) self.label = u"%s确认(盖章):" % party self.width = width self.height = PDF_SEAL_SIZE + 4 * mm self.seal = seal def draw(self): c = self.canv c.saveState() font_size = 9.5 center_y = self.height / 2 baseline = center_y - font_size * 0.35 c.setFont(FONT_BODY, font_size) c.setFillColor(colors.black) c.drawString(0, baseline, self.label) signing_start = pdfmetrics.stringWidth(self.label, FONT_BODY, font_size) + 2 * mm signing_end = self.width - 1 * mm if self.seal: # Center the seal in the buyer's signing space, next to its label. self.seal.drawOn(c, (signing_start + signing_end - self.seal.width) / 2, center_y - self.seal.height / 2) else: c.setStrokeColor(colors.HexColor("#888888")) c.setLineWidth(0.4) c.line(signing_start, baseline - 1.5, signing_end, baseline - 1.5) c.restoreState() def _find_font(candidates): for d in FONT_DIRS: for name in candidates: p = os.path.join(d, name) if os.path.exists(p): return p return None def register_fonts(): if FONT_BODY in pdfmetrics.getRegisteredFontNames(): return body = _find_font(BODY_CANDIDATES) bold = _find_font(BOLD_CANDIDATES) if not body: raise RuntimeError(u"未找到中文字体,请确认系统已安装宋体或黑体") pdfmetrics.registerFont(TTFont(FONT_BODY, body, subfontIndex=0)) pdfmetrics.registerFont(TTFont(FONT_BOLD, bold or body, subfontIndex=0)) pdfmetrics.registerFont(TTFont(FONT_SEAL, _find_font(SEAL_CANDIDATES) or body, subfontIndex=0)) number_font = _find_font(SEAL_NUMBER_CANDIDATES) if number_font: pdfmetrics.registerFont(TTFont(FONT_SEAL_NUMBER, number_font, subfontIndex=0)) else: pdfmetrics.registerFont(pdfmetrics.Font(FONT_SEAL_NUMBER, 'Helvetica', 'WinAnsiEncoding')) def _fmt_money(v): try: return "{:,.2f}".format(money(v)) except (TypeError, ValueError): return "0.00" def _fmt_num(v): try: return format_price(v) except (TypeError, ValueError): return "" def P(text, style): """转义后生成 Paragraph,避免 & < > 破坏 XML。""" s = escape(u"" if text is None else str(text)).replace("\n", "
") return Paragraph(s or " ", style) def build_pdf(order, items, out_path, watermark=None): register_fonts() if watermark is None: watermark = None if order.status == 'approved' else STATUS_WATERMARK.get(order.status, '未批准') page_w, page_h = A4 margin_x = 11 * mm doc = BaseDocTemplate(io.BytesIO(), pagesize=A4, leftMargin=margin_x, rightMargin=margin_x, topMargin=11 * mm, bottomMargin=16 * mm, title=u"%s %s" % (order.doc_type_text, order.order_no), author=COMPANY_NAME) frame = Frame(margin_x, 16 * mm, page_w - 2 * margin_x, page_h - 27 * mm, id="body", showBoundary=0) counters = {"total": 0} def on_page(canvas, doc_): canvas.saveState() canvas.setFont(FONT_BODY, 8) canvas.setFillColor(colors.HexColor("#666666")) footer = u"%s %s 第 %d 页 / 共 %d 页 打印时间 %s" % ( order.doc_type_text, order.order_no, doc_.page, counters["total"] or doc_.page, datetime.now().strftime("%Y-%m-%d %H:%M")) canvas.drawCentredString(page_w / 2.0, 10 * mm, footer) canvas.setStrokeColor(colors.HexColor("#BBBBBB")) canvas.setLineWidth(0.4) canvas.line(margin_x, 13 * mm, page_w - margin_x, 13 * mm) if watermark: canvas.setFont(FONT_BOLD, 34) canvas.setFillColor(colors.Color(0.85, 0.15, 0.15, alpha=0.15)) canvas.saveState() canvas.translate(page_w / 2.0, page_h / 2.0) canvas.rotate(28) canvas.drawCentredString(0, 0, watermark) canvas.restoreState() canvas.restoreState() doc.addPageTemplates([PageTemplate(id="main", frames=[frame], onPage=on_page)]) styles = { "title": ParagraphStyle("title", fontName=FONT_BOLD, fontSize=17, leading=22, alignment=TA_CENTER), "company": ParagraphStyle("company", fontName=FONT_BOLD, fontSize=20, leading=26, alignment=TA_CENTER), "cell": ParagraphStyle("cell", fontName=FONT_BODY, fontSize=8.6, leading=11.5, alignment=TA_LEFT), "cell_c": ParagraphStyle("cell_c", fontName=FONT_BODY, fontSize=8.6, leading=11.5, alignment=TA_CENTER), "th": ParagraphStyle("th", fontName=FONT_BOLD, fontSize=9, leading=12, alignment=TA_CENTER), "term": ParagraphStyle("term", fontName=FONT_BODY, fontSize=8.8, leading=13.5), "sign": ParagraphStyle("sign", fontName=FONT_BODY, fontSize=9.5, leading=15), } def make_story(): story = [] story.append(P(order.our_company or COMPANY_NAME, styles["company"])) story.append(Spacer(1, 3 * mm)) story.append(P(order.doc_type_text or DOC_TYPES.get(order.doc_type, u""), styles["title"])) story.append(Spacer(1, 4 * mm)) # ---------- 抬头区 ---------- left_rows = [ (u"供应商:", order.supplier_name), (u"联系人:", order.supplier_contact), (u"电 话:", order.supplier_phone), (u"付款方式:", order.payment_terms), (u"税 号:", order.supplier_tax_no), (u"银行帐号:", order.supplier_bank_account), (u"开户银行:", order.supplier_bank_name), ] right_rows = [ (u"需 方:", order.our_company), (u"联系人:", order.our_contact), (u"电 话:", order.our_phone), (u"税 点:", order.tax_rate), (u"税 号:", order.our_tax_no), (u"采购单号:", order.order_no), (u"采购日期:", order.order_date), ] head_data = [[P(l1 + (v1 or ""), styles["cell"]), P(l2 + (v2 or ""), styles["cell"])] for (l1, v1), (l2, v2) in zip(left_rows, right_rows)] head = Table(head_data, colWidths=[93 * mm, 93 * mm]) head.setStyle(TableStyle([ ("GRID", (0, 0), (-1, -1), 0.5, colors.HexColor("#333333")), ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), ("LEFTPADDING", (0, 0), (-1, -1), 4), ("RIGHTPADDING", (0, 0), (-1, -1), 4), ("TOPPADDING", (0, 0), (-1, -1), 1.5), ("BOTTOMPADDING", (0, 0), (-1, -1), 1.5), ])) story.append(head) story.append(Spacer(1, 4 * mm)) # ---------- 明细表 ---------- ratio = COL_RATIO.get(order.doc_type, COL_RATIO["purchase"]) total_w = 186 * mm s = float(sum(ratio)) col_w = [total_w * r / s for r in ratio] # 序号列保底宽度,避免表头换行;差值从最宽的规格列扣除 min_first = 9 * mm if col_w[0] < min_first: col_w[2] -= (min_first - col_w[0]) col_w[0] = min_first headers = [u"序号", u"物料代码", u"物料规格", u"单位", u"数量", u"含税单价", u"金额", u"交期", u"备注"] data = [[P(h, styles["th"]) for h in headers]] total = ZERO for idx, it in enumerate(items, 1): total += money(it.amount) data.append([ P(str(idx), styles["cell_c"]), P(it.material_code, styles["cell"]), P(it.material_spec, styles["cell"]), P(it.unit, styles["cell_c"]), P(_fmt_num(it.qty), styles["cell_c"]), P(format_price(it.price), styles["cell_c"]), P(_fmt_money(it.amount), styles["cell_c"]), P(it.delivery_date, styles["cell_c"]), P(it.remark, styles["cell"]), ]) while len(data) - 1 < 5: data.append([P("", styles["cell"]) for _ in range(9)]) detail = LongTable(data, colWidths=col_w, repeatRows=1) detail.setStyle(TableStyle([ ("GRID", (0, 0), (-1, -1), 0.5, colors.HexColor("#333333")), ("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#EFEFEF")), ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), ("LEFTPADDING", (0, 0), (-1, -1), 3), ("RIGHTPADDING", (0, 0), (-1, -1), 3), ("TOPPADDING", (0, 0), (-1, -1), 3), ("BOTTOMPADDING", (0, 0), (-1, -1), 3), ])) story.append(detail) # ---------- 合计区 ---------- cn_total = amount_to_cn(total) sum_data = [ [P(u"合计(小写)", styles["cell"]), P("", styles["cell"]), P("", styles["cell"]), P(_fmt_money(total), styles["cell_c"])] + [P("", styles["cell"]) for _ in range(5)], [P(u"合计(人民币)", styles["cell"]), P("", styles["cell"]), P(cn_total, styles["cell_c"])] + [P("", styles["cell"]) for _ in range(4)] + [P("", styles["cell"]), P("", styles["cell"])], ] sum_t = Table(sum_data, colWidths=col_w, rowHeights=[7.5 * mm, 7.5 * mm], splitByRow=0) sum_t.setStyle(TableStyle([ ("GRID", (0, 0), (-1, -1), 0.5, colors.HexColor("#333333")), ("SPAN", (0, 0), (2, 0)), ("SPAN", (3, 0), (8, 0)), ("SPAN", (0, 1), (1, 1)), ("SPAN", (2, 1), (8, 1)), ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), ("LEFTPADDING", (0, 0), (-1, -1), 4), ("RIGHTPADDING", (0, 0), (-1, -1), 4), ])) story.append(sum_t) story.append(Spacer(1, 3 * mm)) # ---------- 备注条款 ---------- terms_block = [P(u"备注:", styles["term"])] for t in TERMS: terms_block.append(P(t, styles["term"])) if order.delivery_place: terms_block.append(P(u"9、交货地点:%s" % order.delivery_place, styles["term"])) if order.note: terms_block.append(P(u"补充说明:%s" % order.note, styles["term"])) story.append(KeepTogether(terms_block)) story.append(Spacer(1, 8 * mm)) # ---------- 签署区 ---------- def stamp(name, dt): return (u"%s %s" % (name, dt.strftime("%Y-%m-%d %H:%M"))) if dt else (name or u"待处理") sign_line = u"制单:%s\n审批:%s" % ( stamp(order.created_by_name, order.created_at), stamp(order.approved_by, order.approved_at)) # One final approval produces one procurement seal. Historical archived # PDFs are served unchanged by order_pdf and never regenerated here. seal = (SealFlowable(order.our_company or COMPANY_NAME, u"采购专用章") if order.approved_by else None) party_width = 83 * mm confirmations = Table( [[ConfirmationFlowable(u"供方", party_width), "", ConfirmationFlowable(u"需方", party_width, seal)]], colWidths=[party_width, 20 * mm, party_width]) confirmations.setStyle(TableStyle([ ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), ("LEFTPADDING", (0, 0), (-1, -1), 0), ("RIGHTPADDING", (0, 0), (-1, -1), 0), ("TOPPADDING", (0, 0), (-1, -1), 0), ("BOTTOMPADDING", (0, 0), (-1, -1), 0), ])) story.append(KeepTogether([P(sign_line, styles["sign"]), Spacer(1, 3 * mm), confirmations])) return story doc.build(make_story()) counters["total"] = doc.page doc.filename = out_path doc.build(make_story()) return out_path