3
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Pythonで見積作成を自動化するデスクトップアプリの作成~既存のオーダー用Excelファイルを見積フォーマットへ出力~

Last updated at Posted at 2025-11-09

はじめに

2025年9月からPythonの学習を始めました。
今回は学習内容のアウトプットとして、営業業務で行っている見積書作成の自動化に挑戦しました。

私の業務では、見積書をExcelで作成しています。
しかし、会社のシステムで出力できる見積書は「製品名」と「金額」しか表示されず、
お客様から「内容がわかりにくい」と言われることが多くありました。

そのため普段は、見積書テンプレートに製品名・価格・製品画像を手動で貼り付けています。
この作業をPythonで自動化し、業務の効率化と見積書の品質向上を目指しました。

補足

クラス設計を学んでいた時期だったため、今回のスクリプトでは学習のアウトプットを兼ねてクラス構成で作成しています。
(実際の処理内容としては、モジュールを分割して関数ベースでも実装可能です。)

1. 自動化の概要

自動化の基本的な流れは以下の通りです。

1.自社ホームページよりスクレイピングで画像データの取得
2.アセンブリ.xlsx(オーダー用Excelファイル)から見積作成に必要なデータの取得と成型
3.製品マスタ.xlsxより製品名の変換の辞書を作成
4.見積書原紙をコピーして新しく生成(ファイル名は会社名 御見積書)
5.見積書の行がたりなければxlwingsで不足分を挿入
6.見積書に書き込みを行う

以上の内容をtkinterでデスクトップアプリを起動して
必要なExcelファイルを選択して、[見積作成]を押すことで実行します。

image.png

2. 成果物のイメージ

画像のような写真付きの見積が自動作成できます。

出力結果

3. 自動化で使用するExcelファイルのイメージ

3-1. 見積原紙.xlsx

作成する見積の元となるフォーマットです。

見積原紙

3-2. アセンブリ.xlsx(オーダー用)

見積を作成する際の元データとなるシートです。

オーダーシート

3-3. 製品マスタ.xlsx (イメージ)

オーダーシート内でわかりにくい製品名を、
「変換」列に記載した名前へ置き換えるために使用します。

品番 製品名 変換
0001 オプション用付属品A 3.11.22 付属品A

4. 環境構築

4-1. 使用する言語

言語 バージョン
Python 3.11.9

pyenvとpoetryを使用して環境構築を行いました。
(使用OS:Windows)

4-2. 使用するライブラリ

使用目的 ライブラリ バージョン
ファイル・ディレクトリ操作 os 標準ライブラリ
見積原紙をコピーして新しく作成 shutil 標準ライブラリ
画像データをメモリ上で操作 io.BytesIO 標準ライブラリ
日付処理 datetime 標準ライブラリ
月末日を取得するために使用 calendar 標準ライブラリ
正規表現による文字列処理 re 標準ライブラリ
見積内容のデータを操作、成型 pandas >=2.3.3,<3.0.0
Excelファイルの読み書き openpyxl >=3.1.5,<4.0.0
Excel操作(行挿入) xlwings >=0.33.16,<0.34.0
自社サイトから画像URLを取得 beautifulsoup4 >=4.14.2,<5.0.0
Webサイトからのデータ取得、画像URLから画像を取得 requests >=2.32.5,<3.0.0
Excelに画像を貼り付ける pillow >=12.0.0,<13.0.0

5. フォルダ構成

CREATE_QUOTATION/
│
├── core/                           # アプリの主要ロジックをまとめたディレクトリ
│   ├── __pycache__/                
│   ├── __init__.py                 
│   ├── estimate_creator.py         # 見積書への書き込み処理を行うモジュール
│   ├── estimate_data.py            # オーダー情報を取得・整形して見積用データを生成するモジュール
│   ├── scraper.py                  # 会社ホームページから製品画像をスクレイピングするモジュール
│   └── utils.py                    # 各モジュールで共通利用するユーティリティ関数群
│
├── ui/                             # GUI関連モジュール
│   ├── __pycache__/                
│   ├── __init__.py                 
│   └── app.py                      # Tkinterを用いてUIを構築するモジュール
│
├── main.py                         # アプリのエントリーポイント(UIを起動する)
│
└── その他.venv,tomlなど

