はじめに
2025年9月からPythonの学習を始めました。
今回は学習内容のアウトプットとして、営業業務で行っている見積書作成の自動化に挑戦しました。
私の業務では、見積書をExcelで作成しています。
しかし、会社のシステムで出力できる見積書は「製品名」と「金額」しか表示されず、
お客様から「内容がわかりにくい」と言われることが多くありました。
そのため普段は、見積書テンプレートに製品名・価格・製品画像を手動で貼り付けています。
この作業をPythonで自動化し、業務の効率化と見積書の品質向上を目指しました。
補足
クラス設計を学んでいた時期だったため、今回のスクリプトでは学習のアウトプットを兼ねてクラス構成で作成しています。
(実際の処理内容としては、モジュールを分割して関数ベースでも実装可能です。)
1. 自動化の概要
自動化の基本的な流れは以下の通りです。
1.自社ホームページよりスクレイピングで画像データの取得
2.アセンブリ.xlsx(オーダー用Excelファイル)から見積作成に必要なデータの取得と成型
3.製品マスタ.xlsxより製品名の変換の辞書を作成
4.見積書原紙をコピーして新しく生成(ファイル名は会社名 御見積書)
5.見積書の行がたりなければxlwingsで不足分を挿入
6.見積書に書き込みを行う
以上の内容をtkinterでデスクトップアプリを起動して
必要なExcelファイルを選択して、[見積作成]を押すことで実行します。
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
見積作成アプリを起動するコードです。
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を構築するモジュール
"""見積作成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
会社ホームページから製品画像をスクレイピングするモジュール
"""スクレイピングで画像の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 | プライベートメソッド |
requests と BeautifulSoup を用いてHTMLデータを取得・解析する。 |
| create_prodact_df | パブリックメソッド | HTML内の製品名・リンク・画像URLを抽出し、pandas.DataFrame に整形して返す。 |
7-4. 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
"""見積作成の処理"""
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
各モジュールで共通利用するユーティリティ関数群
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. 今後の展望
本アプリケーションは、requests と BeautifulSoup を使用してスクレイピングを行っているため、
対象サイトの構成や仕様が変更された際には、スクレイピング部分の修正 が必要になります。
また、アセンブリ.xlsx のフォーマット が変更された場合も、
データ取得や整形のロジックを修正する必要があります。
このように、運用にあたっては継続的な保守が求められます。
今後さらに学習を進める中で、新しい技術やより効率的な方法を習得した際には、
本アプリをより便利に改良していく予定です。



