feat(v2.2): 建立分区关系表和AppData迁移预演

This commit is contained in:
Script Generator
2026-07-03 10:23:18 +08:00
parent 0d9e6fee29
commit 5a90923031
16 changed files with 2440 additions and 8 deletions

View File

@@ -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
);

View File

@@ -49,6 +49,7 @@ model Project {
product Product @relation(fields: [productId], references: [id])
sprints Sprint[]
tasks Task[]
versions Version[]
members ProjectMember[]
@@map("projects")
@@ -72,6 +73,7 @@ model Sprint {
model Version {
id String @id @default(cuid())
productId String @map("product_id")
projectId String? @map("project_id")
name String
description String @default("")
releaseDate DateTime? @map("release_date")
@@ -79,25 +81,35 @@ model Version {
updatedAt DateTime @updatedAt @map("updated_at")
product Product @relation(fields: [productId], references: [id])
project Project? @relation(fields: [projectId], references: [id])
tasks Task[]
@@map("versions")
}
model Requirement {
id String @id @default(cuid())
id String @default(cuid())
productId String @map("product_id")
projectId String? @map("project_id")
versionId String? @map("version_id")
code String
title String
description String @default("")
status String @default("draft")
priority Int @default(0)
creatorId String @map("creator_id")
type String?
sourceType String? @map("source_type")
sourceTarget String? @map("source_target")
platform String?
creatorId String? @map("creator_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
product Product @relation(fields: [productId], references: [id])
creator User @relation(fields: [creatorId], references: [id])
creator User? @relation(fields: [creatorId], references: [id])
@@id([id, productId])
@@unique([productId, code])
@@map("requirements")
}
@@ -168,6 +180,250 @@ model ProjectMember {
@@map("project_members")
}
model TaskCategory {
id String @id @default(cuid())
name String
code String?
group String
isSystem Boolean @default(false) @map("is_system")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@unique([group, name])
@@map("task_categories")
}
model VersionPlan {
id String @id @default(cuid())
versionId String @map("version_id")
productId String @map("product_id")
projectId String? @map("project_id")
type String
title String
status String @default("pending")
ownerId String? @map("owner_id")
expectedStartAt DateTime? @map("expected_start_at")
expectedEndAt DateTime? @map("expected_end_at")
actualStartAt DateTime? @map("actual_start_at")
completedAt DateTime? @map("completed_at")
resultUrl String? @map("result_url")
requirementCoverage Json @default("[]") @map("requirement_coverage")
logs Json @default("[]")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@map("version_plans")
}
model DevTask {
id String @default(cuid())
versionId String @map("version_id")
productId String @map("product_id")
projectId String @map("project_id")
requirementId String? @map("requirement_id")
requirementProductId String? @map("requirement_product_id")
categoryId String? @map("category_id")
code String
title String
description String @default("")
status String @default("todo")
priority Int @default(0)
assigneeId String? @map("assignee_id")
creatorId String? @map("creator_id")
isBlocked Boolean @default(false) @map("is_blocked")
blockReason String? @map("block_reason")
expectedStartAt DateTime? @map("expected_start_at")
expectedEndAt DateTime? @map("expected_end_at")
startDate DateTime? @map("start_date")
completedAt DateTime? @map("completed_at")
estimateHours Float? @map("estimate_hours")
aiEstimateHours Float? @map("ai_estimate_hours")
references Json @default("[]")
aiDraft Boolean @default(false) @map("ai_draft")
aiDraftAt DateTime? @map("ai_draft_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@id([id, versionId])
@@unique([versionId, code])
@@map("dev_tasks")
}
model TestCase {
id String @default(cuid())
versionId String @map("version_id")
productId String @map("product_id")
projectId String @map("project_id")
requirementId String? @map("requirement_id")
requirementProductId String? @map("requirement_product_id")
categoryId String? @map("category_id")
code String
title String
description String @default("")
status String @default("pending")
roundNo Int @default(1) @map("round_no")
priority Int @default(0)
assigneeId String? @map("assignee_id")
creatorId String? @map("creator_id")
plannedTestAt DateTime? @map("planned_test_at")
plannedEndAt DateTime? @map("planned_end_at")
startedAt DateTime? @map("started_at")
completedAt DateTime? @map("completed_at")
estimateHours Float? @map("estimate_hours")
aiEstimateHours Float? @map("ai_estimate_hours")
references Json @default("[]")
aiDraft Boolean @default(false) @map("ai_draft")
aiDraftAt DateTime? @map("ai_draft_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@id([id, versionId])
@@unique([versionId, code])
@@map("test_cases")
}
model Bug {
id String @default(cuid())
versionId String @map("version_id")
productId String @map("product_id")
projectId String @map("project_id")
testCaseId String? @map("test_case_id")
testCaseVersionId String? @map("test_case_version_id")
code String
title String
description String @default("")
status String @default("open")
severity String @default("normal")
priority Int @default(0)
assigneeId String? @map("assignee_id")
reporterId String? @map("reporter_id")
plannedFixAt DateTime? @map("planned_fix_at")
resolvedAt DateTime? @map("resolved_at")
closedAt DateTime? @map("closed_at")
resolution String?
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@id([id, versionId])
@@unique([versionId, code])
@@map("bugs")
}
model WorkActivity {
id String @default(cuid())
versionId String? @map("version_id")
productId String? @map("product_id")
projectId String? @map("project_id")
actorId String? @map("actor_id")
actorName String @default("") @map("actor_name")
sourceType String @map("source_type")
sourceId String @map("source_id")
sourceVersionId String? @map("source_version_id")
action String
title String
metadata Json @default("{}")
occurredAt DateTime @map("occurred_at")
createdAt DateTime @default(now()) @map("created_at")
@@id([id, createdAt])
@@map("work_activities")
}
model TaskWorklog {
id String @default(cuid())
versionId String? @map("version_id")
productId String? @map("product_id")
projectId String? @map("project_id")
userId String? @map("user_id")
sourceType String @map("source_type")
sourceId String @map("source_id")
sourceVersionId String? @map("source_version_id")
workDate DateTime @db.Date @map("work_date")
hours Float @default(0)
content String @default("")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@id([id, createdAt])
@@map("task_worklogs")
}
model OvertimeRecord {
id String @default(cuid())
productId String? @map("product_id")
projectId String? @map("project_id")
versionId String? @map("version_id")
userId String? @map("user_id")
reason String @default("")
startAt DateTime @map("start_at")
endAt DateTime @map("end_at")
hours Float @default(0)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@id([id, createdAt])
@@map("overtime_records")
}
model XiaobaoRiskSnapshot {
id String @default(cuid())
versionId String @map("version_id")
snapshotDate DateTime @db.Date @map("snapshot_date")
riskLevel String @map("risk_level")
riskScore Int @default(0) @map("risk_score")
riskSignature String @map("risk_signature")
snapshot Json
createdAt DateTime @default(now()) @map("created_at")
@@id([id, createdAt])
@@map("xiaobao_risk_snapshots")
}
model XiaobaoRiskInsight {
id String @default(cuid())
versionId String @map("version_id")
riskSignature String @map("risk_signature")
status String @default("generated")
insight Json
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@id([id, createdAt])
@@map("xiaobao_risk_insights")
}
model AiLog {
id String @default(cuid())
provider String
model String
operation String
status String
promptTokens Int @default(0) @map("prompt_tokens")
completionTokens Int @default(0) @map("completion_tokens")
durationMs Int @default(0) @map("duration_ms")
errorMessage String? @map("error_message")
metadata Json @default("{}")
createdAt DateTime @default(now()) @map("created_at")
@@id([id, createdAt])
@@map("ai_logs")
}
model XiaobaoRiskSummary {
versionId String @id @map("version_id")
riskLevel String @map("risk_level")
riskScore Int @default(0) @map("risk_score")
confidence Int @default(0)
forecastReleaseDate DateTime? @map("forecast_release_date")
riskSignature String @map("risk_signature")
summary Json
dirty Boolean @default(false)
recomputedAt DateTime? @map("recomputed_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@map("xiaobao_risk_summaries")
}
model AppData {
key String @id
value Json

View 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/);
});
});

View 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/);
});
});