main.pyを実行することでアプリを起動することができます。

6. class設計

以下の表の内容でclassを作成しました。

class名 所属モジュール 説明 継承関係
Scraper core/scraper.py 会社ホームページから製品画像データをスクレイピングで取得するclass object
EstimateHeaderData core/estimate_data.py 見積書のヘッダー部分(会社名・日付など)のデータを扱うclass object
EstimateData core/estimate_data.py 見積書の明細部分(品名・数量・単価など)のデータを扱うclass EstimateHeaderData
EstimateCreator core/estimate_creator.py 見積書の新規作成およびテンプレート操作を行うclass object
EstimateWriter core/estimate_creator.py 見積内容をExcel見積書に書き込むclass EstimateCreator
EstimateApp ui/app.py Tkinterを用いてUIを構築し、見積作成処理を操作するclass object

7. コードの詳細

7-1.main.py

見積作成アプリを起動するコードです。

main.py
from ui.app import EstimateApp


def main():
    app = EstimateApp()
    app.run()

if __name__ == '__main__':
    main()

uiディレクトリからEstimateApp classをimportして
インスタンス化してapp.runで起動しています。

7-2. ui/app.py

Tkinterを用いてUIを構築するモジュール

ui/app.py
"""見積作成APPのUI"""
import os

import tkinter
from tkinter import filedialog

from core.estimate_creator import EstimateWriter


class EstimateApp:
    """見積作成APPのUIのクラス"""
    def __init__(self):
        self.window = tkinter.Tk()
        self.files = {"assembly": None, "estimate": None, "master": None}
        self.warning_label = None
        self.result_label = None
        self.file_labels = {}
        self.texts_types = [        
            ('アセンブリシート貼り付け','assembly'),
            ('見積原紙貼り付け','estimate'),
            ('製品マスタ貼り付け','master')
        ]
        self.insert_row = len(self.texts_types) * 2
        self._build_ui()

    def _build_ui(self):
        """UI部分の作成"""
        # タイトルの設定
        self.window.title('見積作成アプリ')
        # Windowのサイズ設定、位置の指定
        self.window.geometry('900x500+300+300')
        # Windowのサイズを固定する
        self.window.resizable(0,0)
        # Windowを一番手前に表示させる
        self.window.attributes('-topmost',True)

        for i, (text, file_type) in enumerate(self.texts_types):
            label = tkinter.Label(self.window, text=text, font=('Arial',16))
            label.grid(row=i*2, column=0, padx=30, pady=10)
            select_file_btn = tkinter.Button(
                self.window, 
                text=f'Excelファイル:{text}', 
                width=60, 
                command=lambda i=i*2, t=file_type: self._select_files(i,t)
            )
            select_file_btn.grid(row=i*2, column=1, padx=30,pady=10)

        app_run_btn = tkinter.Button(self.window, text='見積作成', width=40, command=self._app_run)
        app_run_btn.grid(row=self.insert_row , columnspan=2, padx=10, pady=20)

    def _select_files(self, i, file_type):
        """指定された種類のExcelファイルをファイルダイアログで選択し、パスを保存する。

        Args:
            i (int): ラベルを配置する行インデックス。
            file_type (str): 'assembly' / 'estimate' / 'master' のいずれか。
        """
        filepath = filedialog.askopenfilename(
            title='Excelファイルを選択',
            filetypes=[('Excelファイル', '*.xlsx *.xls')]
            )
        if not filepath:
            return
        
        file_name = os.path.basename(filepath)

        if file_type in self.file_labels.keys():
            self.file_labels[file_type].config(text=file_name)
        else:
            label = tkinter.Label(self.window, text=file_name, font=('Arial',12))
            label.grid(row=i+1,column=1,padx=30, pady=5)
            self.file_labels[file_type] = label

        self.files[file_type] = filepath
    
    def _app_run(self):
        """「見積作成」ボタン押下時の処理。

        - ファイルがすべて選択されていなければ警告を表示。
        - EstimateWriterを呼び出し見積書を作成。
        - 結果またはエラーをラベルで表示。
        """
        if self.warning_label is not None:
            self.warning_label.destroy()
            self.warning_label = None
        if self.result_label is not None:
            self.result_label.destroy()
            self.result_label = None
        #1つでもExcelファイルを貼り付けてないと警告ラベルの追加
        if None in self.files.values():
            self.warning_label = tkinter.Label(self.window,text='すべてのファイルを選択してくだい',font=('Arial',12), fg='red')
            self.warning_label.grid(row=self.insert_row*2, columnspan=2, padx=10,pady=5)
            return
        
        try:
            estimate_writer = EstimateWriter(self.files['assembly'], self.files['master'], self.files['estimate'])
            estimate_writer.writing_estimate()
        except Exception as e:
            self.result_label = tkinter.Label(
                self.window, 
                text=f'エラーが発生しました:{e}', 
                font=('Arial', 12), 
                fg='red'
            )
            self.result_label.grid(row=self.insert_row*2, columnspan=2, padx=10,pady=5)
            return

        self.result_label = tkinter.Label(self.window, text='見積作成が完了しました!', font=('Arial', 12), fg='green')
        self.result_label.grid(row=self.insert_row*2, columnspan=2, padx=10,pady=5)

    def run(self):
        self.window.mainloop()

