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

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