🍞はじめに
学習135時間目となる現役のトラックドライバーがPython学習の一環として、Flask・PostgreSQL・Gemini APIを使ったベーカリー向け売上管理アプリを開発しています。
(💡READMEの更新 + 大幅アップデートしました)
今回、商品マスタの編集機能を追加したところ、次の問題にぶつかりました。
- 新しいメニューを追加すると、既存の売上履歴との関係が壊れる
- 売上履歴を持つ商品を物理削除できない
- 商品名の変更が「別商品の追加」として扱われる
- ダッシュボードを開くたびにGemini APIを消費する
- 無料枠の上限到達時に、利用者へ分かりにくいエラーが表示される
そこで約6時間かけて
『商品更新・販売終了・DBマイグレーション・本番起動・AI実行方法まで』
まとめて見直しました。
この記事では、失敗した実装と、その後どのように安全な構成へ変更したかを記録します。
(※本日の記録は長文になります。大目に見ていただければ幸いです🙇)
開発環境
- Python 3.12
- Flask
- Flask-SQLAlchemy
- Flask-Migrate
- PostgreSQL(本番)
- SQLite(ローカル)
- Docker
- Render
- Gunicorn
- Google Gemini API
- Chart.js
- GitHub Actions
- pytest
最初に起きたエラー
商品マスタを更新したとき、次のエラーが発生しました。
psycopg2.errors.NotNullViolation:
null value in column "product_id" of relation "daily_sales"
violates not-null constraint
SQLAlchemyが実行しようとしていたSQLは次の内容でした。
UPDATE daily_sales
SET product_id = NULL
WHERE daily_sales.id = ...
daily_sales.product_idにはnullable=Falseを設定しています。
product_id = db.Column(
db.Integer,
db.ForeignKey("products.id"),
nullable=False
)
そのため、売上履歴が関連している商品を削除しようとすると、DailySales.product_idをNULLに変更できず、エラーになりました。
原因:既存商品を削除して再登録していた
当初の商品マスタ更新処理は、対象年月の商品を一度削除し、フォーム内容を登録し直す方式でした。
Product.query.filter_by(
year=year,
month=month
).delete()
for product in products_data:
db.session.add(
Product(
year=year,
month=month,
name=product["name"],
price=product["price"]
)
)
db.session.commit()
商品マスタだけを見ると単純ですが、日次売上テーブルはProduct.idを参照しています。
products
└── id = 1
daily_sales
└── product_id = 1
一度削除して再登録すると、同じ商品名であっても別のProduct.idになります。
削除前:食パン id=1
再登録後:食パン id=15
人間から見れば同じ「食パン」でも、データベースから見れば別レコードです。
対策1:商品名ではなく商品IDで更新する
既存商品のフォームにProduct.idをhiddenで持たせました。
<input type="hidden" name="product_id" value="{{ product.id }}">
JavaScriptで追加する新商品には、空のIDを入れます。
<input type="hidden" name="product_id" value="">
Flask側では、商品ID・商品名・価格を同じ順番で受け取ります。
product_ids = request.form.getlist("product_id")
product_names = request.form.getlist("prod_name")
product_prices = request.form.getlist("prod_price")
products_data = []
for product_id, name, price in zip(
product_ids,
product_names,
product_prices
):
if name.strip():
products_data.append({
"id": int(product_id) if product_id.isdigit() else None,
"name": name.strip(),
"price": int(price) if price.isdigit() else 0
})
既存商品はIDを基準に更新し、IDがない商品だけ新規追加します。
existing_products = Product.query.filter_by(
year=year,
month=month
).all()
existing_dict = {
product.id: product
for product in existing_products
}
for product_data in products_data:
product_id = product_data["id"]
if product_id is not None and product_id in existing_dict:
existing_product = existing_dict[product_id]
existing_product.name = product_data["name"]
existing_product.price = product_data["price"]
existing_product.is_active = True
else:
db.session.add(
Product(
year=year,
month=month,
name=product_data["name"],
price=product_data["price"]
)
)
これにより、商品名を変更しても同じProduct.idのまま更新できます。
対策2:物理削除ではなく論理削除にする
販売終了商品は、DBから削除するのではなく、販売状態を変更することにしました。
is_active = db.Column(
db.Boolean,
nullable=False,
default=True,
server_default=db.true()
)
画面から消された既存商品のIDを調べ、Falseへ変更します。
submitted_ids = {
product_data["id"]
for product_data in products_data
if product_data["id"] is not None
}
for product in existing_products:
if product.id not in submitted_ids:
product.is_active = False
販売中の商品だけを画面へ表示します。
products = Product.query.filter_by(
year=year,
month=month,
is_active=True
).all()
これで、販売終了商品を入力画面から除外しながら、DailySales.product_idとの関連を維持できます。
対策3:Flask-Migrateで列を追加する
ローカルでマイグレーションを作成しました。
flask db migrate -m "add is_active to products"
SQLite向けに生成されたserver_default=sa.text("1")を、PostgreSQLでも扱いやすい形へ変更しました。
server_default=sa.true()
ローカルへ適用します。
flask db upgrade
flask db current
Render側でも次のログを確認できました。
Running upgrade -> 043c481b4069, add is_active to products
注意:stampとupgradeは役割が違う
flask db upgrade
→ マイグレーションを実行してDB構造を変更
flask db stamp head
→ 実行せず、適用済みという印だけ付ける
起動時に毎回stampすると、未適用の変更まで適用済み扱いになる危険があります。
そこで、アプリ内の自動stampを削除し、起動コマンドでupgradeを実行する構成にしました。
対策4:本番起動をGunicornへ変更
Flask内蔵サーバーではなく、Gunicornで起動するように変更しました。
requirements.txt:
gunicorn==26.0.0
起動コマンド:
CMD [
"sh",
"-c",
"flask db upgrade && gunicorn --bind 0.0.0.0:${PORT:-5000} app:app"
]
デプロイ後は次のログになりました。
Starting gunicorn 26.0.0
Listening at: http://0.0.0.0:10000
Booting worker
Your service is live
次の問題:Gemini APIの無料枠を使い切った
動作確認中、次の429エラーが発生しました。
429 RESOURCE_EXHAUSTED
Quota exceeded for metric: generate_content_free_tier_requests
limit: 20
model: gemini-2.5-flash
当初は、ページを開くだけでもGemini APIを実行していました。
ダッシュボードを開く
→ AI経営アドバイス生成
日次売上入力画面を開く
→ AI挨拶生成
期間を変更してデータを抽出
→ AI経営アドバイスを再生成
無料枠では、この設計は少しもったいないと感じました。
対策5:AIは必要なときだけボタンで実行する
ページ表示時には定型文を表示し、必要な人だけAIを呼ぶ方式へ変更しました。
AI専用APIを分離します。
@app.route("/api/ai-advice")
def api_ai_advice():
year_param = request.args.get("year")
month_param = request.args.get("month")
target_year = int(year_param) if year_param else None
target_month = int(month_param) if month_param else None
sales_data = _get_sales_from_db(
target_year,
target_month
)
ranked_sales = sorted(
sales_data.items(),
key=lambda item: item[1],
reverse=True
)
return jsonify({
"ai_advice": _generate_ai_advice(ranked_sales)
})
ボタンを押したときだけ呼び出します。
function loadAiAdvice() {
const year = document.getElementById("selectYear").value;
const month = document.getElementById("selectMonth").value;
fetch(`/api/ai-advice?year=${year}&month=${month}`)
.then(response => response.json())
.then(data => {
document.getElementById("aiAdviceText").innerHTML =
data.ai_advice.replace(/\n/g, "<br>");
});
}
日次売上入力の挨拶も同じ考え方です。
ページを開く
→ Gemini 0回
「今日のひとこと」を押す
→ Gemini 1回
連打対策
AI実行中はボタンを無効化しました。
aiButton.disabled = true;
aiButton.textContent = "分析中...";
処理終了後に戻します。
.finally(() => {
aiButton.disabled = false;
aiButton.textContent = "🤖 もう一度詳しいアドバイスを聞く";
});
429と503を分けて案内する
- 429:利用上限・速度制限
- 503:サービス混雑・一時停止
error_text = str(error)
if "GenerateRequestsPerDayPerProjectPerModel-FreeTier" in error_text:
return (
"☕【本日のAI分析回数が上限に達しました】\n"
"売上データの保存と集計は正常です。"
)
if "429" in error_text or "RESOURCE_EXHAUSTED" in error_text:
return "☕【AIが少し休憩中です】"
if "503" in error_text or "UNAVAILABLE" in error_text:
return "🥐【AIアシスタントが混み合っています】"
詳細な例外は画面へ出さず、ログへ残します。
デプロイ時のインデントエラー
最後のデプロイで、次のエラーも発生しました。
IndentationError: expected an indented block after 'if' statement
デプロイ前に次の構文チェックを実行することで防げます。
python -m py_compile app.py
今後の出荷前点検に追加します。
git status
git diff
python -m py_compile app.py
pytest
最終的な動作
商品マスタ
新商品
→ INSERT
既存商品
→ Product.idを基準にUPDATE
画面から外した商品
→ is_active=False
過去の売上
→ 保持
Gemini API
ダッシュボード表示
→ 0回
期間変更・データ抽出
→ 0回
詳しいアドバイスを聞く
→ 1回
日次売上入力画面を表示
→ 0回
今日のひとことを聞く
→ 1回
今回の学び
1. 同じ商品名でも、DBではIDが違えば別物
名称ではなく、主キーを基準に更新する必要がありました。
2. 履歴を持つデータは簡単に削除しない
売上履歴を残すには、物理削除より論理削除が適していました。
3. マイグレーションは「作成・確認・適用」の3段階
自動生成された内容をそのまま信用せず、SQLiteとPostgreSQLの違いも確認する必要があります。
4. AIは自動実行が正解とは限らない
必要なときだけ明示的に実行する方が、表示速度・無料枠・操作の分かりやすさを改善できました。
5. 出荷前に構文チェックをする
python -m py_compile app.py
1行のインデントミスでも本番アプリは起動できないため、事前点検が重要でした。
まとめ
今回の修正は、単なる「削除ボタン追加」ではありませんでした。
商品更新
↓
外部キーエラー
↓
ID基準の更新へ変更
↓
論理削除を導入
↓
マイグレーション作成
↓
本番DBへ適用
↓
Gunicornへ変更
↓
Gemini APIの使用方法を改善
↓
429・503の案内を改善
「商品が消えればよい」ではなく、売上履歴・外部キー・本番DB・API利用回数・利用者向けエラー表示まで含めて確認し、ようやく一区切りになりました。
今後は、商品更新・論理削除・APIエンドポイントについてもpytestの対象を拡充したいと思います。
🙇♀️最後までお読みいただき、ありがとうございました!
関連リンク
- 🍞GitHub:
- 🌐オンラインデモ:
sales_data_app 整備記録シリーズ
「本番出走前・車検三部作」
🚚「出走後メンテナンスシリーズ」
