Issue #17 の年度締めでは、締める年度の商品を販売停止にし、SystemSetting.active_season を次年度へ切り替える。一方、次年度の商品は自動作成されないため、年度切替前の準備では、同じ品種・種類・容量の商品を1件ずつ登録し直す必要がある。
本Issueでは、商品マスタからコピー元年度とコピー先年度を指定し、コピー元年度の商品構成を後続の販売年度へ安全に一括複製できるようにする。複製後の商品は必ず「未公開」とし、価格確認と販売開始は管理者が明示的に行う。
関連Issue:
Product は SalesSeason を必須FKとして持つ。Product には (season, variety, type, weight_kg) の一意制約がある。SalesSeason.status は OPEN / CLOSED の2値で、将来年度も事前に OPEN で作成できる。OPEN 年度で許可されている。dashboard.services.management_season がセッションで管理している。初回実装では、コピー元年度に属する全商品を対象とする。
年度更新という用途では全商品コピーが基本であり、部分コピーを同時に導入すると「チェック対象がページ・絞り込み変更後も維持されるか」「選択商品がコピー元年度に属するか」などの追加仕様が必要になるため、初回範囲から外す。
SalesSeason を選択できる。OPEN / CLOSED のどちらもコピー元にできる。年度締め前にアクティブ年度から準備中の次年度へコピーする運用を可能にするため、コピー元を CLOSED に限定しない。
コピー先は、次の条件をすべて満たす年度に限定する。
SalesSeason である。status == OPEN である。target.year > source.year である。したがって、同一年度、過去年度、CLOSED 年度へのコピーは拒否する。コピー先はアクティブ年度に限定せず、準備中の未来年度も許可する。
コピー先の初期値は、コピー元より後にある OPEN 年度のうち最も近い年度とする。該当年度がない場合は未選択とし、販売年度管理画面での年度作成を案内する。
| 項目 | 扱い |
|---|---|
season |
コピー先年度へ置き換える |
variety |
コピー元を引き継ぐ |
type |
コピー元を引き継ぐ |
weight_kg |
コピー元を引き継ぐ |
price_coefficient |
コピー元を引き継ぐ |
price |
コピー元を引き継ぐ |
status |
コピー元に関係なく PREPARATION(未公開)にする |
name |
直接コピーせず、既存の Product.save() により品種・種類・容量から再生成する |
価格と価格係数はそのまま引き継ぐ。複製後の編集は必須にせず、確認画面で引き継ぐことを明示し、実行後の商品一覧を確認・編集導線とする。
次のデータは作成・更新しない。
SeasonStock新年度在庫は従来どおり0から開始する。商品複製を契機に SeasonStock を作成することもしない。在庫画面を表示した際の既存処理による0初期化とは区別する。
コピー先に同じ (season, variety, type, weight_kg) の商品がある場合はスキップする。
既存商品の値を新年度向けに管理者が調整済みである可能性があるため、コピー元を正として同期する処理にはしない。
templates/mg_masters/product_list.html の画面上部に「年度の商品を複製」ボタンを追加する。
CLOSED でもボタンは利用可能とする。締め済み年度をコピー元にできるためである。専用画面で次を選択する。
画面上で、次の注意事項を常時表示する。
コピー先の選択肢には OPEN 年度だけを表示し、コピー元より後の年度でなければならないことをヘルプテキストで明示する。コピー元の選び直しに応じてJavaScriptで選択肢を書き換える処理は初回実装に含めず、年度の前後関係はフォーム送信時に検証する。HTML改ざんに備え、同じ条件を実行サービスでも再検証する。
年度選択をPOSTすると、DBを変更せずに次を集計して確認画面を表示する。
確認画面の予定件数は参考値である。別セッションの操作により確認後に商品が追加・編集される可能性があるため、実行時に年度状態と対象件数を再取得し、実行結果を正とする。
実行成功後はPRG(Post/Redirect/Get)とし、次の順に処理する。
set_management_season(request, target_season) で共通販売年度をコピー先へ切り替える。作成0件・全件スキップも成功として扱う。
新規ファイル:
mg_masters/services/product_copy.py
ビューから年度判定・件数計算・複製処理を分離する。想定する公開インターフェースは次のとおり。
@dataclass(frozen=True)
class ProductCopyPlan:
source_season: SalesSeason
target_season: SalesSeason
total_count: int
create_count: int
skip_count: int
@dataclass(frozen=True)
class ProductCopyResult:
source_season: SalesSeason
target_season: SalesSeason
created_count: int
skipped_count: int
class ProductCopyError(Exception):
pass
def build_product_copy_plan(source_season, target_season) -> ProductCopyPlan:
"""DBを変更せず、確認画面用の予定件数を返す。"""
def copy_products_to_season(source_season_id, target_season_id) -> ProductCopyResult:
"""年度条件を再検証し、単一トランザクションで商品を複製する。"""
build_product_copy_plan() と copy_products_to_season() は共通の年度組み合わせ検証を使う。ただし、実行関数は渡されたモデルインスタンスの状態を信用せず、IDからロック付きで再取得する。
新規ファイル:
mg_masters/forms.py
ProductCopyForm に次のフィールドを持たせる。
source_season: ModelChoiceFieldtarget_season: ModelChoiceFieldclean() で存在確認に加えて、次を検証する。
OPEN である。フォーム検証は利用者へ分かりやすくエラーを返すためのものであり、排他制御や実行時の正当性保証はサービス層が担当する。
mg_masters/urls.py に次を追加する。
GET /management/masters/products/copy/ 選択画面
POST /management/masters/products/copy/ 確認画面
POST /management/masters/products/copy/execute/ 複製実行
ビュー例:
product_copy_view
login_required とスタッフ判定を適用する。build_product_copy_plan() を呼び、確認画面を表示する。product_copy_execute_view
login_required、スタッフ判定、require_POST を適用する。copy_products_to_season() 内で年度状態を再検証する。確認画面から送られる予定件数は信用しない。実行POSTはコピー元年度IDとコピー先年度IDだけを受け取り、件数はすべてサーバー側で再計算する。
実行処理は transaction.atomic() 内で、次の順序に統一する。
SystemSetting を select_for_update() でロックする。SalesSeason を主キー順に select_for_update() で取得する。OPEN かつコピー元より後であることを再検証する。select_for_update() で取得する。ProductCopyError として終了する。get_or_create() する。SystemSetting を年度締め処理と同じく最初にロックすることで、アクティブ年度を閉じる年度締めと商品複製が同時に実行された場合の順序を直列化する。ロック取得順を固定し、年度締めとのデッドロックを避ける。
コピー元商品をロックすることで、実行中の価格・係数・種類・容量変更と競合しないようにする。通常の商品追加がコピー先で同時に行われた場合は、DBの一意制約と get_or_create() により重複を防ぐ。
概略:
with transaction.atomic():
SystemSetting.objects.select_for_update().get(pk=1)
seasons = {
season.pk: season
for season in SalesSeason.objects.select_for_update()
.filter(pk__in=[source_season_id, target_season_id])
.order_by('pk')
}
source = seasons.get(source_season_id)
target = seasons.get(target_season_id)
validate_season_pair(source, target)
source_products = list(
Product.objects.select_for_update()
.filter(season=source)
.select_related('variety')
.order_by('pk')
)
if not source_products:
raise ProductCopyError('コピー元年度に商品がありません。')
created_count = 0
skipped_count = 0
for source_product in source_products:
_, created = Product.objects.get_or_create(
season=target,
variety=source_product.variety,
type=source_product.type,
weight_kg=source_product.weight_kg,
defaults={
'price_coefficient': source_product.price_coefficient,
'price': source_product.price,
'status': Product.ProductStatus.PREPARATION,
},
)
if created:
created_count += 1
else:
skipped_count += 1
return ProductCopyResult(
source_season=source,
target_season=target,
created_count=created_count,
skipped_count=skipped_count,
)
Product.objects.get_or_create() の作成経路は既存の Product.save() を通るため、name は現行仕様どおり再生成される。bulk_create() は save() を呼ばず、重複件数の把握とDB別の ignore_conflicts 挙動も複雑になるため採用しない。
ProductCopyError として利用者向けメッセージに変換する。ProductCopyError はビューで捕捉し、コピー画面で再選択・再確認できる利用者向けエラーとして表示する。transaction.atomic() によりその実行で作成した全商品をロールバックした上で、通常のサーバーエラー処理へ渡す。| ファイル | 変更内容 |
|---|---|
mg_masters/services/__init__.py |
サービスパッケージ追加 |
mg_masters/services/product_copy.py |
計画作成、年度検証、複製処理 |
mg_masters/forms.py |
ProductCopyForm |
mg_masters/views.py |
選択・確認・実行ビュー |
mg_masters/urls.py |
コピー画面と実行URL |
templates/mg_masters/product_copy.html |
年度選択・確認UI |
templates/mg_masters/product_list.html |
「年度の商品を複製」導線 |
mg_masters/tests.py または mg_masters/test_issue22.py |
サービス・画面・権限・回帰テスト |
docs/features/商品在庫マスタ.md |
実装完了時に操作仕様を追記 |
モデル変更はないため、マイグレーションは作成しない。
少なくとも次を用意する。
CLOSEDOPEN かつアクティブOPEN かつ非アクティブの準備中年度CLOSED 年度へのコピーを拒否する。OPEN / CLOSED のどちらでも利用できる。variety、type、weight_kg、price_coefficient、price が引き継がれる。season がコピー先になる。status == PREPARATION になる。name が既存ルールで正しく生成される。ロールバック試験では、サービスが使う Product.objects.get_or_create() をテスト内で一時的に差し替え、2件目で例外を発生させる。サービス呼び出しが例外になることと、コピー先の商品件数が実行前と一致することを検証する。
SeasonStock の件数・供給量・注文量が変わらない。各段階でテストが通る状態を維持する。
対象:
mg_masters/services/__init__.pymg_masters/services/product_copy.py内容:
この段階ではURLを公開しない。既存画面への影響を与えず、業務ロジックを先に確定する。
対象:
mg_masters/forms.pymg_masters/views.pymg_masters/urls.pytemplates/mg_masters/product_copy.htmltemplates/mg_masters/product_list.html内容:
サービス層と画面側テストを同じPhase内で更新し、URL追加時点でテストスイートを緑に保つ。
対象:
docs/features/商品在庫マスタ.md内容:
開発DBへ接続するため、DjangoコマンドはDocker開発環境内で実行する。
docker compose -f docker-compose.dev.yml exec app python manage.py test mg_masters
docker compose -f docker-compose.dev.yml exec app python manage.py test
docker compose -f docker-compose.dev.yml exec app python manage.py check
docker compose -f docker-compose.dev.yml exec app python manage.py makemigrations --check --dry-run
推奨する年度更新の流れは次のとおり。
SalesSeason を作成する。商品が未公開で作成されるため、年度締め前に準備しても一般顧客向け画面には表示されない。
これらが必要になった場合は、今回の全件コピーを基礎に別Issueで設計する。