使用する関数(メソッド)の一覧

関数名 種類 説明
init コンストラクタ ウィンドウやUI要素の初期設定を行い、_build_ui() を呼び出す。
_build_ui プライベートメソッド Tkinterを使ってUI(ラベル・ボタン)を動的に構築する。
_select_files プライベートメソッド ファイルダイアログを開き、選択されたExcelファイルのパスを保存・表示する。
_app_run プライベートメソッド 「見積作成」ボタン押下時の処理。ファイル選択チェック → EstimateWriter呼び出し → 結果表示までを行う。
run パブリックメソッド Tkinterのメインループを開始し、アプリを起動する。

7-3. core/scraper.py

会社ホームページから製品画像をスクレイピングするモジュール

core/scraper.py
"""スクレイピングで画像のURLを取得してDFを作成"""
import pandas as pd
import requests
from bs4 import BeautifulSoup


BASE_URL = '会社サイトのベースURL'
TARGET_URL = '製品画像情報のあるURL'


class Scraper:
    """スクレイピングで画像データを取得するクラス"""
    def __init__(self, base_url=BASE_URL, target_url=TARGET_URL):
        """
        Scraperクラスの初期化。

        Args:
            base_url (str): ベースとなるURL
            target_url (str): 情報を取得する対象URL
        """
        self.base_url = base_url
        self.target_url = target_url

    def _get_html(self):
        """
        html 情報の取得

        Returns: 
            soup(BeautifulSoup) : htmlの情報
        """
        try:
            response = requests.get(self.target_url)
            soup = BeautifulSoup(response.text, 'html.parser')
            return soup
        except requests.exceptions.RequestException as e:
            raise Exception(f"HTML取得エラー: {e}")
        
    def create_prodact_df(self):
        """ 
        html情報より必要情報を抽出してDataFrameを作成
        Returns:
            prodact_df(DataFrame): 画像URL情報の入っているDataFrame
        """
        soup = self._get_html()
        #divタグの指定クラスを変数に格納
        div_class = soup.find_all('div', class_='o-editorial-module m-editorial-cols--fit')
        
        prodact_names = []
        prodact_links = []
        prodact_imgs = []
        for div in div_class:
            a_tags = div.find_all('a', class_='a-link a-link--arrow-after a-link--large')
            img_tags = div.find_all('img', class_='cmp-image__image aem-core-image')

            for a_tag, img_tag in zip(a_tags, img_tags):
                prodact_names.append(a_tag.get_text())
                prodact_links.append(a_tag.get('href'))
                
                img_url =img_tag.get('src')
                prodact_imgs.append(self.base_url + img_url)

            # 抽出した各リストより必要DFの作成
        prodact_df = pd.DataFrame({
            '品名':prodact_names,
            'リンク先':prodact_links,
            '画像':prodact_imgs
            })   
        return prodact_df


