v0.2.0: CRM/ERP 系统升级 - 清理 .gitignore 并移除误提交的 venv/env/db 文件
- 更新 .gitignore:全面覆盖环境变量、数据库、日志、缓存、上传文件 - 移除误跟踪的 server/venv/、crm_data.db、.env 文件 - 新增 server/.env.example 模板 - 新增合同管理、利润核算、AI教练等功能模块 - 新增 Playwright e2e 测试套件 - 前后端多项功能升级和 bug 修复
This commit is contained in:
@@ -0,0 +1,762 @@
|
||||
"""
|
||||
合同管理 Service 层
|
||||
核心逻辑:CRUD + 一键推单 + 账期引擎 + 执行进度聚合
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import date, datetime, timedelta
|
||||
import re
|
||||
|
||||
from sqlalchemy import func, select, update, delete
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.core.exceptions import BizException, ForbiddenException, NotFoundException
|
||||
from app.models.contract import ErpContract, ErpContractItem, ErpContractAttachment
|
||||
from app.models.order import ErpOrder, ErpOrderItem
|
||||
from app.models.shipping import ErpShippingRecord
|
||||
from app.models.finance import FinSalesInvoice
|
||||
from app.models.erp import ProductSku
|
||||
from app.models.crm import CrmCustomer
|
||||
from app.schemas.auth import CurrentUserPayload
|
||||
from app.schemas.contract import (
|
||||
ContractCreate,
|
||||
ContractUpdate,
|
||||
ContractItemResponse,
|
||||
ContractListResponse,
|
||||
ContractProgressResponse,
|
||||
ContractResponse,
|
||||
)
|
||||
|
||||
|
||||
# ── 金额大写转换 ─────────────────────────────────────────
|
||||
_CN_DIGITS = "零壹贰叁肆伍陆柒捌玖"
|
||||
_CN_UNITS = ["", "拾", "佰", "仟"]
|
||||
_CN_BIG_UNITS = ["", "万", "亿", "兆"]
|
||||
|
||||
|
||||
def amount_to_cn(amount: float) -> str:
|
||||
"""将金额转为中文大写"""
|
||||
if amount == 0:
|
||||
return "零元整"
|
||||
neg = ""
|
||||
if amount < 0:
|
||||
neg = "负"
|
||||
amount = -amount
|
||||
|
||||
yuan = int(amount)
|
||||
jiao = int(amount * 10) % 10
|
||||
fen = int(amount * 100) % 10
|
||||
|
||||
parts = []
|
||||
if yuan > 0:
|
||||
yuan_str = str(yuan)
|
||||
n = len(yuan_str)
|
||||
zero_flag = False
|
||||
for i, ch in enumerate(yuan_str):
|
||||
d = int(ch)
|
||||
pos = n - 1 - i
|
||||
big_idx = pos // 4
|
||||
unit_idx = pos % 4
|
||||
if d == 0:
|
||||
zero_flag = True
|
||||
if unit_idx == 0 and big_idx > 0:
|
||||
parts.append(_CN_BIG_UNITS[big_idx])
|
||||
else:
|
||||
if zero_flag:
|
||||
parts.append("零")
|
||||
zero_flag = False
|
||||
parts.append(_CN_DIGITS[d] + _CN_UNITS[unit_idx])
|
||||
if unit_idx == 0 and big_idx > 0:
|
||||
parts.append(_CN_BIG_UNITS[big_idx])
|
||||
parts.append("元")
|
||||
else:
|
||||
parts.append("零元")
|
||||
|
||||
if jiao > 0:
|
||||
parts.append(_CN_DIGITS[jiao] + "角")
|
||||
if fen > 0:
|
||||
parts.append(_CN_DIGITS[fen] + "分")
|
||||
else:
|
||||
if jiao == 0:
|
||||
parts.append("整")
|
||||
|
||||
return neg + "".join(parts)
|
||||
|
||||
|
||||
# ── 生成合同编号 ─────────────────────────────────────────
|
||||
async def _gen_contract_no(db: AsyncSession) -> str:
|
||||
today_str = date.today().strftime("%Y%m%d")
|
||||
prefix = f"HT-{today_str}-"
|
||||
count_stmt = select(func.count()).select_from(ErpContract).where(
|
||||
ErpContract.contract_no.like(f"{prefix}%")
|
||||
)
|
||||
count = (await db.execute(count_stmt)).scalar() or 0
|
||||
return f"{prefix}{count + 1:03d}"
|
||||
|
||||
|
||||
# ── 账期引擎 ────────────────────────────────────────────
|
||||
def calc_payment_due_date(payment_terms: str, base_date: date) -> date | None:
|
||||
"""根据付款条件枚举和基准日期(开票/发货)推算回款截止日"""
|
||||
m = re.search(r"(\d+)天", payment_terms)
|
||||
if m:
|
||||
days = int(m.group(1))
|
||||
return base_date + timedelta(days=days)
|
||||
if "货到" in payment_terms or "全款" in payment_terms:
|
||||
return base_date # 当天
|
||||
return None
|
||||
|
||||
|
||||
# ── ORM → Response ──────────────────────────────────────
|
||||
def _item_to_response(item: ErpContractItem) -> ContractItemResponse:
|
||||
sku = item.sku
|
||||
return ContractItemResponse(
|
||||
id=item.id,
|
||||
sku_id=item.sku_id,
|
||||
sku_code=sku.sku_code if sku else None,
|
||||
sku_name=sku.name if sku else None,
|
||||
spec=sku.spec if sku else None,
|
||||
unit=sku.unit if sku else None,
|
||||
qty=float(item.qty),
|
||||
unit_price=float(item.unit_price),
|
||||
sub_total=float(item.sub_total),
|
||||
)
|
||||
|
||||
|
||||
def _to_response(c: ErpContract, progress: ContractProgressResponse | None = None) -> ContractResponse:
|
||||
return ContractResponse(
|
||||
id=c.id,
|
||||
contract_no=c.contract_no,
|
||||
buyer_customer_id=c.buyer_customer_id,
|
||||
buyer_customer_name=c.buyer_customer.name if c.buyer_customer else None,
|
||||
seller_company_id=c.seller_company_id,
|
||||
seller_company_name=c.seller_company.name if c.seller_company else None,
|
||||
company_id=c.company_id,
|
||||
total_amount_excl_tax=float(c.total_amount_excl_tax or 0),
|
||||
total_amount_incl_tax=float(c.total_amount_incl_tax or 0),
|
||||
total_amount_cn=c.total_amount_cn,
|
||||
payment_terms=c.payment_terms,
|
||||
shipping_terms=c.shipping_terms,
|
||||
status=c.status,
|
||||
is_signed=c.is_signed,
|
||||
signed_file_url=c.signed_file_url,
|
||||
linked_order_id=c.linked_order_id,
|
||||
salesperson_id=c.salesperson_id,
|
||||
salesperson_name=c.salesperson.real_name if c.salesperson else None,
|
||||
sign_date=c.sign_date,
|
||||
remark=c.remark,
|
||||
delivery_terms=c.delivery_terms,
|
||||
items=[_item_to_response(i) for i in (c.items or []) if not i.is_deleted],
|
||||
progress=progress,
|
||||
created_at=c.created_at,
|
||||
updated_at=c.updated_at,
|
||||
)
|
||||
|
||||
|
||||
# ── 执行进度聚合 ────────────────────────────────────────
|
||||
async def _get_progress(db: AsyncSession, contract: ErpContract) -> ContractProgressResponse:
|
||||
progress = ContractProgressResponse(is_signed=contract.is_signed)
|
||||
|
||||
if contract.linked_order_id:
|
||||
progress.has_order = True
|
||||
progress.order_id = contract.linked_order_id
|
||||
|
||||
# 是否有发货
|
||||
ship_count = (await db.execute(
|
||||
select(func.count()).select_from(ErpShippingRecord).where(
|
||||
ErpShippingRecord.order_id == contract.linked_order_id,
|
||||
ErpShippingRecord.is_deleted.is_(False),
|
||||
)
|
||||
)).scalar() or 0
|
||||
progress.has_shipped = ship_count > 0
|
||||
|
||||
# 是否有销项发票
|
||||
inv_count = (await db.execute(
|
||||
select(func.count()).select_from(FinSalesInvoice).where(
|
||||
FinSalesInvoice.order_id == contract.linked_order_id,
|
||||
FinSalesInvoice.is_deleted.is_(False),
|
||||
)
|
||||
)).scalar() or 0
|
||||
progress.has_invoice = inv_count > 0
|
||||
|
||||
# 是否回款(检查订单回款状态)
|
||||
order = (await db.execute(
|
||||
select(ErpOrder).where(ErpOrder.id == contract.linked_order_id)
|
||||
)).scalar_one_or_none()
|
||||
if order and order.payment_state == "paid":
|
||||
progress.is_paid = True
|
||||
|
||||
return progress
|
||||
|
||||
|
||||
# ── 公共 eager-load 选项 ────────────────────────────────────
|
||||
def _contract_load_options():
|
||||
"""返回 selectinload 链,保证 commit 后仍可安全访问关系属性"""
|
||||
return [
|
||||
selectinload(ErpContract.buyer_customer),
|
||||
selectinload(ErpContract.seller_company),
|
||||
selectinload(ErpContract.salesperson),
|
||||
selectinload(ErpContract.items).selectinload(ErpContractItem.sku),
|
||||
]
|
||||
|
||||
|
||||
# ── Service Functions ────────────────────────────────────
|
||||
|
||||
async def create_contract(
|
||||
db: AsyncSession,
|
||||
user: CurrentUserPayload,
|
||||
company_id: uuid.UUID,
|
||||
body: ContractCreate,
|
||||
) -> ContractResponse:
|
||||
contract_no = await _gen_contract_no(db)
|
||||
|
||||
# 计算合计
|
||||
total = sum(item.sub_total for item in body.items)
|
||||
|
||||
contract = ErpContract(
|
||||
contract_no=contract_no,
|
||||
buyer_customer_id=body.buyer_customer_id,
|
||||
seller_company_id=company_id,
|
||||
company_id=company_id,
|
||||
total_amount_excl_tax=total,
|
||||
total_amount_incl_tax=total, # 含税金额默认同不含税,可后续区分
|
||||
total_amount_cn=amount_to_cn(total),
|
||||
payment_terms=body.payment_terms,
|
||||
shipping_terms=body.shipping_terms,
|
||||
sign_date=body.sign_date,
|
||||
remark=body.remark,
|
||||
delivery_terms=body.delivery_terms,
|
||||
salesperson_id=user.user_id,
|
||||
status="draft",
|
||||
)
|
||||
db.add(contract)
|
||||
await db.flush()
|
||||
|
||||
# 添加明细行
|
||||
for item_data in body.items:
|
||||
item = ErpContractItem(
|
||||
contract_id=contract.id,
|
||||
sku_id=item_data.sku_id,
|
||||
qty=item_data.qty,
|
||||
unit_price=item_data.unit_price,
|
||||
sub_total=item_data.sub_total,
|
||||
)
|
||||
db.add(item)
|
||||
|
||||
await db.commit()
|
||||
|
||||
# 重新查询并 eager-load 所有关系,避免 commit 后隐式 lazy load
|
||||
fresh = (await db.execute(
|
||||
select(ErpContract)
|
||||
.where(ErpContract.id == contract.id)
|
||||
.options(*_contract_load_options())
|
||||
)).scalar_one()
|
||||
return _to_response(fresh)
|
||||
|
||||
|
||||
async def list_contracts(
|
||||
db: AsyncSession,
|
||||
company_id: uuid.UUID,
|
||||
page: int = 1,
|
||||
size: int = 20,
|
||||
keyword: str | None = None,
|
||||
status: str | None = None,
|
||||
) -> ContractListResponse:
|
||||
base_where = [
|
||||
ErpContract.company_id == company_id,
|
||||
ErpContract.is_deleted.is_(False),
|
||||
]
|
||||
if keyword:
|
||||
base_where.append(ErpContract.contract_no.ilike(f"%{keyword}%"))
|
||||
if status:
|
||||
base_where.append(ErpContract.status == status)
|
||||
|
||||
total = (await db.execute(
|
||||
select(func.count()).select_from(ErpContract).where(*base_where)
|
||||
)).scalar() or 0
|
||||
|
||||
stmt = (
|
||||
select(ErpContract)
|
||||
.where(*base_where)
|
||||
.options(*_contract_load_options())
|
||||
.order_by(ErpContract.created_at.desc())
|
||||
.offset((page - 1) * size)
|
||||
.limit(size)
|
||||
)
|
||||
contracts = (await db.execute(stmt)).scalars().all()
|
||||
|
||||
return ContractListResponse(
|
||||
total=total,
|
||||
items=[_to_response(c) for c in contracts],
|
||||
page=page,
|
||||
size=size,
|
||||
)
|
||||
|
||||
|
||||
async def get_contract(
|
||||
db: AsyncSession,
|
||||
contract_id: uuid.UUID,
|
||||
company_id: uuid.UUID,
|
||||
) -> ContractResponse:
|
||||
stmt = (
|
||||
select(ErpContract)
|
||||
.where(
|
||||
ErpContract.id == contract_id,
|
||||
ErpContract.company_id == company_id,
|
||||
ErpContract.is_deleted.is_(False),
|
||||
)
|
||||
.options(*_contract_load_options())
|
||||
)
|
||||
contract = (await db.execute(stmt)).scalar_one_or_none()
|
||||
if contract is None:
|
||||
raise NotFoundException("合同不存在")
|
||||
|
||||
progress = await _get_progress(db, contract)
|
||||
return _to_response(contract, progress)
|
||||
|
||||
|
||||
async def update_contract(
|
||||
db: AsyncSession,
|
||||
contract_id: uuid.UUID,
|
||||
company_id: uuid.UUID,
|
||||
body: ContractUpdate,
|
||||
) -> ContractResponse:
|
||||
stmt = select(ErpContract).where(
|
||||
ErpContract.id == contract_id,
|
||||
ErpContract.company_id == company_id,
|
||||
ErpContract.is_deleted.is_(False),
|
||||
)
|
||||
contract = (await db.execute(stmt)).scalar_one_or_none()
|
||||
if contract is None:
|
||||
raise NotFoundException("合同不存在")
|
||||
|
||||
# 更新主表字段
|
||||
update_data = body.model_dump(exclude_unset=True, exclude={"items"})
|
||||
if update_data:
|
||||
update_data["updated_at"] = datetime.utcnow()
|
||||
await db.execute(
|
||||
update(ErpContract).where(ErpContract.id == contract_id).values(**update_data)
|
||||
)
|
||||
|
||||
# 如果有明细行更新,删旧增新
|
||||
if body.items is not None:
|
||||
await db.execute(
|
||||
update(ErpContractItem)
|
||||
.where(ErpContractItem.contract_id == contract_id)
|
||||
.values(is_deleted=True)
|
||||
)
|
||||
total = 0
|
||||
for item_data in body.items:
|
||||
item = ErpContractItem(
|
||||
contract_id=contract_id,
|
||||
sku_id=item_data.sku_id,
|
||||
qty=item_data.qty,
|
||||
unit_price=item_data.unit_price,
|
||||
sub_total=item_data.sub_total,
|
||||
)
|
||||
total += item_data.sub_total
|
||||
db.add(item)
|
||||
|
||||
await db.execute(
|
||||
update(ErpContract).where(ErpContract.id == contract_id).values(
|
||||
total_amount_excl_tax=total,
|
||||
total_amount_incl_tax=total,
|
||||
total_amount_cn=amount_to_cn(total),
|
||||
)
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
|
||||
updated = (await db.execute(
|
||||
select(ErpContract)
|
||||
.where(ErpContract.id == contract_id)
|
||||
.options(*_contract_load_options())
|
||||
)).scalar_one()
|
||||
return _to_response(updated)
|
||||
|
||||
|
||||
async def delete_contract(
|
||||
db: AsyncSession,
|
||||
contract_id: uuid.UUID,
|
||||
company_id: uuid.UUID,
|
||||
) -> None:
|
||||
stmt = select(ErpContract).where(
|
||||
ErpContract.id == contract_id,
|
||||
ErpContract.company_id == company_id,
|
||||
ErpContract.is_deleted.is_(False),
|
||||
)
|
||||
contract = (await db.execute(stmt)).scalar_one_or_none()
|
||||
if contract is None:
|
||||
raise NotFoundException("合同不存在")
|
||||
|
||||
await db.execute(
|
||||
update(ErpContract)
|
||||
.where(ErpContract.id == contract_id)
|
||||
.values(is_deleted=True, updated_at=datetime.utcnow())
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def generate_order_from_contract(
|
||||
db: AsyncSession,
|
||||
user: CurrentUserPayload,
|
||||
contract_id: uuid.UUID,
|
||||
company_id: uuid.UUID,
|
||||
) -> dict:
|
||||
"""一键从合同生成订单 —— 防篡改推单逻辑"""
|
||||
stmt = (
|
||||
select(ErpContract)
|
||||
.where(
|
||||
ErpContract.id == contract_id,
|
||||
ErpContract.company_id == company_id,
|
||||
ErpContract.is_deleted.is_(False),
|
||||
)
|
||||
.options(*_contract_load_options())
|
||||
)
|
||||
contract = (await db.execute(stmt)).scalar_one_or_none()
|
||||
if contract is None:
|
||||
raise NotFoundException("合同不存在")
|
||||
|
||||
if contract.linked_order_id is not None:
|
||||
raise BizException(message="该合同已关联订单,不可重复生成")
|
||||
|
||||
# 生成订单编号
|
||||
today_str = date.today().strftime("%Y%m%d")
|
||||
prefix = f"SO-{today_str}-"
|
||||
count = (await db.execute(
|
||||
select(func.count()).select_from(ErpOrder).where(
|
||||
ErpOrder.order_no.like(f"{prefix}%")
|
||||
)
|
||||
)).scalar() or 0
|
||||
order_no = f"{prefix}{count + 1:03d}"
|
||||
|
||||
# 创建订单
|
||||
new_order = ErpOrder(
|
||||
order_no=order_no,
|
||||
customer_id=contract.buyer_customer_id,
|
||||
salesperson_id=user.user_id,
|
||||
company_id=company_id,
|
||||
contract_id=contract_id,
|
||||
total_amount=float(contract.total_amount_incl_tax or 0),
|
||||
order_date=date.today(),
|
||||
)
|
||||
db.add(new_order)
|
||||
await db.flush()
|
||||
|
||||
# 复制合同明细到订单明细
|
||||
active_items = [i for i in (contract.items or []) if not i.is_deleted]
|
||||
for ci in active_items:
|
||||
oi = ErpOrderItem(
|
||||
order_id=new_order.id,
|
||||
sku_id=ci.sku_id,
|
||||
qty=float(ci.qty),
|
||||
unit_price=float(ci.unit_price),
|
||||
sub_total=float(ci.sub_total),
|
||||
)
|
||||
db.add(oi)
|
||||
|
||||
# 回填合同 linked_order_id + 激活状态
|
||||
await db.execute(
|
||||
update(ErpContract)
|
||||
.where(ErpContract.id == contract_id)
|
||||
.values(
|
||||
linked_order_id=new_order.id,
|
||||
status="active",
|
||||
updated_at=datetime.utcnow(),
|
||||
)
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
return {"order_id": str(new_order.id), "order_no": order_no}
|
||||
|
||||
|
||||
# ── 数字转中文大写金额 ──────────────────────────────────────
|
||||
def _amount_to_cn(amount: float) -> str:
|
||||
"""将数字金额转换为中文大写"""
|
||||
digits = "零壹贰叁肆伍陆柒捌玖"
|
||||
units = ["", "拾", "佰", "仟"]
|
||||
big_units = ["", "万", "亿"]
|
||||
|
||||
if amount == 0:
|
||||
return "零元整"
|
||||
|
||||
yuan = int(round(amount * 100))
|
||||
jiao = (yuan % 100) // 10
|
||||
fen = yuan % 10
|
||||
yuan_part = yuan // 100
|
||||
|
||||
result = ""
|
||||
if yuan_part > 0:
|
||||
s = str(yuan_part)
|
||||
n = len(s)
|
||||
for i, ch in enumerate(s):
|
||||
d = int(ch)
|
||||
pos = n - i - 1
|
||||
big_pos = pos // 4
|
||||
unit_pos = pos % 4
|
||||
if d != 0:
|
||||
result += digits[d] + units[unit_pos]
|
||||
else:
|
||||
if result and not result.endswith("零"):
|
||||
result += "零"
|
||||
if unit_pos == 0 and big_pos > 0:
|
||||
result = result.rstrip("零") + big_units[big_pos]
|
||||
result = result.rstrip("零") + "元"
|
||||
else:
|
||||
result = ""
|
||||
|
||||
if jiao == 0 and fen == 0:
|
||||
result += "整"
|
||||
else:
|
||||
if jiao > 0:
|
||||
result += digits[jiao] + "角"
|
||||
if fen > 0:
|
||||
result += digits[fen] + "分"
|
||||
|
||||
return result
|
||||
|
||||
|
||||
async def generate_contract_docx(
|
||||
db: AsyncSession,
|
||||
contract_id: uuid.UUID,
|
||||
company_id: uuid.UUID,
|
||||
) -> bytes:
|
||||
"""纯代码生成合同 Word 文档(紧凑排版,2 页以内)"""
|
||||
import io
|
||||
from docx import Document as DocxDocument
|
||||
from docx.shared import Pt, Cm, Emu, RGBColor
|
||||
from docx.enum.table import WD_TABLE_ALIGNMENT
|
||||
from docx.enum.text import WD_ALIGN_PARAGRAPH
|
||||
from docx.oxml.ns import qn
|
||||
|
||||
from app.models.sys import SysCompany
|
||||
|
||||
# ── 1) 数据准备 ─────────────────────────────────────────
|
||||
contract = (await db.execute(
|
||||
select(ErpContract)
|
||||
.where(
|
||||
ErpContract.id == contract_id,
|
||||
ErpContract.company_id == company_id,
|
||||
ErpContract.is_deleted.is_(False),
|
||||
)
|
||||
.options(*_contract_load_options())
|
||||
)).scalar_one_or_none()
|
||||
if contract is None:
|
||||
raise NotFoundException("合同不存在")
|
||||
|
||||
seller = (await db.execute(
|
||||
select(SysCompany).where(SysCompany.id == contract.seller_company_id)
|
||||
)).scalar_one_or_none()
|
||||
seller_info = (seller.full_info or {}) if seller else {}
|
||||
|
||||
buyer = contract.buyer_customer
|
||||
buyer_billing = {}
|
||||
if buyer and hasattr(buyer, "billing_info") and buyer.billing_info:
|
||||
buyer_billing = buyer.billing_info
|
||||
|
||||
total_incl = float(contract.total_amount_incl_tax or 0)
|
||||
sign_date_str = (contract.sign_date or date.today()).strftime("%Y年%m月%d日")
|
||||
buyer_name = buyer_billing.get("company_name") or (buyer.name if buyer else "")
|
||||
seller_name = seller_info.get("company_name") or (seller.name if seller else "")
|
||||
items = [i for i in (contract.items or []) if not i.is_deleted]
|
||||
|
||||
# ── 2) 创建文档 ─────────────────────────────────────────
|
||||
doc = DocxDocument()
|
||||
|
||||
# 页边距:上下2cm 左右2.5cm(紧凑)
|
||||
for section in doc.sections:
|
||||
section.top_margin = Cm(2)
|
||||
section.bottom_margin = Cm(1.5)
|
||||
section.left_margin = Cm(2.5)
|
||||
section.right_margin = Cm(2.5)
|
||||
|
||||
# ── 辅助函数 ─────────────────────────────────────────────
|
||||
# 小四 = 12pt, 1.5倍行距 = 18pt
|
||||
def add_para(text: str, font_size: int = 12, bold: bool = False,
|
||||
align=WD_ALIGN_PARAGRAPH.LEFT, space_before: int = 0,
|
||||
space_after: int = 0, font_name: str = "宋体"):
|
||||
p = doc.add_paragraph()
|
||||
p.alignment = align
|
||||
p.paragraph_format.space_before = Pt(space_before)
|
||||
p.paragraph_format.space_after = Pt(space_after)
|
||||
p.paragraph_format.line_spacing = Pt(18) # 1.5倍行距(12pt×1.5)
|
||||
run = p.add_run(text)
|
||||
run.font.size = Pt(font_size)
|
||||
run.font.bold = bold
|
||||
run.font.name = font_name
|
||||
run._element.rPr.rFonts.set(qn("w:eastAsia"), font_name)
|
||||
return p
|
||||
|
||||
def set_cell(cell, text: str, font_size: int = 12, bold: bool = False,
|
||||
align=WD_ALIGN_PARAGRAPH.CENTER):
|
||||
cell.text = ""
|
||||
p = cell.paragraphs[0]
|
||||
p.alignment = align
|
||||
p.paragraph_format.space_before = Pt(0)
|
||||
p.paragraph_format.space_after = Pt(0)
|
||||
p.paragraph_format.line_spacing = Pt(18) # 1.5倍行距
|
||||
run = p.add_run(text)
|
||||
run.font.size = Pt(font_size)
|
||||
run.font.bold = bold
|
||||
run.font.name = "宋体"
|
||||
run._element.rPr.rFonts.set(qn("w:eastAsia"), "宋体")
|
||||
|
||||
# ── 3) 标题 ──────────────────────────────────────────────
|
||||
add_para("产 品 购 销 合 同", font_size=18, bold=True,
|
||||
align=WD_ALIGN_PARAGRAPH.CENTER, space_after=4, font_name="黑体")
|
||||
|
||||
add_para(f"合同编号:{contract.contract_no}",
|
||||
align=WD_ALIGN_PARAGRAPH.RIGHT, space_after=4)
|
||||
|
||||
# ── 4) 甲乙方信息(紧凑表格) ────────────────────────────
|
||||
info_tbl = doc.add_table(rows=4, cols=4)
|
||||
info_tbl.alignment = WD_TABLE_ALIGNMENT.CENTER
|
||||
info_tbl.style = "Table Grid"
|
||||
|
||||
info_data = [
|
||||
("买方(甲方)", buyer_name,
|
||||
"卖方(乙方)", seller_name),
|
||||
("税号", buyer_billing.get("tax_id", "") or "",
|
||||
"税号", seller_info.get("tax_id", "") or ""),
|
||||
("地址", buyer_billing.get("address", "") or "",
|
||||
"地址", seller_info.get("address", "") or ""),
|
||||
("开户行 / 账号",
|
||||
f"{buyer_billing.get('bank_name', '') or ''} {buyer_billing.get('bank_account', '') or ''}".strip(),
|
||||
"开户行 / 账号",
|
||||
f"{seller_info.get('bank_name', '') or ''} {seller_info.get('bank_account', '') or ''}".strip()),
|
||||
]
|
||||
for ri, row_data in enumerate(info_data):
|
||||
for ci, val in enumerate(row_data):
|
||||
bold = ri == 0 and ci in (0, 2)
|
||||
set_cell(info_tbl.cell(ri, ci), val, bold=bold,
|
||||
align=WD_ALIGN_PARAGRAPH.LEFT)
|
||||
|
||||
# ── 5) 一、产品明细 ──────────────────────────────────────
|
||||
add_para("一、产品明细", bold=True, space_before=6, space_after=2)
|
||||
|
||||
cols = 6
|
||||
tbl = doc.add_table(rows=1 + len(items) + 1, cols=cols)
|
||||
tbl.alignment = WD_TABLE_ALIGNMENT.CENTER
|
||||
tbl.style = "Table Grid"
|
||||
|
||||
headers = ["序号", "产品名称", "规格", "数量", "单价(元)", "小计(元)"]
|
||||
for ci, h in enumerate(headers):
|
||||
set_cell(tbl.cell(0, ci), h, bold=True)
|
||||
|
||||
for ri, item in enumerate(items):
|
||||
sku_name = item.sku.name if item.sku else ""
|
||||
sku_spec = item.sku.spec if item.sku else ""
|
||||
set_cell(tbl.cell(ri + 1, 0), str(ri + 1))
|
||||
set_cell(tbl.cell(ri + 1, 1), sku_name, align=WD_ALIGN_PARAGRAPH.LEFT)
|
||||
set_cell(tbl.cell(ri + 1, 2), sku_spec or "-")
|
||||
set_cell(tbl.cell(ri + 1, 3), str(float(item.qty)))
|
||||
set_cell(tbl.cell(ri + 1, 4), f"{float(item.unit_price):,.2f}",
|
||||
align=WD_ALIGN_PARAGRAPH.RIGHT)
|
||||
set_cell(tbl.cell(ri + 1, 5), f"{float(item.sub_total):,.2f}",
|
||||
align=WD_ALIGN_PARAGRAPH.RIGHT)
|
||||
|
||||
# 合计行
|
||||
last_row = len(items) + 1
|
||||
set_cell(tbl.cell(last_row, 0), "合计", bold=True)
|
||||
# 合并序号~单价列
|
||||
for ci in range(1, 4):
|
||||
set_cell(tbl.cell(last_row, ci), "")
|
||||
set_cell(tbl.cell(last_row, 4), "", align=WD_ALIGN_PARAGRAPH.RIGHT)
|
||||
set_cell(tbl.cell(last_row, 5), f"{total_incl:,.2f}", bold=True,
|
||||
align=WD_ALIGN_PARAGRAPH.RIGHT)
|
||||
|
||||
# 大写金额
|
||||
add_para(f"合计金额(大写):{_amount_to_cn(total_incl)} (含13%增值税)",
|
||||
bold=True, space_before=2, space_after=2)
|
||||
|
||||
# ── 6) 二、交货及付款条件 ────────────────────────────────
|
||||
add_para("二、交货及付款条件", bold=True, space_before=4, space_after=2)
|
||||
delivery_text = contract.delivery_terms or "按双方约定"
|
||||
add_para(f"1. 货 期:{delivery_text}")
|
||||
add_para(f"2. 交货方式:{contract.shipping_terms or '买方自提'}")
|
||||
add_para(f"3. 付款条件:{contract.payment_terms or '货到付全款'}")
|
||||
|
||||
# ── 7) 三、发票信息 ──────────────────────────────────────
|
||||
add_para("三、发票信息", bold=True, space_before=4, space_after=2)
|
||||
add_para("卖方给买方开具合同金额增值税专用发票(13%增值税)。")
|
||||
|
||||
# ── 8) 四、合同细则 ──────────────────────────────────────
|
||||
add_para("四、合同细则", bold=True, space_before=4, space_after=2)
|
||||
|
||||
# 紧凑输出细则内容
|
||||
terms = [
|
||||
"第一条 质量标准:按照厂家标准执行,由于买方储存不当(如露天暴晒、混入杂质、超过保质期等)或未按产品说明书操作导致的质量问题,卖方不承担责任。",
|
||||
"第二条 卖方对质量负责的条件及期限:自货到12个月。",
|
||||
"第三条 包装标准包装物的供应与回收:产品包装均应采用国家或专业标准保护措施进行包装,以确保产品不受损害为原则,由于包装不善所引起的货物污染、损坏、损失均由卖方负担,采取装箱包装的应在包装箱内附一份详细装箱单和质量合格证,包装物不回收。",
|
||||
"第四条 合理损耗标准及计算方法:标的货物送至买方指定地点前的合理损耗由卖方负责。",
|
||||
"第五条 标的物所有权:在买方付清本合同项下全部货款之前,标的物的所有权仍属于卖方。",
|
||||
"第六条 检验标准、方法、地点及期限:按第二条标准检验。",
|
||||
"第七条 发票信息:卖方给买方开具合同金额增值税专用发票(13%增值税)。",
|
||||
"第八条 本合同解除条件:合同执行完毕。",
|
||||
(
|
||||
"第九条 违约责任:\n"
|
||||
"1、卖方应保证产品质量合格,买方有权在货到后7个工作日内且未开封状态下将卖方产品送质监局或第三方部门检验单位检验,"
|
||||
"送检样品的取样过程必须经卖方现场确认或双方共同封样,否则检验结果无效。检验结果不合格,则所发生的所有检验费用,"
|
||||
"均由卖方承担,买方可根据实际情况选择要求退货或更换。\n"
|
||||
"赔偿限额:卖方对本合同项下违约责任的赔偿总额,以本合同约定的总货款金额为限,"
|
||||
"且不承担任何间接损失(包括但不限于停工损失、利润损失等)。"
|
||||
),
|
||||
(
|
||||
"第十条 合同争议的解决方式:本合同在履行过程中发生的争执,由双方当事人协商解决,"
|
||||
"也可由当地工商行政管理部门调解;协商或调解不成的,按下列第二种方式解决。\n"
|
||||
"(一)提交当地仲裁委员会仲裁;(二)依法向卖方所在地的人民法院起诉。"
|
||||
),
|
||||
"第十一条 本合同一式两份,自双方签字盖章起生效。",
|
||||
(
|
||||
"第十二条 其他约定事项:\n"
|
||||
"1、卖方必须遵守国家有关能源管理的法律、法规;\n"
|
||||
"2、卖方必须执行买方对其提出的对能源控制进行改善的要求;\n"
|
||||
"3、卖方在运输途中和施工作业中的各种行为不应对能源造成浪费或负面影响;\n"
|
||||
"4、如卖方提供货物存在质量问题,买方书面(包括但不限于传真、邮件)通知对方,"
|
||||
"卖方在接到买方书面通知后3个工作日内要给与买方书面回复,否则将视为卖方已经认可买方提出的质量问题;"
|
||||
"如果双方意见产生争议,由卖方负责安排经买方同意的第三方进行检验,否则视为卖方质量问题;\n"
|
||||
"5、未经对方书面同意,不得将合同部分或者全部权利义务转给第三方。\n"
|
||||
"6、如遇战争、原材料短缺、工厂停产、物流管制等不可抗力因素导致货期延长,卖方不承担违约责任。"
|
||||
),
|
||||
]
|
||||
|
||||
for term in terms:
|
||||
add_para(term)
|
||||
|
||||
# ── 9) 签章区 ────────────────────────────────────────────
|
||||
add_para("", space_before=6, space_after=0) # 小间距
|
||||
|
||||
sig_tbl = doc.add_table(rows=4, cols=2)
|
||||
sig_tbl.alignment = WD_TABLE_ALIGNMENT.CENTER
|
||||
# 去边框
|
||||
for row in sig_tbl.rows:
|
||||
for cell in row.cells:
|
||||
for paragraph in cell.paragraphs:
|
||||
paragraph.paragraph_format.space_before = Pt(0)
|
||||
paragraph.paragraph_format.space_after = Pt(0)
|
||||
|
||||
set_cell(sig_tbl.cell(0, 0), "买方(盖章):", bold=True,
|
||||
align=WD_ALIGN_PARAGRAPH.LEFT)
|
||||
set_cell(sig_tbl.cell(0, 1), "卖方(盖章):", bold=True,
|
||||
align=WD_ALIGN_PARAGRAPH.LEFT)
|
||||
set_cell(sig_tbl.cell(1, 0), "授权代表签字:",
|
||||
align=WD_ALIGN_PARAGRAPH.LEFT)
|
||||
set_cell(sig_tbl.cell(1, 1), "授权代表签字:",
|
||||
align=WD_ALIGN_PARAGRAPH.LEFT)
|
||||
set_cell(sig_tbl.cell(2, 0), f"日期:{sign_date_str}",
|
||||
align=WD_ALIGN_PARAGRAPH.LEFT)
|
||||
set_cell(sig_tbl.cell(2, 1), f"日期:{sign_date_str}",
|
||||
align=WD_ALIGN_PARAGRAPH.LEFT)
|
||||
set_cell(sig_tbl.cell(3, 0), f"联系电话:{buyer_billing.get('phone', '') or ''}",
|
||||
align=WD_ALIGN_PARAGRAPH.LEFT)
|
||||
set_cell(sig_tbl.cell(3, 1), f"联系电话:{seller_info.get('phone', '') or ''}",
|
||||
align=WD_ALIGN_PARAGRAPH.LEFT)
|
||||
|
||||
# ── 10) 输出 ─────────────────────────────────────────────
|
||||
buffer = io.BytesIO()
|
||||
doc.save(buffer)
|
||||
buffer.seek(0)
|
||||
return buffer.getvalue()
|
||||
|
||||
Reference in New Issue
Block a user