feat(v2.2): 建立分区关系表和AppData迁移预演
This commit is contained in:
@@ -0,0 +1,414 @@
|
||||
-- V2.2 domain schema foundation.
|
||||
-- High-growth business tables are partitioned from the start so primary,
|
||||
-- unique, and foreign key shapes do not need to be redesigned after data grows.
|
||||
|
||||
-- Preserve the early prototype requirement table before replacing it with the
|
||||
-- V2.2 partitioned requirements pool table.
|
||||
ALTER TABLE "requirements" RENAME TO "requirements_legacy";
|
||||
ALTER TABLE "requirements_legacy" RENAME CONSTRAINT "requirements_pkey" TO "requirements_legacy_pkey";
|
||||
ALTER TABLE "requirements_legacy" RENAME CONSTRAINT "requirements_product_id_fkey" TO "requirements_legacy_product_id_fkey";
|
||||
ALTER TABLE "requirements_legacy" RENAME CONSTRAINT "requirements_creator_id_fkey" TO "requirements_legacy_creator_id_fkey";
|
||||
|
||||
ALTER TABLE "versions" ADD COLUMN "project_id" TEXT;
|
||||
ALTER TABLE "versions" ADD CONSTRAINT "versions_project_id_fkey" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
CREATE INDEX "versions_product_id_project_id_idx" ON "versions"("product_id", "project_id");
|
||||
|
||||
CREATE TABLE "task_categories" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"code" TEXT,
|
||||
"group" TEXT NOT NULL,
|
||||
"is_system" BOOLEAN NOT NULL DEFAULT false,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "task_categories_pkey" PRIMARY KEY ("id"),
|
||||
CONSTRAINT "task_categories_group_name_key" UNIQUE ("group", "name")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "task_categories_group_code_key" ON "task_categories"("group", "code") WHERE "code" IS NOT NULL;
|
||||
|
||||
CREATE TABLE "requirements" (
|
||||
"id" TEXT NOT NULL,
|
||||
"product_id" TEXT NOT NULL,
|
||||
"project_id" TEXT,
|
||||
"version_id" TEXT,
|
||||
"code" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"description" TEXT NOT NULL DEFAULT '',
|
||||
"status" TEXT NOT NULL DEFAULT 'draft',
|
||||
"priority" INTEGER NOT NULL DEFAULT 0,
|
||||
"type" TEXT,
|
||||
"source_type" TEXT,
|
||||
"source_target" TEXT,
|
||||
"platform" TEXT,
|
||||
"creator_id" TEXT,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "requirements_pkey" PRIMARY KEY ("id", "product_id"),
|
||||
CONSTRAINT "requirements_product_id_code_key" UNIQUE ("product_id", "code"),
|
||||
CONSTRAINT "requirements_product_id_fkey" FOREIGN KEY ("product_id") REFERENCES "products"("id") ON DELETE RESTRICT ON UPDATE CASCADE,
|
||||
CONSTRAINT "requirements_project_id_fkey" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE SET NULL ON UPDATE CASCADE,
|
||||
CONSTRAINT "requirements_version_id_fkey" FOREIGN KEY ("version_id") REFERENCES "versions"("id") ON DELETE SET NULL ON UPDATE CASCADE,
|
||||
CONSTRAINT "requirements_creator_id_fkey" FOREIGN KEY ("creator_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE
|
||||
) PARTITION BY HASH ("product_id");
|
||||
|
||||
CREATE TABLE "requirements_p00" PARTITION OF "requirements" FOR VALUES WITH (MODULUS 16, REMAINDER 0);
|
||||
CREATE TABLE "requirements_p01" PARTITION OF "requirements" FOR VALUES WITH (MODULUS 16, REMAINDER 1);
|
||||
CREATE TABLE "requirements_p02" PARTITION OF "requirements" FOR VALUES WITH (MODULUS 16, REMAINDER 2);
|
||||
CREATE TABLE "requirements_p03" PARTITION OF "requirements" FOR VALUES WITH (MODULUS 16, REMAINDER 3);
|
||||
CREATE TABLE "requirements_p04" PARTITION OF "requirements" FOR VALUES WITH (MODULUS 16, REMAINDER 4);
|
||||
CREATE TABLE "requirements_p05" PARTITION OF "requirements" FOR VALUES WITH (MODULUS 16, REMAINDER 5);
|
||||
CREATE TABLE "requirements_p06" PARTITION OF "requirements" FOR VALUES WITH (MODULUS 16, REMAINDER 6);
|
||||
CREATE TABLE "requirements_p07" PARTITION OF "requirements" FOR VALUES WITH (MODULUS 16, REMAINDER 7);
|
||||
CREATE TABLE "requirements_p08" PARTITION OF "requirements" FOR VALUES WITH (MODULUS 16, REMAINDER 8);
|
||||
CREATE TABLE "requirements_p09" PARTITION OF "requirements" FOR VALUES WITH (MODULUS 16, REMAINDER 9);
|
||||
CREATE TABLE "requirements_p10" PARTITION OF "requirements" FOR VALUES WITH (MODULUS 16, REMAINDER 10);
|
||||
CREATE TABLE "requirements_p11" PARTITION OF "requirements" FOR VALUES WITH (MODULUS 16, REMAINDER 11);
|
||||
CREATE TABLE "requirements_p12" PARTITION OF "requirements" FOR VALUES WITH (MODULUS 16, REMAINDER 12);
|
||||
CREATE TABLE "requirements_p13" PARTITION OF "requirements" FOR VALUES WITH (MODULUS 16, REMAINDER 13);
|
||||
CREATE TABLE "requirements_p14" PARTITION OF "requirements" FOR VALUES WITH (MODULUS 16, REMAINDER 14);
|
||||
CREATE TABLE "requirements_p15" PARTITION OF "requirements" FOR VALUES WITH (MODULUS 16, REMAINDER 15);
|
||||
|
||||
CREATE INDEX "requirements_product_project_status_created_at_idx" ON "requirements"("product_id", "project_id", "status", "created_at" DESC);
|
||||
CREATE INDEX "requirements_version_status_created_at_idx" ON "requirements"("version_id", "status", "created_at" DESC);
|
||||
CREATE INDEX "requirements_creator_created_at_idx" ON "requirements"("creator_id", "created_at" DESC);
|
||||
|
||||
CREATE TABLE "version_plans" (
|
||||
"id" TEXT NOT NULL,
|
||||
"version_id" TEXT NOT NULL,
|
||||
"product_id" TEXT NOT NULL,
|
||||
"project_id" TEXT,
|
||||
"type" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"status" TEXT NOT NULL DEFAULT 'pending',
|
||||
"owner_id" TEXT,
|
||||
"expected_start_at" TIMESTAMP(3),
|
||||
"expected_end_at" TIMESTAMP(3),
|
||||
"actual_start_at" TIMESTAMP(3),
|
||||
"completed_at" TIMESTAMP(3),
|
||||
"result_url" TEXT,
|
||||
"requirement_coverage" JSONB NOT NULL DEFAULT '[]',
|
||||
"logs" JSONB NOT NULL DEFAULT '[]',
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "version_plans_pkey" PRIMARY KEY ("id"),
|
||||
CONSTRAINT "version_plans_version_id_fkey" FOREIGN KEY ("version_id") REFERENCES "versions"("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "version_plans_product_id_fkey" FOREIGN KEY ("product_id") REFERENCES "products"("id") ON DELETE RESTRICT ON UPDATE CASCADE,
|
||||
CONSTRAINT "version_plans_project_id_fkey" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE SET NULL ON UPDATE CASCADE,
|
||||
CONSTRAINT "version_plans_owner_id_fkey" FOREIGN KEY ("owner_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX "version_plans_version_type_status_idx" ON "version_plans"("version_id", "type", "status");
|
||||
CREATE INDEX "version_plans_owner_status_end_idx" ON "version_plans"("owner_id", "status", "expected_end_at") WHERE "status" <> 'completed';
|
||||
|
||||
CREATE TABLE "dev_tasks" (
|
||||
"id" TEXT NOT NULL,
|
||||
"version_id" TEXT NOT NULL,
|
||||
"product_id" TEXT NOT NULL,
|
||||
"project_id" TEXT NOT NULL,
|
||||
"requirement_id" TEXT,
|
||||
"requirement_product_id" TEXT,
|
||||
"category_id" TEXT,
|
||||
"code" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"description" TEXT NOT NULL DEFAULT '',
|
||||
"status" TEXT NOT NULL DEFAULT 'todo',
|
||||
"priority" INTEGER NOT NULL DEFAULT 0,
|
||||
"assignee_id" TEXT,
|
||||
"creator_id" TEXT,
|
||||
"is_blocked" BOOLEAN NOT NULL DEFAULT false,
|
||||
"block_reason" TEXT,
|
||||
"expected_start_at" TIMESTAMP(3),
|
||||
"expected_end_at" TIMESTAMP(3),
|
||||
"start_date" TIMESTAMP(3),
|
||||
"completed_at" TIMESTAMP(3),
|
||||
"estimate_hours" DOUBLE PRECISION,
|
||||
"ai_estimate_hours" DOUBLE PRECISION,
|
||||
"references" JSONB NOT NULL DEFAULT '[]',
|
||||
"ai_draft" BOOLEAN NOT NULL DEFAULT false,
|
||||
"ai_draft_at" TIMESTAMP(3),
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "dev_tasks_pkey" PRIMARY KEY ("id", "version_id"),
|
||||
CONSTRAINT "dev_tasks_version_id_code_key" UNIQUE ("version_id", "code"),
|
||||
CONSTRAINT "dev_tasks_version_id_fkey" FOREIGN KEY ("version_id") REFERENCES "versions"("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "dev_tasks_product_id_fkey" FOREIGN KEY ("product_id") REFERENCES "products"("id") ON DELETE RESTRICT ON UPDATE CASCADE,
|
||||
CONSTRAINT "dev_tasks_project_id_fkey" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE RESTRICT ON UPDATE CASCADE,
|
||||
CONSTRAINT "dev_tasks_requirement_fkey" FOREIGN KEY ("requirement_id", "requirement_product_id") REFERENCES "requirements"("id", "product_id") ON DELETE SET NULL ON UPDATE CASCADE,
|
||||
CONSTRAINT "dev_tasks_category_id_fkey" FOREIGN KEY ("category_id") REFERENCES "task_categories"("id") ON DELETE SET NULL ON UPDATE CASCADE,
|
||||
CONSTRAINT "dev_tasks_assignee_id_fkey" FOREIGN KEY ("assignee_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE,
|
||||
CONSTRAINT "dev_tasks_creator_id_fkey" FOREIGN KEY ("creator_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE
|
||||
) PARTITION BY HASH ("version_id");
|
||||
|
||||
CREATE TABLE "dev_tasks_p00" PARTITION OF "dev_tasks" FOR VALUES WITH (MODULUS 16, REMAINDER 0);
|
||||
CREATE TABLE "dev_tasks_p01" PARTITION OF "dev_tasks" FOR VALUES WITH (MODULUS 16, REMAINDER 1);
|
||||
CREATE TABLE "dev_tasks_p02" PARTITION OF "dev_tasks" FOR VALUES WITH (MODULUS 16, REMAINDER 2);
|
||||
CREATE TABLE "dev_tasks_p03" PARTITION OF "dev_tasks" FOR VALUES WITH (MODULUS 16, REMAINDER 3);
|
||||
CREATE TABLE "dev_tasks_p04" PARTITION OF "dev_tasks" FOR VALUES WITH (MODULUS 16, REMAINDER 4);
|
||||
CREATE TABLE "dev_tasks_p05" PARTITION OF "dev_tasks" FOR VALUES WITH (MODULUS 16, REMAINDER 5);
|
||||
CREATE TABLE "dev_tasks_p06" PARTITION OF "dev_tasks" FOR VALUES WITH (MODULUS 16, REMAINDER 6);
|
||||
CREATE TABLE "dev_tasks_p07" PARTITION OF "dev_tasks" FOR VALUES WITH (MODULUS 16, REMAINDER 7);
|
||||
CREATE TABLE "dev_tasks_p08" PARTITION OF "dev_tasks" FOR VALUES WITH (MODULUS 16, REMAINDER 8);
|
||||
CREATE TABLE "dev_tasks_p09" PARTITION OF "dev_tasks" FOR VALUES WITH (MODULUS 16, REMAINDER 9);
|
||||
CREATE TABLE "dev_tasks_p10" PARTITION OF "dev_tasks" FOR VALUES WITH (MODULUS 16, REMAINDER 10);
|
||||
CREATE TABLE "dev_tasks_p11" PARTITION OF "dev_tasks" FOR VALUES WITH (MODULUS 16, REMAINDER 11);
|
||||
CREATE TABLE "dev_tasks_p12" PARTITION OF "dev_tasks" FOR VALUES WITH (MODULUS 16, REMAINDER 12);
|
||||
CREATE TABLE "dev_tasks_p13" PARTITION OF "dev_tasks" FOR VALUES WITH (MODULUS 16, REMAINDER 13);
|
||||
CREATE TABLE "dev_tasks_p14" PARTITION OF "dev_tasks" FOR VALUES WITH (MODULUS 16, REMAINDER 14);
|
||||
CREATE TABLE "dev_tasks_p15" PARTITION OF "dev_tasks" FOR VALUES WITH (MODULUS 16, REMAINDER 15);
|
||||
|
||||
CREATE INDEX "dev_tasks_version_status_updated_at_idx" ON "dev_tasks"("version_id", "status", "updated_at" DESC);
|
||||
CREATE INDEX "dev_tasks_requirement_id_idx" ON "dev_tasks"("requirement_id");
|
||||
CREATE INDEX "dev_tasks_assignee_unfinished_idx" ON "dev_tasks"("assignee_id", "status", "updated_at" DESC) WHERE "status" <> 'submitted';
|
||||
|
||||
CREATE TABLE "test_cases" (
|
||||
"id" TEXT NOT NULL,
|
||||
"version_id" TEXT NOT NULL,
|
||||
"product_id" TEXT NOT NULL,
|
||||
"project_id" TEXT NOT NULL,
|
||||
"requirement_id" TEXT,
|
||||
"requirement_product_id" TEXT,
|
||||
"category_id" TEXT,
|
||||
"code" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"description" TEXT NOT NULL DEFAULT '',
|
||||
"status" TEXT NOT NULL DEFAULT 'pending',
|
||||
"round_no" INTEGER NOT NULL DEFAULT 1,
|
||||
"priority" INTEGER NOT NULL DEFAULT 0,
|
||||
"assignee_id" TEXT,
|
||||
"creator_id" TEXT,
|
||||
"planned_test_at" TIMESTAMP(3),
|
||||
"planned_end_at" TIMESTAMP(3),
|
||||
"started_at" TIMESTAMP(3),
|
||||
"completed_at" TIMESTAMP(3),
|
||||
"estimate_hours" DOUBLE PRECISION,
|
||||
"ai_estimate_hours" DOUBLE PRECISION,
|
||||
"references" JSONB NOT NULL DEFAULT '[]',
|
||||
"ai_draft" BOOLEAN NOT NULL DEFAULT false,
|
||||
"ai_draft_at" TIMESTAMP(3),
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "test_cases_pkey" PRIMARY KEY ("id", "version_id"),
|
||||
CONSTRAINT "test_cases_version_id_code_key" UNIQUE ("version_id", "code"),
|
||||
CONSTRAINT "test_cases_version_id_fkey" FOREIGN KEY ("version_id") REFERENCES "versions"("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "test_cases_product_id_fkey" FOREIGN KEY ("product_id") REFERENCES "products"("id") ON DELETE RESTRICT ON UPDATE CASCADE,
|
||||
CONSTRAINT "test_cases_project_id_fkey" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE RESTRICT ON UPDATE CASCADE,
|
||||
CONSTRAINT "test_cases_requirement_fkey" FOREIGN KEY ("requirement_id", "requirement_product_id") REFERENCES "requirements"("id", "product_id") ON DELETE SET NULL ON UPDATE CASCADE,
|
||||
CONSTRAINT "test_cases_category_id_fkey" FOREIGN KEY ("category_id") REFERENCES "task_categories"("id") ON DELETE SET NULL ON UPDATE CASCADE,
|
||||
CONSTRAINT "test_cases_assignee_id_fkey" FOREIGN KEY ("assignee_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE,
|
||||
CONSTRAINT "test_cases_creator_id_fkey" FOREIGN KEY ("creator_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE
|
||||
) PARTITION BY HASH ("version_id");
|
||||
|
||||
CREATE TABLE "test_cases_p00" PARTITION OF "test_cases" FOR VALUES WITH (MODULUS 16, REMAINDER 0);
|
||||
CREATE TABLE "test_cases_p01" PARTITION OF "test_cases" FOR VALUES WITH (MODULUS 16, REMAINDER 1);
|
||||
CREATE TABLE "test_cases_p02" PARTITION OF "test_cases" FOR VALUES WITH (MODULUS 16, REMAINDER 2);
|
||||
CREATE TABLE "test_cases_p03" PARTITION OF "test_cases" FOR VALUES WITH (MODULUS 16, REMAINDER 3);
|
||||
CREATE TABLE "test_cases_p04" PARTITION OF "test_cases" FOR VALUES WITH (MODULUS 16, REMAINDER 4);
|
||||
CREATE TABLE "test_cases_p05" PARTITION OF "test_cases" FOR VALUES WITH (MODULUS 16, REMAINDER 5);
|
||||
CREATE TABLE "test_cases_p06" PARTITION OF "test_cases" FOR VALUES WITH (MODULUS 16, REMAINDER 6);
|
||||
CREATE TABLE "test_cases_p07" PARTITION OF "test_cases" FOR VALUES WITH (MODULUS 16, REMAINDER 7);
|
||||
CREATE TABLE "test_cases_p08" PARTITION OF "test_cases" FOR VALUES WITH (MODULUS 16, REMAINDER 8);
|
||||
CREATE TABLE "test_cases_p09" PARTITION OF "test_cases" FOR VALUES WITH (MODULUS 16, REMAINDER 9);
|
||||
CREATE TABLE "test_cases_p10" PARTITION OF "test_cases" FOR VALUES WITH (MODULUS 16, REMAINDER 10);
|
||||
CREATE TABLE "test_cases_p11" PARTITION OF "test_cases" FOR VALUES WITH (MODULUS 16, REMAINDER 11);
|
||||
CREATE TABLE "test_cases_p12" PARTITION OF "test_cases" FOR VALUES WITH (MODULUS 16, REMAINDER 12);
|
||||
CREATE TABLE "test_cases_p13" PARTITION OF "test_cases" FOR VALUES WITH (MODULUS 16, REMAINDER 13);
|
||||
CREATE TABLE "test_cases_p14" PARTITION OF "test_cases" FOR VALUES WITH (MODULUS 16, REMAINDER 14);
|
||||
CREATE TABLE "test_cases_p15" PARTITION OF "test_cases" FOR VALUES WITH (MODULUS 16, REMAINDER 15);
|
||||
|
||||
CREATE INDEX "test_cases_version_round_status_updated_at_idx" ON "test_cases"("version_id", "round_no", "status", "updated_at" DESC);
|
||||
CREATE INDEX "test_cases_requirement_id_idx" ON "test_cases"("requirement_id");
|
||||
CREATE INDEX "test_cases_assignee_unfinished_idx" ON "test_cases"("assignee_id", "status", "updated_at" DESC) WHERE "status" NOT IN ('passed', 'failed', 'blocked');
|
||||
|
||||
CREATE TABLE "bugs" (
|
||||
"id" TEXT NOT NULL,
|
||||
"version_id" TEXT NOT NULL,
|
||||
"product_id" TEXT NOT NULL,
|
||||
"project_id" TEXT NOT NULL,
|
||||
"test_case_id" TEXT,
|
||||
"test_case_version_id" TEXT,
|
||||
"code" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"description" TEXT NOT NULL DEFAULT '',
|
||||
"status" TEXT NOT NULL DEFAULT 'open',
|
||||
"severity" TEXT NOT NULL DEFAULT 'normal',
|
||||
"priority" INTEGER NOT NULL DEFAULT 0,
|
||||
"assignee_id" TEXT,
|
||||
"reporter_id" TEXT,
|
||||
"planned_fix_at" TIMESTAMP(3),
|
||||
"resolved_at" TIMESTAMP(3),
|
||||
"closed_at" TIMESTAMP(3),
|
||||
"resolution" TEXT,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "bugs_pkey" PRIMARY KEY ("id", "version_id"),
|
||||
CONSTRAINT "bugs_version_id_code_key" UNIQUE ("version_id", "code"),
|
||||
CONSTRAINT "bugs_version_id_fkey" FOREIGN KEY ("version_id") REFERENCES "versions"("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "bugs_product_id_fkey" FOREIGN KEY ("product_id") REFERENCES "products"("id") ON DELETE RESTRICT ON UPDATE CASCADE,
|
||||
CONSTRAINT "bugs_project_id_fkey" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE RESTRICT ON UPDATE CASCADE,
|
||||
CONSTRAINT "bugs_test_case_fkey" FOREIGN KEY ("test_case_id", "test_case_version_id") REFERENCES "test_cases"("id", "version_id") ON DELETE SET NULL ON UPDATE CASCADE,
|
||||
CONSTRAINT "bugs_assignee_id_fkey" FOREIGN KEY ("assignee_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE,
|
||||
CONSTRAINT "bugs_reporter_id_fkey" FOREIGN KEY ("reporter_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE
|
||||
) PARTITION BY HASH ("version_id");
|
||||
|
||||
CREATE TABLE "bugs_p00" PARTITION OF "bugs" FOR VALUES WITH (MODULUS 16, REMAINDER 0);
|
||||
CREATE TABLE "bugs_p01" PARTITION OF "bugs" FOR VALUES WITH (MODULUS 16, REMAINDER 1);
|
||||
CREATE TABLE "bugs_p02" PARTITION OF "bugs" FOR VALUES WITH (MODULUS 16, REMAINDER 2);
|
||||
CREATE TABLE "bugs_p03" PARTITION OF "bugs" FOR VALUES WITH (MODULUS 16, REMAINDER 3);
|
||||
CREATE TABLE "bugs_p04" PARTITION OF "bugs" FOR VALUES WITH (MODULUS 16, REMAINDER 4);
|
||||
CREATE TABLE "bugs_p05" PARTITION OF "bugs" FOR VALUES WITH (MODULUS 16, REMAINDER 5);
|
||||
CREATE TABLE "bugs_p06" PARTITION OF "bugs" FOR VALUES WITH (MODULUS 16, REMAINDER 6);
|
||||
CREATE TABLE "bugs_p07" PARTITION OF "bugs" FOR VALUES WITH (MODULUS 16, REMAINDER 7);
|
||||
CREATE TABLE "bugs_p08" PARTITION OF "bugs" FOR VALUES WITH (MODULUS 16, REMAINDER 8);
|
||||
CREATE TABLE "bugs_p09" PARTITION OF "bugs" FOR VALUES WITH (MODULUS 16, REMAINDER 9);
|
||||
CREATE TABLE "bugs_p10" PARTITION OF "bugs" FOR VALUES WITH (MODULUS 16, REMAINDER 10);
|
||||
CREATE TABLE "bugs_p11" PARTITION OF "bugs" FOR VALUES WITH (MODULUS 16, REMAINDER 11);
|
||||
CREATE TABLE "bugs_p12" PARTITION OF "bugs" FOR VALUES WITH (MODULUS 16, REMAINDER 12);
|
||||
CREATE TABLE "bugs_p13" PARTITION OF "bugs" FOR VALUES WITH (MODULUS 16, REMAINDER 13);
|
||||
CREATE TABLE "bugs_p14" PARTITION OF "bugs" FOR VALUES WITH (MODULUS 16, REMAINDER 14);
|
||||
CREATE TABLE "bugs_p15" PARTITION OF "bugs" FOR VALUES WITH (MODULUS 16, REMAINDER 15);
|
||||
|
||||
CREATE INDEX "bugs_version_status_severity_updated_at_idx" ON "bugs"("version_id", "status", "severity", "updated_at" DESC);
|
||||
CREATE INDEX "bugs_test_case_id_idx" ON "bugs"("test_case_id");
|
||||
CREATE INDEX "bugs_assignee_open_idx" ON "bugs"("assignee_id", "status", "updated_at" DESC) WHERE "status" IN ('open', 'fixing', 'fixed', 'verifying');
|
||||
|
||||
CREATE TABLE "work_activities" (
|
||||
"id" TEXT NOT NULL,
|
||||
"version_id" TEXT,
|
||||
"product_id" TEXT,
|
||||
"project_id" TEXT,
|
||||
"actor_id" TEXT,
|
||||
"actor_name" TEXT NOT NULL DEFAULT '',
|
||||
"source_type" TEXT NOT NULL,
|
||||
"source_id" TEXT NOT NULL,
|
||||
"source_version_id" TEXT,
|
||||
"action" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"metadata" JSONB NOT NULL DEFAULT '{}',
|
||||
"occurred_at" TIMESTAMP(3) NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "work_activities_pkey" PRIMARY KEY ("id", "created_at")
|
||||
) PARTITION BY RANGE ("created_at");
|
||||
|
||||
CREATE TABLE "work_activities_default" PARTITION OF "work_activities" DEFAULT;
|
||||
CREATE INDEX "work_activities_version_created_at_idx" ON "work_activities"("version_id", "created_at" DESC);
|
||||
CREATE INDEX "work_activities_actor_created_at_idx" ON "work_activities"("actor_id", "created_at" DESC);
|
||||
|
||||
CREATE TABLE "task_worklogs" (
|
||||
"id" TEXT NOT NULL,
|
||||
"version_id" TEXT,
|
||||
"product_id" TEXT,
|
||||
"project_id" TEXT,
|
||||
"user_id" TEXT,
|
||||
"source_type" TEXT NOT NULL,
|
||||
"source_id" TEXT NOT NULL,
|
||||
"source_version_id" TEXT,
|
||||
"work_date" DATE NOT NULL,
|
||||
"hours" DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
"content" TEXT NOT NULL DEFAULT '',
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "task_worklogs_pkey" PRIMARY KEY ("id", "created_at")
|
||||
) PARTITION BY RANGE ("created_at");
|
||||
|
||||
CREATE TABLE "task_worklogs_default" PARTITION OF "task_worklogs" DEFAULT;
|
||||
CREATE INDEX "task_worklogs_user_date_idx" ON "task_worklogs"("user_id", "work_date" DESC);
|
||||
CREATE INDEX "task_worklogs_version_date_idx" ON "task_worklogs"("version_id", "work_date" DESC);
|
||||
|
||||
CREATE TABLE "overtime_records" (
|
||||
"id" TEXT NOT NULL,
|
||||
"product_id" TEXT,
|
||||
"project_id" TEXT,
|
||||
"version_id" TEXT,
|
||||
"user_id" TEXT,
|
||||
"reason" TEXT NOT NULL DEFAULT '',
|
||||
"start_at" TIMESTAMP(3) NOT NULL,
|
||||
"end_at" TIMESTAMP(3) NOT NULL,
|
||||
"hours" DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "overtime_records_pkey" PRIMARY KEY ("id", "created_at")
|
||||
) PARTITION BY RANGE ("created_at");
|
||||
|
||||
CREATE TABLE "overtime_records_default" PARTITION OF "overtime_records" DEFAULT;
|
||||
CREATE INDEX "overtime_records_user_created_at_idx" ON "overtime_records"("user_id", "created_at" DESC);
|
||||
CREATE INDEX "overtime_records_project_created_at_idx" ON "overtime_records"("project_id", "created_at" DESC);
|
||||
|
||||
CREATE TABLE "xiaobao_risk_snapshots" (
|
||||
"id" TEXT NOT NULL,
|
||||
"version_id" TEXT NOT NULL,
|
||||
"snapshot_date" DATE NOT NULL,
|
||||
"risk_level" TEXT NOT NULL,
|
||||
"risk_score" INTEGER NOT NULL DEFAULT 0,
|
||||
"risk_signature" TEXT NOT NULL,
|
||||
"snapshot" JSONB NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "xiaobao_risk_snapshots_pkey" PRIMARY KEY ("id", "created_at"),
|
||||
CONSTRAINT "xiaobao_risk_snapshots_version_id_fkey" FOREIGN KEY ("version_id") REFERENCES "versions"("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
) PARTITION BY RANGE ("created_at");
|
||||
|
||||
CREATE TABLE "xiaobao_risk_snapshots_default" PARTITION OF "xiaobao_risk_snapshots" DEFAULT;
|
||||
CREATE INDEX "xiaobao_risk_snapshots_version_created_at_idx" ON "xiaobao_risk_snapshots"("version_id", "created_at" DESC);
|
||||
CREATE INDEX "xiaobao_risk_snapshots_date_idx" ON "xiaobao_risk_snapshots"("snapshot_date" DESC);
|
||||
|
||||
CREATE TABLE "xiaobao_risk_insights" (
|
||||
"id" TEXT NOT NULL,
|
||||
"version_id" TEXT NOT NULL,
|
||||
"risk_signature" TEXT NOT NULL,
|
||||
"status" TEXT NOT NULL DEFAULT 'generated',
|
||||
"insight" JSONB NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "xiaobao_risk_insights_pkey" PRIMARY KEY ("id", "created_at"),
|
||||
CONSTRAINT "xiaobao_risk_insights_version_id_fkey" FOREIGN KEY ("version_id") REFERENCES "versions"("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
) PARTITION BY RANGE ("created_at");
|
||||
|
||||
CREATE TABLE "xiaobao_risk_insights_default" PARTITION OF "xiaobao_risk_insights" DEFAULT;
|
||||
CREATE INDEX "xiaobao_risk_insights_version_signature_idx" ON "xiaobao_risk_insights"("version_id", "risk_signature", "created_at" DESC);
|
||||
|
||||
CREATE TABLE "ai_logs" (
|
||||
"id" TEXT NOT NULL,
|
||||
"provider" TEXT NOT NULL,
|
||||
"model" TEXT NOT NULL,
|
||||
"operation" TEXT NOT NULL,
|
||||
"status" TEXT NOT NULL,
|
||||
"prompt_tokens" INTEGER NOT NULL DEFAULT 0,
|
||||
"completion_tokens" INTEGER NOT NULL DEFAULT 0,
|
||||
"duration_ms" INTEGER NOT NULL DEFAULT 0,
|
||||
"error_message" TEXT,
|
||||
"metadata" JSONB NOT NULL DEFAULT '{}',
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "ai_logs_pkey" PRIMARY KEY ("id", "created_at")
|
||||
) PARTITION BY RANGE ("created_at");
|
||||
|
||||
CREATE TABLE "ai_logs_default" PARTITION OF "ai_logs" DEFAULT;
|
||||
CREATE INDEX "ai_logs_operation_created_at_idx" ON "ai_logs"("operation", "created_at" DESC);
|
||||
CREATE INDEX "ai_logs_status_created_at_idx" ON "ai_logs"("status", "created_at" DESC);
|
||||
|
||||
CREATE TABLE "xiaobao_risk_summaries" (
|
||||
"version_id" TEXT NOT NULL,
|
||||
"risk_level" TEXT NOT NULL,
|
||||
"risk_score" INTEGER NOT NULL DEFAULT 0,
|
||||
"confidence" INTEGER NOT NULL DEFAULT 0,
|
||||
"forecast_release_date" TIMESTAMP(3),
|
||||
"risk_signature" TEXT NOT NULL,
|
||||
"summary" JSONB NOT NULL,
|
||||
"dirty" BOOLEAN NOT NULL DEFAULT false,
|
||||
"recomputed_at" TIMESTAMP(3),
|
||||
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "xiaobao_risk_summaries_pkey" PRIMARY KEY ("version_id"),
|
||||
CONSTRAINT "xiaobao_risk_summaries_version_id_fkey" FOREIGN KEY ("version_id") REFERENCES "versions"("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
@@ -49,6 +49,7 @@ model Project {
|
||||
product Product @relation(fields: [productId], references: [id])
|
||||
sprints Sprint[]
|
||||
tasks Task[]
|
||||
versions Version[]
|
||||
members ProjectMember[]
|
||||
|
||||
@@map("projects")
|
||||
@@ -72,6 +73,7 @@ model Sprint {
|
||||
model Version {
|
||||
id String @id @default(cuid())
|
||||
productId String @map("product_id")
|
||||
projectId String? @map("project_id")
|
||||
name String
|
||||
description String @default("")
|
||||
releaseDate DateTime? @map("release_date")
|
||||
@@ -79,25 +81,35 @@ model Version {
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
product Product @relation(fields: [productId], references: [id])
|
||||
project Project? @relation(fields: [projectId], references: [id])
|
||||
tasks Task[]
|
||||
|
||||
@@map("versions")
|
||||
}
|
||||
|
||||
model Requirement {
|
||||
id String @id @default(cuid())
|
||||
id String @default(cuid())
|
||||
productId String @map("product_id")
|
||||
projectId String? @map("project_id")
|
||||
versionId String? @map("version_id")
|
||||
code String
|
||||
title String
|
||||
description String @default("")
|
||||
status String @default("draft")
|
||||
priority Int @default(0)
|
||||
creatorId String @map("creator_id")
|
||||
type String?
|
||||
sourceType String? @map("source_type")
|
||||
sourceTarget String? @map("source_target")
|
||||
platform String?
|
||||
creatorId String? @map("creator_id")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
product Product @relation(fields: [productId], references: [id])
|
||||
creator User @relation(fields: [creatorId], references: [id])
|
||||
creator User? @relation(fields: [creatorId], references: [id])
|
||||
|
||||
@@id([id, productId])
|
||||
@@unique([productId, code])
|
||||
@@map("requirements")
|
||||
}
|
||||
|
||||
@@ -168,6 +180,250 @@ model ProjectMember {
|
||||
@@map("project_members")
|
||||
}
|
||||
|
||||
model TaskCategory {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
code String?
|
||||
group String
|
||||
isSystem Boolean @default(false) @map("is_system")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@@unique([group, name])
|
||||
@@map("task_categories")
|
||||
}
|
||||
|
||||
model VersionPlan {
|
||||
id String @id @default(cuid())
|
||||
versionId String @map("version_id")
|
||||
productId String @map("product_id")
|
||||
projectId String? @map("project_id")
|
||||
type String
|
||||
title String
|
||||
status String @default("pending")
|
||||
ownerId String? @map("owner_id")
|
||||
expectedStartAt DateTime? @map("expected_start_at")
|
||||
expectedEndAt DateTime? @map("expected_end_at")
|
||||
actualStartAt DateTime? @map("actual_start_at")
|
||||
completedAt DateTime? @map("completed_at")
|
||||
resultUrl String? @map("result_url")
|
||||
requirementCoverage Json @default("[]") @map("requirement_coverage")
|
||||
logs Json @default("[]")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@@map("version_plans")
|
||||
}
|
||||
|
||||
model DevTask {
|
||||
id String @default(cuid())
|
||||
versionId String @map("version_id")
|
||||
productId String @map("product_id")
|
||||
projectId String @map("project_id")
|
||||
requirementId String? @map("requirement_id")
|
||||
requirementProductId String? @map("requirement_product_id")
|
||||
categoryId String? @map("category_id")
|
||||
code String
|
||||
title String
|
||||
description String @default("")
|
||||
status String @default("todo")
|
||||
priority Int @default(0)
|
||||
assigneeId String? @map("assignee_id")
|
||||
creatorId String? @map("creator_id")
|
||||
isBlocked Boolean @default(false) @map("is_blocked")
|
||||
blockReason String? @map("block_reason")
|
||||
expectedStartAt DateTime? @map("expected_start_at")
|
||||
expectedEndAt DateTime? @map("expected_end_at")
|
||||
startDate DateTime? @map("start_date")
|
||||
completedAt DateTime? @map("completed_at")
|
||||
estimateHours Float? @map("estimate_hours")
|
||||
aiEstimateHours Float? @map("ai_estimate_hours")
|
||||
references Json @default("[]")
|
||||
aiDraft Boolean @default(false) @map("ai_draft")
|
||||
aiDraftAt DateTime? @map("ai_draft_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@@id([id, versionId])
|
||||
@@unique([versionId, code])
|
||||
@@map("dev_tasks")
|
||||
}
|
||||
|
||||
model TestCase {
|
||||
id String @default(cuid())
|
||||
versionId String @map("version_id")
|
||||
productId String @map("product_id")
|
||||
projectId String @map("project_id")
|
||||
requirementId String? @map("requirement_id")
|
||||
requirementProductId String? @map("requirement_product_id")
|
||||
categoryId String? @map("category_id")
|
||||
code String
|
||||
title String
|
||||
description String @default("")
|
||||
status String @default("pending")
|
||||
roundNo Int @default(1) @map("round_no")
|
||||
priority Int @default(0)
|
||||
assigneeId String? @map("assignee_id")
|
||||
creatorId String? @map("creator_id")
|
||||
plannedTestAt DateTime? @map("planned_test_at")
|
||||
plannedEndAt DateTime? @map("planned_end_at")
|
||||
startedAt DateTime? @map("started_at")
|
||||
completedAt DateTime? @map("completed_at")
|
||||
estimateHours Float? @map("estimate_hours")
|
||||
aiEstimateHours Float? @map("ai_estimate_hours")
|
||||
references Json @default("[]")
|
||||
aiDraft Boolean @default(false) @map("ai_draft")
|
||||
aiDraftAt DateTime? @map("ai_draft_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@@id([id, versionId])
|
||||
@@unique([versionId, code])
|
||||
@@map("test_cases")
|
||||
}
|
||||
|
||||
model Bug {
|
||||
id String @default(cuid())
|
||||
versionId String @map("version_id")
|
||||
productId String @map("product_id")
|
||||
projectId String @map("project_id")
|
||||
testCaseId String? @map("test_case_id")
|
||||
testCaseVersionId String? @map("test_case_version_id")
|
||||
code String
|
||||
title String
|
||||
description String @default("")
|
||||
status String @default("open")
|
||||
severity String @default("normal")
|
||||
priority Int @default(0)
|
||||
assigneeId String? @map("assignee_id")
|
||||
reporterId String? @map("reporter_id")
|
||||
plannedFixAt DateTime? @map("planned_fix_at")
|
||||
resolvedAt DateTime? @map("resolved_at")
|
||||
closedAt DateTime? @map("closed_at")
|
||||
resolution String?
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@@id([id, versionId])
|
||||
@@unique([versionId, code])
|
||||
@@map("bugs")
|
||||
}
|
||||
|
||||
model WorkActivity {
|
||||
id String @default(cuid())
|
||||
versionId String? @map("version_id")
|
||||
productId String? @map("product_id")
|
||||
projectId String? @map("project_id")
|
||||
actorId String? @map("actor_id")
|
||||
actorName String @default("") @map("actor_name")
|
||||
sourceType String @map("source_type")
|
||||
sourceId String @map("source_id")
|
||||
sourceVersionId String? @map("source_version_id")
|
||||
action String
|
||||
title String
|
||||
metadata Json @default("{}")
|
||||
occurredAt DateTime @map("occurred_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
@@id([id, createdAt])
|
||||
@@map("work_activities")
|
||||
}
|
||||
|
||||
model TaskWorklog {
|
||||
id String @default(cuid())
|
||||
versionId String? @map("version_id")
|
||||
productId String? @map("product_id")
|
||||
projectId String? @map("project_id")
|
||||
userId String? @map("user_id")
|
||||
sourceType String @map("source_type")
|
||||
sourceId String @map("source_id")
|
||||
sourceVersionId String? @map("source_version_id")
|
||||
workDate DateTime @db.Date @map("work_date")
|
||||
hours Float @default(0)
|
||||
content String @default("")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@@id([id, createdAt])
|
||||
@@map("task_worklogs")
|
||||
}
|
||||
|
||||
model OvertimeRecord {
|
||||
id String @default(cuid())
|
||||
productId String? @map("product_id")
|
||||
projectId String? @map("project_id")
|
||||
versionId String? @map("version_id")
|
||||
userId String? @map("user_id")
|
||||
reason String @default("")
|
||||
startAt DateTime @map("start_at")
|
||||
endAt DateTime @map("end_at")
|
||||
hours Float @default(0)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@@id([id, createdAt])
|
||||
@@map("overtime_records")
|
||||
}
|
||||
|
||||
model XiaobaoRiskSnapshot {
|
||||
id String @default(cuid())
|
||||
versionId String @map("version_id")
|
||||
snapshotDate DateTime @db.Date @map("snapshot_date")
|
||||
riskLevel String @map("risk_level")
|
||||
riskScore Int @default(0) @map("risk_score")
|
||||
riskSignature String @map("risk_signature")
|
||||
snapshot Json
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
@@id([id, createdAt])
|
||||
@@map("xiaobao_risk_snapshots")
|
||||
}
|
||||
|
||||
model XiaobaoRiskInsight {
|
||||
id String @default(cuid())
|
||||
versionId String @map("version_id")
|
||||
riskSignature String @map("risk_signature")
|
||||
status String @default("generated")
|
||||
insight Json
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@@id([id, createdAt])
|
||||
@@map("xiaobao_risk_insights")
|
||||
}
|
||||
|
||||
model AiLog {
|
||||
id String @default(cuid())
|
||||
provider String
|
||||
model String
|
||||
operation String
|
||||
status String
|
||||
promptTokens Int @default(0) @map("prompt_tokens")
|
||||
completionTokens Int @default(0) @map("completion_tokens")
|
||||
durationMs Int @default(0) @map("duration_ms")
|
||||
errorMessage String? @map("error_message")
|
||||
metadata Json @default("{}")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
@@id([id, createdAt])
|
||||
@@map("ai_logs")
|
||||
}
|
||||
|
||||
model XiaobaoRiskSummary {
|
||||
versionId String @id @map("version_id")
|
||||
riskLevel String @map("risk_level")
|
||||
riskScore Int @default(0) @map("risk_score")
|
||||
confidence Int @default(0)
|
||||
forecastReleaseDate DateTime? @map("forecast_release_date")
|
||||
riskSignature String @map("risk_signature")
|
||||
summary Json
|
||||
dirty Boolean @default(false)
|
||||
recomputedAt DateTime? @map("recomputed_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@@map("xiaobao_risk_summaries")
|
||||
}
|
||||
|
||||
model AppData {
|
||||
key String @id
|
||||
value Json
|
||||
|
||||
104
apps/server/prisma/v22-partitioned-schema.spec.ts
Normal file
104
apps/server/prisma/v22-partitioned-schema.spec.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
const migrationPath = join(
|
||||
process.cwd(),
|
||||
'prisma',
|
||||
'migrations',
|
||||
'20260703000000_v22_partitioned_domain_schema',
|
||||
'migration.sql',
|
||||
);
|
||||
|
||||
function readMigrationSql() {
|
||||
if (!existsSync(migrationPath)) {
|
||||
throw new Error(`Missing V2.2 migration: ${migrationPath}`);
|
||||
}
|
||||
return readFileSync(migrationPath, 'utf8');
|
||||
}
|
||||
|
||||
function expectPartitionedTable(
|
||||
sql: string,
|
||||
table: string,
|
||||
method: 'HASH' | 'RANGE',
|
||||
key: string,
|
||||
primaryKey: string[],
|
||||
) {
|
||||
const quotedPrimaryKey = primaryKey.map((column) => `"${column}"`).join(', ');
|
||||
expect(sql).toMatch(
|
||||
new RegExp(
|
||||
`CREATE TABLE "${table}"[\\s\\S]*CONSTRAINT "${table}_pkey" PRIMARY KEY \\(${quotedPrimaryKey}\\)[\\s\\S]*PARTITION BY ${method} \\("${key}"\\);`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function expectHashPartitions(sql: string, table: string, count: number) {
|
||||
const matches = sql.match(
|
||||
new RegExp(`PARTITION OF "${table}" FOR VALUES WITH \\(MODULUS ${count}, REMAINDER`, 'g'),
|
||||
);
|
||||
expect(matches).toHaveLength(count);
|
||||
}
|
||||
|
||||
describe('V2.2 partitioned domain schema migration', () => {
|
||||
it('keeps every hash partitioned business table primary key anchored by the partition key', () => {
|
||||
const sql = readMigrationSql();
|
||||
|
||||
expectPartitionedTable(sql, 'requirements', 'HASH', 'product_id', ['id', 'product_id']);
|
||||
expectPartitionedTable(sql, 'dev_tasks', 'HASH', 'version_id', ['id', 'version_id']);
|
||||
expectPartitionedTable(sql, 'test_cases', 'HASH', 'version_id', ['id', 'version_id']);
|
||||
expectPartitionedTable(sql, 'bugs', 'HASH', 'version_id', ['id', 'version_id']);
|
||||
|
||||
expectHashPartitions(sql, 'requirements', 16);
|
||||
expectHashPartitions(sql, 'dev_tasks', 16);
|
||||
expectHashPartitions(sql, 'test_cases', 16);
|
||||
expectHashPartitions(sql, 'bugs', 16);
|
||||
});
|
||||
|
||||
it('includes partition keys in business unique constraints', () => {
|
||||
const sql = readMigrationSql();
|
||||
|
||||
expect(sql).toMatch(/CONSTRAINT "requirements_product_id_code_key" UNIQUE \("product_id", "code"\)/);
|
||||
expect(sql).toMatch(/CONSTRAINT "dev_tasks_version_id_code_key" UNIQUE \("version_id", "code"\)/);
|
||||
expect(sql).toMatch(/CONSTRAINT "test_cases_version_id_code_key" UNIQUE \("version_id", "code"\)/);
|
||||
expect(sql).toMatch(/CONSTRAINT "bugs_version_id_code_key" UNIQUE \("version_id", "code"\)/);
|
||||
});
|
||||
|
||||
it('uses composite foreign keys when a referenced table is partitioned', () => {
|
||||
const sql = readMigrationSql();
|
||||
|
||||
expect(sql).toMatch(
|
||||
/FOREIGN KEY \("requirement_id", "requirement_product_id"\) REFERENCES "requirements"\("id", "product_id"\)/,
|
||||
);
|
||||
expect(sql).toMatch(
|
||||
/FOREIGN KEY \("test_case_id", "test_case_version_id"\) REFERENCES "test_cases"\("id", "version_id"\)/,
|
||||
);
|
||||
});
|
||||
|
||||
it('range partitions append-only evidence and snapshot tables by created_at', () => {
|
||||
const sql = readMigrationSql();
|
||||
|
||||
expectPartitionedTable(sql, 'work_activities', 'RANGE', 'created_at', ['id', 'created_at']);
|
||||
expectPartitionedTable(sql, 'task_worklogs', 'RANGE', 'created_at', ['id', 'created_at']);
|
||||
expectPartitionedTable(sql, 'overtime_records', 'RANGE', 'created_at', ['id', 'created_at']);
|
||||
expectPartitionedTable(sql, 'xiaobao_risk_snapshots', 'RANGE', 'created_at', ['id', 'created_at']);
|
||||
expectPartitionedTable(sql, 'ai_logs', 'RANGE', 'created_at', ['id', 'created_at']);
|
||||
|
||||
for (const table of [
|
||||
'work_activities',
|
||||
'task_worklogs',
|
||||
'overtime_records',
|
||||
'xiaobao_risk_snapshots',
|
||||
'ai_logs',
|
||||
]) {
|
||||
expect(sql).toMatch(new RegExp(`CREATE TABLE "${table}_default" PARTITION OF "${table}" DEFAULT;`));
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps Xiaobao current summaries as one row per version instead of partitioned history', () => {
|
||||
const sql = readMigrationSql();
|
||||
|
||||
expect(sql).toMatch(
|
||||
/CREATE TABLE "xiaobao_risk_summaries"[\s\S]*CONSTRAINT "xiaobao_risk_summaries_pkey" PRIMARY KEY \("version_id"\)/,
|
||||
);
|
||||
expect(sql).not.toMatch(/CREATE TABLE "xiaobao_risk_summaries"[\s\S]*PARTITION BY/);
|
||||
});
|
||||
});
|
||||
50
apps/server/prisma/v22-prisma-models.spec.ts
Normal file
50
apps/server/prisma/v22-prisma-models.spec.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
const schemaPath = join(process.cwd(), 'prisma', 'schema.prisma');
|
||||
|
||||
function readSchema() {
|
||||
return readFileSync(schemaPath, 'utf8');
|
||||
}
|
||||
|
||||
function modelBlock(schema: string, modelName: string) {
|
||||
const match = schema.match(new RegExp(`model ${modelName} \\{[\\s\\S]*?\\n\\}`));
|
||||
if (!match) throw new Error(`Missing Prisma model: ${modelName}`);
|
||||
return match[0];
|
||||
}
|
||||
|
||||
describe('V2.2 Prisma partitioned model shape', () => {
|
||||
it('models partitioned business tables with composite ids that include the partition key', () => {
|
||||
const schema = readSchema();
|
||||
|
||||
expect(modelBlock(schema, 'Requirement')).toMatch(/@@id\(\[id, productId\]\)/);
|
||||
expect(modelBlock(schema, 'DevTask')).toMatch(/@@id\(\[id, versionId\]\)/);
|
||||
expect(modelBlock(schema, 'TestCase')).toMatch(/@@id\(\[id, versionId\]\)/);
|
||||
expect(modelBlock(schema, 'Bug')).toMatch(/@@id\(\[id, versionId\]\)/);
|
||||
});
|
||||
|
||||
it('keeps partition-key-scoped unique business codes in Prisma models', () => {
|
||||
const schema = readSchema();
|
||||
|
||||
expect(modelBlock(schema, 'Requirement')).toMatch(/@@unique\(\[productId, code\]\)/);
|
||||
expect(modelBlock(schema, 'DevTask')).toMatch(/@@unique\(\[versionId, code\]\)/);
|
||||
expect(modelBlock(schema, 'TestCase')).toMatch(/@@unique\(\[versionId, code\]\)/);
|
||||
expect(modelBlock(schema, 'Bug')).toMatch(/@@unique\(\[versionId, code\]\)/);
|
||||
});
|
||||
|
||||
it('models append-only partitioned tables with createdAt in the composite id', () => {
|
||||
const schema = readSchema();
|
||||
|
||||
expect(modelBlock(schema, 'WorkActivity')).toMatch(/@@id\(\[id, createdAt\]\)/);
|
||||
expect(modelBlock(schema, 'TaskWorklog')).toMatch(/@@id\(\[id, createdAt\]\)/);
|
||||
expect(modelBlock(schema, 'OvertimeRecord')).toMatch(/@@id\(\[id, createdAt\]\)/);
|
||||
expect(modelBlock(schema, 'XiaobaoRiskSnapshot')).toMatch(/@@id\(\[id, createdAt\]\)/);
|
||||
expect(modelBlock(schema, 'AiLog')).toMatch(/@@id\(\[id, createdAt\]\)/);
|
||||
});
|
||||
|
||||
it('keeps Xiaobao current summaries keyed by version id only', () => {
|
||||
const schema = readSchema();
|
||||
|
||||
expect(modelBlock(schema, 'XiaobaoRiskSummary')).toMatch(/versionId\s+String\s+@id/);
|
||||
});
|
||||
});
|
||||
@@ -5,9 +5,10 @@ import { RequirementModule } from './modules/requirement/requirement.module';
|
||||
import { AiModule } from './modules/ai/ai.module';
|
||||
import { ConfigModule } from './modules/config/config.module';
|
||||
import { DataModule } from './modules/data/data.module';
|
||||
import { MigrationModule } from './modules/migration/migration.module';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, ProductModule, RequirementModule, ConfigModule, DataModule, AiModule],
|
||||
imports: [PrismaModule, ProductModule, RequirementModule, ConfigModule, DataModule, MigrationModule, AiModule],
|
||||
controllers: [],
|
||||
providers: [],
|
||||
})
|
||||
|
||||
415
apps/server/src/modules/migration/app-data-v22.mapper.spec.ts
Normal file
415
apps/server/src/modules/migration/app-data-v22.mapper.spec.ts
Normal file
@@ -0,0 +1,415 @@
|
||||
import { mapAppDataToV22Rows } from './app-data-v22.mapper';
|
||||
|
||||
const now = '2026-01-04T10:00:00.000Z';
|
||||
|
||||
function buildAppData(): Record<string, any> {
|
||||
return {
|
||||
'products-overview': [
|
||||
{
|
||||
id: 'product-1',
|
||||
name: 'FTB',
|
||||
description: 'Product suite',
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
updatedAt: '2026-01-02T00:00:00.000Z',
|
||||
projects: [
|
||||
{
|
||||
id: 'project-1',
|
||||
name: 'CRM',
|
||||
description: 'Customer project',
|
||||
createdAt: '2026-01-01T01:00:00.000Z',
|
||||
},
|
||||
],
|
||||
versions: [
|
||||
{
|
||||
id: 'version-1',
|
||||
name: 'CRM V1.0',
|
||||
description: 'First release',
|
||||
status: 'in_progress',
|
||||
releaseDate: '2026-02-01',
|
||||
expectedReleaseDate: '2026-02-03',
|
||||
createdAt: '2026-01-01T02:00:00.000Z',
|
||||
updatedAt: '2026-01-02T02:00:00.000Z',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
requirements: {
|
||||
requirements: [
|
||||
{
|
||||
id: 'req-1',
|
||||
code: 'REQ-001',
|
||||
title: 'Customer import',
|
||||
description: 'Import customers in bulk',
|
||||
productId: 'product-1',
|
||||
projectId: 'project-1',
|
||||
versionId: 'version-1',
|
||||
sourceType: 'customer',
|
||||
sourceTarget: 'ACME',
|
||||
platforms: ['Web', 'App'],
|
||||
typeId: 'type-feature',
|
||||
status: 'adopted',
|
||||
priority: 'P1',
|
||||
creator: 'member-pm',
|
||||
createdAt: '2026-01-02T08:00:00.000Z',
|
||||
updatedAt: '2026-01-02T09:00:00.000Z',
|
||||
},
|
||||
],
|
||||
},
|
||||
'task-categories': [
|
||||
{
|
||||
id: 'cat-fe',
|
||||
code: 'frontend_development',
|
||||
name: 'Frontend development',
|
||||
group: 'development',
|
||||
isSystem: true,
|
||||
},
|
||||
{
|
||||
id: 'cat-test',
|
||||
code: 'test_functional',
|
||||
name: 'Functional test',
|
||||
group: 'testing',
|
||||
isSystem: true,
|
||||
},
|
||||
],
|
||||
'version-plans': [
|
||||
{
|
||||
id: 'plan-1',
|
||||
versionId: 'version-1',
|
||||
type: 'product',
|
||||
title: 'Product plan',
|
||||
owner: 'member-pm',
|
||||
startTime: '2026-01-02T09:00:00.000Z',
|
||||
endTime: '2026-01-02T18:00:00.000Z',
|
||||
status: 'completed',
|
||||
requirementCoverage: [
|
||||
{
|
||||
requirementId: 'req-1',
|
||||
status: 'completed',
|
||||
updatedAt: now,
|
||||
updatedBy: 'member-pm',
|
||||
},
|
||||
],
|
||||
logs: [
|
||||
{
|
||||
id: 'plan-log-1',
|
||||
type: 'requirement_progress',
|
||||
createdAt: now,
|
||||
actor: 'member-pm',
|
||||
title: 'Requirement completed',
|
||||
},
|
||||
],
|
||||
resultUrl: 'https://example.com/prototype',
|
||||
createdAt: '2026-01-02T08:30:00.000Z',
|
||||
completedAt: '2026-01-02T17:00:00.000Z',
|
||||
addedBy: 'member-pm',
|
||||
},
|
||||
],
|
||||
'dev-tasks': [
|
||||
{
|
||||
id: 'dev-1',
|
||||
taskNo: 'DEV-001',
|
||||
requirementId: 'req-1',
|
||||
title: 'Build import UI',
|
||||
description: 'Upload and preview CSV',
|
||||
categoryId: 'cat-fe',
|
||||
assigneeId: 'member-dev',
|
||||
priority: 'P0',
|
||||
expectedStartAt: '2026-01-03T09:00:00.000Z',
|
||||
expectedEndAt: '2026-01-03T18:00:00.000Z',
|
||||
actualStartAt: '2026-01-03T10:00:00.000Z',
|
||||
actualEndAt: '2026-01-03T17:30:00.000Z',
|
||||
estimateHours: 6,
|
||||
aiEstimateHours: 7,
|
||||
status: 'submitted',
|
||||
isBlocked: false,
|
||||
references: [{ type: 'requirement', id: 'req-1', label: 'REQ-001' }],
|
||||
aiDraft: false,
|
||||
createdBy: 'member-pm',
|
||||
createdAt: '2026-01-03T08:30:00.000Z',
|
||||
updatedAt: '2026-01-03T17:30:00.000Z',
|
||||
},
|
||||
],
|
||||
'test-cases': [
|
||||
{
|
||||
id: 'tc-1',
|
||||
caseNo: 'TC-001',
|
||||
versionId: 'version-1',
|
||||
requirementId: 'req-1',
|
||||
title: 'Import success path',
|
||||
description: 'Upload a valid CSV',
|
||||
categoryId: 'cat-test',
|
||||
priority: 'P2',
|
||||
assigneeId: 'member-test',
|
||||
status: 'failed',
|
||||
roundNo: 1,
|
||||
plannedTestAt: '2026-01-04T09:00:00.000Z',
|
||||
plannedEndAt: '2026-01-04T12:00:00.000Z',
|
||||
startedAt: '2026-01-04T09:30:00.000Z',
|
||||
completedAt: '2026-01-04T10:30:00.000Z',
|
||||
estimateHours: 2,
|
||||
aiEstimateHours: 2.5,
|
||||
references: [{ type: 'requirement', id: 'req-1', label: 'REQ-001' }],
|
||||
createdBy: 'member-test',
|
||||
createdAt: '2026-01-04T08:00:00.000Z',
|
||||
updatedAt: '2026-01-04T10:30:00.000Z',
|
||||
},
|
||||
],
|
||||
bugs: [
|
||||
{
|
||||
id: 'bug-1',
|
||||
bugNo: 'BUG-001',
|
||||
versionId: 'version-1',
|
||||
testCaseId: 'tc-1',
|
||||
requirementId: 'req-1',
|
||||
title: 'Preview count is wrong',
|
||||
description: 'Total rows shows 0',
|
||||
severity: 'major',
|
||||
priority: 'P1',
|
||||
reportedBy: 'member-test',
|
||||
assigneeId: 'member-dev',
|
||||
status: 'fixing',
|
||||
plannedFixAt: '2026-01-04T18:00:00.000Z',
|
||||
createdAt: '2026-01-04T10:40:00.000Z',
|
||||
updatedAt: '2026-01-04T11:00:00.000Z',
|
||||
},
|
||||
],
|
||||
'work-activities': [
|
||||
{
|
||||
id: 'activity-1',
|
||||
actorId: 'member-dev',
|
||||
date: '2026-01-03',
|
||||
occurredAt: '2026-01-03T10:00:00.000Z',
|
||||
sourceType: 'dev_task',
|
||||
sourceId: 'dev-1',
|
||||
action: 'dev_task_started',
|
||||
category: 'progress',
|
||||
title: 'Started import UI',
|
||||
summary: 'Started development',
|
||||
metadata: { toStatus: 'in_progress' },
|
||||
},
|
||||
],
|
||||
'task-worklogs': [
|
||||
{
|
||||
id: 'worklog-1',
|
||||
taskId: 'dev-1',
|
||||
userId: 'member-dev',
|
||||
date: '2026-01-03',
|
||||
hours: 2.5,
|
||||
workContent: 'Built CSV preview',
|
||||
createdAt: '2026-01-03T19:00:00.000Z',
|
||||
},
|
||||
],
|
||||
overtime: {
|
||||
records: [
|
||||
{
|
||||
id: 'overtime-1',
|
||||
projectId: 'project-1',
|
||||
versionId: 'version-1',
|
||||
person: 'member-dev',
|
||||
startTime: '2026-01-03T18:00:00.000Z',
|
||||
endTime: '2026-01-03T20:00:00.000Z',
|
||||
duration: 2,
|
||||
reasonId: 'reason-3',
|
||||
remark: 'Release sprint',
|
||||
createdAt: '2026-01-03T20:10:00.000Z',
|
||||
},
|
||||
],
|
||||
},
|
||||
'xiaobao-risk-snapshots': [
|
||||
{
|
||||
versionId: 'version-1',
|
||||
date: '2026-01-04',
|
||||
riskScore: 72,
|
||||
riskLevel: 'at_risk',
|
||||
forecastReleaseDate: '2026-02-05',
|
||||
openBugCount: 3,
|
||||
criticalBugCount: 1,
|
||||
failedTestCount: 2,
|
||||
blockedCount: 1,
|
||||
silentRiskCount: 0,
|
||||
confidence: 80,
|
||||
createdAt: '2026-01-04T12:00:00.000Z',
|
||||
},
|
||||
],
|
||||
'xiaobao-risk-insights': [
|
||||
{
|
||||
versionId: 'version-1',
|
||||
riskSignature: 'risk-signature-1',
|
||||
insight: { summary: 'Risk is rising', why: ['Bug count rose'] },
|
||||
generatedAt: '2026-01-04T12:05:00.000Z',
|
||||
providerInfo: { providerId: 'anthropic', model: 'claude-test' },
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
describe('mapAppDataToV22Rows', () => {
|
||||
it('maps AppData entities into partition-key-ready relational rows', () => {
|
||||
const result = mapAppDataToV22Rows(buildAppData());
|
||||
|
||||
expect(result.products).toEqual([
|
||||
expect.objectContaining({ id: 'product-1', name: 'FTB' }),
|
||||
]);
|
||||
expect(result.projects).toEqual([
|
||||
expect.objectContaining({ id: 'project-1', productId: 'product-1', name: 'CRM' }),
|
||||
]);
|
||||
expect(result.versions).toEqual([
|
||||
expect.objectContaining({
|
||||
id: 'version-1',
|
||||
productId: 'product-1',
|
||||
projectId: 'project-1',
|
||||
releaseDate: '2026-02-01',
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(result.requirements).toEqual([
|
||||
expect.objectContaining({
|
||||
id: 'req-1',
|
||||
productId: 'product-1',
|
||||
projectId: 'project-1',
|
||||
versionId: 'version-1',
|
||||
code: 'REQ-001',
|
||||
priority: 1,
|
||||
platform: 'Web,App',
|
||||
creatorId: 'member-pm',
|
||||
}),
|
||||
]);
|
||||
expect(result.devTasks).toEqual([
|
||||
expect.objectContaining({
|
||||
id: 'dev-1',
|
||||
versionId: 'version-1',
|
||||
productId: 'product-1',
|
||||
projectId: 'project-1',
|
||||
requirementId: 'req-1',
|
||||
requirementProductId: 'product-1',
|
||||
code: 'DEV-001',
|
||||
priority: 0,
|
||||
startDate: '2026-01-03T10:00:00.000Z',
|
||||
completedAt: '2026-01-03T17:30:00.000Z',
|
||||
}),
|
||||
]);
|
||||
expect(result.testCases).toEqual([
|
||||
expect.objectContaining({
|
||||
id: 'tc-1',
|
||||
versionId: 'version-1',
|
||||
productId: 'product-1',
|
||||
projectId: 'project-1',
|
||||
requirementProductId: 'product-1',
|
||||
code: 'TC-001',
|
||||
priority: 2,
|
||||
}),
|
||||
]);
|
||||
expect(result.bugs).toEqual([
|
||||
expect.objectContaining({
|
||||
id: 'bug-1',
|
||||
versionId: 'version-1',
|
||||
productId: 'product-1',
|
||||
projectId: 'project-1',
|
||||
testCaseId: 'tc-1',
|
||||
testCaseVersionId: 'version-1',
|
||||
code: 'BUG-001',
|
||||
priority: 1,
|
||||
}),
|
||||
]);
|
||||
expect(result.skipped).toEqual([]);
|
||||
});
|
||||
|
||||
it('maps append-only activity, worklog, overtime, and Xiaobao rows with createdAt partition keys', () => {
|
||||
const result = mapAppDataToV22Rows(buildAppData());
|
||||
|
||||
expect(result.workActivities).toEqual([
|
||||
expect.objectContaining({
|
||||
id: 'activity-1',
|
||||
versionId: 'version-1',
|
||||
productId: 'product-1',
|
||||
projectId: 'project-1',
|
||||
sourceType: 'dev_task',
|
||||
sourceId: 'dev-1',
|
||||
sourceVersionId: 'version-1',
|
||||
occurredAt: '2026-01-03T10:00:00.000Z',
|
||||
createdAt: '2026-01-03T10:00:00.000Z',
|
||||
}),
|
||||
]);
|
||||
expect(result.taskWorklogs).toEqual([
|
||||
expect.objectContaining({
|
||||
id: 'worklog-1',
|
||||
versionId: 'version-1',
|
||||
productId: 'product-1',
|
||||
projectId: 'project-1',
|
||||
sourceType: 'dev_task',
|
||||
sourceId: 'dev-1',
|
||||
sourceVersionId: 'version-1',
|
||||
workDate: '2026-01-03',
|
||||
createdAt: '2026-01-03T19:00:00.000Z',
|
||||
}),
|
||||
]);
|
||||
expect(result.overtimeRecords).toEqual([
|
||||
expect.objectContaining({
|
||||
id: 'overtime-1',
|
||||
productId: 'product-1',
|
||||
projectId: 'project-1',
|
||||
versionId: 'version-1',
|
||||
userId: 'member-dev',
|
||||
reason: 'reason-3',
|
||||
hours: 2,
|
||||
createdAt: '2026-01-03T20:10:00.000Z',
|
||||
}),
|
||||
]);
|
||||
expect(result.xiaobaoRiskSnapshots).toEqual([
|
||||
expect.objectContaining({
|
||||
versionId: 'version-1',
|
||||
snapshotDate: '2026-01-04',
|
||||
riskLevel: 'at_risk',
|
||||
riskScore: 72,
|
||||
riskSignature: 'version-1|2026-01-04|72|at_risk|2026-02-05|3|1|2|1|0|80',
|
||||
createdAt: '2026-01-04T12:00:00.000Z',
|
||||
}),
|
||||
]);
|
||||
expect(result.xiaobaoRiskInsights).toEqual([
|
||||
expect.objectContaining({
|
||||
versionId: 'version-1',
|
||||
riskSignature: 'risk-signature-1',
|
||||
status: 'generated',
|
||||
createdAt: '2026-01-04T12:05:00.000Z',
|
||||
}),
|
||||
]);
|
||||
expect(result.xiaobaoRiskSummaries).toEqual([
|
||||
expect.objectContaining({
|
||||
versionId: 'version-1',
|
||||
riskLevel: 'at_risk',
|
||||
riskScore: 72,
|
||||
confidence: 80,
|
||||
riskSignature: 'version-1|2026-01-04|72|at_risk|2026-02-05|3|1|2|1|0|80',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('skips rows that cannot resolve the partition key instead of inventing unsafe ownership', () => {
|
||||
const appData = buildAppData();
|
||||
appData['dev-tasks'].push({
|
||||
id: 'dev-orphan',
|
||||
taskNo: 'DEV-999',
|
||||
requirementId: 'missing-req',
|
||||
title: 'Cannot resolve version',
|
||||
categoryId: 'cat-fe',
|
||||
assigneeId: 'member-dev',
|
||||
priority: 'P2',
|
||||
expectedStartAt: '',
|
||||
expectedEndAt: '',
|
||||
status: 'todo',
|
||||
isBlocked: false,
|
||||
createdBy: 'member-pm',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
|
||||
const result = mapAppDataToV22Rows(appData);
|
||||
|
||||
expect(result.devTasks.map((task: { id: string }) => task.id)).toEqual(['dev-1']);
|
||||
expect(result.skipped).toContainEqual({
|
||||
key: 'dev-tasks',
|
||||
id: 'dev-orphan',
|
||||
reason: 'missing version partition context',
|
||||
});
|
||||
});
|
||||
});
|
||||
922
apps/server/src/modules/migration/app-data-v22.mapper.ts
Normal file
922
apps/server/src/modules/migration/app-data-v22.mapper.ts
Normal file
@@ -0,0 +1,922 @@
|
||||
type AppDataRecord = Record<string, unknown>;
|
||||
|
||||
interface SkipReason {
|
||||
key: string;
|
||||
id?: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
interface ProductRow {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
interface ProjectRow {
|
||||
id: string;
|
||||
productId: string;
|
||||
name: string;
|
||||
description: string;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
interface VersionRow {
|
||||
id: string;
|
||||
productId: string;
|
||||
projectId?: string;
|
||||
name: string;
|
||||
description: string;
|
||||
releaseDate?: string;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
interface RequirementRow {
|
||||
id: string;
|
||||
productId: string;
|
||||
projectId?: string;
|
||||
versionId?: string;
|
||||
code: string;
|
||||
title: string;
|
||||
description: string;
|
||||
status: string;
|
||||
priority: number;
|
||||
type?: string;
|
||||
sourceType?: string;
|
||||
sourceTarget?: string;
|
||||
platform?: string;
|
||||
creatorId?: string;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
interface TaskCategoryRow {
|
||||
id: string;
|
||||
name: string;
|
||||
code?: string;
|
||||
group: string;
|
||||
isSystem: boolean;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
interface VersionPlanRow {
|
||||
id: string;
|
||||
versionId: string;
|
||||
productId: string;
|
||||
projectId?: string;
|
||||
type: string;
|
||||
title: string;
|
||||
status: string;
|
||||
ownerId?: string;
|
||||
expectedStartAt?: string;
|
||||
expectedEndAt?: string;
|
||||
actualStartAt?: string;
|
||||
completedAt?: string;
|
||||
resultUrl?: string;
|
||||
requirementCoverage: unknown[];
|
||||
logs: unknown[];
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
interface DevTaskRow {
|
||||
id: string;
|
||||
versionId: string;
|
||||
productId: string;
|
||||
projectId: string;
|
||||
requirementId?: string;
|
||||
requirementProductId?: string;
|
||||
categoryId?: string;
|
||||
code: string;
|
||||
title: string;
|
||||
description: string;
|
||||
status: string;
|
||||
priority: number;
|
||||
assigneeId?: string;
|
||||
creatorId?: string;
|
||||
isBlocked: boolean;
|
||||
blockReason?: string;
|
||||
expectedStartAt?: string;
|
||||
expectedEndAt?: string;
|
||||
startDate?: string;
|
||||
completedAt?: string;
|
||||
estimateHours?: number;
|
||||
aiEstimateHours?: number;
|
||||
references: unknown[];
|
||||
aiDraft: boolean;
|
||||
aiDraftAt?: string;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
interface TestCaseRow {
|
||||
id: string;
|
||||
versionId: string;
|
||||
productId: string;
|
||||
projectId: string;
|
||||
requirementId?: string;
|
||||
requirementProductId?: string;
|
||||
categoryId?: string;
|
||||
code: string;
|
||||
title: string;
|
||||
description: string;
|
||||
status: string;
|
||||
roundNo: number;
|
||||
priority: number;
|
||||
assigneeId?: string;
|
||||
creatorId?: string;
|
||||
plannedTestAt?: string;
|
||||
plannedEndAt?: string;
|
||||
startedAt?: string;
|
||||
completedAt?: string;
|
||||
estimateHours?: number;
|
||||
aiEstimateHours?: number;
|
||||
references: unknown[];
|
||||
aiDraft: boolean;
|
||||
aiDraftAt?: string;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
interface BugRow {
|
||||
id: string;
|
||||
versionId: string;
|
||||
productId: string;
|
||||
projectId: string;
|
||||
testCaseId?: string;
|
||||
testCaseVersionId?: string;
|
||||
code: string;
|
||||
title: string;
|
||||
description: string;
|
||||
status: string;
|
||||
severity: string;
|
||||
priority: number;
|
||||
assigneeId?: string;
|
||||
reporterId?: string;
|
||||
plannedFixAt?: string;
|
||||
resolvedAt?: string;
|
||||
closedAt?: string;
|
||||
resolution?: string;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
interface WorkActivityRow {
|
||||
id: string;
|
||||
versionId?: string;
|
||||
productId?: string;
|
||||
projectId?: string;
|
||||
actorId?: string;
|
||||
actorName: string;
|
||||
sourceType: string;
|
||||
sourceId: string;
|
||||
sourceVersionId?: string;
|
||||
action: string;
|
||||
title: string;
|
||||
metadata: unknown;
|
||||
occurredAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface TaskWorklogRow {
|
||||
id: string;
|
||||
versionId?: string;
|
||||
productId?: string;
|
||||
projectId?: string;
|
||||
userId?: string;
|
||||
sourceType: string;
|
||||
sourceId: string;
|
||||
sourceVersionId?: string;
|
||||
workDate: string;
|
||||
hours: number;
|
||||
content: string;
|
||||
createdAt: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
interface OvertimeRecordRow {
|
||||
id: string;
|
||||
productId?: string;
|
||||
projectId?: string;
|
||||
versionId?: string;
|
||||
userId?: string;
|
||||
reason: string;
|
||||
startAt: string;
|
||||
endAt: string;
|
||||
hours: number;
|
||||
createdAt: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
interface XiaobaoRiskSnapshotRow {
|
||||
id: string;
|
||||
versionId: string;
|
||||
snapshotDate: string;
|
||||
riskLevel: string;
|
||||
riskScore: number;
|
||||
riskSignature: string;
|
||||
snapshot: unknown;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface XiaobaoRiskInsightRow {
|
||||
id: string;
|
||||
versionId: string;
|
||||
riskSignature: string;
|
||||
status: string;
|
||||
insight: unknown;
|
||||
createdAt: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
interface XiaobaoRiskSummaryRow {
|
||||
versionId: string;
|
||||
riskLevel: string;
|
||||
riskScore: number;
|
||||
confidence: number;
|
||||
forecastReleaseDate?: string;
|
||||
riskSignature: string;
|
||||
summary: unknown;
|
||||
dirty: boolean;
|
||||
recomputedAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
interface SourceContext {
|
||||
versionId?: string;
|
||||
productId?: string;
|
||||
projectId?: string;
|
||||
}
|
||||
|
||||
export interface V22MappedRows {
|
||||
products: ProductRow[];
|
||||
projects: ProjectRow[];
|
||||
versions: VersionRow[];
|
||||
requirements: RequirementRow[];
|
||||
taskCategories: TaskCategoryRow[];
|
||||
versionPlans: VersionPlanRow[];
|
||||
devTasks: DevTaskRow[];
|
||||
testCases: TestCaseRow[];
|
||||
bugs: BugRow[];
|
||||
workActivities: WorkActivityRow[];
|
||||
taskWorklogs: TaskWorklogRow[];
|
||||
overtimeRecords: OvertimeRecordRow[];
|
||||
xiaobaoRiskSnapshots: XiaobaoRiskSnapshotRow[];
|
||||
xiaobaoRiskInsights: XiaobaoRiskInsightRow[];
|
||||
xiaobaoRiskSummaries: XiaobaoRiskSummaryRow[];
|
||||
aiLogs: unknown[];
|
||||
skipped: SkipReason[];
|
||||
}
|
||||
|
||||
export function mapAppDataToV22Rows(appData: Record<string, unknown>): V22MappedRows {
|
||||
const skipped: SkipReason[] = [];
|
||||
const products: ProductRow[] = [];
|
||||
const projects: ProjectRow[] = [];
|
||||
const versions: VersionRow[] = [];
|
||||
const requirements: RequirementRow[] = [];
|
||||
const taskCategories: TaskCategoryRow[] = [];
|
||||
const versionPlans: VersionPlanRow[] = [];
|
||||
const devTasks: DevTaskRow[] = [];
|
||||
const testCases: TestCaseRow[] = [];
|
||||
const bugs: BugRow[] = [];
|
||||
const workActivities: WorkActivityRow[] = [];
|
||||
const taskWorklogs: TaskWorklogRow[] = [];
|
||||
const overtimeRecords: OvertimeRecordRow[] = [];
|
||||
const xiaobaoRiskSnapshots: XiaobaoRiskSnapshotRow[] = [];
|
||||
const xiaobaoRiskInsights: XiaobaoRiskInsightRow[] = [];
|
||||
const xiaobaoRiskSummaries: XiaobaoRiskSummaryRow[] = [];
|
||||
|
||||
const productById = new Map<string, ProductRow>();
|
||||
const projectById = new Map<string, ProjectRow>();
|
||||
const versionById = new Map<string, VersionRow>();
|
||||
const requirementById = new Map<string, RequirementRow>();
|
||||
const devTaskById = new Map<string, DevTaskRow>();
|
||||
const testCaseById = new Map<string, TestCaseRow>();
|
||||
const bugById = new Map<string, BugRow>();
|
||||
const versionPlanById = new Map<string, VersionPlanRow>();
|
||||
|
||||
const productRecords = asArray(appData['products-overview']);
|
||||
for (const [productIndex, product] of productRecords.entries()) {
|
||||
const productId = stringField(product, 'id') ?? `product-${productIndex + 1}`;
|
||||
const productRow: ProductRow = {
|
||||
id: productId,
|
||||
name: stringField(product, 'name') ?? productId,
|
||||
description: stringField(product, 'description') ?? '',
|
||||
createdAt: stringField(product, 'createdAt'),
|
||||
updatedAt: stringField(product, 'updatedAt') ?? stringField(product, 'createdAt'),
|
||||
};
|
||||
products.push(productRow);
|
||||
productById.set(productId, productRow);
|
||||
|
||||
const productProjects = asArray(product.projects);
|
||||
for (const [projectIndex, project] of productProjects.entries()) {
|
||||
const projectId = stringField(project, 'id') ?? `${productId}-project-${projectIndex + 1}`;
|
||||
const projectRow: ProjectRow = {
|
||||
id: projectId,
|
||||
productId,
|
||||
name: stringField(project, 'name') ?? projectId,
|
||||
description: stringField(project, 'description') ?? '',
|
||||
createdAt: stringField(project, 'createdAt') ?? productRow.createdAt,
|
||||
updatedAt: stringField(project, 'updatedAt') ?? stringField(project, 'createdAt') ?? productRow.updatedAt,
|
||||
};
|
||||
projects.push(projectRow);
|
||||
projectById.set(projectId, projectRow);
|
||||
}
|
||||
|
||||
const productVersions = asArray(product.versions);
|
||||
for (const [versionIndex, version] of productVersions.entries()) {
|
||||
const versionId = stringField(version, 'id') ?? `${productId}-version-${versionIndex + 1}`;
|
||||
const projectId = stringField(version, 'projectId') ?? inferProjectIdForVersion(version, productProjects);
|
||||
const versionRow: VersionRow = {
|
||||
id: versionId,
|
||||
productId,
|
||||
projectId,
|
||||
name: stringField(version, 'name') ?? versionId,
|
||||
description: stringField(version, 'description') ?? '',
|
||||
releaseDate: stringField(version, 'releaseDate') ?? stringField(version, 'expectedReleaseDate'),
|
||||
createdAt: stringField(version, 'createdAt') ?? productRow.createdAt,
|
||||
updatedAt: stringField(version, 'updatedAt') ?? stringField(version, 'createdAt') ?? productRow.updatedAt,
|
||||
};
|
||||
versions.push(versionRow);
|
||||
versionById.set(versionId, versionRow);
|
||||
}
|
||||
}
|
||||
|
||||
const defaultProductId = products[0]?.id;
|
||||
const requirementCode = scopedCodeFactory('REQ');
|
||||
for (const [index, requirement] of readNestedArray(appData.requirements, 'requirements').entries()) {
|
||||
const id = stringField(requirement, 'id') ?? `requirement-${index + 1}`;
|
||||
const productId = stringField(requirement, 'productId') ?? defaultProductId;
|
||||
if (!productId) {
|
||||
skipped.push({ key: 'requirements', id, reason: 'missing product partition context' });
|
||||
continue;
|
||||
}
|
||||
const versionId = stringField(requirement, 'versionId');
|
||||
const version = versionId ? versionById.get(versionId) : undefined;
|
||||
const platforms = unknownArray(requirement.platforms)
|
||||
.map((platform) => stringFromUnknown(platform))
|
||||
.filter((platform): platform is string => Boolean(platform));
|
||||
const row: RequirementRow = {
|
||||
id,
|
||||
productId,
|
||||
projectId: stringField(requirement, 'projectId') ?? version?.projectId,
|
||||
versionId,
|
||||
code: requirementCode(productId, stringField(requirement, 'code'), index),
|
||||
title: stringField(requirement, 'title') ?? id,
|
||||
description: stringField(requirement, 'description') ?? '',
|
||||
status: stringField(requirement, 'status') ?? 'pending_review',
|
||||
priority: priorityRank(requirement.priority),
|
||||
type: stringField(requirement, 'type') ?? stringField(requirement, 'typeId'),
|
||||
sourceType: stringField(requirement, 'sourceType'),
|
||||
sourceTarget: stringField(requirement, 'sourceTarget'),
|
||||
platform: platforms.join(',') || stringField(requirement, 'platform'),
|
||||
creatorId: stringField(requirement, 'creatorId') ?? stringField(requirement, 'creator'),
|
||||
createdAt: stringField(requirement, 'createdAt'),
|
||||
updatedAt: stringField(requirement, 'updatedAt') ?? stringField(requirement, 'createdAt'),
|
||||
};
|
||||
requirements.push(row);
|
||||
requirementById.set(row.id, row);
|
||||
}
|
||||
|
||||
for (const [index, category] of asArray(appData['task-categories']).entries()) {
|
||||
const id = stringField(category, 'id') ?? `category-${index + 1}`;
|
||||
taskCategories.push({
|
||||
id,
|
||||
name: stringField(category, 'name') ?? id,
|
||||
code: stringField(category, 'code'),
|
||||
group: stringField(category, 'group') ?? 'other',
|
||||
isSystem: booleanField(category, 'isSystem') ?? false,
|
||||
createdAt: stringField(category, 'createdAt'),
|
||||
updatedAt: stringField(category, 'updatedAt') ?? stringField(category, 'createdAt'),
|
||||
});
|
||||
}
|
||||
|
||||
for (const [index, plan] of asArray(appData['version-plans']).entries()) {
|
||||
const id = stringField(plan, 'id') ?? `version-plan-${index + 1}`;
|
||||
const versionId = stringField(plan, 'versionId');
|
||||
const version = versionId ? versionById.get(versionId) : undefined;
|
||||
if (!versionId || !version?.productId) {
|
||||
skipped.push({ key: 'version-plans', id, reason: 'missing version partition context' });
|
||||
continue;
|
||||
}
|
||||
const row: VersionPlanRow = {
|
||||
id,
|
||||
versionId,
|
||||
productId: version.productId,
|
||||
projectId: version.projectId,
|
||||
type: stringField(plan, 'type') ?? 'product',
|
||||
title: stringField(plan, 'title') ?? id,
|
||||
status: stringField(plan, 'status') ?? 'pending',
|
||||
ownerId: stringField(plan, 'ownerId') ?? stringField(plan, 'owner'),
|
||||
expectedStartAt: stringField(plan, 'expectedStartAt') ?? stringField(plan, 'startTime'),
|
||||
expectedEndAt: stringField(plan, 'expectedEndAt') ?? stringField(plan, 'endTime'),
|
||||
actualStartAt: stringField(plan, 'actualStartAt'),
|
||||
completedAt: stringField(plan, 'completedAt'),
|
||||
resultUrl: stringField(plan, 'resultUrl'),
|
||||
requirementCoverage: unknownArray(plan.requirementCoverage),
|
||||
logs: unknownArray(plan.logs),
|
||||
createdAt: stringField(plan, 'createdAt'),
|
||||
updatedAt: stringField(plan, 'updatedAt') ?? stringField(plan, 'completedAt') ?? stringField(plan, 'createdAt'),
|
||||
};
|
||||
versionPlans.push(row);
|
||||
versionPlanById.set(row.id, row);
|
||||
}
|
||||
|
||||
const devCode = scopedCodeFactory('DEV');
|
||||
for (const [index, task] of asArray(appData['dev-tasks']).entries()) {
|
||||
const id = stringField(task, 'id') ?? `dev-task-${index + 1}`;
|
||||
const context = resolveVersionContext(task, requirementById, versionById);
|
||||
if (!context.versionId || !context.productId || !context.projectId) {
|
||||
skipped.push({ key: 'dev-tasks', id, reason: 'missing version partition context' });
|
||||
continue;
|
||||
}
|
||||
const requirementId = stringField(task, 'requirementId');
|
||||
const requirement = requirementId ? requirementById.get(requirementId) : undefined;
|
||||
const row: DevTaskRow = {
|
||||
id,
|
||||
versionId: context.versionId,
|
||||
productId: context.productId,
|
||||
projectId: context.projectId,
|
||||
requirementId,
|
||||
requirementProductId: requirement?.productId,
|
||||
categoryId: stringField(task, 'categoryId'),
|
||||
code: devCode(context.versionId, stringField(task, 'code') ?? stringField(task, 'taskNo'), index),
|
||||
title: stringField(task, 'title') ?? id,
|
||||
description: stringField(task, 'description') ?? '',
|
||||
status: stringField(task, 'status') ?? 'todo',
|
||||
priority: priorityRank(task.priority),
|
||||
assigneeId: stringField(task, 'assigneeId'),
|
||||
creatorId: stringField(task, 'creatorId') ?? stringField(task, 'createdBy'),
|
||||
isBlocked: booleanField(task, 'isBlocked') ?? false,
|
||||
blockReason: stringField(task, 'blockReason'),
|
||||
expectedStartAt: stringField(task, 'expectedStartAt'),
|
||||
expectedEndAt: stringField(task, 'expectedEndAt'),
|
||||
startDate: stringField(task, 'startDate') ?? stringField(task, 'actualStartAt'),
|
||||
completedAt: stringField(task, 'completedAt') ?? stringField(task, 'actualEndAt'),
|
||||
estimateHours: numberField(task, 'estimateHours'),
|
||||
aiEstimateHours: numberField(task, 'aiEstimateHours'),
|
||||
references: unknownArray(task.references),
|
||||
aiDraft: booleanField(task, 'aiDraft') ?? false,
|
||||
aiDraftAt: stringField(task, 'aiDraftAt'),
|
||||
createdAt: stringField(task, 'createdAt'),
|
||||
updatedAt: stringField(task, 'updatedAt') ?? stringField(task, 'createdAt'),
|
||||
};
|
||||
devTasks.push(row);
|
||||
devTaskById.set(row.id, row);
|
||||
}
|
||||
|
||||
const testCode = scopedCodeFactory('TC');
|
||||
for (const [index, testCase] of asArray(appData['test-cases']).entries()) {
|
||||
const id = stringField(testCase, 'id') ?? `test-case-${index + 1}`;
|
||||
const context = resolveVersionContext(testCase, requirementById, versionById);
|
||||
if (!context.versionId || !context.productId || !context.projectId) {
|
||||
skipped.push({ key: 'test-cases', id, reason: 'missing version partition context' });
|
||||
continue;
|
||||
}
|
||||
const requirementId = stringField(testCase, 'requirementId');
|
||||
const requirement = requirementId ? requirementById.get(requirementId) : undefined;
|
||||
const row: TestCaseRow = {
|
||||
id,
|
||||
versionId: context.versionId,
|
||||
productId: context.productId,
|
||||
projectId: context.projectId,
|
||||
requirementId,
|
||||
requirementProductId: requirement?.productId,
|
||||
categoryId: stringField(testCase, 'categoryId'),
|
||||
code: testCode(context.versionId, stringField(testCase, 'code') ?? stringField(testCase, 'caseNo'), index),
|
||||
title: stringField(testCase, 'title') ?? id,
|
||||
description: stringField(testCase, 'description') ?? '',
|
||||
status: stringField(testCase, 'status') ?? 'pending',
|
||||
roundNo: integerField(testCase, 'roundNo') ?? 1,
|
||||
priority: priorityRank(testCase.priority),
|
||||
assigneeId: stringField(testCase, 'assigneeId'),
|
||||
creatorId: stringField(testCase, 'creatorId') ?? stringField(testCase, 'createdBy'),
|
||||
plannedTestAt: stringField(testCase, 'plannedTestAt'),
|
||||
plannedEndAt: stringField(testCase, 'plannedEndAt'),
|
||||
startedAt: stringField(testCase, 'startedAt'),
|
||||
completedAt: stringField(testCase, 'completedAt'),
|
||||
estimateHours: numberField(testCase, 'estimateHours'),
|
||||
aiEstimateHours: numberField(testCase, 'aiEstimateHours'),
|
||||
references: unknownArray(testCase.references),
|
||||
aiDraft: booleanField(testCase, 'aiDraft') ?? false,
|
||||
aiDraftAt: stringField(testCase, 'aiDraftAt'),
|
||||
createdAt: stringField(testCase, 'createdAt'),
|
||||
updatedAt: stringField(testCase, 'updatedAt') ?? stringField(testCase, 'createdAt'),
|
||||
};
|
||||
testCases.push(row);
|
||||
testCaseById.set(row.id, row);
|
||||
}
|
||||
|
||||
const bugCode = scopedCodeFactory('BUG');
|
||||
for (const [index, bug] of asArray(appData.bugs).entries()) {
|
||||
const id = stringField(bug, 'id') ?? `bug-${index + 1}`;
|
||||
const testCaseId = stringField(bug, 'testCaseId');
|
||||
const linkedTestCase = testCaseId ? testCaseById.get(testCaseId) : undefined;
|
||||
const context = resolveVersionContext(
|
||||
{ ...bug, versionId: stringField(bug, 'versionId') ?? linkedTestCase?.versionId },
|
||||
requirementById,
|
||||
versionById,
|
||||
);
|
||||
if (!context.versionId || !context.productId || !context.projectId) {
|
||||
skipped.push({ key: 'bugs', id, reason: 'missing version partition context' });
|
||||
continue;
|
||||
}
|
||||
const row: BugRow = {
|
||||
id,
|
||||
versionId: context.versionId,
|
||||
productId: context.productId,
|
||||
projectId: context.projectId,
|
||||
testCaseId,
|
||||
testCaseVersionId: linkedTestCase?.versionId,
|
||||
code: bugCode(context.versionId, stringField(bug, 'code') ?? stringField(bug, 'bugNo'), index),
|
||||
title: stringField(bug, 'title') ?? id,
|
||||
description: stringField(bug, 'description') ?? '',
|
||||
status: stringField(bug, 'status') ?? 'open',
|
||||
severity: stringField(bug, 'severity') ?? 'minor',
|
||||
priority: priorityRank(bug.priority),
|
||||
assigneeId: stringField(bug, 'assigneeId'),
|
||||
reporterId: stringField(bug, 'reporterId') ?? stringField(bug, 'reportedBy'),
|
||||
plannedFixAt: stringField(bug, 'plannedFixAt'),
|
||||
resolvedAt: stringField(bug, 'resolvedAt'),
|
||||
closedAt: stringField(bug, 'closedAt'),
|
||||
resolution: stringField(bug, 'resolution'),
|
||||
createdAt: stringField(bug, 'createdAt'),
|
||||
updatedAt: stringField(bug, 'updatedAt') ?? stringField(bug, 'createdAt'),
|
||||
};
|
||||
bugs.push(row);
|
||||
bugById.set(row.id, row);
|
||||
}
|
||||
|
||||
for (const [index, activity] of asArray(appData['work-activities']).entries()) {
|
||||
const id = stringField(activity, 'id') ?? `work-activity-${index + 1}`;
|
||||
const occurredAt = stringField(activity, 'occurredAt') ?? stringField(activity, 'createdAt');
|
||||
if (!occurredAt) {
|
||||
skipped.push({ key: 'work-activities', id, reason: 'missing createdAt partition context' });
|
||||
continue;
|
||||
}
|
||||
const sourceType = stringField(activity, 'sourceType') ?? 'manual';
|
||||
const sourceId = stringField(activity, 'sourceId') ?? id;
|
||||
const sourceContext = resolveSourceContext(sourceType, sourceId, {
|
||||
devTaskById,
|
||||
testCaseById,
|
||||
bugById,
|
||||
versionPlanById,
|
||||
});
|
||||
workActivities.push({
|
||||
id,
|
||||
versionId: sourceContext.versionId,
|
||||
productId: sourceContext.productId,
|
||||
projectId: sourceContext.projectId,
|
||||
actorId: stringField(activity, 'actorId'),
|
||||
actorName: stringField(activity, 'actorName') ?? '',
|
||||
sourceType,
|
||||
sourceId,
|
||||
sourceVersionId: sourceContext.versionId,
|
||||
action: stringField(activity, 'action') ?? 'progress_note_added',
|
||||
title: stringField(activity, 'title') ?? stringField(activity, 'summary') ?? id,
|
||||
metadata: recordField(activity, 'metadata') ?? {},
|
||||
occurredAt,
|
||||
createdAt: stringField(activity, 'createdAt') ?? occurredAt,
|
||||
});
|
||||
}
|
||||
|
||||
for (const [index, worklog] of asArray(appData['task-worklogs']).entries()) {
|
||||
const id = stringField(worklog, 'id') ?? `task-worklog-${index + 1}`;
|
||||
const createdAt = stringField(worklog, 'createdAt');
|
||||
const sourceId = stringField(worklog, 'sourceId') ?? stringField(worklog, 'taskId') ?? id;
|
||||
const sourceContext = resolveWorklogSource(sourceId, { devTaskById, testCaseById, bugById });
|
||||
taskWorklogs.push({
|
||||
id,
|
||||
versionId: sourceContext.context.versionId,
|
||||
productId: sourceContext.context.productId,
|
||||
projectId: sourceContext.context.projectId,
|
||||
userId: stringField(worklog, 'userId'),
|
||||
sourceType: stringField(worklog, 'sourceType') ?? sourceContext.sourceType,
|
||||
sourceId,
|
||||
sourceVersionId: sourceContext.context.versionId,
|
||||
workDate: stringField(worklog, 'workDate') ?? stringField(worklog, 'date') ?? sliceDate(createdAt) ?? '',
|
||||
hours: numberField(worklog, 'hours') ?? 0,
|
||||
content: stringField(worklog, 'content') ?? stringField(worklog, 'workContent') ?? '',
|
||||
createdAt: createdAt ?? stringField(worklog, 'date') ?? '',
|
||||
updatedAt: stringField(worklog, 'updatedAt') ?? createdAt,
|
||||
});
|
||||
}
|
||||
|
||||
for (const [index, overtime] of readNestedArray(appData.overtime, 'records').entries()) {
|
||||
const id = stringField(overtime, 'id') ?? `overtime-${index + 1}`;
|
||||
const versionId = stringField(overtime, 'versionId');
|
||||
const projectId = stringField(overtime, 'projectId');
|
||||
const version = versionId ? versionById.get(versionId) : undefined;
|
||||
const project = projectId ? projectById.get(projectId) : undefined;
|
||||
const startAt = stringField(overtime, 'startAt') ?? stringField(overtime, 'startTime');
|
||||
const endAt = stringField(overtime, 'endAt') ?? stringField(overtime, 'endTime');
|
||||
const createdAt = stringField(overtime, 'createdAt') ?? startAt;
|
||||
if (!startAt || !endAt || !createdAt) {
|
||||
skipped.push({ key: 'overtime', id, reason: 'missing createdAt partition context' });
|
||||
continue;
|
||||
}
|
||||
overtimeRecords.push({
|
||||
id,
|
||||
productId: stringField(overtime, 'productId') ?? version?.productId ?? project?.productId,
|
||||
projectId: projectId ?? version?.projectId,
|
||||
versionId,
|
||||
userId: stringField(overtime, 'userId') ?? stringField(overtime, 'person'),
|
||||
reason: stringField(overtime, 'reason') ?? stringField(overtime, 'reasonId') ?? '',
|
||||
startAt,
|
||||
endAt,
|
||||
hours: numberField(overtime, 'hours') ?? numberField(overtime, 'duration') ?? 0,
|
||||
createdAt,
|
||||
updatedAt: stringField(overtime, 'updatedAt') ?? createdAt,
|
||||
});
|
||||
}
|
||||
|
||||
const summaryByVersion = new Map<string, XiaobaoRiskSummaryRow>();
|
||||
for (const [index, snapshot] of asArray(appData['xiaobao-risk-snapshots']).entries()) {
|
||||
const versionId = stringField(snapshot, 'versionId');
|
||||
const snapshotDate = stringField(snapshot, 'snapshotDate') ?? stringField(snapshot, 'date');
|
||||
const createdAt = stringField(snapshot, 'createdAt') ?? snapshotDate;
|
||||
if (!versionId || !snapshotDate || !createdAt) {
|
||||
skipped.push({
|
||||
key: 'xiaobao-risk-snapshots',
|
||||
id: stringField(snapshot, 'id') ?? `xiaobao-risk-snapshot-${index + 1}`,
|
||||
reason: 'missing createdAt partition context',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const riskSignature = stringField(snapshot, 'riskSignature') ?? buildRiskSignature(snapshot, versionId, snapshotDate);
|
||||
const row: XiaobaoRiskSnapshotRow = {
|
||||
id: stringField(snapshot, 'id') ?? `risk-snapshot-${versionId}-${snapshotDate}-${index + 1}`,
|
||||
versionId,
|
||||
snapshotDate,
|
||||
riskLevel: stringField(snapshot, 'riskLevel') ?? 'on_track',
|
||||
riskScore: integerField(snapshot, 'riskScore') ?? 0,
|
||||
riskSignature,
|
||||
snapshot,
|
||||
createdAt,
|
||||
};
|
||||
xiaobaoRiskSnapshots.push(row);
|
||||
const summary: XiaobaoRiskSummaryRow = {
|
||||
versionId,
|
||||
riskLevel: row.riskLevel,
|
||||
riskScore: row.riskScore,
|
||||
confidence: integerField(snapshot, 'confidence') ?? 0,
|
||||
forecastReleaseDate: stringField(snapshot, 'forecastReleaseDate'),
|
||||
riskSignature,
|
||||
summary: snapshot,
|
||||
dirty: false,
|
||||
recomputedAt: createdAt,
|
||||
updatedAt: createdAt,
|
||||
};
|
||||
const previous = summaryByVersion.get(versionId);
|
||||
if (!previous || compareIso(summary.updatedAt, previous.updatedAt) >= 0) {
|
||||
summaryByVersion.set(versionId, summary);
|
||||
}
|
||||
}
|
||||
xiaobaoRiskSummaries.push(...summaryByVersion.values());
|
||||
|
||||
for (const [index, insight] of asArray(appData['xiaobao-risk-insights']).entries()) {
|
||||
const versionId = stringField(insight, 'versionId');
|
||||
const riskSignature = stringField(insight, 'riskSignature');
|
||||
const createdAt = stringField(insight, 'createdAt') ?? stringField(insight, 'generatedAt');
|
||||
if (!versionId || !riskSignature || !createdAt) {
|
||||
skipped.push({
|
||||
key: 'xiaobao-risk-insights',
|
||||
id: stringField(insight, 'id') ?? `xiaobao-risk-insight-${index + 1}`,
|
||||
reason: 'missing createdAt partition context',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
xiaobaoRiskInsights.push({
|
||||
id: stringField(insight, 'id') ?? `risk-insight-${versionId}-${index + 1}`,
|
||||
versionId,
|
||||
riskSignature,
|
||||
status: stringField(insight, 'status') ?? 'generated',
|
||||
insight: recordField(insight, 'insight') ?? {},
|
||||
createdAt,
|
||||
updatedAt: stringField(insight, 'updatedAt') ?? createdAt,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
products,
|
||||
projects,
|
||||
versions,
|
||||
requirements,
|
||||
taskCategories,
|
||||
versionPlans,
|
||||
devTasks,
|
||||
testCases,
|
||||
bugs,
|
||||
workActivities,
|
||||
taskWorklogs,
|
||||
overtimeRecords,
|
||||
xiaobaoRiskSnapshots,
|
||||
xiaobaoRiskInsights,
|
||||
xiaobaoRiskSummaries,
|
||||
aiLogs: [],
|
||||
skipped,
|
||||
};
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): AppDataRecord | undefined {
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) return undefined;
|
||||
return value as AppDataRecord;
|
||||
}
|
||||
|
||||
function asArray(value: unknown): AppDataRecord[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value.map(asRecord).filter((item): item is AppDataRecord => Boolean(item));
|
||||
}
|
||||
|
||||
function unknownArray(value: unknown): unknown[] {
|
||||
return Array.isArray(value) ? value : [];
|
||||
}
|
||||
|
||||
function readNestedArray(value: unknown, key: string): AppDataRecord[] {
|
||||
if (Array.isArray(value)) return asArray(value);
|
||||
return asArray(asRecord(value)?.[key]);
|
||||
}
|
||||
|
||||
function stringFromUnknown(value: unknown): string | undefined {
|
||||
if (typeof value === 'string') {
|
||||
const trimmed = value.trim();
|
||||
return trimmed || undefined;
|
||||
}
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return String(value);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function stringField(record: AppDataRecord, key: string): string | undefined {
|
||||
return stringFromUnknown(record[key]);
|
||||
}
|
||||
|
||||
function recordField(record: AppDataRecord, key: string): AppDataRecord | undefined {
|
||||
return asRecord(record[key]);
|
||||
}
|
||||
|
||||
function numberField(record: AppDataRecord, key: string): number | undefined {
|
||||
const value = record[key];
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function integerField(record: AppDataRecord, key: string): number | undefined {
|
||||
const value = numberField(record, key);
|
||||
return typeof value === 'number' ? Math.floor(value) : undefined;
|
||||
}
|
||||
|
||||
function booleanField(record: AppDataRecord, key: string): boolean | undefined {
|
||||
const value = record[key];
|
||||
if (typeof value === 'boolean') return value;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function priorityRank(value: unknown): number {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return Math.max(0, Math.floor(value));
|
||||
if (typeof value === 'string') {
|
||||
const normalized = value.trim().toUpperCase();
|
||||
const match = /^P([0-4])$/.exec(normalized);
|
||||
if (match) return Number(match[1]);
|
||||
const numeric = Number(normalized);
|
||||
if (Number.isFinite(numeric)) return Math.max(0, Math.floor(numeric));
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function scopedCodeFactory(prefix: string) {
|
||||
const usedByScope = new Map<string, Set<string>>();
|
||||
return (scope: string, candidate: string | undefined, index: number): string => {
|
||||
const used = usedByScope.get(scope) ?? new Set<string>();
|
||||
usedByScope.set(scope, used);
|
||||
const base = candidate?.trim() || `${prefix}-${String(index + 1).padStart(3, '0')}`;
|
||||
if (!used.has(base)) {
|
||||
used.add(base);
|
||||
return base;
|
||||
}
|
||||
let suffix = 2;
|
||||
while (used.has(`${base}-${suffix}`)) suffix++;
|
||||
const next = `${base}-${suffix}`;
|
||||
used.add(next);
|
||||
return next;
|
||||
};
|
||||
}
|
||||
|
||||
function inferProjectIdForVersion(version: AppDataRecord, projects: AppDataRecord[]): string | undefined {
|
||||
const explicitProjectId = stringField(version, 'projectId');
|
||||
if (explicitProjectId) return explicitProjectId;
|
||||
const versionName = stringField(version, 'name')?.toLowerCase();
|
||||
if (!versionName) return undefined;
|
||||
const project = projects.find((item) => {
|
||||
const projectName = stringField(item, 'name')?.toLowerCase();
|
||||
return Boolean(projectName && versionName.startsWith(projectName));
|
||||
});
|
||||
return project ? stringField(project, 'id') : undefined;
|
||||
}
|
||||
|
||||
function resolveVersionContext(
|
||||
row: AppDataRecord,
|
||||
requirementById: Map<string, RequirementRow>,
|
||||
versionById: Map<string, VersionRow>,
|
||||
): SourceContext {
|
||||
const requirementId = stringField(row, 'requirementId');
|
||||
const requirement = requirementId ? requirementById.get(requirementId) : undefined;
|
||||
const versionId = stringField(row, 'versionId') ?? requirement?.versionId;
|
||||
const version = versionId ? versionById.get(versionId) : undefined;
|
||||
return {
|
||||
versionId,
|
||||
productId: stringField(row, 'productId') ?? requirement?.productId ?? version?.productId,
|
||||
projectId: stringField(row, 'projectId') ?? requirement?.projectId ?? version?.projectId,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveSourceContext(
|
||||
sourceType: string,
|
||||
sourceId: string,
|
||||
maps: {
|
||||
devTaskById: Map<string, DevTaskRow>;
|
||||
testCaseById: Map<string, TestCaseRow>;
|
||||
bugById: Map<string, BugRow>;
|
||||
versionPlanById: Map<string, VersionPlanRow>;
|
||||
},
|
||||
): SourceContext {
|
||||
if (sourceType === 'dev_task') return contextFromRow(maps.devTaskById.get(sourceId));
|
||||
if (sourceType === 'test_case') return contextFromRow(maps.testCaseById.get(sourceId));
|
||||
if (sourceType === 'bug') return contextFromRow(maps.bugById.get(sourceId));
|
||||
if (sourceType === 'version_plan') return contextFromRow(maps.versionPlanById.get(sourceId));
|
||||
return {};
|
||||
}
|
||||
|
||||
function resolveWorklogSource(
|
||||
sourceId: string,
|
||||
maps: {
|
||||
devTaskById: Map<string, DevTaskRow>;
|
||||
testCaseById: Map<string, TestCaseRow>;
|
||||
bugById: Map<string, BugRow>;
|
||||
},
|
||||
): { sourceType: string; context: SourceContext } {
|
||||
const devTask = maps.devTaskById.get(sourceId);
|
||||
if (devTask) return { sourceType: 'dev_task', context: contextFromRow(devTask) };
|
||||
const testCase = maps.testCaseById.get(sourceId);
|
||||
if (testCase) return { sourceType: 'test_case', context: contextFromRow(testCase) };
|
||||
const bug = maps.bugById.get(sourceId);
|
||||
if (bug) return { sourceType: 'bug', context: contextFromRow(bug) };
|
||||
return { sourceType: 'legacy_task', context: {} };
|
||||
}
|
||||
|
||||
function contextFromRow(row: SourceContext | undefined): SourceContext {
|
||||
if (!row) return {};
|
||||
return {
|
||||
versionId: row.versionId,
|
||||
productId: row.productId,
|
||||
projectId: row.projectId,
|
||||
};
|
||||
}
|
||||
|
||||
function sliceDate(value?: string): string | undefined {
|
||||
if (!value) return undefined;
|
||||
return /^\d{4}-\d{2}-\d{2}/.test(value) ? value.slice(0, 10) : undefined;
|
||||
}
|
||||
|
||||
function compareIso(a?: string, b?: string): number {
|
||||
const aTime = a ? new Date(a).getTime() : 0;
|
||||
const bTime = b ? new Date(b).getTime() : 0;
|
||||
const safeA = Number.isFinite(aTime) ? aTime : 0;
|
||||
const safeB = Number.isFinite(bTime) ? bTime : 0;
|
||||
return safeA - safeB;
|
||||
}
|
||||
|
||||
function buildRiskSignature(snapshot: AppDataRecord, versionId: string, date: string): string {
|
||||
return [
|
||||
versionId,
|
||||
date,
|
||||
clampScore(integerField(snapshot, 'riskScore') ?? 0),
|
||||
stringField(snapshot, 'riskLevel') ?? 'on_track',
|
||||
normalizeForecastDate(stringField(snapshot, 'forecastReleaseDate')),
|
||||
integerField(snapshot, 'openBugCount') ?? 0,
|
||||
integerField(snapshot, 'criticalBugCount') ?? 0,
|
||||
integerField(snapshot, 'failedTestCount') ?? 0,
|
||||
integerField(snapshot, 'blockedCount') ?? 0,
|
||||
integerField(snapshot, 'silentRiskCount') ?? 0,
|
||||
clampScore(integerField(snapshot, 'confidence') ?? 0),
|
||||
].join('|');
|
||||
}
|
||||
|
||||
function clampScore(score: number): number {
|
||||
return Math.max(0, Math.min(100, Math.round(score)));
|
||||
}
|
||||
|
||||
function normalizeForecastDate(value?: string): string {
|
||||
if (!value) return '';
|
||||
if (/^\d{4}-\d{2}-\d{2}/.test(value)) return value.slice(0, 10);
|
||||
const time = new Date(value).getTime();
|
||||
if (!Number.isFinite(time)) return value;
|
||||
return new Date(time).toISOString().slice(0, 10);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { APP_DATA_KEYS } from '../data/data-keys';
|
||||
import { AppDataV22MigrationService } from './app-data-v22.migration.service';
|
||||
|
||||
describe('AppDataV22MigrationService', () => {
|
||||
it('loads allowed AppData keys and returns a migration preview without writing domain tables', async () => {
|
||||
const findMany = jest.fn().mockResolvedValue([
|
||||
{
|
||||
key: 'products-overview',
|
||||
value: [
|
||||
{
|
||||
id: 'product-1',
|
||||
name: 'FTB',
|
||||
projects: [{ id: 'project-1', name: 'CRM', description: '', createdAt: '2026-01-01T00:00:00.000Z' }],
|
||||
versions: [{ id: 'version-1', name: 'CRM V1.0', createdAt: '2026-01-01T00:00:00.000Z' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'requirements',
|
||||
value: {
|
||||
requirements: [
|
||||
{
|
||||
id: 'req-1',
|
||||
code: 'REQ-001',
|
||||
productId: 'product-1',
|
||||
projectId: 'project-1',
|
||||
versionId: 'version-1',
|
||||
title: 'Customer import',
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'dev-tasks',
|
||||
value: [
|
||||
{
|
||||
id: 'dev-1',
|
||||
taskNo: 'DEV-001',
|
||||
requirementId: 'req-1',
|
||||
title: 'Build import UI',
|
||||
status: 'todo',
|
||||
isBlocked: false,
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
const createMany = jest.fn();
|
||||
const prisma = { appData: { findMany }, requirement: { createMany } };
|
||||
const service = new AppDataV22MigrationService(prisma as any);
|
||||
|
||||
const preview = await service.preview();
|
||||
|
||||
expect(findMany).toHaveBeenCalledWith({
|
||||
where: { key: { in: APP_DATA_KEYS } },
|
||||
select: { key: true, value: true },
|
||||
});
|
||||
expect(createMany).not.toHaveBeenCalled();
|
||||
expect(preview.readyToImport).toBe(true);
|
||||
expect(preview.counts).toEqual(
|
||||
expect.objectContaining({
|
||||
products: 1,
|
||||
projects: 1,
|
||||
versions: 1,
|
||||
requirements: 1,
|
||||
devTasks: 1,
|
||||
}),
|
||||
);
|
||||
expect(preview.skipped).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { APP_DATA_KEYS } from '../data/data-keys';
|
||||
import { mapAppDataToV22Rows, type V22MappedRows } from './app-data-v22.mapper';
|
||||
|
||||
const COUNT_KEYS = [
|
||||
'products',
|
||||
'projects',
|
||||
'versions',
|
||||
'requirements',
|
||||
'taskCategories',
|
||||
'versionPlans',
|
||||
'devTasks',
|
||||
'testCases',
|
||||
'bugs',
|
||||
'workActivities',
|
||||
'taskWorklogs',
|
||||
'overtimeRecords',
|
||||
'xiaobaoRiskSnapshots',
|
||||
'xiaobaoRiskInsights',
|
||||
'xiaobaoRiskSummaries',
|
||||
'aiLogs',
|
||||
] as const;
|
||||
|
||||
type CountKey = (typeof COUNT_KEYS)[number];
|
||||
|
||||
export interface AppDataV22MigrationPreview {
|
||||
readyToImport: boolean;
|
||||
counts: Record<CountKey, number>;
|
||||
skipped: V22MappedRows['skipped'];
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AppDataV22MigrationService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async loadSnapshot(): Promise<Record<string, unknown>> {
|
||||
const rows = await this.prisma.appData.findMany({
|
||||
where: { key: { in: [...APP_DATA_KEYS] } },
|
||||
select: { key: true, value: true },
|
||||
});
|
||||
return Object.fromEntries(rows.map((row) => [row.key, row.value]));
|
||||
}
|
||||
|
||||
mapSnapshot(snapshot: Record<string, unknown>): V22MappedRows {
|
||||
return mapAppDataToV22Rows(snapshot);
|
||||
}
|
||||
|
||||
async preview(): Promise<AppDataV22MigrationPreview> {
|
||||
const mapped = this.mapSnapshot(await this.loadSnapshot());
|
||||
return {
|
||||
readyToImport: mapped.skipped.length === 0,
|
||||
counts: countRows(mapped),
|
||||
skipped: mapped.skipped,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function countRows(mapped: V22MappedRows): Record<CountKey, number> {
|
||||
return Object.fromEntries(COUNT_KEYS.map((key) => [key, mapped[key].length])) as Record<CountKey, number>;
|
||||
}
|
||||
8
apps/server/src/modules/migration/migration.module.ts
Normal file
8
apps/server/src/modules/migration/migration.module.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AppDataV22MigrationService } from './app-data-v22.migration.service';
|
||||
|
||||
@Module({
|
||||
providers: [AppDataV22MigrationService],
|
||||
exports: [AppDataV22MigrationService],
|
||||
})
|
||||
export class MigrationModule {}
|
||||
@@ -1,6 +1,10 @@
|
||||
import { IsString, IsNotEmpty, IsOptional, IsInt, Min, Max } from 'class-validator';
|
||||
|
||||
export class CreateRequirementDto {
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
code?: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
title!: string;
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { RequirementService } from './requirement.service';
|
||||
|
||||
describe('RequirementService with V2.2 composite requirement key', () => {
|
||||
const makeService = () => {
|
||||
const prisma = {
|
||||
requirement: {
|
||||
create: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
findFirst: jest.fn(),
|
||||
findMany: jest.fn(),
|
||||
update: jest.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
prisma,
|
||||
service: new RequirementService(prisma as any),
|
||||
};
|
||||
};
|
||||
|
||||
it('creates requirements with a partition-key-scoped business code', async () => {
|
||||
const { prisma, service } = makeService();
|
||||
prisma.requirement.create.mockResolvedValue({ id: 'req-1', productId: 'product-1', code: 'REQ-001' });
|
||||
|
||||
await service.create('product-1', {
|
||||
code: 'REQ-001',
|
||||
title: 'Payment',
|
||||
creatorId: 'user-1',
|
||||
});
|
||||
|
||||
expect(prisma.requirement.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
productId: 'product-1',
|
||||
code: 'REQ-001',
|
||||
title: 'Payment',
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('updates requirements by id plus product id', async () => {
|
||||
const { prisma, service } = makeService();
|
||||
prisma.requirement.findFirst.mockResolvedValue({
|
||||
id: 'req-1',
|
||||
productId: 'product-1',
|
||||
status: 'draft',
|
||||
});
|
||||
prisma.requirement.update.mockResolvedValue({ id: 'req-1', productId: 'product-1' });
|
||||
|
||||
await service.update('product-1', 'req-1', { title: 'Payment v2' });
|
||||
|
||||
expect(prisma.requirement.update).toHaveBeenCalledWith({
|
||||
where: { id_productId: { id: 'req-1', productId: 'product-1' } },
|
||||
data: { title: 'Payment v2' },
|
||||
});
|
||||
});
|
||||
|
||||
it('deletes requirements by id plus product id', async () => {
|
||||
const { prisma, service } = makeService();
|
||||
prisma.requirement.findFirst.mockResolvedValue({
|
||||
id: 'req-1',
|
||||
productId: 'product-1',
|
||||
status: 'draft',
|
||||
});
|
||||
prisma.requirement.delete.mockResolvedValue({ id: 'req-1', productId: 'product-1' });
|
||||
|
||||
await service.remove('product-1', 'req-1');
|
||||
|
||||
expect(prisma.requirement.delete).toHaveBeenCalledWith({
|
||||
where: { id_productId: { id: 'req-1', productId: 'product-1' } },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -12,6 +12,13 @@ const VALID_TRANSITIONS: Record<string, string[]> = {
|
||||
[RequirementStatus.DELIVERED]: [],
|
||||
};
|
||||
|
||||
function createFallbackRequirementCode() {
|
||||
return `REQ-${Date.now().toString(36).toUpperCase()}-${Math.random()
|
||||
.toString(36)
|
||||
.slice(2, 6)
|
||||
.toUpperCase()}`;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class RequirementService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
@@ -20,6 +27,7 @@ export class RequirementService {
|
||||
return this.prisma.requirement.create({
|
||||
data: {
|
||||
productId,
|
||||
code: dto.code?.trim() || createFallbackRequirementCode(),
|
||||
title: dto.title,
|
||||
description: dto.description || '',
|
||||
priority: dto.priority ?? 0,
|
||||
@@ -51,8 +59,9 @@ export class RequirementService {
|
||||
async update(productId: string, id: string, dto: UpdateRequirementDto) {
|
||||
await this.findOne(productId, id);
|
||||
return this.prisma.requirement.update({
|
||||
where: { id },
|
||||
where: { id_productId: { id, productId } },
|
||||
data: {
|
||||
...(dto.code !== undefined && { code: dto.code }),
|
||||
...(dto.title !== undefined && { title: dto.title }),
|
||||
...(dto.description !== undefined && { description: dto.description }),
|
||||
...(dto.priority !== undefined && { priority: dto.priority }),
|
||||
@@ -69,13 +78,13 @@ export class RequirementService {
|
||||
);
|
||||
}
|
||||
return this.prisma.requirement.update({
|
||||
where: { id },
|
||||
where: { id_productId: { id, productId } },
|
||||
data: { status: newStatus },
|
||||
});
|
||||
}
|
||||
|
||||
async remove(productId: string, id: string) {
|
||||
await this.findOne(productId, id);
|
||||
return this.prisma.requirement.delete({ where: { id } });
|
||||
return this.prisma.requirement.delete({ where: { id_productId: { id, productId } } });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user