if __name__ == '__main__':
    scraper = Scraper()
    print(scraper.create_prodact_df())

使用する関数(メソッド)の一覧

関数名 種類 説明
init コンストラクタ ベースURLと対象URLを初期化し、スクレイピングの準備を行う。
_get_html プライベートメソッド requestsBeautifulSoup を用いてHTMLデータを取得・解析する。
create_prodact_df パブリックメソッド HTML内の製品名・リンク・画像URLを抽出し、pandas.DataFrame に整形して返す。

7-4. core/estimate_data.py

見積に必要なデータを取得、成型するモジュール

core/estimate_data.py
"""見積作成の情報を集める処理"""
import calendar
from datetime import datetime

import pandas as pd
from openpyxl import load_workbook

from core.scraper import Scraper
from core import utils


class EstimateHeaderData:
    """見積ヘッダー部分のデータを扱うclass"""
    def __init__(self, assembly_xl):
        """
        Args:
            assembly_xl: アッセンブリシート.xslx
        """
        self.assembly_xl = assembly_xl
        with pd.ExcelFile(assembly_xl) as xls:
            sheet_list = xls.sheet_names
        self.assembly_sheet_list = sheet_list
    
    def get_company_name(self):
        """アッセンブリシート.xlsxより会社名を取得する関数
        
        Returns:
            company_name: 見積の提出先の会社名
        """
        try:
            wb_order = load_workbook(self.assembly_xl)
            ws_order = wb_order[self.assembly_sheet_list[2]]
            company_name = ws_order.cell(4, 3).value
            return company_name
        finally:
            wb_order.close()
    
    def get_validity_period(self):
        """見積有効期限の日にちを月末にするため実行月の月末の日を取得

        Returns:
            end_month: 月末日のdatetime型
        """
        today = datetime.today()
        last_day = calendar.monthrange(today.year, today.month)
        end_month = datetime(today.year,today.month,last_day[1])
        return end_month


class EstimateData(EstimateHeaderData):
    """見積の内容に必要なデータを扱うclass"""
    def __init__(self, assembly_xl, master_xl):
        """
        Args:
            assembly_xl: アッセンブリシート.xslx
            master_xl: 製品マスタ.xlsx
        """
        super().__init__(assembly_xl)
        self.master_xl = master_xl
        self.prodact_df = Scraper().create_prodact_df()

    def _loop(self):
        """create_assembly_dfで使用"""
        for i in range(1, 3):
            yield i

    def _insert_img_url_to_assembly_df(self, split_assembly_df):
        """
        create_assembly_dfで使用 split_assembly_dfに画像URLの追加

        Args:
            split_assembly_df: Noごとに分割したDataFrame
        Returns:
            split_assembly_df: 画像URLを追加したDataFrame
        """
        normalize_key_names = [utils.normalize(key_name) for key_name in self.prodact_df['品名']]
        self.prodact_df['key'] = normalize_key_names
        split_assembly_df['画像'] = None
        for i, tool_name in enumerate(split_assembly_df['品名']):
            normalozed_tool_name = utils.normalize(tool_name)
            match_row = self.prodact_df[self.prodact_df['key'].apply(lambda k: k in normalozed_tool_name)]
            
            if not match_row.empty:
                split_assembly_df.at[i, '画像'] = match_row.iloc[0]['画像']     
        return split_assembly_df

    def create_assembly_df_list(self):
        """
        アッセンブリシートのDataFrameを作成してNoカラムの数字でフィルターをかけることでDataFrameを分けてlistに格納
       
        Returns:
         df_list: Noごとに分割したDataFrameが入っているリスト
        """
        int_col = ['No', '品番', '', '総月額', '契約月数']
        #分割したDFを格納するために使用
        assembly_df_list = [] 
        
        for i in self._loop():
            assembly_df = pd.read_excel(self.assembly_xl, sheet_name=self.assembly_sheet_list[i],skiprows=11)
            # データフレームの必要なカラムの取得
            assembly_df = assembly_df.iloc[:, [0, 1, 2, 3, 5, 6, 7, 15]]
            assembly_df = assembly_df.copy()
           
            #NoカラムのNanを数値に変換(上がNANではない数値を代入)
            assembly_df['No'] = assembly_df['No'].ffill()
            # 数量が0の行をNaNに変換(ゼロ除算エラーを防ぐため)
            assembly_df[''] = assembly_df[''].replace(0, pd.NA)
            assembly_df = assembly_df.dropna()
            #指定したカラムのデータ型をint型に変換
            assembly_df[int_col] = assembly_df[int_col].astype(int)
            #アクセサリの後ろの番号を削除
            assembly_df['本体・アクセサリ'] = assembly_df['本体・アクセサリ'].str.replace(r"[0-90-9]+$", '', regex=True)

            order_set_numbers = assembly_df['No'].unique()
            # ナンバーの種類ごとにデータフレームを分割して各データフレームをリストに格納
            for order_set_number in order_set_numbers:
                split_assembly_df = assembly_df[assembly_df['No'] == order_set_number]
                split_assembly_df = split_assembly_df.reset_index(drop=True)
                split_assembly_df = self._insert_img_url_to_assembly_df(split_assembly_df)
                assembly_df_list.append(split_assembly_df)
        return assembly_df_list
    
    def create_rename_dict(self):
        """製品の名前変換のマスタ作成

        Returns:
            rename_dict: セット内容へ入力時に名前を変換するときに使う辞書
        """
        df_data = pd.read_excel(self.master_xl, skiprows=3, sheet_name='2-d_価格表')
        df_data2 = pd.read_excel(self.master_xl, skiprows=3, sheet_name='2-e_Nuron価格表 ')
        df_masta = pd.concat([df_data,df_data2]).reset_index(drop=True)
        df_masta = df_masta.copy()
        df_masta = df_masta.dropna()
        #品番と変換後の辞書を作成
        rename_dict = {k : v for k, v in zip(df_masta['品番'], df_masta['変換'])}
        return rename_dict