View File

@@ -5,9 +5,10 @@ import { RequirementModule } from './modules/requirement/requirement.module';
import { AiModule } from './modules/ai/ai.module';
import { ConfigModule } from './modules/config/config.module';
import { DataModule } from './modules/data/data.module';
import { MigrationModule } from './modules/migration/migration.module';
@Module({
imports: [PrismaModule, ProductModule, RequirementModule, ConfigModule, DataModule, AiModule],
imports: [PrismaModule, ProductModule, RequirementModule, ConfigModule, DataModule, MigrationModule, AiModule],
controllers: [],
providers: [],
})

View 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',
});
});
});

View 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);
}

View File

@@ -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([]);
});
});

View File

@@ -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>;
}

View 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 {}

View File

@@ -1,6 +1,10 @@
import { IsString, IsNotEmpty, IsOptional, IsInt, Min, Max } from 'class-validator';
export class CreateRequirementDto {
@IsString()
@IsOptional()
code?: string;
@IsString()
@IsNotEmpty()
title!: string;

View File

@@ -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' } },
});
});
});

View File

@@ -12,6 +12,13 @@ const VALID_TRANSITIONS: Record<string, string[]> = {
[RequirementStatus.DELIVERED]: [],
};
function createFallbackRequirementCode() {
return `REQ-${Date.now().toString(36).toUpperCase()}-${Math.random()
.toString(36)
.slice(2, 6)
.toUpperCase()}`;
}
@Injectable()
export class RequirementService {
constructor(private prisma: PrismaService) {}
@@ -20,6 +27,7 @@ export class RequirementService {
return this.prisma.requirement.create({
data: {
productId,
code: dto.code?.trim() || createFallbackRequirementCode(),
title: dto.title,
description: dto.description || '',
priority: dto.priority ?? 0,
@@ -51,8 +59,9 @@ export class RequirementService {
async update(productId: string, id: string, dto: UpdateRequirementDto) {
await this.findOne(productId, id);
return this.prisma.requirement.update({
where: { id },
where: { id_productId: { id, productId } },
data: {
...(dto.code !== undefined && { code: dto.code }),
...(dto.title !== undefined && { title: dto.title }),
...(dto.description !== undefined && { description: dto.description }),
...(dto.priority !== undefined && { priority: dto.priority }),
@@ -69,13 +78,13 @@ export class RequirementService {
);
}
return this.prisma.requirement.update({
where: { id },
where: { id_productId: { id, productId } },
data: { status: newStatus },
});
}
async remove(productId: string, id: string) {
await this.findOne(productId, id);
return this.prisma.requirement.delete({ where: { id } });
return this.prisma.requirement.delete({ where: { id_productId: { id, productId } } });
}
}
}