📌 この記事を5行でまとめると
- Bakery Hubの商品登録を、毎回すべて手入力する方式から 18商品のテンプレート選択方式 へ改善しました。
- 商品一覧を、現在の商品だけでなく 今月の商品 / 過去に登録された商品 を確認できる履歴Catalogへ変更しました。
- 月ごとの販売データを持つ
Productと、商品登録時に使う「商品Catalog」の役割を分離しました。 - Adminから商品名・初期価格・読み・画像を申請すると、GitHub上に専用branchとPRを作る仕組みを追加しました。
- 商品画像はJPEG / PNG / WebPを検証し、入力は10 MiBまで受け付け、保存時は500 KiB以下のWebPへ変換する構成にしました。
はじめに
本業でトラックドライバーとして働きながら、限られた空き時間にAIを活用し、Python / Flaskを中心にWebアプリを個人開発している tosane932 です。
2026年5月12日からPythonを学び始め、現在は個人開発した作品を Tosane Works としてまとめています。
その中で一番長く開発を続けているのが、ベーカリー店舗向けWebアプリ Bakery Hub です。
商品登録、日次売上、売上分析、材料発注、店舗メモ、Task管理などを少しずつ追加しながら、自分でスマホやPCから実際に触って、気になったところを直す形で開発を続けています。
前回は、店舗メモとTask機能について書きました。
店舗メモではCRUDだけでなく、検索・ゴミ箱・復元・URLリンクまで追加し、Taskではスマホの長押しや並び替えまで実機で何度も調整しました。
前回の記事はこちらです。
その後もBakery Hubを使っていると、今度は商品管理側の小さな違和感が気になり始めました。
今回の記事では、
毎月の商品登録
↓
商品テンプレート
↓
今月 / 過去の商品履歴
↓
Adminから商品Catalogを追加
まで、商品管理を少しずつ作り直していった流れをまとめます。
Bakery HubはLive Demoも公開しています。
Renderの無料環境で公開しているため、しばらくアクセスがなかった後は起動まで少し時間がかかる場合があります。
すぐに画面が表示されない場合は、少し待つと起動します。
ソースコードはTosane Worksで公開しています。
Bakery Hubはこちらです。
まず気になったのは「毎月の商品登録」
もともとのBakery Hubでは、商品を登録するときに、
商品名
価格
を入力していました。
自由に商品を登録できるので、仕組みとしてはこれでも問題ありません。
ただ、実際の店舗をイメージすると、
食パン
メロンパン
クロワッサン
カレーパン
のような定番商品を、毎月ほぼ同じように入力することになります。
何度か触っているうちに、
よく使う商品まで毎回ゼロから入力する必要はないよな
と思うようになりました。
そこで、定番商品は最初から選べるようにしました。
18商品のテンプレートを追加
商品登録画面に、あらかじめ18種類の商品テンプレートを用意しました。
たとえば、
- 食パン
- メロンパン
- クロワッサン
- カレーパン
- コロネ
- ホットドッグ
- ハムチーズデリサンド
- 栗あんバター
- 焼きいもクリームパン
- きのことベーコンのグラタンパン
などです。
各テンプレートには、以下の情報を持たせています。
| 項目 | 役割 |
|---|---|
| 商品名 | 登録時に使用する商品名 |
| 初期価格 | 登録時に提案する価格 |
| 読み | 商品を探しやすくするための情報 |
| 商品画像 | 見た目から商品を識別するための画像 |
以前は文字を見ながら商品を登録していましたが、画像も一緒に表示することで、かなり選びやすくなりました。
「初期価格」と「実際の売価」は別にする
テンプレートを作るときに、価格の扱いも整理しました。
Catalog側の価格は、
この商品なら最初はこのくらい
という初期値です。
たとえばCatalogでは、
食パン 200円
になっていても、その店舗では今月、
食パン 240円
で販売するかもしれません。
そこで、価格の役割を次のように分けました。
| 比較項目 | 商品Catalog | 月次Product |
|---|---|---|
| 価格の意味 | 登録時の初期値 | その月の実際の売価 |
| 食パンの例 | 200円 | 240円 |
| 使用する場面 | 商品登録時 | 日次売上の入力・集計 |
| 価格変更の影響 | 登録時の候補価格 | 対象月の商品価格 |
つまり、
商品Catalog
↓
登録時の初期値を提案
月次Product
↓
その月に実際に販売する価格
という関係です。
Catalogの価格と過去の売価は連動させない
Catalogの初期価格を変更したからといって、過去に登録した商品の販売価格まで変わってしまってはいけません。
商品登録時の候補と、実際の販売データは別々に管理する必要があります。
テンプレート化しても自由入力は残す
便利にするためにテンプレートを追加しましたが、
18商品以外は登録できません
にはしたくありませんでした。
ベーカリーなら、新商品や期間限定商品もあります。
そのため、登録方法は2種類用意しています。
| 登録方法 | 用途 |
|---|---|
| テンプレートから登録 | 定番商品を素早く登録する |
| 自由入力で登録 | 新商品・限定商品などを登録する |
便利にしようとして、逆に使い方を狭くしないようにしています。
価格入力も少し整える
商品価格と売上数量の入力仕様も整理しました。
| 項目 | 設定 |
|---|---|
| 商品価格 | 0円〜9,990円 |
| 価格の入力単位 | 10円単位 |
| 売上数量 | 0〜1,000個 |
UIでは、
¥ 250
のように円記号を固定表示し、数字だけを入力する形にしました。
こうした制約は画面側だけに任せず、Flask側でもPOSTされた値を検証します。
ブラウザ側の入力制限だけでは、直接POSTされた値までは守れないためです。
次に気になったのが「登録中」の表示
商品テンプレートを整えている途中で、今度は商品一覧の表示に違和感が出てきました。
たとえば10月の商品一覧を見ているのに、
9月に登録した商品
↓
登録中
と表示されるケースがありました。
原因は、商品名ごとに最新の Product を取得し、
is_active == True
なら「登録中」と表示していたためです。
でも、
2026年9月に販売していた
≠
2026年10月も販売している
です。
active だからといって、「今月の商品」とは限りません。
商品一覧を「今月」と「過去」に分ける
そこで商品一覧を、
今月登録の商品
過去に登録された商品
の2つに分けました。
10月なら、イメージはこんな感じです。
| 表示区分 | 商品名 | 登録情報 |
|---|---|---|
| 今月登録の商品 | クロワッサン | 2026年10月 |
| 今月登録の商品 | 食パン | 2026年10月 |
| 今月登録の商品 | メロンパン | 2026年10月 |
| 過去に登録された商品 | 焼きいもクリームパン | 最終登録:2026年9月 |
| 過去に登録された商品 | 栗あんバター | 最終登録:2026年8月 |
これで商品一覧が、
今月何を販売しているかを見る場所
だけではなく、
過去にどんな商品を扱っていたか振り返る場所
にもなりました。
今月の商品と過去の商品は重複させない
同じクロワッサンが、
9月
10月
の両方に登録されている場合、
今月登録の商品
クロワッサン
過去に登録された商品
クロワッサン
と2回表示すると少し分かりにくくなります。
そのため、
今月にactiveな同名商品がある
↓
今月側だけに表示
としています。
| 商品の状態 | 表示する場所 |
|---|---|
| 今月に有効な登録がある | 今月登録の商品 |
| 過去に登録があり、今月に有効な登録がない | 過去に登録された商品 |
| 今月と過去の両方に登録がある | 今月側のみ表示 |
過去データそのものを消しているわけではありません。
過去の Product や DailySales はPostgreSQLにそのまま残ります。
つまり、一覧での見せ方を整理しただけで、売上履歴を削除したわけではありません。
Datasetも混ぜない
Bakery Hubでは、AdminとGuestのデータをDatasetで分離しています。
イメージとしては、
Admin
Guest A
Guest B
のような状態です。
商品履歴でも当然、
Adminの商品
Guest Aの商品
Guest Bの商品
が混ざってはいけません。
そのため商品一覧を取得するときは、次のような条件で対象を絞っています。
| 条件 | 確認すること |
|---|---|
Dataset |
現在の利用者のデータか |
year |
対象年の商品か |
month |
対象月の商品か |
is_active |
有効な商品か |
現在年月の判定には、以前追加した business_today() を使っています。
Asia/Tokyo基準の日付
↓
現在の年・月を決定
↓
現在Datasetの商品を取得
という流れです。
Product と商品Catalogは別物
今回の商品管理を作っていく中で、設計上かなり大事になったのが、
月次Productと商品Catalogを分けること
でした。
Bakery Hubの Product はPostgreSQLに保存され、
Dataset
year
month
商品名
販売価格
is_active
などを持っています。
さらに DailySales と紐づきます。
つまり、
Product
=
その月に実際に扱った商品
です。
一方、今回追加した商品Catalogは、
これから商品登録するときに選ぶ候補
です。
2つの違いを整理すると、次のようになります。
| 比較項目 | 月次Product | 商品Catalog |
|---|---|---|
| 主な役割 | 実際の販売商品を管理 | 商品登録時の候補を管理 |
| 価格 | その月の販売価格 | 初期価格 |
| 年月との関係 | 年・月を持つ | 月次販売データとは独立 |
| Dataset | Datasetごとに管理 | 登録候補として利用 |
| DailySales | 紐づく | 直接は紐づかない |
| 保存先 | PostgreSQL | リポジトリ内のCatalog定義 |
| 追加したとき | 月次商品として登録される | 登録候補が増える |
そのためCatalogへ商品を追加しただけでは、PostgreSQLへ Product を自動作成しません。
実際にその月の商品として登録した時点で、初めて月次 Product になります。
ここを一緒にすると、テンプレートを追加しただけなのに売上管理側へ商品が勝手に増えてしまいます。
なので、役割を明確に分けました。
Adminから商品Catalogを追加したい
最初に用意した18商品だけでも使えます。
ただ、今後Catalogへ商品を追加するたびに、
ファイルを編集
↓
commit
↓
push
↓
PR
を毎回手作業でするのも少し面倒です。
そこでAdmin画面から、次の情報を入力して新しいCatalog商品を申請できるようにしました。
| 入力項目 | 必須・任意 |
|---|---|
| 商品名 | 必須 |
| 初期価格 | 必須 |
| 読み | 必須 |
| 商品画像 | 任意 |
画像は必須ではありません。
まず画像なしで追加
↓
あとから画像を設定
もできます。
ただしRenderには直接保存しない
Admin画面から画像をアップロードできるようにすると、
Renderのstatic/product_images/
へ直接保存したくなります。
実装だけ考えると、その方が簡単です。
ただ、Render上で動いているアプリのローカルファイルを、Catalogの正本にはしたくありませんでした。
そこで、
Catalog情報と画像の正本はGitHubリポジトリ
としました。
保存先は次の2箇所です。
| 保存対象 | 保存先 |
|---|---|
| Admin追加商品の情報 | product_catalog/admin_products.json |
| Admin追加商品の画像 | static/product_images/admin/ |
どちらも store/bakery-hub/ 以下のパスです。
Admin操作からGitHub PRを作る
AdminがCatalog商品を申請すると、
Bakery Hub
↓
GitHub API
↓
専用branchを作成
↓
JSONを更新
↓
必要なら画像を追加
↓
commit
↓
main向けPRを作成
という流れになります。
ここで大事にしたのは、
mainへ直接書き込まないこと
です。
アプリから申請しても、最後はGitHub上で差分を確認できます。
アプリから申請
↓
GitHub PR
↓
人間が確認
↓
merge
↓
Deploy
↓
Catalogへ反映
という流れです。
なぜわざわざPRを挟むのか
自分しか使わないAdmin機能なら、
登録ボタン
↓
mainへ直接commit
でも動かすことはできます。
でも、それだと商品名や画像を間違えたときも、そのまま本番コードへ入ってしまいます。
PRを挟めば、
商品名
初期価格
読み
画像
JSON差分
をGitHubでもう一度確認できます。
| 比較項目 | mainへ直接commit | PRを経由 |
|---|---|---|
| 変更内容の確認 | 反映前の確認工程がない | merge前に差分を確認できる |
| 商品名・価格の誤り | そのまま反映され得る | 事前に発見・修正できる |
| 画像の確認 | 変更が直接入る | 追加・変更内容を確認できる |
| 本番への反映 | 直接変更される | mergeとDeployを経て反映 |
| 人間による最終判断 | 仕組み上は不要 | merge前に判断できる |
WebアプリやAIからGitHubを操作するときでも、
最後に人間が確認できる場所は残しておく
という形の方が、自分には合っています。
商品画像はそのまま保存しない
画像アップロードも、そのまま信用して保存する形にはしていません。
許可する形式は、
JPEG
PNG
WebP
です。
確認するのは拡張子だけではありません。
| 検証対象 | 確認内容 |
|---|---|
| ファイル名 | 受け取った名前が適切か |
| 拡張子 | 許可された画像形式か |
| MIME | 申告されたファイル種別が適切か |
| 実際の画像format | 画像として読み込んだ形式が許可対象か |
SVGやHTMLなどは対象外にしています。
最初は200 KiBまでだった
最初は、入力画像も変換後の画像も 200 KiB以下 という制限にしていました。
ただ、実際にスマホで撮影・保存した画像を使おうとすると、200 KiBではかなり厳しく、画像を用意する側が毎回小さく加工しないと登録できません。
そこで、
アップロード時は一般的なスマホ画像も受け付けて、保存するときにアプリ側で軽くすればいい
と考え直しました。
入力制限と保存制限を分ける
現在はPillowで画像を読み込み直し、以下の仕様にしています。
| 項目 | 現在の仕様 |
|---|---|
| 対応入力形式 | JPEG / PNG / WebP |
| 入力ファイルサイズ | 最大10 MiB |
| 最大画素数 | 4096 × 4096相当 |
| 保存形式 | WebP |
| 変換品質 | 82 |
| 保存後のファイルサイズ | 最大500 KiB |
| 保存サイズ超過時 | 段階的に縮小 |
| 縮小しても超過する場合 | 保存せず拒否 |
つまり、ユーザー側には最大10 MiBまで受け付けつつ、リポジトリへ保存する画像は500 KiB以下に抑える形です。
変換後も500 KiBを超える場合は、
元のサイズ
↓
最大1200px
↓
最大960px
↓
最大768px
と段階的に縮小します。
それでも500 KiB以下に収まらない場合は、無理に保存せず拒否します。
元のファイル名も使わない
アップロードされた画像のファイル名も、そのまま保存には使いません。
保存名はUUIDから作ります。
admin/<UUID>.webp
のような形です。
これでファイル名の衝突を避けつつ、アップロードされた名前をそのままパスとして扱わない構成にしています。
画像を変更しても過去商品を壊さない
画像を扱うようになると、もう1つ気になることがありました。
Catalog画像を差し替えたら、過去の商品画像まで変わらないか?
という点です。
同じファイルへ単純に上書きすると、過去の月次Productが参照していた画像まで変わる可能性があります。
そのため画像を変更するときは、
旧画像
↓
そのまま保持
新画像
↓
新しいassetとして追加
としています。
| 対象 | 画像変更時の扱い |
|---|---|
| 旧画像 | 削除・上書きせず保持 |
| 新画像 | 新しいassetとして追加 |
| 過去の商品 | 既存の画像参照を維持 |
| Catalog | 新しい画像を利用できるよう更新 |
過去の商品から参照されている画像を、後から壊さないためです。
標準18商品はそのまま残す
Admin Catalogを追加しても、最初から用意した18商品はそのまま残しています。
読み込み時は、
標準18商品
+
Admin追加商品
という形です。
Adminから商品を増やしても、標準商品のkey・価格・画像などが勝手に書き換わることはありません。
システム概要も一緒に更新
Bakery Hubには、現在の実装をあとから自分で復習できるように、
システム概要
ページを作っています。
今回の商品管理改善後は、次の項目も反映しました。
| 分類 | 反映した内容 |
|---|---|
| 商品登録 | 18商品テンプレート・自由入力 |
| 価格管理 | 初期価格と実際の売価 |
| データ構造 | 月次Product・DailySales |
| DB・データ分離 | PostgreSQL・Dataset |
| 商品履歴 | 今月 / 過去の商品Catalog |
| 管理者機能 | Admin Catalog |
| 外部連携 | GitHub API |
| 画像処理 | 商品画像検証・変換 |
機能だけ増えて、
半年後に自分で見ても何をしているのか分からない
という状態にはしたくありません。
コードを変更したら、学習用の説明も一緒に更新するようにしています。
pytestでも商品管理を守る
商品管理では、
ページが200で表示された
だけを確認しているわけではありません。
たとえば、次のような条件もpytestで確認しています。
| 分類 | 主な検証内容 |
|---|---|
| Dataset分離 | AdminとGuestのデータが混ざらない |
| 年月判定 | 異なる年月の商品が混ざらない |
| 商品状態 | inactive商品を現在の商品として扱わない |
| 商品履歴 | 将来月の商品を過去の商品として扱わない |
| 重複表示 | 同名商品を重複表示しない |
| 権限管理 | GuestがAdmin Catalogを変更できない |
| 画像検証 | 不正な画像を拒否する |
| 画像任意登録 | 画像なしでもCatalog登録できる |
| 画像更新 | 新しい画像に変更しても旧assetを残す |
| GitHub連携 | PR作成失敗時にbranchをcleanupする |
機能が増えるたびにpytestも追加してきたので、この時点ですでにかなりのテスト数になっていました。
そして、ここで次の違和感が出てきました。
pytestが大量に通っているけど、
本当に「壊したらちゃんとREDになるテスト」になっているのか?
おわりに
今回の商品管理改善は、
毎月同じ商品を入力するのが少し面倒
という、本当に小さな違和感から始まりました。
そこから、
18商品テンプレート
↓
初期価格と実売価を分離
↓
今月 / 過去の商品履歴
↓
ProductとCatalogを分離
↓
AdminからCatalog商品を申請
↓
GitHub PRで確認してから反映
まで広がりました。
最初から全部を設計して、一気に作ったわけではありません。
実際に触って、
ここ、ちょっと使いにくいな
この表示、意味が少し違うな
と感じたところを、一つずつ直していった結果です。
本業のトラックドライバーでも、毎日まったく同じ条件で仕事が進むわけではありません。
道路状況、納品先、待機時間、構内の状況などを見ながら、その日の動き方を調整します。
Web開発でも自分は、
まず使う
↓
違和感に気づく
↓
直す
↓
もう一度使う
という進め方が合っているようです。
ただ、商品管理を改善する中で、pytestもさらに増えてきました。
実は2026年8月にも、手動Mutation Testingを11件実施しています。
当時は、正常なコードではGREENになるpytestが、意図的に故障を入れてもGREENのままになるケースを5件発見しました。
そこで不足していたfixtureやassertを強化し、最終的には11件すべてのMutationを検知できる状態にしました。
しかし、それからもBakery Hubには新しい機能を追加しています。
これまでは、
機能追加
↓
pytest追加
↓
CI GREEN
を繰り返してきました。
そこで、以前のMutation Testingで得た経験をもとに、
あれから機能もpytestも増えたけど、今のBakery Hubはどこまで故障を検知できるんだろう?
以前の11件だけでなく、もっと広い範囲を検証してみたい。
と考えるようになりました。
こうして、2回目のMutation Testingに取り組むことにしました。
今回は以前と違い、Codexに監査作業を任せ、まずは専用ツールを使わない60件のMutationから検証を開始しました。
さらにCosmic Rayも導入し、手動で選んだ故障だけでなく、自動生成されたMutation候補にも監査範囲を広げていきます。
ところが、この再監査は想像していたよりもずっと大きな取り組みになりました。
次の記事では、
「CodexとCosmic Rayを使って、2回目のMutation Testingをどのように進めたのか」
について書きます。
関連リンク
今回の商品管理改善からMutation Testing再監査、mainへの反映までを3本の記事にまとめました。
| 記事 | タイトル・リンク |
|---|---|
| 第1記事 | 🥐Bakery Hubの商品管理を作り直した|18商品テンプレート・履歴Catalog・Admin登録まで |
| 第2記事 | 🧪pytestが通るだけでは安心できない|CodexとCosmic Rayで2度目のMutation Testing |
| 第3記事 | 🧪Codexで挑んだ1,286件のMutation Testing|pytest再監査からmain反映までの5日間 |
その他の関連資料
| 分類 | リンク・内容 |
|---|---|
| 📖 2026年8月の初回Mutation Testing | 📝pytestを「事故防止台帳」として育てる 第5段階:GREENを疑い、Mutation Testingで「本当に守れているか」を検証した |
| 🔀 GitHub PR #79 | 商品テンプレート選択・Guest UI・商品画像などの改善 |
| 🔀 GitHub PR #80 | 今月 / 過去の商品Catalog・Admin商品カタログ申請・画像アップロード |
| 🌐 Live Demo | Bakery Hubをブラウザで操作 |
| 💻 ソースコード | Bakery Hub |
| 📁 ポートフォリオ | Tosane Works |
Live DemoはRenderの無料環境で公開しているため、スリープ状態からの起動時は少し待ち時間が発生する場合があります。