EstimateHeaderData クラスで使用する関数(メソッド)の一覧

関数名 種類 説明
init コンストラクタ アッセンブリExcelファイルを受け取り、シート名リストを取得・保持する。
get_company_name パブリックメソッド 指定されたシートから会社名を取得し、見積書の宛先情報として返す。
get_validity_period パブリックメソッド 見積有効期限を当月の月末日に設定して返す。

EstimateData クラスで使用する関数(メソッド)の一覧

関数名 種類 説明
init コンストラクタ アッセンブリ・マスタファイルを受け取り、Scraperクラスで画像情報を取得して初期化。
_loop プライベートメソッド アッセンブリシートのインデックスをループで返す内部ジェネレーター。
_insert_img_url_to_assembly_df プライベートメソッド アッセンブリDataFrameに製品画像URLを付与する。
create_assembly_df_list パブリックメソッド アッセンブリExcelをDataFrame化し、「No」ごとに分割したリストを作成。
create_rename_dict パブリックメソッド 製品マスタから「品番→変換名」の辞書を生成し、品名変換に利用する。

7-5. core/estimate_creator.py

core/estimate_creator.py
"""見積作成の処理"""
import os
import shutil
from io import BytesIO

import pandas as pd
import requests
import xlwings as xw
from openpyxl import load_workbook
from openpyxl.styles import Font
from openpyxl.drawing.image import Image
from openpyxl.drawing.spreadsheet_drawing import AnchorMarker
from openpyxl.drawing.spreadsheet_drawing import OneCellAnchor
from openpyxl.drawing.xdr import XDRPositiveSize2D

from core.estimate_data import EstimateData
from core import utils


INSERT_START_ROW_XLWINGS = 31
WRITE_BODY_START_ROW = 15


