feat(v2.3): 完成关系表写入闭环

This commit is contained in:
Script Generator
2026-07-03 13:28:59 +08:00
parent e9ff986bac
commit bba02775dc
17 changed files with 732 additions and 18 deletions

View File

@@ -1,7 +1,21 @@
import { APP_DATA_KEYS } from '../data/data-keys';
import { AppDataV22MigrationService } from './app-data-v22.migration.service';
import { AppDataV22MigrationService, normalizeDateTimeFields } from './app-data-v22.migration.service';
describe('AppDataV22MigrationService', () => {
it('normalizes date-only fields used by relation-table writes', () => {
expect(
normalizeDateTimeFields({
workDate: '2026-07-03',
snapshotDate: '2026-07-03',
createdAt: '2026-07-03',
}),
).toEqual({
workDate: '2026-07-03T00:00:00.000Z',
snapshotDate: '2026-07-03T00:00:00.000Z',
createdAt: '2026-07-03T00:00:00.000Z',
});
});
it('loads allowed AppData keys and returns a migration preview without writing domain tables', async () => {
const findMany = jest.fn().mockResolvedValue([
{

View File

@@ -89,6 +89,8 @@ const DATE_TIME_KEYS = new Set([
'aiDraftAt',
'recomputedAt',
'forecastReleaseDate',
'snapshotDate',
'workDate',
]);
export interface AppDataV22MigrationPreview {
@@ -172,7 +174,7 @@ async function createMany(delegate: CreateManyDelegate, data: unknown[]): Promis
return result.count;
}
function normalizeDateTimeFields(row: unknown): unknown {
export function normalizeDateTimeFields(row: unknown): unknown {
if (typeof row !== 'object' || row === null || Array.isArray(row)) return row;
return Object.fromEntries(
Object.entries(row).map(([key, value]) => [

View File

@@ -0,0 +1,186 @@
import { AppDataV23SyncService } from './app-data-v23-sync.service';
describe('AppDataV23SyncService', () => {
const makeDelegate = () => ({
createMany: jest.fn().mockResolvedValue({ count: 0 }),
deleteMany: jest.fn().mockResolvedValue({ count: 0 }),
upsert: jest.fn().mockResolvedValue({}),
});
const makePrisma = (snapshot: Record<string, unknown>) => {
const prisma: any = {
appData: {
findMany: jest.fn().mockResolvedValue(
Object.entries(snapshot).map(([key, value]) => ({
key,
value,
})),
),
},
user: makeDelegate(),
product: makeDelegate(),
project: makeDelegate(),
version: makeDelegate(),
taskCategory: makeDelegate(),
requirement: makeDelegate(),
versionPlan: makeDelegate(),
devTask: makeDelegate(),
testCase: makeDelegate(),
bug: makeDelegate(),
workActivity: makeDelegate(),
taskWorklog: makeDelegate(),
overtimeRecord: makeDelegate(),
xiaobaoRiskSnapshot: makeDelegate(),
xiaobaoRiskInsight: makeDelegate(),
xiaobaoRiskSummary: makeDelegate(),
$transaction: jest.fn(async (callback: (tx: any) => Promise<unknown>): Promise<unknown> => callback(prisma)),
};
return prisma;
};
const productTree = [
{
id: 'product-1',
name: 'Product 1',
projects: [{ id: 'project-1', name: 'Project 1' }],
versions: [{ id: 'version-1', projectId: 'project-1', name: 'V1' }],
},
];
it('replaces requirement rows by product partition scope', async () => {
const prisma = makePrisma({
'products-overview': productTree,
requirements: [
{
id: 'req-1',
productId: 'product-1',
versionId: 'version-1',
code: 'REQ-001',
title: 'Login',
status: 'adopted',
},
],
});
const service = new AppDataV23SyncService(prisma as any);
await service.syncAfterAppDataPut('requirements');
expect(prisma.requirement.deleteMany).toHaveBeenCalledWith({
where: { productId: { in: ['product-1'] } },
});
expect(prisma.requirement.createMany).toHaveBeenCalledWith({
data: [
expect.objectContaining({
id: 'req-1',
productId: 'product-1',
versionId: 'version-1',
code: 'REQ-001',
}),
],
skipDuplicates: true,
});
});
it('replaces version detail rows by version partition scope', async () => {
const prisma = makePrisma({
'products-overview': productTree,
'version-plans': [
{
id: 'plan-1',
versionId: 'version-1',
type: 'product',
title: 'PRD',
status: 'in_progress',
},
],
'dev-tasks': [
{
id: 'dev-1',
versionId: 'version-1',
productId: 'product-1',
projectId: 'project-1',
code: 'DEV-001',
title: 'API',
status: 'in_progress',
},
],
'test-cases': [
{
id: 'tc-1',
versionId: 'version-1',
productId: 'product-1',
projectId: 'project-1',
code: 'TC-001',
title: 'Login success',
status: 'running',
},
],
bugs: [
{
id: 'bug-1',
versionId: 'version-1',
productId: 'product-1',
projectId: 'project-1',
code: 'BUG-001',
title: 'Login failure',
status: 'open',
},
],
});
const service = new AppDataV23SyncService(prisma as any);
await service.syncAfterAppDataPut('dev-tasks');
expect(prisma.devTask.deleteMany).toHaveBeenCalledWith({
where: { versionId: { in: ['version-1'] } },
});
expect(prisma.devTask.createMany).toHaveBeenCalledWith({
data: [expect.objectContaining({ id: 'dev-1', versionId: 'version-1', code: 'DEV-001' })],
skipDuplicates: true,
});
expect(prisma.testCase.deleteMany).not.toHaveBeenCalled();
expect(prisma.bug.deleteMany).not.toHaveBeenCalled();
expect(prisma.xiaobaoRiskSummary.upsert).toHaveBeenCalledWith(
expect.objectContaining({
where: { versionId: 'version-1' },
update: { dirty: true },
}),
);
});
it('refreshes Xiaobao summaries from the latest risk snapshot', async () => {
const prisma = makePrisma({
'products-overview': productTree,
'xiaobao-risk-snapshots': [
{
id: 'snapshot-1',
versionId: 'version-1',
snapshotDate: '2026-07-03',
riskLevel: 'likely_delayed',
riskScore: 78,
confidence: 85,
forecastReleaseDate: '2026-07-10',
createdAt: '2026-07-03T10:00:00.000Z',
},
],
});
const service = new AppDataV23SyncService(prisma as any);
await service.syncAfterAppDataPut('xiaobao-risk-snapshots');
expect(prisma.xiaobaoRiskSnapshot.createMany).toHaveBeenCalledWith({
data: [expect.objectContaining({ id: 'snapshot-1', versionId: 'version-1', riskScore: 78 })],
skipDuplicates: true,
});
expect(prisma.xiaobaoRiskSummary.upsert).toHaveBeenCalledWith(
expect.objectContaining({
where: { versionId: 'version-1' },
update: expect.objectContaining({
riskLevel: 'likely_delayed',
riskScore: 78,
dirty: false,
}),
}),
);
});
});

View File

@@ -0,0 +1,270 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { APP_DATA_KEYS, isAppDataKey } from '../data/data-keys';
import { mapAppDataToV22Rows, type V22MappedRows } from './app-data-v22.mapper';
import { normalizeDateTimeFields } from './app-data-v22.migration.service';
type DelegateName =
| 'user'
| 'product'
| 'project'
| 'version'
| 'taskCategory'
| 'requirement'
| 'versionPlan'
| 'devTask'
| 'testCase'
| 'bug'
| 'workActivity'
| 'taskWorklog'
| 'overtimeRecord'
| 'xiaobaoRiskSnapshot'
| 'xiaobaoRiskInsight'
| 'xiaobaoRiskSummary';
type CreateManyDelegate = {
createMany(args: { data: unknown[]; skipDuplicates: boolean }): Promise<{ count: number }>;
};
type DeleteManyDelegate = {
deleteMany(args: { where: unknown }): Promise<{ count: number }>;
};
type UpsertDelegate = {
upsert(args: unknown): Promise<unknown>;
};
type SyncDelegate = Partial<CreateManyDelegate & DeleteManyDelegate & UpsertDelegate>;
type SyncTransaction = Record<DelegateName, SyncDelegate>;
const RISK_INPUT_KEYS = new Set([
'version-plans',
'dev-tasks',
'test-cases',
'bugs',
'work-activities',
'task-worklogs',
'overtime',
]);
@Injectable()
export class AppDataV23SyncService {
constructor(private readonly prisma: PrismaService) {}
async syncAfterAppDataPut(key: string): Promise<void> {
if (!isAppDataKey(key)) return;
const mapped = mapAppDataToV22Rows(await this.loadSnapshot());
await this.prisma.$transaction(async (tx) => {
const client = tx as unknown as SyncTransaction;
await this.syncBaseTables(client, key, mapped);
await this.syncCurrentStateTables(client, key, mapped);
await this.syncAppendOnlyTables(client, key, mapped);
await this.syncXiaobaoSummaries(client, key, mapped);
});
}
private 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]));
}
private async syncBaseTables(client: SyncTransaction, key: string, mapped: V22MappedRows) {
if (key === 'products-overview') {
await upsertById(client.product, mapped.products);
await upsertById(client.project, mapped.projects);
await upsertById(client.version, mapped.versions);
}
if (key === 'members') {
await upsertById(client.user, mapped.users);
}
if (key === 'task-categories') {
await upsertById(client.taskCategory, mapped.taskCategories);
}
}
private async syncCurrentStateTables(client: SyncTransaction, key: string, mapped: V22MappedRows) {
if (key === 'requirements') {
const productIds = getProductScope(mapped);
await replaceRows(
client.requirement,
{ productId: { in: productIds } },
mapped.requirements.filter((row) => productIds.includes(row.productId)),
);
}
if (key === 'version-plans') {
const versionIds = getVersionScope(mapped, mapped.versionPlans.map((row) => row.versionId));
await replaceRows(
client.versionPlan,
{ versionId: { in: versionIds } },
mapped.versionPlans.filter((row) => versionIds.includes(row.versionId)),
);
}
if (key === 'dev-tasks') {
const versionIds = getVersionScope(mapped, mapped.devTasks.map((row) => row.versionId));
await replaceRows(
client.devTask,
{ versionId: { in: versionIds } },
mapped.devTasks.filter((row) => versionIds.includes(row.versionId)),
);
}
if (key === 'test-cases') {
const versionIds = getVersionScope(mapped, mapped.testCases.map((row) => row.versionId));
await replaceRows(
client.testCase,
{ versionId: { in: versionIds } },
mapped.testCases.filter((row) => versionIds.includes(row.versionId)),
);
}
if (key === 'bugs') {
const versionIds = getVersionScope(mapped, mapped.bugs.map((row) => row.versionId));
await replaceRows(
client.bug,
{ versionId: { in: versionIds } },
mapped.bugs.filter((row) => versionIds.includes(row.versionId)),
);
}
}
private async syncAppendOnlyTables(client: SyncTransaction, key: string, mapped: V22MappedRows) {
if (key === 'work-activities') {
await createMany(client.workActivity, mapped.workActivities);
}
if (key === 'task-worklogs') {
await createMany(client.taskWorklog, mapped.taskWorklogs);
}
if (key === 'overtime') {
await createMany(client.overtimeRecord, mapped.overtimeRecords);
}
if (key === 'xiaobao-risk-snapshots') {
await createMany(client.xiaobaoRiskSnapshot, mapped.xiaobaoRiskSnapshots);
}
if (key === 'xiaobao-risk-insights') {
await createMany(client.xiaobaoRiskInsight, mapped.xiaobaoRiskInsights);
}
}
private async syncXiaobaoSummaries(client: SyncTransaction, key: string, mapped: V22MappedRows) {
if (key === 'xiaobao-risk-snapshots') {
await upsertRiskSummaries(client.xiaobaoRiskSummary, mapped.xiaobaoRiskSummaries);
return;
}
if (!RISK_INPUT_KEYS.has(key)) return;
await markSummariesDirty(client.xiaobaoRiskSummary, getAffectedRiskVersionIds(key, mapped));
}
}
async function upsertById(delegate: SyncDelegate, rows: Array<{ id: string }>) {
if (!delegate.upsert) return;
for (const row of rows) {
const data = normalizeDateTimeFields(row);
await delegate.upsert({
where: { id: row.id },
update: omitKeys(data, ['id']),
create: data,
});
}
}
async function replaceRows(delegate: SyncDelegate, where: unknown, rows: unknown[]) {
if (!delegate.deleteMany || !delegate.createMany) return;
await delegate.deleteMany({ where });
await createMany(delegate, rows);
}
async function createMany(delegate: SyncDelegate, rows: unknown[]) {
if (!delegate.createMany || rows.length === 0) return;
await delegate.createMany({
data: rows.map(normalizeDateTimeFields),
skipDuplicates: true,
});
}
async function upsertRiskSummaries(delegate: SyncDelegate, rows: V22MappedRows['xiaobaoRiskSummaries']) {
if (!delegate.upsert) return;
for (const row of rows) {
const data = normalizeDateTimeFields(row);
await delegate.upsert({
where: { versionId: row.versionId },
update: omitKeys(data, ['versionId', 'updatedAt']),
create: data,
});
}
}
async function markSummariesDirty(delegate: SyncDelegate, versionIds: string[]) {
if (!delegate.upsert) return;
for (const versionId of unique(versionIds)) {
const riskSignature = `dirty:${versionId}`;
await delegate.upsert({
where: { versionId },
update: { dirty: true },
create: {
versionId,
riskLevel: 'attention',
riskScore: 1,
confidence: 0,
riskSignature,
summary: {
versionId,
riskLevel: 'attention',
riskScore: 1,
confidence: 0,
riskSignature,
dirty: true,
},
dirty: true,
},
});
}
}
function getProductScope(mapped: V22MappedRows): string[] {
return unique([
...mapped.products.map((row) => row.id),
...mapped.requirements.map((row) => row.productId),
]);
}
function getVersionScope(mapped: V22MappedRows, fallback: string[]): string[] {
return unique([...mapped.versions.map((row) => row.id), ...fallback]);
}
function getAffectedRiskVersionIds(key: string, mapped: V22MappedRows): string[] {
if (key === 'version-plans') return mapped.versionPlans.map((row) => row.versionId);
if (key === 'dev-tasks') return mapped.devTasks.map((row) => row.versionId);
if (key === 'test-cases') return mapped.testCases.map((row) => row.versionId);
if (key === 'bugs') return mapped.bugs.map((row) => row.versionId);
if (key === 'work-activities') return mapped.workActivities.map((row) => row.versionId).filter(isPresent);
if (key === 'task-worklogs') return mapped.taskWorklogs.map((row) => row.versionId).filter(isPresent);
if (key === 'overtime') return mapped.overtimeRecords.map((row) => row.versionId).filter(isPresent);
return [];
}
function omitKeys(value: unknown, keys: string[]): unknown {
if (typeof value !== 'object' || value === null || Array.isArray(value)) return value;
const blocked = new Set(keys);
return Object.fromEntries(Object.entries(value).filter(([key]) => !blocked.has(key)));
}
function unique(values: string[]): string[] {
return Array.from(new Set(values.filter(isPresent)));
}
function isPresent(value: string | undefined): value is string {
return Boolean(value);
}

View File

@@ -1,8 +1,9 @@
import { Module } from '@nestjs/common';
import { AppDataV22MigrationService } from './app-data-v22.migration.service';
import { AppDataV23SyncService } from './app-data-v23-sync.service';
@Module({
providers: [AppDataV22MigrationService],
exports: [AppDataV22MigrationService],
providers: [AppDataV22MigrationService, AppDataV23SyncService],
exports: [AppDataV22MigrationService, AppDataV23SyncService],
})
export class MigrationModule {}