banner
Riceneeder

Riceneeder

卜得山火贲之变艮卦,象曰:装饰既成,宜静宜止。2025下半年,不宜躁进,宜守正持中,沉淀与反思,将为日后之再发打下基石。
github
email

從PDF發票中獲取商品細則

在課題組要做報帳,那麼就免不了根據發票做出入庫單,東西少的時候還好,一多起來真的麻煩死人。所以在我還在參與報帳工作時我就做了一個小工具,可以很暢快地做出入庫單,介面如下圖:

jietuchurukuold

雖然減少心智負擔了,但是這個時候依舊是需要手動輸入發票號碼、代碼、開票日期等信息。現在沒有參與報帳工作了,突然想到要是能直接上傳文件就獲得所有發票信息該多好,說幹就幹。最初的項目是 js/ts 一把梭,這次改用 Python,畢竟人生苦短,我用 Python。

提取發票信息的主體代碼實現如下,主要依賴 pdfplumber 這個庫和正則表達式:

import pdfplumber
import re
from typing import List, Dict, Optional

class InvoiceExtractor:
    def _invoice_pdf2txt(self, pdf_path: str) -> Optional[str]:
        """
        使用 pdfplumber 從 PDF 文件中提取文本。
        :param pdf_path: PDF 文件的路徑。
        :return: 提取的文本作為字符串返回,如果提取失敗則返回 None。
        """
        try:
            with pdfplumber.open(pdf_path) as pdf:
                text = '\n'.join(page.extract_text() for page in pdf.pages if page.extract_text())
            return text
        except Exception as e:
            #print(f"從 {pdf_path} 提取文本時出錯: {e}")
            return None

    def _extract_invoice_product_content(self, content: str) -> str:
        """
        從發票文本中提取商品相關內容。
        :param content: 發票的完整文本。
        :return: 提取的商品相關內容作為字符串返回。
        """
        lines = content.splitlines()
        start_pattern = re.compile(r"^(貨物或應稅勞務|項目名稱)")
        end_pattern = re.compile(r"^價稅合計")

        start_index = next((i for i, line in enumerate(lines) if start_pattern.match(line)), None)
        end_index = next((i for i, line in enumerate(lines) if end_pattern.match(line)), None)

        if start_index is not None and end_index is not None:
            extracted_lines = lines[start_index:end_index + 1]
            return '\n'.join(extracted_lines).strip()
        return "未找到匹配的內容"

    def construct_invoice_product_data(self, raw_text: str) -> List[Dict[str, str]]:
        """
        處理提取的文本,構建發票商品數據列表。
        :param raw_text: 提取的原始文本。
        :return: 商品數據列表,每個商品為一個字典。
        """
        blocks = re.split(r'(?=貨物或應稅勞務|項目名稱)', raw_text.strip())
        records = []

        for block in blocks:
            lines = [line.strip() for line in block.splitlines() if line.strip()]
            if not lines:
                continue

            current_record = ""
            for line in lines[1:]:
                if line.startswith("合") or line.startswith("價稅合計"):
                    continue

                if line.startswith("*"):
                    if current_record:
                        self._process_record(current_record, records)
                    current_record = line
                else:
                    if " " in current_record:
                        first_space_index = current_record.index(" ")
                        current_record = current_record[:first_space_index] + line + current_record[first_space_index:]

            if current_record:
                self._process_record(current_record, records)

        return records

    def _process_record(self, record: str, records: List[Dict[str, str]]):
        """
        處理單條記錄並添加到記錄列表中。
        :param record: 單條記錄的字符串。
        :param records: 記錄列表。
        """
        parts = record.rsplit(maxsplit=7)
        if len(parts) == 8:
            try:
                records.append({
                    "product_name": parts[0].strip(),
                    "specification": parts[1].strip(),
                    "unit": parts[2].strip(),
                    "quantity": parts[3].strip(),
                    "unit_price": float(parts[4].strip()),
                    "amount": float(parts[5].strip()),
                    "tax_rate": parts[6].strip(),
                    "tax_amount": float(parts[7].strip())
                })
            except ValueError as e:
                print(f"記錄解析失敗: {record}, 錯誤: {e}")
                pass

最終呢會得到一個字典,包含了發票的商品名、規格、單位、數量、單價、總價、稅率以及稅額。緊接著,基於這段腳本,再結合 fastapi 和 vue3,就搞了一個拖拽就能獲取發票信息、導出出入庫單的應用啦:

screenshot

當然,我現在又不負責報帳的工作了,做出來也是造福師弟師妹們,管他們用不用,反正我做出來了

載入中......
此文章數據所有權由區塊鏈加密技術和智能合約保障僅歸創作者所有。