class EstimateCreator:
    """見積書を作成するclass"""
    def __init__(self, assembly_xl, master_xl, estimate_xl):
        """
        Args:
            assembly_xl: アッセンブリシート.xslx
            master_xl: 製品マスタ.xlsx
            estimate_xl: 見積原紙.xlsx
        """
        self.assembly = assembly_xl
        self.master_xl = master_xl
        self.estimate_xl = estimate_xl
        self.estimate_data = EstimateData(self.assembly, self.master_xl)
        self.assembly_df_list = self.estimate_data.create_assembly_df_list()
        self.company_name = self.estimate_data.get_company_name()
        self.rename_dict = self.estimate_data.create_rename_dict()
        self.new_estimate_xl = self._create_new_estimate_xl()
        self._insert_row_xlwings()

    def _create_new_estimate_xl(self):
        """
        見積原紙をコピーして新しい見積書フォーマットの作成

        Returns:
            new_estimate_xl: {self.company_name}様.xlsx 会社名を入れたファイル名
        """
        folder_path = os.path.dirname(self.estimate_xl)
        base_file_name = f"{self.company_name}様御見積書"
        ext = '.xlsx'
        new_estimate_xl = os.path.join(folder_path, f"{base_file_name}{ext}")
        
        # すでに同じファイル名が存在するときは会社名_{count}.xlsxの形にして上書きしないようにする
        count = 1
        while os.path.exists(new_estimate_xl):
            new_estimate_xl = os.path.join(folder_path, f"{base_file_name}_{count}{ext}")
            count += 1
        #元のファイルをコピーし、新しいファイルの作成
        shutil.copy(self.estimate_xl, new_estimate_xl)
        return new_estimate_xl
    
    def _insert_row_xlwings(self):
        """
        見積フォーマットへの行の挿入
        
        self.assembly_df_listの要素数が9を超える場合
        9件目以降のデータ入力スペースを確保するために超えた数分、2行単位で行を追加する
        """

        df_list_count = len(self.assembly_df_list)
        if df_list_count > 9:
            with xw.App(visible=False) as app:
                wb = app.books.open(self.new_estimate_xl)
                ws = wb.sheets[0]
                #挿入を開始する行
                insert_row = INSERT_START_ROW_XLWINGS
                # 挿入するブロック数(2行セット)
                num_rows = df_list_count - 9
                # num_rowsの数文繰り返し
                for i in range(num_rows):
                    # 挿入する行の2行上から2行文コピー
                    ws.api.Rows(f'{insert_row - 2}:{insert_row - 1}').Copy() 
                    # -4121 = xlDown
                    ws.api.Rows(insert_row).Insert(Shift=-4121)  
                wb.save()
                wb.close()


