0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

📝売上履歴を壊さず商品を販売終了にしたい――Flaskで論理削除とGemini API節約を実装した記録

0
Last updated at Posted at 2026-07-16

ChatGPT Image 2026年7月16日 22_43_39.png

🍞はじめに

学習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_idNULLに変更できず、エラーになりました。


原因:既存商品を削除して再登録していた

当初の商品マスタ更新処理は、対象年月の商品を一度削除し、フォーム内容を登録し直す方式でした。

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

注意:stampupgradeは役割が違う

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 整備記録シリーズ

「本番出走前・車検三部作」

🚚「出走後メンテナンスシリーズ」

0
0
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
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?