"""Provisioning bundles — group N ``ProvisionPolicy`` rows under a named "Sales Manager Kit" / "SDE Standard Pack" / etc. Revision ID: 231 Revises: 230 Create Date: 2026-06-01 (Renumbered from 230 to 231 during merge — PR #635 ``230_seed_hris_full_sync_job.py`` took 230 first.) Background ========== PR-1 (migration 229) promoted onboarding to a first-class state-machine domain. Provisioning is still policy-by-policy: IT creates one ``ProvisionPolicy`` row per (dept/title/location, target_software) tuple. For a 9-app SDE Standard kit you create 9 rows individually; cloning the kit to a new role requires copy-pasting 9 rows. This migration adds a bundle abstraction on top, **without** changing the matching engine: * ``provisioning_bundles`` — named group (id, name, description, target_role_hint, is_active, created_by_person_id). * ``provisioning_bundle_members`` — N×M join to ``provision_policies``. * ``provision_policies.bundle_id`` — nullable FK back to a bundle, so policies can ALSO be created from a bundle template and stay linked to it for "view all policies in this bundle" + "delete bundle cascades policies". Matching engine (``_match_policies_for_person`` in service.py) is **unchanged** — it still walks `provision_policies` rows. Bundles are purely a grouping / cloning / apply-to-individual surface. Apply semantics =============== ``BundleService.apply_to_person(bundle_id, person_id)`` runs the ``_match_policies_for_person`` logic for the bundle's policy set (not the entire policy table) and creates ``ProvisioningTask`` rows for every target the person doesn't already have. Used when an admin opens an onboarding process detail page and clicks "应用 SDE 标准包" — PR-2's cockpit inline-action surface. Seed bundles (3) ================ Empty CRUD pages are user-hostile; the migration seeds three starter bundles so the page is functional on day-1 and IT has a working template to fork. * **SDE Standard** — engineering hire baseline (Slack + Jira + Confluence + GitHub + AWS dev role, when those are seeded as ProvisionPolicies in this env). * **Sales Standard** — sales rep baseline (CRM + Slack + Zoom). * **Manager Standard** — leadership add-on (admin consoles + Confluence spaces). Seeded bundles start with ``is_active=true`` but **no members** — operators bind the bundle to their existing policies via the new ``POST /provisioning/bundles/{id}/members/{policy_id}`` endpoint. This keeps the migration tenant-agnostic (we can't seed FKs to policy IDs we don't know). Idempotent (NOT EXISTS guard on bundle name). Downgrade drops the FK, removes the column, then drops the two tables. """ from __future__ import annotations import sqlalchemy as sa from alembic import op from app.core.migration_helpers import id_type_for, table_kw_for revision = "231" down_revision = "230" branch_labels = None depends_on = None def upgrade() -> None: persons_id = id_type_for("identity_persons") kw = table_kw_for("identity_persons") op.create_table( "provisioning_bundles", sa.Column("id", persons_id, primary_key=True), sa.Column("name", sa.String(128), nullable=False), sa.Column("description", sa.Text(), nullable=True), sa.Column("target_role_hint", sa.String(128), nullable=True), sa.Column( "is_active", sa.Boolean(), nullable=False, server_default=sa.text("1"), ), sa.Column("created_by_person_id", persons_id, nullable=True), sa.Column( "created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now(), ), sa.Column( "updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now(), ), sa.ForeignKeyConstraint( ["created_by_person_id"], ["identity_persons.id"], name="fk_provisioning_bundles_creator", ondelete="SET NULL", ), sa.UniqueConstraint("name", name="uq_provisioning_bundles_name"), **kw, ) op.create_index( "ix_provisioning_bundles_active", "provisioning_bundles", ["is_active"], ) op.create_table( "provisioning_bundle_members", sa.Column("id", persons_id, primary_key=True), sa.Column("bundle_id", persons_id, nullable=False), sa.Column("policy_id", persons_id, nullable=False), sa.Column( "created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now(), ), sa.ForeignKeyConstraint( ["bundle_id"], ["provisioning_bundles.id"], name="fk_pbm_bundle", ondelete="CASCADE", ), sa.ForeignKeyConstraint( ["policy_id"], ["provision_policies.id"], name="fk_pbm_policy", ondelete="CASCADE", ), sa.UniqueConstraint( "bundle_id", "policy_id", name="uq_pbm_bundle_policy", ), **kw, ) # Add bundle_id to provision_policies — nullable so existing rows # don't break, FK SET NULL so deleting a bundle leaves the # individual policy untouched (admin may have intentionally kept # the policy after retiring the bundle). op.add_column( "provision_policies", sa.Column("bundle_id", persons_id, nullable=True), ) op.create_foreign_key( "fk_provision_policies_bundle", "provision_policies", "provisioning_bundles", ["bundle_id"], ["id"], ondelete="SET NULL", ) op.create_index( "ix_provision_policies_bundle", "provision_policies", ["bundle_id"], ) # Seed three starter bundles (no members — operators bind policies # via the new HTTP / MCP surface). for name, target_role_hint, description in [ ( "SDE 标准包", "Software Engineer", "研发新员工默认包:Slack / Jira / Confluence / GitHub / 工程文档 SSO / AWS Dev 角色等。", ), ( "销售标准包", "Sales / Account Executive", "销售岗位默认包:CRM / Slack / Zoom / 销售知识库 / 客户档案 SSO 等。", ), ( "管理者标准包", "Manager", "经理岗位附加包:管理控制台访问 / 团队 Confluence 空间 / OKR 工具 / 招聘工具等。", ), ]: exists = ( op.get_bind() .execute( sa.text( "SELECT 1 FROM provisioning_bundles WHERE name = :n LIMIT 1" ), {"n": name}, ) .scalar() ) if exists: continue op.get_bind().execute( sa.text( """ INSERT INTO provisioning_bundles (id, name, description, target_role_hint, is_active, created_at, updated_at) VALUES (LOWER(REPLACE(UUID(), '-', '-')), :n, :d, :r, 1, NOW(), NOW()) """ ), {"n": name, "d": description, "r": target_role_hint}, ) def downgrade() -> None: op.drop_index( "ix_provision_policies_bundle", table_name="provision_policies", ) op.drop_constraint( "fk_provision_policies_bundle", "provision_policies", type_="foreignkey", ) op.drop_column("provision_policies", "bundle_id") op.drop_table("provisioning_bundle_members") op.drop_index( "ix_provisioning_bundles_active", table_name="provisioning_bundles", ) op.drop_table("provisioning_bundles")