class EstimateWriter(EstimateCreator):
    """見積内容を書き込むクラス"""
    def __init__(self, assembly_xl, master_xl, estimate_xl):
        """
        Args:
            assembly_xl: アッセンブリシート.xslx
            master_xl: 製品マスタ.xlsx
            estimate_xl: 見積原紙.xlsx
        """
        super().__init__(assembly_xl, master_xl, estimate_xl)
        self.wb = load_workbook(self.new_estimate_xl)
        self.ws = self.wb.worksheets[0]
        self.ws.title = self.company_name

    def _write_header(self):
        """見積フォーマットのヘッダー部分の入力"""
        #見積もりExcelの操作
        self.ws.cell(3, 1).value = f'{self.company_name} 御中'
        self.ws.cell(3, 1).font = Font(size=20, name='メイリオ',bold=True)
        #今月の月末の取得
        end_month = self.estimate_data.get_validity_period()
        # 見積有効期限のセルの操作
        self.ws.cell(8, 3).font = Font(size=12, name='MS Pゴシック')
        self.ws.cell(8, 3).number_format = 'yyyy""m""d""'
        self.ws.cell(8, 3).value = end_month

    def _insert_img_to_cell(self, assembly_df, start_row):
        """
        製品画像をExcelシートの指定セル範囲に挿入する

        _write_body関数から呼び出され、各アッセンブリごとの製品画像を
        対応する行(start_row)に貼り付ける。
        
        Args:
            assembly_df(pd.DataFrame): 1つのアッセンブリ情報を格納したDataFrame
                        '画像' カラムに画像URLが含まれている。
            start_row(int): 画像を挿入するExcelシート上の開始行。
        """
        # 画像を貼り付けるcolumnの横幅の取得
        width = 0
        cols = ['D', 'E', 'F']
        for col in cols:
            width += self.ws.column_dimensions[col].width
         
        # 画像を貼り付けるrowの横幅の取得
        height = 0
        rows = [start_row, start_row + 1]
        for row in rows:
            height += self.ws.row_dimensions[row].height

        img_url = assembly_df['画像'][0]
        try:
            r = requests.get(img_url, timeout=10)
            img_bytes = BytesIO(r.content)
        except requests.exceptions.RequestException as e:
            print(f"警告: 画像の挿入に失敗しました(URL: {img_url})")
            return
        # img変数に画像を格納
        img = Image(img_bytes)
        px_to_emu = 9525  # 1px ≈ 9525 EMU

        # 貼り付ける画像がセルの線にかぶらないようにずらす処理
        # 画像サイズをセルのサイズより12px小さくする
        img.width = utils.col_width_to_pixels(width) -12
        img.height = utils.row_height_to_pixels(height) -12
        ext = XDRPositiveSize2D(img.width * px_to_emu, img.height * px_to_emu)

        col = 3
        row = start_row - 1
        coloff = 6 * px_to_emu
        rowoff = 6 * px_to_emu
        # 画像をずらす処理
        marker = AnchorMarker(col=col, colOff=coloff, row=row, rowOff=rowoff)
        anchor = OneCellAnchor(_from=marker, ext=ext)
        img.anchor = anchor
        self.ws._images.append(img)

    def _write_body(self, start_row=WRITE_BODY_START_ROW):
        """
        見積書の本文部分(製品一覧)をExcelに書き込む。

        各アッセンブリDataFrame(assembly_df)をループ処理し、
        製品名・数量・金額・画像などを見積書の所定行に出力する。
        
        Args:
            start_row(int): 見積の内容を書き込む最初の行
                            デフォルトは定数 WRITE_BODY_START_ROW(15)
        """
        for assembly_df in self.assembly_df_list:
            # 辞書を作成して、名前変換マスタで名前を変換する
            assembly_dict = {k:v for k, v in zip(assembly_df['品番'],assembly_df['品名'])}
            #製品マスタ辞書から品番が一致したら製品辞書の値の書き換え
            for num in assembly_dict.keys():
                assembly_dict[num] = self.rename_dict.get(num, assembly_dict[num])
                #製品辞書の製品名の空白をなくして成型(空白3個目以降の文字は削除)
                assembly_dict[num] = ''.join(assembly_dict[num].split()[:2])
            tools = ', '.join(assembly_dict.values())#結合して変数に格納

            #見積エクセルファイルへの出力
            self.ws.cell(row=start_row,column=1,value=assembly_df.iloc[0,2])
            self.ws.cell(row=start_row,column=7,value=assembly_df.iloc[0,7])
            self.ws.cell(row=start_row,column=8,value='ヵ月')

            tool_count = assembly_df.loc[assembly_df['本体・アクセサリ'] =='本体', '']
            #本体が未入力だった時
            if tool_count.empty:
                tool_count = assembly_df.loc[assembly_df['本体・アクセサリ'] =='アクセサリ', '']

            self.ws.cell(row=start_row,column=10,value=tool_count[0])
            self.ws.cell(row=start_row,column=11,value=assembly_df['総月額'].sum())
            self.ws.cell(row=start_row,column=9,value=assembly_df['総月額'].sum() / tool_count[0])
            self.ws.cell(row=start_row + 1,column=1,value=tools)

            if pd.notna(assembly_df['画像'][0]):
                self._insert_img_to_cell(assembly_df, start_row)

            start_row += 2

    def writing_estimate(self):
        """
        見積フォーマットへの書き込みを実行
        
        ヘッダー情報と本文内容をExcelシートに出力し、
        保存後にワークブックを閉じる。
        """
        try:
            self._write_header()
            self._write_body()
            self.wb.save(self.new_estimate_xl)
        finally:
            self.wb.close()

EstimateCreator クラスで使用する関数(メソッド)の一覧

関数名 種類 説明
init コンストラクタ アッセンブリ・マスタ・見積原紙を受け取り、各種データを初期化して見積書準備を行う。
_create_new_estimate_xl プライベートメソッド 見積原紙をコピーして新しいファイル(例:〇〇様御見積書.xlsx)を生成する。
_insert_row_xlwings プライベートメソッド アッセンブリの件数が多い場合、xlwings を使って見積書に行を自動挿入する。

