init: 博特维采购订单系统 v1.0 源码

This commit is contained in:
star
2026-09-10 16:18:15 +08:00
commit 6327ef7641
35 changed files with 4029 additions and 0 deletions
+397
View File
@@ -0,0 +1,397 @@
# -*- coding: utf-8 -*-
"""按原 Excel 模板版式生成采购单 / 委外生产订单 PDF。"""
import math
import os
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 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"]
FONT_BODY = "CN-Body"
FONT_BOLD = "CN-Bold"
# 明细列宽比例,取自两个 xls 模板的实际列宽
COL_RATIO = {
"purchase": [1184, 3104, 6528, 3232, 2400, 2976, 2400, 3712, 1728],
"outsourcing": [1184, 5440, 3392, 5408, 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"已驳回 · 未生效",
}
SEAL_COLOR = colors.HexColor("#C8102E")
def _arc_text(c, text, cx, cy, radius, start_deg, spread_deg, font, size):
"""沿圆弧排字,用于印章上下缘。角度以度为单位,逆时针为正。"""
if not text:
return
c.setFont(font, size)
n = len(text)
step = spread_deg / float(n - 1) if n > 1 else 0.0
for i, ch in enumerate(text):
ang = start_deg - i * step
rad = math.radians(ang)
c.saveState()
c.translate(cx + radius * math.cos(rad), cy + radius * math.sin(rad))
c.rotate(ang - 90)
c.drawCentredString(0, 0, ch)
c.restoreState()
def _star(c, cx, cy, r_out, points=5, inner_ratio=0.382):
"""画五角星(填充)。"""
path = c.beginPath()
for i in range(points * 2):
r = r_out if i % 2 == 0 else r_out * inner_ratio
ang = math.radians(90 + i * (180.0 / points))
x = cx + r * math.cos(ang)
y = cy + r * math.sin(ang)
if i == 0:
path.moveTo(x, y)
else:
path.lineTo(x, y)
path.close()
c.drawPath(path, stroke=0, fill=1)
class SealFlowable(Flowable):
"""红色圆形电子章:外圈 + 上缘公司名 + 五角星 + 中部用途 + 下缘审核人/日期。"""
def __init__(self, company, title, who, when, size=28 * mm):
Flowable.__init__(self)
self.company = company or u""
self.title = title or u"审核专用章"
self.who = who or u""
self.when = when or u""
self.width = size
self.height = size
def draw(self):
c = self.canv
w, h = self.width, self.height
cx, cy = w / 2.0, h / 2.0
r = min(w, h) / 2.0 - 1.2 * mm
c.saveState()
c.setFillColor(SEAL_COLOR)
c.setStrokeColor(SEAL_COLOR)
c.setLineWidth(1.5)
c.circle(cx, cy, r, stroke=1, fill=0)
c.setLineWidth(0.5)
c.circle(cx, cy, r - 1.1 * mm, stroke=1, fill=0)
# 上缘公司名
_arc_text(c, self.company, cx, cy, r - 3.2 * mm, 152, -124, FONT_BODY, 5.8)
# 中心五角星
_star(c, cx, cy + 2.4 * mm, 2.8 * mm)
# 中部用途
c.setFont(FONT_BOLD, 8.0)
c.drawCentredString(cx, cy - 4.2 * mm, self.title)
# 下缘审核人 + 日期
line = u" ".join(x for x in (self.who, self.when) if x)
if line:
c.setFont(FONT_BODY, 5.4)
c.drawCentredString(cx, cy - 9.0 * mm, line)
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))
def _fmt_money(v):
try:
return "{:,.2f}".format(float(v or 0))
except (TypeError, ValueError):
return "0.00"
def _fmt_num(v):
try:
return "{:g}".format(round(float(v or 0), 4))
except (TypeError, ValueError):
return ""
def P(text, style):
"""转义后生成 Paragraph,避免 & < > 破坏 XML。"""
s = escape(u"" if text is None else str(text)).replace("\n", "<br/>")
return Paragraph(s or "&nbsp;", style)
def build_pdf(order, items, out_path, watermark=None):
register_fonts()
if watermark is None:
watermark = STATUS_WATERMARK.get(order.status)
page_w, page_h = A4
margin_x = 11 * mm
doc = BaseDocTemplate(out_path, 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=12.5, leading=17,
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(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], rowHeights=[6.6 * mm] * 7)
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 = 0.0
for idx, it in enumerate(items, 1):
total += float(it.amount or 0)
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(_fmt_money(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(u"元整", styles["cell"]), P("", styles["cell"])],
]
sum_t = Table(sum_data, colWidths=col_w, rowHeights=[7.5 * mm, 7.5 * mm])
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), (6, 1)),
("SPAN", (7, 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 主管审核:%s 经理批准:%s" % (
stamp(order.created_by_name, order.created_at),
stamp(order.reviewed_by, order.reviewed_at),
stamp(order.approved_by, order.approved_at))
confirm_line = (u"供方确认(盖章):__________________ "
u"需方确认(盖章):__________________")
# 已审核 / 已批准 → 在签署区右侧盖章(两枚时并排)
seals = []
if order.reviewed_by:
seals.append(SealFlowable(
order.our_company or COMPANY_NAME, u"审核专用章", order.reviewed_by,
order.reviewed_at.strftime("%Y-%m-%d") if order.reviewed_at else u""))
if order.approved_by:
seals.append(SealFlowable(
order.our_company or COMPANY_NAME, u"批准专用章", order.approved_by,
order.approved_at.strftime("%Y-%m-%d") if order.approved_at else u""))
if seals:
if len(seals) == 1:
seal_cell = seals[0]
else:
inner = Table([seals], colWidths=[29 * mm, 29 * mm])
inner.setStyle(TableStyle([
("VALIGN", (0, 0), (-1, -1), "CENTER"),
("LEFTPADDING", (0, 0), (-1, -1), 0),
("RIGHTPADDING", (0, 0), (-1, -1), 0),
("TOPPADDING", (0, 0), (-1, -1), 0),
("BOTTOMPADDING", (0, 0), (-1, -1), 0),
]))
seal_cell = inner
sign_tbl = Table(
[[P(sign_line, styles["sign"]), seal_cell],
[P(confirm_line, styles["sign"]), P("", styles["sign"])]],
colWidths=[128 * mm, 58 * mm])
sign_tbl.setStyle(TableStyle([
("VALIGN", (0, 0), (0, -1), "TOP"),
("VALIGN", (1, 0), (1, -1), "CENTER"),
("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(sign_tbl))
else:
story.append(P(sign_line, styles["sign"]))
story.append(Spacer(1, 3 * mm))
story.append(P(confirm_line, styles["sign"]))
return story
doc.build(make_story())
counters["total"] = doc.page
doc.build(make_story())
return out_path