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])
|
product Product @relation(fields: [productId], references: [id])
|
||||||
sprints Sprint[]
|
sprints Sprint[]
|
||||||
tasks Task[]
|
tasks Task[]
|
||||||
|
versions Version[]
|
||||||
members ProjectMember[]
|
members ProjectMember[]
|
||||||
|
|
||||||
@@map("projects")
|
@@map("projects")
|
||||||
@@ -72,6 +73,7 @@ model Sprint {
|
|||||||
model Version {
|
model Version {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
productId String @map("product_id")
|
productId String @map("product_id")
|
||||||
|
projectId String? @map("project_id")
|
||||||
name String
|
name String
|
||||||
description String @default("")
|
description String @default("")
|
||||||
releaseDate DateTime? @map("release_date")
|
releaseDate DateTime? @map("release_date")
|
||||||
@@ -79,25 +81,35 @@ model Version {
|
|||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
product Product @relation(fields: [productId], references: [id])
|
product Product @relation(fields: [productId], references: [id])
|
||||||
|
project Project? @relation(fields: [projectId], references: [id])
|
||||||
tasks Task[]
|
tasks Task[]
|
||||||
|
|
||||||
@@map("versions")
|
@@map("versions")
|
||||||
}
|
}
|
||||||
|
|
||||||
model Requirement {
|
model Requirement {
|
||||||
id String @id @default(cuid())
|
id String @default(cuid())
|
||||||
productId String @map("product_id")
|
productId String @map("product_id")
|
||||||
|
projectId String? @map("project_id")
|
||||||
|
versionId String? @map("version_id")
|
||||||
|
code String
|
||||||
title String
|
title String
|
||||||
description String @default("")
|
description String @default("")
|
||||||
status String @default("draft")
|
status String @default("draft")
|
||||||
priority Int @default(0)
|
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")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
product Product @relation(fields: [productId], references: [id])
|
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")
|
@@map("requirements")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,6 +180,250 @@ model ProjectMember {
|
|||||||
@@map("project_members")
|
@@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 {
|
model AppData {
|
||||||
key String @id
|
key String @id
|
||||||
value Json
|
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 { AiModule } from './modules/ai/ai.module';
|
||||||
import { ConfigModule } from './modules/config/config.module';
|
import { ConfigModule } from './modules/config/config.module';
|
||||||
import { DataModule } from './modules/data/data.module';
|
import { DataModule } from './modules/data/data.module';
|
||||||
|
import { MigrationModule } from './modules/migration/migration.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PrismaModule, ProductModule, RequirementModule, ConfigModule, DataModule, AiModule],
|
imports: [PrismaModule, ProductModule, RequirementModule, ConfigModule, DataModule, MigrationModule, AiModule],
|
||||||
controllers: [],
|
controllers: [],
|
||||||
providers: [],
|
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';
|
import { IsString, IsNotEmpty, IsOptional, IsInt, Min, Max } from 'class-validator';
|
||||||
|
|
||||||
export class CreateRequirementDto {
|
export class CreateRequirementDto {
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
code?: string;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
title!: string;
|
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]: [],
|
[RequirementStatus.DELIVERED]: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function createFallbackRequirementCode() {
|
||||||
|
return `REQ-${Date.now().toString(36).toUpperCase()}-${Math.random()
|
||||||
|
.toString(36)
|
||||||
|
.slice(2, 6)
|
||||||
|
.toUpperCase()}`;
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class RequirementService {
|
export class RequirementService {
|
||||||
constructor(private prisma: PrismaService) {}
|
constructor(private prisma: PrismaService) {}
|
||||||
@@ -20,6 +27,7 @@ export class RequirementService {
|
|||||||
return this.prisma.requirement.create({
|
return this.prisma.requirement.create({
|
||||||
data: {
|
data: {
|
||||||
productId,
|
productId,
|
||||||
|
code: dto.code?.trim() || createFallbackRequirementCode(),
|
||||||
title: dto.title,
|
title: dto.title,
|
||||||
description: dto.description || '',
|
description: dto.description || '',
|
||||||
priority: dto.priority ?? 0,
|
priority: dto.priority ?? 0,
|
||||||
@@ -51,8 +59,9 @@ export class RequirementService {
|
|||||||
async update(productId: string, id: string, dto: UpdateRequirementDto) {
|
async update(productId: string, id: string, dto: UpdateRequirementDto) {
|
||||||
await this.findOne(productId, id);
|
await this.findOne(productId, id);
|
||||||
return this.prisma.requirement.update({
|
return this.prisma.requirement.update({
|
||||||
where: { id },
|
where: { id_productId: { id, productId } },
|
||||||
data: {
|
data: {
|
||||||
|
...(dto.code !== undefined && { code: dto.code }),
|
||||||
...(dto.title !== undefined && { title: dto.title }),
|
...(dto.title !== undefined && { title: dto.title }),
|
||||||
...(dto.description !== undefined && { description: dto.description }),
|
...(dto.description !== undefined && { description: dto.description }),
|
||||||
...(dto.priority !== undefined && { priority: dto.priority }),
|
...(dto.priority !== undefined && { priority: dto.priority }),
|
||||||
@@ -69,13 +78,13 @@ export class RequirementService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
return this.prisma.requirement.update({
|
return this.prisma.requirement.update({
|
||||||
where: { id },
|
where: { id_productId: { id, productId } },
|
||||||
data: { status: newStatus },
|
data: { status: newStatus },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async remove(productId: string, id: string) {
|
async remove(productId: string, id: string) {
|
||||||
await this.findOne(productId, id);
|
await this.findOne(productId, id);
|
||||||
return this.prisma.requirement.delete({ where: { id } });
|
return this.prisma.requirement.delete({ where: { id_productId: { id, productId } } });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -220,3 +220,26 @@ Managers with `xiaobao.warning:manage` can see all unfinished versions. Non-mana
|
|||||||
AI explains rule results only. It writes interpretation cache to `xiaobao-risk-insights` and never mutates Version, Requirement, DevTask, TestCase, Bug, or Member data. Risk snapshots are saved to `xiaobao-risk-snapshots` when the page is opened. The first version uses page-triggered analysis rather than a background scheduled Agent.
|
AI explains rule results only. It writes interpretation cache to `xiaobao-risk-insights` and never mutates Version, Requirement, DevTask, TestCase, Bug, or Member data. Risk snapshots are saved to `xiaobao-risk-snapshots` when the page is opened. The first version uses page-triggered analysis rather than a background scheduled Agent.
|
||||||
|
|
||||||
Per-user warning read state is saved to `xiaobao-warning-views`. The read marker stores `userId + versionId + risk signature`, so the sidebar can turn the Xiaobao badge blue when any visible risk has a completed unread update, then return to the red risk-count badge after the user opens every updated warning. AI interpretation that is still generating only shows the "updating" notice and must not produce the blue update badge yet.
|
Per-user warning read state is saved to `xiaobao-warning-views`. The read marker stores `userId + versionId + risk signature`, so the sidebar can turn the Xiaobao badge blue when any visible risk has a completed unread update, then return to the red risk-count badge after the user opens every updated warning. AI interpretation that is still generating only shows the "updating" notice and must not produce the blue update badge yet.
|
||||||
|
## V2.2 Partitioned Domain Data Layer (2026-07-03)
|
||||||
|
|
||||||
|
V2.2 starts the move from AppData JSON documents to relation tables for high-volume domains. The first database foundation is partitioned from the start so future growth does not require a disruptive rewrite of primary keys, unique constraints, and foreign-key references.
|
||||||
|
|
||||||
|
High-growth business tables use fixed hash partitions:
|
||||||
|
|
||||||
|
- `requirements`: hash partitioned by `product_id`; primary key is `(id, product_id)`.
|
||||||
|
- `dev_tasks`, `test_cases`, and `bugs`: hash partitioned by `version_id`; primary key is `(id, version_id)`.
|
||||||
|
|
||||||
|
Append-only evidence and history tables use range partitions by `created_at`:
|
||||||
|
|
||||||
|
- `work_activities`
|
||||||
|
- `task_worklogs`
|
||||||
|
- `overtime_records`
|
||||||
|
- `xiaobao_risk_snapshots`
|
||||||
|
- `xiaobao_risk_insights`
|
||||||
|
- `ai_logs`
|
||||||
|
|
||||||
|
Partitioned tables must include the partition key in every primary key and business unique constraint. Cross-table references to partitioned tables use composite foreign keys when the referencing row naturally carries the partition key, for example `(requirement_id, requirement_product_id)` and `(test_case_id, test_case_version_id)`. Polymorphic activity references remain logical references.
|
||||||
|
|
||||||
|
Xiaobao uses two storage shapes: `xiaobao_risk_summaries` keeps one current row per version for fast reads, while `xiaobao_risk_snapshots` stores append-only history for trend analysis.
|
||||||
|
|
||||||
|
The AppData migration path is staged through a pure mapper plus preview service. `AppDataV22MigrationService.preview()` reads the allowed `app_data` keys, maps legacy JSON into partition-key-ready rows, and reports row counts plus skipped records before any insert path is enabled.
|
||||||
|
|||||||
@@ -516,3 +516,16 @@
|
|||||||
- 人工新建任务表单继续使用任务类型字典作为可选项,但这个字典会随着 AI 草案采纳自动扩展。
|
- 人工新建任务表单继续使用任务类型字典作为可选项,但这个字典会随着 AI 草案采纳自动扩展。
|
||||||
|
|
||||||
**理由**:人工表单的任务类型是录入辅助,不应该成为 AI 拆解边界。但任务类型字典也不能被单个原型的业务对象污染。`taskTypeName` 只承载可复用分类,具体功能点放在 `title` 和 `description`;采纳时有门槛地补字典,让后续筛选、统计和人工创建复用真正稳定的类型,同时避免 AI 直接写数据库 `categoryId`。
|
**理由**:人工表单的任务类型是录入辅助,不应该成为 AI 拆解边界。但任务类型字典也不能被单个原型的业务对象污染。`taskTypeName` 只承载可复用分类,具体功能点放在 `title` 和 `description`;采纳时有门槛地补字典,让后续筛选、统计和人工创建复用真正稳定的类型,同时避免 AI 直接写数据库 `categoryId`。
|
||||||
|
## 42. V2.2 高增长业务表从一开始采用分区表
|
||||||
|
|
||||||
|
**问题**:需求池、开发任务、测试用例、BUG、工作活动和小宝快照会按年持续增长。若先用普通表,后续再切分区表,不只是搬旧数据,还会牵动主键、唯一约束、外键、迁移窗口和回滚方案。
|
||||||
|
|
||||||
|
**决策**:
|
||||||
|
- `requirements` 按 `product_id` 做固定 HASH 分区,主键为 `(id, product_id)`。
|
||||||
|
- `dev_tasks` / `test_cases` / `bugs` 按 `version_id` 做固定 HASH 分区,主键为 `(id, version_id)`。
|
||||||
|
- `work_activities` / `task_worklogs` / `overtime_records` / `xiaobao_risk_snapshots` / `xiaobao_risk_insights` / `ai_logs` 按 `created_at` 做 RANGE 分区并保留 default partition。
|
||||||
|
- 所有分区表主键和业务唯一约束必须包含分区键,例如 `(version_id, code)`。
|
||||||
|
- 引用分区表时优先使用复合外键,例如 `(requirement_id, requirement_product_id)`、`(test_case_id, test_case_version_id)`;多态活动记录使用逻辑外键。
|
||||||
|
- `xiaobao_risk_summaries` 不分区,保持 `version_id` 单行汇总;历史趋势进入分区快照表。
|
||||||
|
|
||||||
|
**理由**:把分区键纳入主键和唯一约束是 PostgreSQL 分区表的结构性要求。提前做这件事,可以避免客户数据变大后再重塑主键和外键。固定 HASH 分区避免每个项目/版本单独建分区的维护负担;RANGE 分区只用于天然追加、按时间维护的历史数据。
|
||||||
|
|||||||
@@ -6,6 +6,13 @@
|
|||||||
|
|
||||||
### 已完成(按时间倒序)
|
### 已完成(按时间倒序)
|
||||||
|
|
||||||
|
**2026-07-03**
|
||||||
|
- V2.2 partitioned domain schema foundation added: `requirements` uses HASH partitioning by `product_id`; `dev_tasks`, `test_cases`, and `bugs` use HASH partitioning by `version_id`.
|
||||||
|
- Partitioned table primary keys and business unique constraints now include partition keys, for example `(id, version_id)` and `(version_id, code)`.
|
||||||
|
- Xiaobao precompute storage foundation added: `xiaobao_risk_summaries` stores the current version risk, and `xiaobao_risk_snapshots` stores historical snapshots.
|
||||||
|
- Prisma schema now includes the V2.2 relational model skeleton, and the legacy `RequirementService` now uses the `(id, product_id)` composite key.
|
||||||
|
- AppData V2.2 migration mapper and preview service added, so legacy JSON can be rehearsed into relation-table rows with counts and skipped-record diagnostics before inserts are enabled.
|
||||||
|
|
||||||
**2026-07-02**
|
**2026-07-02**
|
||||||
- `app_data` 读写增加乐观锁版本:`GET` 返回 `version`,前端保存携带最近版本,后端用 `key + updatedAt` 原子更新
|
- `app_data` 读写增加乐观锁版本:`GET` 返回 `version`,前端保存携带最近版本,后端用 `key + updatedAt` 原子更新
|
||||||
- stale version / create race 返回 `409 APP_DATA_CONFLICT`,阻止多人同时编辑时的静默覆盖
|
- stale version / create race 返回 `409 APP_DATA_CONFLICT`,阻止多人同时编辑时的静默覆盖
|
||||||
|
|||||||
Reference in New Issue
Block a user