EstimateWriter クラスで使用する関数(メソッド)の一覧

関数名 種類 説明
init コンストラクタ EstimateCreator を継承し、Excelワークブックを読み込み準備を整える。
_write_header プライベートメソッド 見積書の宛名や有効期限などヘッダー情報をExcelに書き込む。
_insert_img_to_cell プライベートメソッド 各製品の画像URLから画像をダウンロードし、対応セルに挿入する。
_write_body プライベートメソッド 各アッセンブリ情報(品名・数量・金額・画像など)を本文に出力する。
writing_estimate パブリックメソッド ヘッダー・本文を書き込み、見積書ファイルを保存・クローズする。

7-6. core/utils.py

各モジュールで共通利用するユーティリティ関数群

core/utils.py
import re


def normalize(s): 
    """
    文字列を正規化する関数。

    部分一致でのマージ(例:assembly_df と product_df の画像URL結合など)を行う際に、
    大文字変換と不要文字(空白・ハイフン・括弧など)の除去を行う。
    Args:
        s (str): 正規化対象の文字列。
    Returns:
        str: 正規化後の文字列。
    """
    return re.sub(r'[\s\-\(\)]','',s.upper())

def col_width_to_pixels(width):
    """
    Excel列幅をピクセル単位に変換する。
    Args:
        width (float): 列の幅(Excel単位)。
    Returns:
        int: ピクセル単位に変換した値。
    """
    return int(width * 7 + 5)

def row_height_to_pixels(height):
    """
    Excel行の高さをピクセル単位に変換する。
    Args:
        height (float): 行の高さ(Excel単位)。
    Returns:
        int: ピクセル単位に変換した値。
    """
    return int(height * 1.33)

core/utils.pyの関数一覧

関数名 説明
normalize 文字列を大文字に統一し、空白・ハイフン・括弧を削除して正規化する。部分一致検索やキー結合時に使用。
col_width_to_pixels Excelの列幅をピクセル単位に変換する。画像の幅をセルに合わせる際に使用。
row_height_to_pixels Excelの行の高さをピクセル単位に変換する。画像の高さをセルに合わせる際に使用。

8. 開発してみての振り返り

今回の見積作成アプリは、Python学習を始めて最初に開発した「見積書自動化スクリプト」を、学習の進捗に合わせて改良し続けてきたものです。

開発当初は tkinter を使用しておらず、変数に直接Excelファイルのパスを指定して実行していました。

その後、tkinterを導入してデスクトップアプリ化し、
現在は モジュール分割とクラス化 を行って構造を整理しています。

学習のたびに得た知識を実際のコードに反映しながら、 段階的に改善してきたプロジェクトです。

この開発を通して、スクレイピングpandasによるデータ整形Excel操作デスクトップアプリ開発(tkinter)モジュール分割クラス など、さまざまな技術を学ぶことができました。

難しかった点は、以下の2つです。

  • 画像をExcelに貼り付ける処理
    画像をセルのサイズに合わせて貼り付けると、枠線が隠れてしまうため、
    少し小さく縮小してセルの中央に配置されるように調整する必要がありました。

  • 見積フォーマットへの行挿入
    フォーマット側でセル結合が多用されていたため、
    行を挿入する際に結合状態が崩れ、見た目が壊れてしまう問題が発生しました。
    そのため、xlwings を使用してコピー&挿入の処理を工夫することで対応しました。

9. 今後の展望

本アプリケーションは、requestsBeautifulSoup を使用してスクレイピングを行っているため、
対象サイトの構成や仕様が変更された際には、スクレイピング部分の修正 が必要になります。

また、アセンブリ.xlsx のフォーマット が変更された場合も、
データ取得や整形のロジックを修正する必要があります。

このように、運用にあたっては継続的な保守が求められます。

今後さらに学習を進める中で、新しい技術やより効率的な方法を習得した際には、
本アプリをより便利に改良していく予定です。

3
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?