256 lines
8.1 KiB
Python
256 lines
8.1 KiB
Python
"""
|
||
PDF 发票信息提取
|
||
|
||
从 PDF 发票文件中提取关键字段,输出为标准化的发票数据列表。
|
||
|
||
对外接口:
|
||
extract_invoices(directory) -> list[dict] 扫描目录下所有 PDF 并提取
|
||
save_csv(invoices, path) 保存为 CSV
|
||
save_markdown(invoices, path) 保存为 Markdown 汇总
|
||
"""
|
||
|
||
import csv
|
||
import re
|
||
from pathlib import Path
|
||
|
||
from . import get_logger
|
||
|
||
log = get_logger("extractor")
|
||
|
||
CSV_COLUMNS = [
|
||
"序号", "发票号码", "开票日期", "项目名称", "规格型号",
|
||
"价税合计", "销售方名称", "人员姓名", "刷卡日期",
|
||
"公务卡号", "刷卡金额", "备注", "工号",
|
||
]
|
||
|
||
|
||
# ------------------------------------------------------------------
|
||
# PDF 文件发现与文本提取
|
||
# ------------------------------------------------------------------
|
||
|
||
def find_pdf_files(directory: str = ".") -> list[Path]:
|
||
"""查找目录下所有 PDF 文件(非递归)"""
|
||
pdf_dir = Path(directory)
|
||
if not pdf_dir.exists():
|
||
return []
|
||
return sorted(pdf_dir.glob("*.pdf"))
|
||
|
||
|
||
def extract_text_from_pdf(filepath: Path) -> str:
|
||
"""从单个 PDF 中提取全部文本"""
|
||
try:
|
||
import pdfplumber
|
||
except ImportError:
|
||
raise ImportError("缺少 pdfplumber,请执行: pip install pdfplumber")
|
||
|
||
try:
|
||
parts = []
|
||
with pdfplumber.open(filepath) as pdf:
|
||
for page in pdf.pages:
|
||
text = page.extract_text()
|
||
if text:
|
||
parts.append(text)
|
||
return "\n".join(parts)
|
||
except Exception as e:
|
||
log.error(f"无法读取 {filepath.name}: {e}")
|
||
return ""
|
||
|
||
|
||
# ------------------------------------------------------------------
|
||
# 字段解析
|
||
# ------------------------------------------------------------------
|
||
|
||
def _first(regexes: list[str], text: str) -> str | None:
|
||
"""尝试多个正则,返回第一个匹配组的文本"""
|
||
for pattern in regexes:
|
||
m = re.search(pattern, text)
|
||
if m:
|
||
return m.group(1).strip()
|
||
return None
|
||
|
||
|
||
def _parse_line_item(line: str) -> dict | None:
|
||
"""解析单行明细(*分类*具体名称 格式)"""
|
||
m = re.match(r"\*([^*]+)\*\s*(.+)", line)
|
||
if m:
|
||
return {
|
||
"项目名称": f"*{m.group(1).strip()}*{m.group(2).strip()}",
|
||
"规格型号": m.group(2).strip(),
|
||
}
|
||
return None
|
||
|
||
|
||
def _extract_line_items(text: str) -> list[dict]:
|
||
"""从发票文本中提取所有明细行"""
|
||
items = []
|
||
skip_keywords = ["项目名称", "合 计", "价税合计", "备注", "开票人"]
|
||
|
||
for line in text.split("\n"):
|
||
line = line.strip()
|
||
if not line:
|
||
continue
|
||
if any(kw in line for kw in skip_keywords):
|
||
continue
|
||
if "*" in line:
|
||
item = _parse_line_item(line)
|
||
if item:
|
||
items.append(item)
|
||
|
||
return items
|
||
|
||
|
||
def _format_date(date_raw: str) -> str:
|
||
"""将「2026年5月18日」转为「2026/5/18」"""
|
||
m = re.match(r"(\d{4})年(\d{1,2})月(\d{1,2})日", date_raw)
|
||
if m:
|
||
return f"{m.group(1)}/{m.group(2)}/{m.group(3)}"
|
||
return date_raw
|
||
|
||
|
||
def parse_invoice(text: str) -> dict:
|
||
"""从发票文本中提取关键字段,返回 dict
|
||
|
||
返回字段:
|
||
发票号码, 开票日期, 销售方名称, 价税合计, _items (明细列表)
|
||
其他字段(人员姓名等)留空,后续由 OCR 步骤填充
|
||
"""
|
||
invoice: dict[str, str] = {}
|
||
|
||
invoice["发票号码"] = _first([r"发票号码[::]?\s*(\d+)"], text) or ""
|
||
|
||
date_raw = _first([r"开票日期[::]?\s*(\d{4}年\d{1,2}月\d{1,2}日)"], text) or ""
|
||
invoice["开票日期"] = _format_date(date_raw) if date_raw else ""
|
||
|
||
invoice["销售方名称"] = _first(
|
||
[
|
||
r"销\s*售?\s*方?\s*名称[::]?\s*(.+?)(?:\n|$)",
|
||
r"销\s*名称[::]?\s*(.+?)(?:\n|$)",
|
||
],
|
||
text,
|
||
) or ""
|
||
|
||
invoice["价税合计"] = _first(
|
||
[r"价税合计.*?(小写)[¥¥]?\s*(\d+\.?\d*)"], text
|
||
) or ""
|
||
|
||
invoice["_items"] = _extract_line_items(text)
|
||
|
||
# 以下字段无法从 PDF 提取,留空由 OCR 步骤填充
|
||
for key in ("项目名称", "规格型号", "人员姓名", "刷卡日期",
|
||
"公务卡号", "刷卡金额", "备注", "工号"):
|
||
if key not in invoice:
|
||
invoice[key] = ""
|
||
|
||
return invoice
|
||
|
||
|
||
# ------------------------------------------------------------------
|
||
# CSV / Markdown 输出
|
||
# ------------------------------------------------------------------
|
||
|
||
def save_csv(invoices: list[dict], output_path: str | Path = "invoice_summary.csv"):
|
||
"""将发票列表保存为 CSV"""
|
||
csv_path = Path(output_path)
|
||
|
||
with open(csv_path, "w", encoding="utf-8", newline="") as f:
|
||
writer = csv.writer(f)
|
||
writer.writerow(CSV_COLUMNS)
|
||
|
||
for idx, inv in enumerate(invoices, 1):
|
||
items = inv.get("_items", [])
|
||
first_item = items[0] if items else {}
|
||
writer.writerow([
|
||
idx,
|
||
inv.get("发票号码", ""),
|
||
inv.get("开票日期", ""),
|
||
first_item.get("项目名称", inv.get("项目名称", "")),
|
||
first_item.get("规格型号", inv.get("规格型号", "")),
|
||
inv.get("价税合计", ""),
|
||
inv.get("销售方名称", ""),
|
||
inv.get("人员姓名", ""),
|
||
inv.get("刷卡日期", ""),
|
||
inv.get("公务卡号", ""),
|
||
inv.get("刷卡金额", ""),
|
||
inv.get("备注", ""),
|
||
inv.get("工号", ""),
|
||
])
|
||
|
||
log.info(f"CSV 已保存: {csv_path.name}")
|
||
|
||
|
||
def save_markdown(invoices: list[dict], output_path: str | Path = "invoice_summary.md"):
|
||
"""将发票列表保存为 Markdown 汇总表"""
|
||
md_path = Path(output_path)
|
||
lines = [
|
||
"# 发票信息汇总表",
|
||
"",
|
||
"| 序号 | 发票号码 | 开票日期 | 项目名称 | 规格型号 | 价税合计 | 销售方名称 |",
|
||
"|------|---------|---------|---------|---------|---------|-----------|",
|
||
]
|
||
|
||
total = 0.0
|
||
for idx, inv in enumerate(invoices, 1):
|
||
amount = 0.0
|
||
try:
|
||
amount = float(inv.get("价税合计", "0"))
|
||
except (ValueError, TypeError):
|
||
pass
|
||
total += amount
|
||
|
||
items = inv.get("_items", [])
|
||
first_item = items[0] if items else {}
|
||
project = first_item.get("项目名称", inv.get("项目名称", "-"))
|
||
spec = first_item.get("规格型号", inv.get("规格型号", "-"))
|
||
|
||
lines.append(
|
||
f"| {idx} "
|
||
f"| {inv.get('发票号码', '')} "
|
||
f"| {inv.get('开票日期', '')} "
|
||
f"| {project} | {spec} "
|
||
f"| ¥{amount:,.2f} "
|
||
f"| {inv.get('销售方名称', '')} |"
|
||
)
|
||
|
||
lines.append("")
|
||
lines.append(f"**总计: ¥{total:,.2f}**")
|
||
lines.append("")
|
||
|
||
with open(md_path, "w", encoding="utf-8") as f:
|
||
f.write("\n".join(lines))
|
||
|
||
log.info(f"Markdown 已保存: {md_path.name}")
|
||
|
||
|
||
# ------------------------------------------------------------------
|
||
# 主入口
|
||
# ------------------------------------------------------------------
|
||
|
||
def extract_invoices(directory: str = ".") -> list[dict]:
|
||
"""扫描目录下所有 PDF,提取发票信息并返回列表"""
|
||
target_dir = Path(directory).absolute()
|
||
|
||
pdf_files = find_pdf_files(directory)
|
||
if not pdf_files:
|
||
log.warning("未找到 PDF 文件")
|
||
return []
|
||
|
||
log.info(f"发现 {len(pdf_files)} 个 PDF 文件")
|
||
|
||
all_invoices = []
|
||
for pdf_path in pdf_files:
|
||
text = extract_text_from_pdf(pdf_path)
|
||
if text:
|
||
invoice = parse_invoice(text)
|
||
if invoice:
|
||
all_invoices.append(invoice)
|
||
else:
|
||
log.warning(f"未能解析: {pdf_path.name}")
|
||
else:
|
||
log.warning(f"未能提取文本: {pdf_path.name}")
|
||
|
||
if all_invoices:
|
||
log.info(f"共处理 {len(all_invoices)} 张发票")
|
||
else:
|
||
log.warning("未成功解析任何发票")
|
||
|
||
return all_invoices |