refactor(data): 收口关系表运行时数据源
Some checks failed
Deploy Production / Build, push, deploy, verify (push) Has been cancelled
Some checks failed
Deploy Production / Build, push, deploy, verify (push) Has been cancelled
- 移除已迁移业务 AppData 运行时 fallback,改走领域 API 和关系表快读 - 补齐需求产品负责人、版本计划任务 JSON 和成员 username 回填迁移 - 统一治理字典入口,并补充 AI provider、数据源契约和领域服务测试 Co-Authored-By: Codex GPT-5 <codex@openai.com>
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
ALTER TABLE "requirements" ADD COLUMN "product_owner_id" TEXT;
|
||||
|
||||
ALTER TABLE "requirements"
|
||||
ADD CONSTRAINT "requirements_product_owner_id_fkey"
|
||||
FOREIGN KEY ("product_owner_id") REFERENCES "users"("id")
|
||||
ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
CREATE INDEX "requirements_product_owner_created_at_idx"
|
||||
ON "requirements"("product_owner_id", "created_at" DESC);
|
||||
@@ -0,0 +1,48 @@
|
||||
WITH raw_members AS (
|
||||
SELECT member
|
||||
FROM app_data
|
||||
CROSS JOIN LATERAL jsonb_array_elements(
|
||||
CASE
|
||||
WHEN jsonb_typeof(value -> 'members') = 'array' THEN value -> 'members'
|
||||
ELSE '[]'::jsonb
|
||||
END
|
||||
) AS member
|
||||
WHERE key = 'members'
|
||||
),
|
||||
members AS (
|
||||
SELECT
|
||||
NULLIF(member ->> 'id', '') AS id,
|
||||
NULLIF(member ->> 'username', '') AS username,
|
||||
NULLIF(member ->> 'departmentId', '') AS department_id,
|
||||
NULLIF(member ->> 'roleId', '') AS role_id,
|
||||
COALESCE(member ->> 'phone', '') AS phone,
|
||||
COALESCE(member ->> 'password', '') AS password,
|
||||
CASE
|
||||
WHEN jsonb_typeof(member -> 'isSystem') = 'boolean' THEN (member ->> 'isSystem')::boolean
|
||||
ELSE NULL
|
||||
END AS is_system,
|
||||
COUNT(*) OVER (PARTITION BY NULLIF(member ->> 'username', '')) AS username_count
|
||||
FROM raw_members
|
||||
)
|
||||
UPDATE users AS u
|
||||
SET
|
||||
username = CASE
|
||||
WHEN NULLIF(u.username, '') IS NULL
|
||||
AND m.username IS NOT NULL
|
||||
AND m.username_count = 1
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM users AS other_user
|
||||
WHERE other_user.id <> u.id
|
||||
AND other_user.username = m.username
|
||||
)
|
||||
THEN m.username
|
||||
ELSE u.username
|
||||
END,
|
||||
department_id = COALESCE(m.department_id, u.department_id),
|
||||
role_id = COALESCE(m.role_id, u.role_id, 'member'),
|
||||
phone = COALESCE(m.phone, u.phone, ''),
|
||||
password = COALESCE(m.password, u.password, ''),
|
||||
is_system = COALESCE(m.is_system, u.is_system, false)
|
||||
FROM members AS m
|
||||
WHERE u.id = m.id;
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE "version_plans"
|
||||
ADD COLUMN IF NOT EXISTS "tasks" JSONB NOT NULL DEFAULT '[]';
|
||||
@@ -24,7 +24,8 @@ model User {
|
||||
createdTasks Task[] @relation("TaskCreator")
|
||||
assignedTasks Task[] @relation("TaskAssignee")
|
||||
projectMembers ProjectMember[]
|
||||
requirements Requirement[]
|
||||
requirements Requirement[] @relation("RequirementCreator")
|
||||
productOwnedRequirements Requirement[] @relation("RequirementProductOwner")
|
||||
watchedTasks TaskWatcher[]
|
||||
|
||||
@@map("users")
|
||||
@@ -116,11 +117,13 @@ model Requirement {
|
||||
sourceTarget String? @map("source_target")
|
||||
platform String?
|
||||
creatorId String? @map("creator_id")
|
||||
productOwnerId String? @map("product_owner_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("RequirementCreator", fields: [creatorId], references: [id])
|
||||
productOwner User? @relation("RequirementProductOwner", fields: [productOwnerId], references: [id])
|
||||
|
||||
@@id([id, productId])
|
||||
@@unique([productId, code])
|
||||
@@ -290,6 +293,7 @@ model VersionPlan {
|
||||
actualStartAt DateTime? @map("actual_start_at")
|
||||
completedAt DateTime? @map("completed_at")
|
||||
resultUrl String? @map("result_url")
|
||||
tasks Json @default("[]")
|
||||
requirementCoverage Json @default("[]") @map("requirement_coverage")
|
||||
logs Json @default("[]")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
25
apps/server/src/common/user-reference.ts
Normal file
25
apps/server/src/common/user-reference.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
export async function resolveUserReference(prisma: any, reference: string | null | undefined): Promise<string | null> {
|
||||
const normalized = emptyToNull(reference);
|
||||
if (!normalized) return null;
|
||||
if (!prisma?.user?.findFirst) return null;
|
||||
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
{ id: normalized },
|
||||
{ name: normalized },
|
||||
{ username: normalized },
|
||||
{ email: normalized },
|
||||
],
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
return user?.id ?? null;
|
||||
}
|
||||
|
||||
function emptyToNull(value: string | null | undefined): string | null {
|
||||
if (value === null) return null;
|
||||
if (value === undefined) return null;
|
||||
const trimmed = value.trim();
|
||||
return trimmed ? trimmed : null;
|
||||
}
|
||||
@@ -271,6 +271,35 @@ describe('AiService', () => {
|
||||
expect(result).toMatchObject({ ok: false, code: 'PARSE_ERROR' });
|
||||
});
|
||||
|
||||
it('returns a readable timeout error when decomposition provider request is aborted', async () => {
|
||||
const callTool = jest.fn().mockRejectedValue(new Error('signal is aborted without reason'));
|
||||
const gateway = {
|
||||
getActiveProvider: jest.fn().mockResolvedValue({
|
||||
callTool,
|
||||
}),
|
||||
getActiveModel: jest.fn().mockResolvedValue('test-model'),
|
||||
} as unknown as AiGatewayService;
|
||||
const service = new AiService(gateway);
|
||||
(service as any).fetchPrototype = jest.fn().mockResolvedValue('QY0001:手机号登录');
|
||||
|
||||
const result = await service.decompose({
|
||||
prototypeUrl: 'https://example.com/prototype',
|
||||
requirements: [{ id: 'req-1', code: 'REQ001', title: '手机号登录' }],
|
||||
members: [{ name: '张三', role: 'frontend' }],
|
||||
versionId: 'version-1',
|
||||
planId: 'plan-1',
|
||||
target: 'dev_tasks',
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
code: 'API_TIMEOUT',
|
||||
error: expect.stringContaining('AI 服务调用超时'),
|
||||
});
|
||||
expect(callTool).toHaveBeenCalledWith(expect.objectContaining({ timeoutMs: expect.any(Number) }));
|
||||
expect(result.ok ? '' : result.error).not.toContain('signal is aborted without reason');
|
||||
});
|
||||
|
||||
it('retries once with shorter prototype context when Anthropic returns no tool_use', async () => {
|
||||
const callTool = jest
|
||||
.fn()
|
||||
|
||||
@@ -26,6 +26,7 @@ import type {
|
||||
} from '@ftb/shared';
|
||||
|
||||
const DECOMPOSE_CONTEXT_CHAR_STEPS = [1800, 1200, 800] as const;
|
||||
const DECOMPOSE_AI_TIMEOUT_MS = 110000;
|
||||
const PROTOTYPE_FETCH_TIMEOUT_MS = 15000;
|
||||
const PROTOTYPE_FETCH_ATTEMPTS = 3;
|
||||
const PROTOTYPE_FETCH_RETRY_DELAY_MS = 500;
|
||||
@@ -97,6 +98,7 @@ export class AiService {
|
||||
forceTool: true,
|
||||
maxTokens: 16000,
|
||||
model,
|
||||
timeoutMs: DECOMPOSE_AI_TIMEOUT_MS,
|
||||
});
|
||||
break;
|
||||
} catch (e: any) {
|
||||
@@ -108,6 +110,15 @@ export class AiService {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (this.isProviderTimeoutError(e)) {
|
||||
this.logger.warn(`AI 拆解调用超时: ${e.message}`);
|
||||
return {
|
||||
ok: false,
|
||||
error: `AI 服务调用超时:上游模型在${this.formatTimeout(DECOMPOSE_AI_TIMEOUT_MS)}内未返回拆解结果,请稍后重试,或在 AI 配置中切换更快/更稳定的提供商。`,
|
||||
code: 'API_TIMEOUT',
|
||||
};
|
||||
}
|
||||
|
||||
this.logger.error(`AI 调用失败: ${e.message}`);
|
||||
return {
|
||||
ok: false,
|
||||
@@ -267,6 +278,17 @@ export class AiService {
|
||||
return message.includes('tool_call arguments') && message.includes('JSON');
|
||||
}
|
||||
|
||||
private isProviderTimeoutError(error: any): boolean {
|
||||
const name = String(error?.name || '');
|
||||
const message = String(error?.message || error || '');
|
||||
return /timeout|timed out|aborted|abort/i.test(`${name} ${message}`);
|
||||
}
|
||||
|
||||
private formatTimeout(ms: number): string {
|
||||
if (ms >= 60000) return `约 ${Math.ceil(ms / 60000)} 分钟`;
|
||||
return `${Math.ceil(ms / 1000)} 秒`;
|
||||
}
|
||||
|
||||
private async fetchPrototype(url: string): Promise<string> {
|
||||
if (!url || !url.startsWith('http')) {
|
||||
throw new Error('原型链接无效');
|
||||
|
||||
@@ -103,4 +103,48 @@ describe('AnthropicProvider', () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('passes request timeout and disables SDK retries for tool calls', async () => {
|
||||
mockCreate.mockResolvedValue({
|
||||
stop_reason: 'tool_use',
|
||||
model: 'claude-test',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_use',
|
||||
id: 'toolu_test',
|
||||
name: 'submit_decompose',
|
||||
input: { ok: true },
|
||||
},
|
||||
],
|
||||
usage: {
|
||||
input_tokens: 12,
|
||||
output_tokens: 34,
|
||||
},
|
||||
});
|
||||
|
||||
const provider = new AnthropicProvider('test-key', 'https://example.test');
|
||||
|
||||
await provider.callTool({
|
||||
systemPrompt: '只通过工具返回',
|
||||
userPrompt: '拆解这个原型',
|
||||
tool: {
|
||||
name: 'submit_decompose',
|
||||
description: '提交拆解结果',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: { ok: { type: 'boolean' } },
|
||||
required: ['ok'],
|
||||
},
|
||||
},
|
||||
forceTool: true,
|
||||
maxTokens: 1000,
|
||||
model: 'claude-test',
|
||||
timeoutMs: 110000,
|
||||
});
|
||||
|
||||
expect(mockCreate).toHaveBeenCalledWith(
|
||||
expect.any(Object),
|
||||
{ timeout: 110000, maxRetries: 0 },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -24,7 +24,7 @@ export class AnthropicProvider implements AiProvider {
|
||||
} as any)
|
||||
: { type: 'auto' as const };
|
||||
|
||||
const response = await this.client.messages.create({
|
||||
const body = {
|
||||
model: req.model,
|
||||
max_tokens: req.maxTokens,
|
||||
system: req.systemPrompt,
|
||||
@@ -36,8 +36,11 @@ export class AnthropicProvider implements AiProvider {
|
||||
},
|
||||
],
|
||||
tool_choice: toolChoice,
|
||||
messages: [{ role: 'user', content: req.userPrompt }],
|
||||
});
|
||||
messages: [{ role: 'user' as const, content: req.userPrompt }],
|
||||
};
|
||||
const response = req.timeoutMs
|
||||
? await this.client.messages.create(body, { timeout: req.timeoutMs, maxRetries: 0 })
|
||||
: await this.client.messages.create(body);
|
||||
|
||||
const toolUse = response.content.find(
|
||||
(c): c is Anthropic.ToolUseBlock => c.type === 'tool_use',
|
||||
|
||||
70
apps/server/src/modules/ai/providers/openai.provider.spec.ts
Normal file
70
apps/server/src/modules/ai/providers/openai.provider.spec.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { OpenAIProvider } from './openai.provider';
|
||||
|
||||
const mockCreate = jest.fn();
|
||||
|
||||
jest.mock('openai', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn().mockImplementation(() => ({
|
||||
chat: {
|
||||
completions: {
|
||||
create: mockCreate,
|
||||
},
|
||||
},
|
||||
})),
|
||||
}));
|
||||
|
||||
describe('OpenAIProvider', () => {
|
||||
beforeEach(() => {
|
||||
mockCreate.mockReset();
|
||||
});
|
||||
|
||||
it('passes request timeout and disables SDK retries for tool calls', async () => {
|
||||
mockCreate.mockResolvedValue({
|
||||
model: 'openai-test',
|
||||
choices: [
|
||||
{
|
||||
message: {
|
||||
tool_calls: [
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'submit_decompose',
|
||||
arguments: JSON.stringify({ ok: true }),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
usage: {
|
||||
prompt_tokens: 12,
|
||||
completion_tokens: 34,
|
||||
},
|
||||
});
|
||||
|
||||
const provider = new OpenAIProvider('test-key', 'https://example.test/v1');
|
||||
|
||||
await provider.callTool({
|
||||
systemPrompt: '只通过工具返回',
|
||||
userPrompt: '拆解这个原型',
|
||||
tool: {
|
||||
name: 'submit_decompose',
|
||||
description: '提交拆解结果',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: { ok: { type: 'boolean' } },
|
||||
required: ['ok'],
|
||||
},
|
||||
},
|
||||
forceTool: true,
|
||||
maxTokens: 1000,
|
||||
model: 'openai-test',
|
||||
timeoutMs: 110000,
|
||||
});
|
||||
|
||||
expect(mockCreate).toHaveBeenCalledWith(
|
||||
expect.any(Object),
|
||||
{ timeout: 110000, maxRetries: 0 },
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -16,16 +16,16 @@ export class OpenAIProvider implements AiProvider {
|
||||
}
|
||||
|
||||
async callTool(req: ToolCallRequest): Promise<ToolCallResponse> {
|
||||
const response = await this.client.chat.completions.create({
|
||||
const body = {
|
||||
model: req.model,
|
||||
max_tokens: req.maxTokens,
|
||||
messages: [
|
||||
{ role: 'system', content: req.systemPrompt },
|
||||
{ role: 'user', content: req.userPrompt },
|
||||
{ role: 'system' as const, content: req.systemPrompt },
|
||||
{ role: 'user' as const, content: req.userPrompt },
|
||||
],
|
||||
tools: [
|
||||
{
|
||||
type: 'function',
|
||||
type: 'function' as const,
|
||||
function: {
|
||||
name: req.tool.name,
|
||||
description: req.tool.description,
|
||||
@@ -34,9 +34,12 @@ export class OpenAIProvider implements AiProvider {
|
||||
},
|
||||
],
|
||||
tool_choice: req.forceTool
|
||||
? { type: 'function', function: { name: req.tool.name } }
|
||||
: 'auto',
|
||||
});
|
||||
? { type: 'function' as const, function: { name: req.tool.name } }
|
||||
: 'auto' as const,
|
||||
};
|
||||
const response = req.timeoutMs
|
||||
? await this.client.chat.completions.create(body, { timeout: req.timeoutMs, maxRetries: 0 })
|
||||
: await this.client.chat.completions.create(body);
|
||||
|
||||
const choice = response.choices[0];
|
||||
if (!choice?.message?.tool_calls?.length) {
|
||||
|
||||
@@ -17,6 +17,7 @@ export interface ToolCallRequest {
|
||||
forceTool: boolean;
|
||||
maxTokens: number;
|
||||
model: string;
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
export interface ToolCallResponse {
|
||||
|
||||
@@ -27,6 +27,16 @@ describe('AppDataRetirementService', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps members AppData writable for legacy department role and password-rule config', () => {
|
||||
expect(APP_DATA_RETIREMENT_CONFIG.members.state).toBe('active');
|
||||
expect(() => service.assertWritable('members')).not.toThrow();
|
||||
});
|
||||
|
||||
it('keeps overtime AppData writable for legacy overtime reason config', () => {
|
||||
expect(APP_DATA_RETIREMENT_CONFIG.overtime.state).toBe('active');
|
||||
expect(() => service.assertWritable('overtime')).not.toThrow();
|
||||
});
|
||||
|
||||
it('treats archived documents as read-only', () => {
|
||||
try {
|
||||
service.assertWritable('xiaobao-risk-snapshots');
|
||||
|
||||
@@ -35,9 +35,9 @@ export const APP_DATA_RETIREMENT_CONFIG = {
|
||||
replacement: '/api/v1/versions/:versionId/bugs',
|
||||
},
|
||||
members: {
|
||||
state: 'write_frozen',
|
||||
replacement: '/api/v1/members',
|
||||
note: '成员身份已迁移;部门、角色和密码策略仍需 V2.7 配置表承接。',
|
||||
state: 'active',
|
||||
replacement: '/api/v1/members for member identities; legacy members AppData remains temporary storage for departments, roles, and password policy.',
|
||||
note: '成员身份已迁移到领域 API;部门、角色和密码策略在 V2.7 配置表承接前仍临时保留 AppData 写入。',
|
||||
},
|
||||
'task-categories': {
|
||||
state: 'write_frozen',
|
||||
@@ -67,9 +67,9 @@ export const APP_DATA_RETIREMENT_CONFIG = {
|
||||
note: '个人已读状态等待企业协作/通知治理阶段承接。',
|
||||
},
|
||||
overtime: {
|
||||
state: 'write_frozen',
|
||||
replacement: '/api/v1/overtime',
|
||||
note: '加班记录已迁移;加班原因配置仍需 V2.7 配置表承接。',
|
||||
state: 'active',
|
||||
replacement: '/api/v1/overtime for overtime records; legacy overtime AppData remains temporary storage for overtime reason config.',
|
||||
note: '加班记录已迁移到领域 API;加班原因配置在 V2.7 配置表承接前仍临时保留 AppData 写入。',
|
||||
},
|
||||
} satisfies Record<AppDataKey, AppDataRetirementEntry>;
|
||||
|
||||
|
||||
@@ -7,6 +7,9 @@ describe('BugService domain writes', () => {
|
||||
record: jest.fn().mockResolvedValue({ id: 'activity-1' }),
|
||||
};
|
||||
const prisma = {
|
||||
user: {
|
||||
findFirst: createUserFindFirstMock(),
|
||||
},
|
||||
version: {
|
||||
findUnique: jest.fn(),
|
||||
},
|
||||
@@ -26,6 +29,61 @@ describe('BugService domain writes', () => {
|
||||
};
|
||||
};
|
||||
|
||||
it('resolves assignee and reporter names to user ids before relation writes', async () => {
|
||||
const { prisma, service } = makeService();
|
||||
prisma.user.findFirst = createUserFindFirstMock({ 开发: 'dev-1', 测试: 'qa-1' });
|
||||
prisma.version.findUnique.mockResolvedValue({ id: 'version-1', productId: 'product-1', projectId: 'project-1' });
|
||||
prisma.bug.create.mockResolvedValue({ id: 'bug-1', versionId: 'version-1', title: '登录报错' });
|
||||
|
||||
await service.create('version-1', {
|
||||
title: '登录报错',
|
||||
assigneeId: '开发',
|
||||
reportedBy: '测试',
|
||||
} as any);
|
||||
|
||||
expect(prisma.user.findFirst).toHaveBeenCalledTimes(2);
|
||||
expect(prisma.bug.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
assigneeId: 'dev-1',
|
||||
reporterId: 'qa-1',
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves transfer assignee names before updating bug foreign keys', async () => {
|
||||
const { prisma, workActivity, service } = makeService();
|
||||
prisma.user.findFirst = createUserFindFirstMock({ 李四: 'dev-2' });
|
||||
prisma.bug.findFirst.mockResolvedValue({
|
||||
id: 'bug-1',
|
||||
versionId: 'version-1',
|
||||
productId: 'product-1',
|
||||
projectId: 'project-1',
|
||||
status: 'open',
|
||||
title: '登录报错',
|
||||
assigneeId: 'dev-1',
|
||||
reporterId: 'qa-1',
|
||||
});
|
||||
prisma.bug.update.mockResolvedValue({
|
||||
id: 'bug-1',
|
||||
versionId: 'version-1',
|
||||
productId: 'product-1',
|
||||
projectId: 'project-1',
|
||||
title: '登录报错',
|
||||
assigneeId: 'dev-2',
|
||||
reporterId: 'qa-1',
|
||||
});
|
||||
|
||||
await service.transfer('version-1', 'bug-1', '李四', 'qa-1');
|
||||
|
||||
expect(prisma.bug.update).toHaveBeenCalledWith({
|
||||
where: { id_versionId: { id: 'bug-1', versionId: 'version-1' } },
|
||||
data: { assigneeId: 'dev-2' },
|
||||
});
|
||||
expect(workActivity.record).toHaveBeenCalledWith(expect.objectContaining({
|
||||
metadata: expect.objectContaining({ fromAssigneeId: 'dev-1', toAssigneeId: 'dev-2' }),
|
||||
}));
|
||||
});
|
||||
|
||||
it('creates bugs directly under a version partition', async () => {
|
||||
const { prisma, workActivity, service } = makeService();
|
||||
prisma.version.findUnique.mockResolvedValue({ id: 'version-1', productId: 'product-1', projectId: 'project-1' });
|
||||
@@ -151,3 +209,13 @@ describe('BugService domain writes', () => {
|
||||
expect(prisma.bug.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
function createUserFindFirstMock(mapping: Record<string, string> = {}) {
|
||||
return jest.fn(({ where }: any) => {
|
||||
const refs = (where?.OR ?? [])
|
||||
.flatMap((condition: Record<string, string>) => Object.values(condition))
|
||||
.filter(Boolean);
|
||||
const ref = refs[0];
|
||||
return Promise.resolve(ref ? { id: mapping[ref] ?? ref } : null);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { resolveUserReference } from '../../common/user-reference';
|
||||
import { WorkActivityService } from '../work-activity/work-activity.service';
|
||||
import { CreateBugDto } from './dto/create-bug.dto';
|
||||
import { UpdateBugDto } from './dto/update-bug.dto';
|
||||
@@ -14,9 +15,10 @@ export class BugService {
|
||||
|
||||
async create(versionId: string, dto: CreateBugDto) {
|
||||
const version = await this.ensureVersion(versionId);
|
||||
const bugData = await this.toBugData(dto);
|
||||
const item = await this.prisma.bug.create({
|
||||
data: {
|
||||
...this.toBugData(dto),
|
||||
...bugData,
|
||||
versionId,
|
||||
productId: version.productId,
|
||||
projectId: version.projectId,
|
||||
@@ -43,7 +45,7 @@ export class BugService {
|
||||
await this.ensureBugInVersion(versionId, id);
|
||||
const item = await this.prisma.bug.update({
|
||||
where: { id_versionId: { id, versionId } },
|
||||
data: this.toBugData(dto),
|
||||
data: await this.toBugData(dto),
|
||||
});
|
||||
await this.workActivity.markXiaobaoSummaryDirty(versionId);
|
||||
return { item, activities: [] };
|
||||
@@ -70,16 +72,17 @@ export class BugService {
|
||||
|
||||
async transfer(versionId: string, id: string, assigneeId: string, operator?: string) {
|
||||
const current = await this.ensureBugInVersion(versionId, id);
|
||||
const resolvedAssigneeId = await resolveUserReference(this.prisma, assigneeId);
|
||||
const item = await this.prisma.bug.update({
|
||||
where: { id_versionId: { id, versionId } },
|
||||
data: { assigneeId },
|
||||
data: { assigneeId: resolvedAssigneeId },
|
||||
});
|
||||
const activity = await this.recordBugActivity(
|
||||
item,
|
||||
'bug_transferred',
|
||||
'progress',
|
||||
`转交 Bug:${item.title} → ${assigneeId}`,
|
||||
{ fromAssigneeId: current.assigneeId, toAssigneeId: assigneeId, operator },
|
||||
`转交 Bug:${item.title} → ${resolvedAssigneeId ?? '-'}`,
|
||||
{ fromAssigneeId: current.assigneeId, toAssigneeId: resolvedAssigneeId, operator },
|
||||
);
|
||||
return { item, activities: [activity] };
|
||||
}
|
||||
@@ -91,7 +94,7 @@ export class BugService {
|
||||
return item;
|
||||
}
|
||||
|
||||
private toBugData(dto: Partial<CreateBugDto>) {
|
||||
private async toBugData(dto: Partial<CreateBugDto>) {
|
||||
return {
|
||||
...(dto.testCaseId !== undefined && { testCaseId: emptyToNull(dto.testCaseId) }),
|
||||
...(dto.testCaseVersionId !== undefined && { testCaseVersionId: emptyToNull(dto.testCaseVersionId) }),
|
||||
@@ -101,8 +104,10 @@ export class BugService {
|
||||
...(dto.status !== undefined && { status: dto.status }),
|
||||
...(dto.severity !== undefined && { severity: dto.severity ?? 'minor' }),
|
||||
...(dto.priority !== undefined && { priority: parsePriority(dto.priority) ?? 0 }),
|
||||
...(dto.assigneeId !== undefined && { assigneeId: emptyToNull(dto.assigneeId) }),
|
||||
...(dto.reporterId !== undefined || dto.reportedBy !== undefined ? { reporterId: emptyToNull(dto.reporterId ?? dto.reportedBy) } : {}),
|
||||
...(dto.assigneeId !== undefined && { assigneeId: await resolveUserReference(this.prisma, dto.assigneeId) }),
|
||||
...(dto.reporterId !== undefined || dto.reportedBy !== undefined
|
||||
? { reporterId: await resolveUserReference(this.prisma, dto.reporterId ?? dto.reportedBy) }
|
||||
: {}),
|
||||
...(dto.plannedFixAt !== undefined && { plannedFixAt: parseOptionalDate(dto.plannedFixAt) }),
|
||||
...(dto.resolvedAt !== undefined && { resolvedAt: parseOptionalDate(dto.resolvedAt) }),
|
||||
...(dto.closedAt !== undefined && { closedAt: parseOptionalDate(dto.closedAt) }),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { BadRequestException, ConflictException } from '@nestjs/common';
|
||||
import { AppDataRetirementService } from '../app-data-retirement/app-data-retirement.service';
|
||||
import { DataService } from './data.service';
|
||||
|
||||
describe('DataService', () => {
|
||||
@@ -14,9 +15,7 @@ describe('DataService', () => {
|
||||
const syncService = {
|
||||
syncAfterAppDataPut: jest.fn(),
|
||||
};
|
||||
const retirementService = {
|
||||
assertWritable: jest.fn(),
|
||||
};
|
||||
const retirementService = new AppDataRetirementService();
|
||||
return {
|
||||
prisma,
|
||||
syncService,
|
||||
@@ -58,36 +57,29 @@ describe('DataService', () => {
|
||||
await expect(service.get('unknown-key')).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('upserts JSON values for allowed keys', async () => {
|
||||
it('upserts JSON values for active config keys', async () => {
|
||||
const { prisma, service, syncService, retirementService } = makeService();
|
||||
const value = [{ id: 'p1', name: 'Product 1' }];
|
||||
const value = { departments: [], members: [], roles: [], passwordRule: null };
|
||||
const updatedAt = new Date('2026-07-02T08:01:00.000Z');
|
||||
prisma.appData.upsert.mockResolvedValue({ key: 'products-overview', value, updatedAt });
|
||||
const writableSpy = jest.spyOn(retirementService, 'assertWritable');
|
||||
prisma.appData.upsert.mockResolvedValue({ key: 'members', value, updatedAt });
|
||||
|
||||
await expect(service.put('products-overview', value)).resolves.toEqual({
|
||||
key: 'products-overview',
|
||||
await expect(service.put('members', value)).resolves.toEqual({
|
||||
key: 'members',
|
||||
value,
|
||||
version: updatedAt.toISOString(),
|
||||
});
|
||||
expect(prisma.appData.upsert).toHaveBeenCalledWith({
|
||||
where: { key: 'products-overview' },
|
||||
where: { key: 'members' },
|
||||
update: { value },
|
||||
create: { key: 'products-overview', value },
|
||||
create: { key: 'members', value },
|
||||
});
|
||||
expect(retirementService.assertWritable).toHaveBeenCalledWith('products-overview');
|
||||
expect(syncService.syncAfterAppDataPut).toHaveBeenCalledWith('products-overview');
|
||||
expect(writableSpy).toHaveBeenCalledWith('members');
|
||||
expect(syncService.syncAfterAppDataPut).toHaveBeenCalledWith('members');
|
||||
});
|
||||
|
||||
it('rejects frozen AppData writes before touching storage or relation sync', async () => {
|
||||
const { prisma, service, syncService, retirementService } = makeService();
|
||||
retirementService.assertWritable.mockImplementation(() => {
|
||||
throw new ConflictException({
|
||||
code: 'APP_DATA_WRITE_FROZEN',
|
||||
key: 'dev-tasks',
|
||||
state: 'write_frozen',
|
||||
replacement: '/api/v1/versions/:versionId/dev-tasks',
|
||||
});
|
||||
});
|
||||
const { prisma, service, syncService } = makeService();
|
||||
|
||||
await expect(service.put('dev-tasks', [{ id: 'dt-1' }])).rejects.toMatchObject({
|
||||
response: expect.objectContaining({
|
||||
@@ -102,59 +94,62 @@ describe('DataService', () => {
|
||||
expect(syncService.syncAfterAppDataPut).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows supporting business data keys migrated from browser storage', async () => {
|
||||
it('allows only active config AppData keys after business key retirement', async () => {
|
||||
const { prisma, service } = makeService();
|
||||
const value: unknown[] = [];
|
||||
const membersValue = { departments: [], members: [], roles: [], passwordRule: null };
|
||||
const overtimeValue = { records: [], reasons: [] };
|
||||
prisma.appData.upsert.mockImplementation(({ where }) =>
|
||||
Promise.resolve({ key: where.key, value, updatedAt: new Date('2026-07-02T08:02:00.000Z') }),
|
||||
Promise.resolve({
|
||||
key: where.key,
|
||||
value: where.key === 'members' ? membersValue : overtimeValue,
|
||||
updatedAt: new Date('2026-07-02T08:02:00.000Z'),
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(service.put('task-worklogs', value)).resolves.toMatchObject({ key: 'task-worklogs', value });
|
||||
await expect(service.put('work-activities', value)).resolves.toMatchObject({ key: 'work-activities', value });
|
||||
await expect(service.put('overtime', { records: [], reasons: [] })).resolves.toMatchObject({
|
||||
key: 'overtime',
|
||||
value,
|
||||
});
|
||||
await expect(service.put('members', membersValue)).resolves.toMatchObject({ key: 'members', value: membersValue });
|
||||
await expect(service.put('overtime', overtimeValue)).resolves.toMatchObject({ key: 'overtime', value: overtimeValue });
|
||||
await expect(service.put('task-worklogs', [])).rejects.toBeInstanceOf(ConflictException);
|
||||
await expect(service.put('work-activities', [])).rejects.toBeInstanceOf(ConflictException);
|
||||
});
|
||||
|
||||
it('updates only when the supplied version matches the stored row version', async () => {
|
||||
const { prisma, service, syncService } = makeService();
|
||||
const previousVersion = '2026-07-02T08:03:00.000Z';
|
||||
const nextUpdatedAt = new Date('2026-07-02T08:04:00.000Z');
|
||||
const nextValue = [{ id: 'p2', name: 'Product 2' }];
|
||||
const nextValue = { departments: [], members: [], roles: [], passwordRule: null };
|
||||
prisma.appData.updateMany.mockResolvedValue({ count: 1 });
|
||||
prisma.appData.findUnique.mockResolvedValue({
|
||||
key: 'products-overview',
|
||||
key: 'members',
|
||||
value: nextValue,
|
||||
updatedAt: nextUpdatedAt,
|
||||
});
|
||||
|
||||
await expect(service.put('products-overview', nextValue, previousVersion)).resolves.toEqual({
|
||||
key: 'products-overview',
|
||||
await expect(service.put('members', nextValue, previousVersion)).resolves.toEqual({
|
||||
key: 'members',
|
||||
value: nextValue,
|
||||
version: nextUpdatedAt.toISOString(),
|
||||
});
|
||||
expect(prisma.appData.updateMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
key: 'products-overview',
|
||||
key: 'members',
|
||||
updatedAt: new Date(previousVersion),
|
||||
},
|
||||
data: { value: nextValue },
|
||||
});
|
||||
expect(syncService.syncAfterAppDataPut).toHaveBeenCalledWith('products-overview');
|
||||
expect(syncService.syncAfterAppDataPut).toHaveBeenCalledWith('members');
|
||||
});
|
||||
|
||||
it('rejects stale AppData versions without overwriting the current value', async () => {
|
||||
const { prisma, service, syncService } = makeService();
|
||||
prisma.appData.updateMany.mockResolvedValue({ count: 0 });
|
||||
prisma.appData.findUnique.mockResolvedValue({
|
||||
key: 'products-overview',
|
||||
value: [{ id: 'current' }],
|
||||
key: 'members',
|
||||
value: { departments: [], members: [], roles: [], passwordRule: null },
|
||||
updatedAt: new Date('2026-07-02T08:05:00.000Z'),
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.put('products-overview', [{ id: 'stale' }], '2026-07-02T08:03:00.000Z'),
|
||||
service.put('members', { departments: [], members: [], roles: [], passwordRule: null }, '2026-07-02T08:03:00.000Z'),
|
||||
).rejects.toBeInstanceOf(ConflictException);
|
||||
expect(prisma.appData.upsert).not.toHaveBeenCalled();
|
||||
expect(syncService.syncAfterAppDataPut).not.toHaveBeenCalled();
|
||||
@@ -162,32 +157,32 @@ describe('DataService', () => {
|
||||
|
||||
it('creates a missing row only when the client loaded a null version', async () => {
|
||||
const { prisma, service, syncService } = makeService();
|
||||
const value = [{ id: 'p1' }];
|
||||
const value = { departments: [], members: [], roles: [], passwordRule: null };
|
||||
const updatedAt = new Date('2026-07-02T08:06:00.000Z');
|
||||
prisma.appData.create.mockResolvedValue({ key: 'products-overview', value, updatedAt });
|
||||
prisma.appData.create.mockResolvedValue({ key: 'members', value, updatedAt });
|
||||
|
||||
await expect(service.put('products-overview', value, null)).resolves.toEqual({
|
||||
key: 'products-overview',
|
||||
await expect(service.put('members', value, null)).resolves.toEqual({
|
||||
key: 'members',
|
||||
value,
|
||||
version: updatedAt.toISOString(),
|
||||
});
|
||||
expect(prisma.appData.create).toHaveBeenCalledWith({
|
||||
data: { key: 'products-overview', value },
|
||||
data: { key: 'members', value },
|
||||
});
|
||||
expect(prisma.appData.upsert).not.toHaveBeenCalled();
|
||||
expect(syncService.syncAfterAppDataPut).toHaveBeenCalledWith('products-overview');
|
||||
expect(syncService.syncAfterAppDataPut).toHaveBeenCalledWith('members');
|
||||
});
|
||||
|
||||
it('rejects create-only writes when another client created the row first', async () => {
|
||||
const { prisma, service, syncService } = makeService();
|
||||
prisma.appData.create.mockRejectedValue({ code: 'P2002' });
|
||||
prisma.appData.findUnique.mockResolvedValue({
|
||||
key: 'products-overview',
|
||||
value: [{ id: 'current' }],
|
||||
key: 'members',
|
||||
value: { departments: [], members: [], roles: [], passwordRule: null },
|
||||
updatedAt: new Date('2026-07-02T08:07:00.000Z'),
|
||||
});
|
||||
|
||||
await expect(service.put('products-overview', [{ id: 'new' }], null)).rejects.toBeInstanceOf(
|
||||
await expect(service.put('members', { departments: [], members: [], roles: [], passwordRule: null }, null)).rejects.toBeInstanceOf(
|
||||
ConflictException,
|
||||
);
|
||||
expect(syncService.syncAfterAppDataPut).not.toHaveBeenCalled();
|
||||
@@ -196,24 +191,24 @@ describe('DataService', () => {
|
||||
it('rejects invalid AppData versions', async () => {
|
||||
const { service } = makeService();
|
||||
|
||||
await expect(service.put('products-overview', [], 'not-a-date')).rejects.toBeInstanceOf(
|
||||
await expect(service.put('members', { departments: [], members: [], roles: [], passwordRule: null }, 'not-a-date')).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the AppData response successful when relation sync fails', async () => {
|
||||
const { prisma, service, syncService } = makeService();
|
||||
const value = [{ id: 'p1' }];
|
||||
const value = { departments: [], members: [], roles: [], passwordRule: null };
|
||||
const updatedAt = new Date('2026-07-02T08:08:00.000Z');
|
||||
const warnSpy = jest.spyOn((service as any).logger, 'warn').mockImplementation();
|
||||
prisma.appData.upsert.mockResolvedValue({ key: 'products-overview', value, updatedAt });
|
||||
prisma.appData.upsert.mockResolvedValue({ key: 'members', value, updatedAt });
|
||||
syncService.syncAfterAppDataPut.mockRejectedValue(new Error('sync failed'));
|
||||
|
||||
await expect(service.put('products-overview', value)).resolves.toEqual({
|
||||
key: 'products-overview',
|
||||
await expect(service.put('members', value)).resolves.toEqual({
|
||||
key: 'members',
|
||||
value,
|
||||
version: updatedAt.toISOString(),
|
||||
});
|
||||
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('products-overview'));
|
||||
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('members'));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,9 @@ describe('DevTaskService domain writes', () => {
|
||||
record: jest.fn().mockResolvedValue({ id: 'activity-1' }),
|
||||
};
|
||||
const prisma = {
|
||||
user: {
|
||||
findFirst: createUserFindFirstMock(),
|
||||
},
|
||||
version: {
|
||||
findUnique: jest.fn(),
|
||||
},
|
||||
@@ -26,6 +29,58 @@ describe('DevTaskService domain writes', () => {
|
||||
};
|
||||
};
|
||||
|
||||
it('resolves assignee and creator names to user ids before relation writes', async () => {
|
||||
const { prisma, service } = makeService();
|
||||
prisma.user.findFirst = createUserFindFirstMock({ 张三: 'dev-1', 产品经理: 'pm-1' });
|
||||
prisma.version.findUnique.mockResolvedValue({ id: 'version-1', productId: 'product-1', projectId: 'project-1' });
|
||||
prisma.devTask.create.mockResolvedValue({ id: 'task-1', versionId: 'version-1', title: '开发登录' });
|
||||
|
||||
await service.create('version-1', {
|
||||
title: '开发登录',
|
||||
assigneeId: '张三',
|
||||
createdBy: '产品经理',
|
||||
} as any);
|
||||
|
||||
expect(prisma.user.findFirst).toHaveBeenCalledTimes(2);
|
||||
expect(prisma.devTask.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
assigneeId: 'dev-1',
|
||||
creatorId: 'pm-1',
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves transfer assignee names before updating foreign keys', async () => {
|
||||
const { prisma, workActivity, service } = makeService();
|
||||
prisma.user.findFirst = createUserFindFirstMock({ 李四: 'dev-2' });
|
||||
prisma.devTask.findFirst.mockResolvedValue({
|
||||
id: 'task-1',
|
||||
versionId: 'version-1',
|
||||
productId: 'product-1',
|
||||
projectId: 'project-1',
|
||||
title: '开发登录',
|
||||
assigneeId: 'dev-1',
|
||||
});
|
||||
prisma.devTask.update.mockResolvedValue({
|
||||
id: 'task-1',
|
||||
versionId: 'version-1',
|
||||
productId: 'product-1',
|
||||
projectId: 'project-1',
|
||||
title: '开发登录',
|
||||
assigneeId: 'dev-2',
|
||||
});
|
||||
|
||||
await service.transfer('version-1', 'task-1', '李四');
|
||||
|
||||
expect(prisma.devTask.update).toHaveBeenCalledWith({
|
||||
where: { id_versionId: { id: 'task-1', versionId: 'version-1' } },
|
||||
data: { assigneeId: 'dev-2' },
|
||||
});
|
||||
expect(workActivity.record).toHaveBeenCalledWith(expect.objectContaining({
|
||||
metadata: expect.objectContaining({ fromAssigneeId: 'dev-1', toAssigneeId: 'dev-2' }),
|
||||
}));
|
||||
});
|
||||
|
||||
it('creates dev tasks directly under a version partition', async () => {
|
||||
const { prisma, workActivity, service } = makeService();
|
||||
prisma.version.findUnique.mockResolvedValue({ id: 'version-1', productId: 'product-1', projectId: 'project-1' });
|
||||
@@ -151,3 +206,13 @@ describe('DevTaskService domain writes', () => {
|
||||
expect(prisma.devTask.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
function createUserFindFirstMock(mapping: Record<string, string> = {}) {
|
||||
return jest.fn(({ where }: any) => {
|
||||
const refs = (where?.OR ?? [])
|
||||
.flatMap((condition: Record<string, string>) => Object.values(condition))
|
||||
.filter(Boolean);
|
||||
const ref = refs[0];
|
||||
return Promise.resolve(ref ? { id: mapping[ref] ?? ref } : null);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { resolveUserReference } from '../../common/user-reference';
|
||||
import { WorkActivityService } from '../work-activity/work-activity.service';
|
||||
import { CreateDevTaskDto } from './dto/create-dev-task.dto';
|
||||
import { UpdateDevTaskDto } from './dto/update-dev-task.dto';
|
||||
@@ -17,9 +18,10 @@ export class DevTaskService {
|
||||
if (!version.projectId) {
|
||||
throw new BadRequestException('开发任务所属版本必须归属于项目');
|
||||
}
|
||||
const taskData = await this.toTaskData(dto);
|
||||
const item = await this.prisma.devTask.create({
|
||||
data: {
|
||||
...this.toTaskData(dto),
|
||||
...taskData,
|
||||
versionId,
|
||||
productId: version.productId,
|
||||
projectId: version.projectId,
|
||||
@@ -44,7 +46,7 @@ export class DevTaskService {
|
||||
await this.ensureTaskInVersion(versionId, id);
|
||||
const item = await this.prisma.devTask.update({
|
||||
where: { id_versionId: { id, versionId } },
|
||||
data: this.toTaskData(dto),
|
||||
data: await this.toTaskData(dto),
|
||||
});
|
||||
await this.workActivity.markXiaobaoSummaryDirty(versionId);
|
||||
return { item, activities: [] };
|
||||
@@ -84,16 +86,17 @@ export class DevTaskService {
|
||||
|
||||
async transfer(versionId: string, id: string, assigneeId: string) {
|
||||
const current = await this.ensureTaskInVersion(versionId, id);
|
||||
const resolvedAssigneeId = await resolveUserReference(this.prisma, assigneeId);
|
||||
const item = await this.prisma.devTask.update({
|
||||
where: { id_versionId: { id, versionId } },
|
||||
data: { assigneeId },
|
||||
data: { assigneeId: resolvedAssigneeId },
|
||||
});
|
||||
const activity = await this.recordTaskActivity(
|
||||
item,
|
||||
'dev_task_transferred',
|
||||
'progress',
|
||||
`转派开发任务:${item.title}`,
|
||||
{ fromAssigneeId: current.assigneeId, toAssigneeId: assigneeId },
|
||||
{ fromAssigneeId: current.assigneeId, toAssigneeId: resolvedAssigneeId },
|
||||
);
|
||||
return { item, activities: [activity] };
|
||||
}
|
||||
@@ -105,7 +108,7 @@ export class DevTaskService {
|
||||
return item;
|
||||
}
|
||||
|
||||
private toTaskData(dto: Partial<CreateDevTaskDto>) {
|
||||
private async toTaskData(dto: Partial<CreateDevTaskDto>) {
|
||||
return {
|
||||
...(dto.requirementId !== undefined && { requirementId: emptyToNull(dto.requirementId) }),
|
||||
...(dto.requirementProductId !== undefined && { requirementProductId: emptyToNull(dto.requirementProductId) }),
|
||||
@@ -115,8 +118,10 @@ export class DevTaskService {
|
||||
...(dto.description !== undefined && { description: dto.description ?? '' }),
|
||||
...(dto.status !== undefined && { status: dto.status }),
|
||||
...(dto.priority !== undefined && { priority: parsePriority(dto.priority) ?? 0 }),
|
||||
...(dto.assigneeId !== undefined && { assigneeId: emptyToNull(dto.assigneeId) }),
|
||||
...(dto.creatorId !== undefined || dto.createdBy !== undefined ? { creatorId: emptyToNull(dto.creatorId ?? dto.createdBy) } : {}),
|
||||
...(dto.assigneeId !== undefined && { assigneeId: await resolveUserReference(this.prisma, dto.assigneeId) }),
|
||||
...(dto.creatorId !== undefined || dto.createdBy !== undefined
|
||||
? { creatorId: await resolveUserReference(this.prisma, dto.creatorId ?? dto.createdBy) }
|
||||
: {}),
|
||||
...(dto.isBlocked !== undefined && { isBlocked: dto.isBlocked }),
|
||||
...(dto.blockReason !== undefined && { blockReason: emptyToNull(dto.blockReason) }),
|
||||
...(dto.expectedStartAt !== undefined && { expectedStartAt: parseOptionalDate(dto.expectedStartAt) }),
|
||||
|
||||
@@ -20,6 +20,9 @@ describe('GovernanceService', () => {
|
||||
update: jest.fn(),
|
||||
upsert: jest.fn(),
|
||||
},
|
||||
appData: {
|
||||
findUnique: jest.fn(),
|
||||
},
|
||||
};
|
||||
const audit = { record: jest.fn() } as unknown as AuditService;
|
||||
const rbac = {
|
||||
@@ -80,6 +83,56 @@ describe('GovernanceService', () => {
|
||||
}));
|
||||
});
|
||||
|
||||
it('does not backfill requirement dictionaries from legacy requirement AppData during runtime list', async () => {
|
||||
const { prisma, service } = makeService();
|
||||
prisma.governanceDictionary.findMany.mockResolvedValue([]);
|
||||
|
||||
await expect(service.list('requirement_type')).resolves.toEqual([]);
|
||||
|
||||
expect(prisma.appData.findUnique).not.toHaveBeenCalled();
|
||||
expect(prisma.governanceDictionary.upsert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('seeds default task categories without reading legacy AppData on first governance list', async () => {
|
||||
const { prisma, service } = makeService();
|
||||
const seeded = [{ id: 'cat-1', name: '前端开发', group: 'development' }];
|
||||
prisma.taskCategory.findMany
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce(seeded);
|
||||
prisma.taskCategory.create.mockResolvedValue(seeded[0]);
|
||||
|
||||
await expect(service.list('task_category')).resolves.toEqual(seeded);
|
||||
|
||||
expect(prisma.taskCategory.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
id: 'cat-1',
|
||||
name: '前端开发',
|
||||
group: 'development',
|
||||
code: 'frontend_development',
|
||||
isSystem: true,
|
||||
}),
|
||||
});
|
||||
expect(prisma.appData.findUnique).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('seeds default development and testing task categories when relation table is empty', async () => {
|
||||
const { prisma, service } = makeService();
|
||||
prisma.taskCategory.findMany
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce([{ id: 'cat-1', name: '前端开发', group: 'development' }]);
|
||||
prisma.taskCategory.create.mockResolvedValue({ id: 'cat-1', name: '前端开发', group: 'development' });
|
||||
|
||||
await service.list('task_category');
|
||||
|
||||
expect(prisma.taskCategory.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ id: 'cat-1', name: '前端开发', group: 'development' }),
|
||||
});
|
||||
expect(prisma.taskCategory.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ id: 'cat-test-functional', name: '功能测试', group: 'testing' }),
|
||||
});
|
||||
expect(prisma.appData.findUnique).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('exports task categories and governance dictionaries together', async () => {
|
||||
const { prisma, service } = makeService();
|
||||
prisma.taskCategory.findMany.mockResolvedValue([{ id: 'cat-1', name: '前端' }]);
|
||||
|
||||
@@ -33,17 +33,28 @@ export class GovernanceService {
|
||||
private readonly rbacService: RbacService,
|
||||
) {}
|
||||
|
||||
list(kind: GovernanceDictionaryKind) {
|
||||
async list(kind: GovernanceDictionaryKind) {
|
||||
assertDictionaryKind(kind);
|
||||
if (kind === 'task_category') {
|
||||
return this.prisma.taskCategory.findMany({ orderBy: [{ group: 'asc' }, { name: 'asc' }] });
|
||||
const rows = await this.findTaskCategories();
|
||||
if (rows.length > 0) return rows;
|
||||
await this.seedDefaultTaskCategories();
|
||||
return this.findTaskCategories();
|
||||
}
|
||||
return this.findGovernanceDictionaries(kind);
|
||||
}
|
||||
|
||||
private findGovernanceDictionaries(kind: GovernanceDictionaryKind) {
|
||||
return this.prisma.governanceDictionary.findMany({
|
||||
where: { kind, deletedAt: null },
|
||||
orderBy: { name: 'asc' },
|
||||
});
|
||||
}
|
||||
|
||||
private findTaskCategories() {
|
||||
return this.prisma.taskCategory.findMany({ orderBy: [{ group: 'asc' }, { name: 'asc' }] });
|
||||
}
|
||||
|
||||
async create(input: GovernanceDictionaryInput) {
|
||||
assertDictionaryKind(input.kind);
|
||||
const actorId = requireText(input.actorId, 'actorId');
|
||||
@@ -212,6 +223,20 @@ export class GovernanceService {
|
||||
requiredPermissions: ['governance:manage'],
|
||||
});
|
||||
}
|
||||
|
||||
private async seedDefaultTaskCategories() {
|
||||
for (const item of DEFAULT_TASK_CATEGORIES) {
|
||||
await this.prisma.taskCategory.create({
|
||||
data: {
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
group: item.group,
|
||||
code: item.code ?? null,
|
||||
isSystem: item.isSystem,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function assertDictionaryKind(kind: string): asserts kind is GovernanceDictionaryKind {
|
||||
@@ -225,3 +250,26 @@ function requireText(value: string | undefined | null, field: string): string {
|
||||
if (!normalized) throw new BadRequestException(`${field} is required`);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
const DEFAULT_TASK_CATEGORIES: Array<{ id: string; code: string; name: string; group: string; isSystem: boolean }> = [
|
||||
{ id: 'cat-1', code: 'frontend_development', name: '前端开发', group: 'development', isSystem: true },
|
||||
{ id: 'cat-frontend-interaction', code: 'frontend_interaction', name: '前端交互', group: 'development', isSystem: true },
|
||||
{ id: 'cat-2', code: 'backend_development', name: '后端开发', group: 'development', isSystem: true },
|
||||
{ id: 'cat-backend-api', code: 'backend_api', name: '后端接口', group: 'development', isSystem: true },
|
||||
{ id: 'cat-3', code: 'database_schema', name: '数据库设计', group: 'development', isSystem: true },
|
||||
{ id: 'cat-4', code: 'api_integration', name: '接口联调', group: 'development', isSystem: true },
|
||||
{ id: 'cat-test-functional', code: 'test_functional', name: '功能测试', group: 'testing', isSystem: true },
|
||||
{ id: 'cat-test-ui-interaction', code: 'test_ui_interaction', name: 'UI交互测试', group: 'testing', isSystem: true },
|
||||
{ id: 'cat-test-form-validation', code: 'test_form_validation', name: '表单校验测试', group: 'testing', isSystem: true },
|
||||
{ id: 'cat-test-api', code: 'test_api', name: '接口测试', group: 'testing', isSystem: true },
|
||||
{ id: 'cat-test-data-consistency', code: 'test_data_consistency', name: '数据一致性测试', group: 'testing', isSystem: true },
|
||||
{ id: 'cat-test-permission', code: 'test_permission', name: '权限测试', group: 'testing', isSystem: true },
|
||||
{ id: 'cat-test-exception', code: 'test_exception', name: '异常场景测试', group: 'testing', isSystem: true },
|
||||
{ id: 'cat-test-boundary', code: 'test_boundary', name: '边界值测试', group: 'testing', isSystem: true },
|
||||
{ id: 'cat-test-state-flow', code: 'test_state_flow', name: '状态流转测试', group: 'testing', isSystem: true },
|
||||
{ id: 'cat-test-compatibility', code: 'test_compatibility', name: '兼容性测试', group: 'testing', isSystem: true },
|
||||
{ id: 'cat-test-regression', code: 'test_regression', name: '回归测试', group: 'testing', isSystem: true },
|
||||
{ id: 'cat-5', code: 'data_processing', name: '数据处理', group: 'implementation', isSystem: true },
|
||||
{ id: 'cat-6', code: 'implementation_support', name: '实施支持', group: 'implementation', isSystem: true },
|
||||
{ id: 'cat-other-doc', code: 'documentation', name: '文档', group: 'other', isSystem: true },
|
||||
];
|
||||
|
||||
@@ -50,6 +50,7 @@ function buildAppData(): Record<string, any> {
|
||||
status: 'adopted',
|
||||
priority: 'P1',
|
||||
creator: 'Product manager',
|
||||
productOwner: 'pm',
|
||||
createdAt: '2026-01-02T08:00:00.000Z',
|
||||
updatedAt: '2026-01-02T09:00:00.000Z',
|
||||
},
|
||||
@@ -62,6 +63,11 @@ function buildAppData(): Record<string, any> {
|
||||
name: 'Product manager',
|
||||
username: 'pm',
|
||||
email: '',
|
||||
phone: '13000000001',
|
||||
roleId: 'role-pm',
|
||||
departmentId: 'dept-product',
|
||||
password: 'Ftb12345',
|
||||
isSystem: false,
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
@@ -287,7 +293,17 @@ describe('mapAppDataToV22Rows', () => {
|
||||
}),
|
||||
]);
|
||||
expect(result.users).toEqual([
|
||||
expect.objectContaining({ id: 'member-pm', email: 'pm@local.ftb', name: 'Product manager' }),
|
||||
expect.objectContaining({
|
||||
id: 'member-pm',
|
||||
email: 'pm@local.ftb',
|
||||
name: 'Product manager',
|
||||
username: 'pm',
|
||||
phone: '13000000001',
|
||||
roleId: 'role-pm',
|
||||
departmentId: 'dept-product',
|
||||
password: 'Ftb12345',
|
||||
isSystem: false,
|
||||
}),
|
||||
expect.objectContaining({ id: 'member-dev', email: 'dev@example.com', name: 'Developer' }),
|
||||
expect.objectContaining({ id: 'member-test', email: 'test@local.ftb', name: 'Tester' }),
|
||||
]);
|
||||
@@ -302,6 +318,7 @@ describe('mapAppDataToV22Rows', () => {
|
||||
priority: 1,
|
||||
platform: 'Web,App',
|
||||
creatorId: 'member-pm',
|
||||
productOwnerId: 'member-pm',
|
||||
}),
|
||||
]);
|
||||
expect(result.devTasks).toEqual([
|
||||
@@ -442,4 +459,15 @@ describe('mapAppDataToV22Rows', () => {
|
||||
reason: 'missing version partition context',
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves legacy person references by member username', () => {
|
||||
const appData = buildAppData();
|
||||
appData.requirements.requirements[0].creator = 'pm';
|
||||
appData['version-plans'][0].owner = 'pm';
|
||||
|
||||
const result = mapAppDataToV22Rows(appData);
|
||||
|
||||
expect(result.requirements[0]).toEqual(expect.objectContaining({ creatorId: 'member-pm' }));
|
||||
expect(result.versionPlans[0]).toEqual(expect.objectContaining({ ownerId: 'member-pm' }));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -47,6 +47,12 @@ interface UserRow {
|
||||
email: string;
|
||||
name: string;
|
||||
avatar?: string;
|
||||
username?: string;
|
||||
departmentId?: string;
|
||||
roleId?: string;
|
||||
phone?: string;
|
||||
password?: string;
|
||||
isSystem?: boolean;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
@@ -66,6 +72,7 @@ interface RequirementRow {
|
||||
sourceTarget?: string;
|
||||
platform?: string;
|
||||
creatorId?: string;
|
||||
productOwnerId?: string;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
@@ -94,6 +101,7 @@ interface VersionPlanRow {
|
||||
actualStartAt?: string;
|
||||
completedAt?: string;
|
||||
resultUrl?: string;
|
||||
tasks: unknown[];
|
||||
requirementCoverage: unknown[];
|
||||
logs: unknown[];
|
||||
createdAt?: string;
|
||||
@@ -383,6 +391,12 @@ export function mapAppDataToV22Rows(appData: Record<string, unknown>): V22Mapped
|
||||
email: nextEmail(email),
|
||||
name: stringField(member, 'name') ?? username,
|
||||
avatar: stringField(member, 'avatar'),
|
||||
username,
|
||||
departmentId: stringField(member, 'departmentId'),
|
||||
roleId: stringField(member, 'roleId') ?? 'member',
|
||||
phone: stringField(member, 'phone') ?? '',
|
||||
password: stringField(member, 'password') ?? '',
|
||||
isSystem: booleanField(member, 'isSystem') ?? false,
|
||||
createdAt: stringField(member, 'createdAt'),
|
||||
updatedAt: stringField(member, 'updatedAt') ?? stringField(member, 'createdAt'),
|
||||
});
|
||||
@@ -418,6 +432,10 @@ export function mapAppDataToV22Rows(appData: Record<string, unknown>): V22Mapped
|
||||
sourceTarget: stringField(requirement, 'sourceTarget'),
|
||||
platform: platforms.join(',') || stringField(requirement, 'platform'),
|
||||
creatorId: resolveUserId(stringField(requirement, 'creatorId') ?? stringField(requirement, 'creator'), userIdByReference),
|
||||
productOwnerId: resolveUserId(
|
||||
stringField(requirement, 'productOwnerId') ?? stringField(requirement, 'productOwner'),
|
||||
userIdByReference,
|
||||
),
|
||||
createdAt: stringField(requirement, 'createdAt'),
|
||||
updatedAt: stringField(requirement, 'updatedAt') ?? stringField(requirement, 'createdAt'),
|
||||
};
|
||||
@@ -460,6 +478,7 @@ export function mapAppDataToV22Rows(appData: Record<string, unknown>): V22Mapped
|
||||
actualStartAt: stringField(plan, 'actualStartAt'),
|
||||
completedAt: stringField(plan, 'completedAt'),
|
||||
resultUrl: stringField(plan, 'resultUrl'),
|
||||
tasks: unknownArray(plan.tasks),
|
||||
requirementCoverage: unknownArray(plan.requirementCoverage),
|
||||
logs: unknownArray(plan.logs),
|
||||
createdAt: stringField(plan, 'createdAt'),
|
||||
@@ -878,8 +897,9 @@ function uniqueEmailFactory() {
|
||||
function buildUserIdByReference(users: UserRow[]): Map<string, string> {
|
||||
const refs = new Map<string, string>();
|
||||
for (const user of users) {
|
||||
refs.set(user.id, user.id);
|
||||
refs.set(user.name, user.id);
|
||||
for (const ref of [user.id, user.name, user.username, user.email]) {
|
||||
if (ref) refs.set(ref, user.id);
|
||||
}
|
||||
}
|
||||
return refs;
|
||||
}
|
||||
|
||||
46
apps/server/src/modules/product/product.service.spec.ts
Normal file
46
apps/server/src/modules/product/product.service.spec.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { NotFoundException } from '@nestjs/common';
|
||||
import { ProductService } from './product.service';
|
||||
|
||||
function buildPrismaMock() {
|
||||
return {
|
||||
product: {
|
||||
findMany: jest.fn(),
|
||||
findUnique: jest.fn(),
|
||||
create: jest.fn(),
|
||||
update: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
},
|
||||
appData: {
|
||||
findUnique: jest.fn(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('ProductService relation source', () => {
|
||||
it('does not fall back to products-overview AppData when relation products are empty', async () => {
|
||||
const prisma = buildPrismaMock();
|
||||
prisma.product.findMany.mockResolvedValue([]);
|
||||
prisma.appData.findUnique.mockResolvedValue({
|
||||
key: 'products-overview',
|
||||
value: [{ id: 'legacy-product', name: '旧产品', projects: [], versions: [] }],
|
||||
});
|
||||
const service = new ProductService(prisma as any);
|
||||
|
||||
await expect(service.findAll()).resolves.toEqual([]);
|
||||
await expect(service.findAllWithChildren()).resolves.toEqual([]);
|
||||
expect(prisma.appData.findUnique).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not fall back to products-overview AppData when a relation product is missing', async () => {
|
||||
const prisma = buildPrismaMock();
|
||||
prisma.product.findUnique.mockResolvedValue(null);
|
||||
prisma.appData.findUnique.mockResolvedValue({
|
||||
key: 'products-overview',
|
||||
value: [{ id: 'legacy-product', name: '旧产品', projects: [], versions: [] }],
|
||||
});
|
||||
const service = new ProductService(prisma as any);
|
||||
|
||||
await expect(service.findOne('legacy-product')).rejects.toBeInstanceOf(NotFoundException);
|
||||
expect(prisma.appData.findUnique).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -3,32 +3,6 @@ import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { CreateProductDto } from './dto/create-product.dto';
|
||||
import { UpdateProductDto } from './dto/update-product.dto';
|
||||
|
||||
type ProductOverviewItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
projects?: { id: string; name: string; description?: string; createdAt?: string }[];
|
||||
versions?: {
|
||||
id: string;
|
||||
productId?: string;
|
||||
projectId?: string | null;
|
||||
name: string;
|
||||
status?: string;
|
||||
currentStage?: string | null;
|
||||
startDate?: string | null;
|
||||
expectedReleaseDate?: string | null;
|
||||
releaseDate?: string | null;
|
||||
members?: unknown[];
|
||||
progress?: unknown[];
|
||||
priority?: string | number | null;
|
||||
links?: unknown;
|
||||
createdAt?: string;
|
||||
}[];
|
||||
_count?: { requirements?: number; projects?: number; versions?: number };
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class ProductService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
@@ -44,15 +18,7 @@ export class ProductService {
|
||||
_count: { select: { requirements: true, projects: true, versions: true } },
|
||||
},
|
||||
});
|
||||
if (products.length > 0) return products;
|
||||
|
||||
const overview = await this.getAppDataOverview();
|
||||
if (!overview) return [];
|
||||
return overview.map((product) => {
|
||||
const normalized = this.normalizeOverviewProduct(product);
|
||||
const { projects: _projects, versions: _versions, ...rest } = normalized;
|
||||
return rest;
|
||||
});
|
||||
return products;
|
||||
}
|
||||
|
||||
async findAllWithChildren() {
|
||||
@@ -87,11 +53,7 @@ export class ProductService {
|
||||
_count: { select: { requirements: true, projects: true, versions: true } },
|
||||
},
|
||||
});
|
||||
if (products.length > 0) return products.map((product) => this.normalizeRelationProduct(product));
|
||||
|
||||
const overview = await this.getAppDataOverview();
|
||||
if (!overview) return [];
|
||||
return overview.map((product) => this.normalizeOverviewProduct(product));
|
||||
return products.map((product) => this.normalizeRelationProduct(product));
|
||||
}
|
||||
|
||||
async findOne(id: string) {
|
||||
@@ -108,14 +70,7 @@ export class ProductService {
|
||||
},
|
||||
});
|
||||
if (product) return product;
|
||||
|
||||
const overview = await this.getAppDataOverview();
|
||||
const fallback = overview?.find((item) => item.id === id);
|
||||
if (!fallback) throw new NotFoundException('产品不存在');
|
||||
return {
|
||||
...this.normalizeOverviewProduct(fallback),
|
||||
requirements: [],
|
||||
};
|
||||
throw new NotFoundException('产品不存在');
|
||||
}
|
||||
|
||||
async update(id: string, dto: UpdateProductDto) {
|
||||
@@ -133,30 +88,6 @@ export class ProductService {
|
||||
if (!exists) throw new NotFoundException('产品不存在');
|
||||
}
|
||||
|
||||
private async getAppDataOverview(): Promise<ProductOverviewItem[] | null> {
|
||||
const row = await this.prisma.appData.findUnique({
|
||||
where: { key: 'products-overview' },
|
||||
});
|
||||
if (!Array.isArray(row?.value)) return null;
|
||||
return row.value as unknown as ProductOverviewItem[];
|
||||
}
|
||||
|
||||
private normalizeOverviewProduct(product: ProductOverviewItem) {
|
||||
const projects = product.projects ?? [];
|
||||
const versions = product.versions ?? [];
|
||||
return {
|
||||
...product,
|
||||
description: product.description ?? '',
|
||||
projects,
|
||||
versions,
|
||||
_count: {
|
||||
requirements: product._count?.requirements ?? 0,
|
||||
projects: projects.length,
|
||||
versions: versions.length,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private normalizeRelationProduct(product: any) {
|
||||
const projects = product.projects ?? [];
|
||||
const versions = (product.versions ?? []).map((version: any) => ({
|
||||
|
||||
@@ -56,4 +56,12 @@ export class CreateRequirementDto {
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
creatorId?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
productOwnerId?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
productOwner?: string;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,9 @@ import { RequirementService } from './requirement.service';
|
||||
describe('RequirementService with V2.2 composite requirement key', () => {
|
||||
const makeService = () => {
|
||||
const prisma = {
|
||||
user: {
|
||||
findFirst: createUserFindFirstMock(),
|
||||
},
|
||||
requirement: {
|
||||
create: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
@@ -18,6 +21,44 @@ describe('RequirementService with V2.2 composite requirement key', () => {
|
||||
};
|
||||
};
|
||||
|
||||
it('resolves creator names to user ids before requirement relation writes', async () => {
|
||||
const { prisma, service } = makeService();
|
||||
prisma.user.findFirst = createUserFindFirstMock({ 产品经理: 'pm-1' });
|
||||
prisma.requirement.create.mockResolvedValue({ id: 'req-1', productId: 'product-1', code: 'REQ-001' });
|
||||
|
||||
await service.create('product-1', {
|
||||
code: 'REQ-001',
|
||||
title: 'Payment',
|
||||
creatorId: '产品经理',
|
||||
});
|
||||
|
||||
expect(prisma.user.findFirst).toHaveBeenCalled();
|
||||
expect(prisma.requirement.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
creatorId: 'pm-1',
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves product owner names to user ids before requirement relation writes', async () => {
|
||||
const { prisma, service } = makeService();
|
||||
prisma.user.findFirst = createUserFindFirstMock({ 产品经理: 'pm-1' });
|
||||
prisma.requirement.create.mockResolvedValue({ id: 'req-1', productId: 'product-1', code: 'REQ-001' });
|
||||
|
||||
await service.create('product-1', {
|
||||
code: 'REQ-001',
|
||||
title: 'Payment',
|
||||
productOwner: '产品经理',
|
||||
} as any);
|
||||
|
||||
expect(prisma.user.findFirst).toHaveBeenCalled();
|
||||
expect(prisma.requirement.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
productOwnerId: 'pm-1',
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
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' });
|
||||
@@ -96,7 +137,10 @@ describe('RequirementService with V2.2 composite requirement key', () => {
|
||||
{ title: { contains: 'login', mode: 'insensitive' } },
|
||||
],
|
||||
},
|
||||
include: { creator: { select: { id: true, name: true } } },
|
||||
include: {
|
||||
creator: { select: { id: true, name: true } },
|
||||
productOwner: { select: { id: true, name: true } },
|
||||
},
|
||||
orderBy: { createdAt: 'asc' },
|
||||
take: 2,
|
||||
cursor: { id_productId: { id: 'req-3', productId: 'product-1' } },
|
||||
@@ -132,6 +176,24 @@ describe('RequirementService with V2.2 composite requirement key', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('updates requirement product owner by id plus product id', async () => {
|
||||
const { prisma, service } = makeService();
|
||||
prisma.user.findFirst = createUserFindFirstMock({ 产品经理: 'pm-1' });
|
||||
prisma.requirement.findFirst.mockResolvedValue({
|
||||
id: 'req-1',
|
||||
productId: 'product-1',
|
||||
status: 'pending_review',
|
||||
});
|
||||
prisma.requirement.update.mockResolvedValue({ id: 'req-1', productId: 'product-1' });
|
||||
|
||||
await service.update('product-1', 'req-1', { productOwner: '产品经理' } as any);
|
||||
|
||||
expect(prisma.requirement.update).toHaveBeenCalledWith({
|
||||
where: { id_productId: { id: 'req-1', productId: 'product-1' } },
|
||||
data: { productOwnerId: 'pm-1' },
|
||||
});
|
||||
});
|
||||
|
||||
it('updates frontend pool fields by id plus product id', async () => {
|
||||
const { prisma, service } = makeService();
|
||||
prisma.requirement.findFirst.mockResolvedValue({
|
||||
@@ -225,3 +287,13 @@ describe('RequirementService with V2.2 composite requirement key', () => {
|
||||
expect(prisma.requirement.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
function createUserFindFirstMock(mapping: Record<string, string> = {}) {
|
||||
return jest.fn(({ where }: any) => {
|
||||
const refs = (where?.OR ?? [])
|
||||
.flatMap((condition: Record<string, string>) => Object.values(condition))
|
||||
.filter(Boolean);
|
||||
const ref = refs[0];
|
||||
return Promise.resolve(ref ? { id: mapping[ref] ?? ref } : null);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { resolveUserReference } from '../../common/user-reference';
|
||||
import { RequirementStatus } from '@ftb/shared';
|
||||
import { CreateRequirementDto } from './dto/create-requirement.dto';
|
||||
import { UpdateRequirementDto } from './dto/update-requirement.dto';
|
||||
@@ -15,6 +16,11 @@ const VALID_TRANSITIONS: Record<string, string[]> = {
|
||||
[RequirementStatus.CLOSED]: [],
|
||||
};
|
||||
|
||||
const REQUIREMENT_USER_INCLUDE = {
|
||||
creator: { select: { id: true, name: true } },
|
||||
productOwner: { select: { id: true, name: true } },
|
||||
} as const;
|
||||
|
||||
export interface RequirementListQuery {
|
||||
projectId?: string;
|
||||
versionId?: string;
|
||||
@@ -38,8 +44,8 @@ function createFallbackRequirementCode() {
|
||||
export class RequirementService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
create(productId: string, dto: CreateRequirementDto) {
|
||||
const data = this.toRequirementData(dto);
|
||||
async create(productId: string, dto: CreateRequirementDto) {
|
||||
const data = await this.toRequirementData(dto);
|
||||
return this.prisma.requirement.create({
|
||||
data: {
|
||||
...data,
|
||||
@@ -79,7 +85,7 @@ export class RequirementService {
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
include: { creator: { select: { id: true, name: true } } },
|
||||
include: REQUIREMENT_USER_INCLUDE,
|
||||
orderBy: parseRequirementSort(query.sort),
|
||||
take: limit + 1,
|
||||
...(query.cursor
|
||||
@@ -100,7 +106,7 @@ export class RequirementService {
|
||||
async findOne(productId: string, id: string) {
|
||||
const req = await this.prisma.requirement.findFirst({
|
||||
where: { id, productId },
|
||||
include: { creator: { select: { id: true, name: true } } },
|
||||
include: REQUIREMENT_USER_INCLUDE,
|
||||
});
|
||||
if (!req) throw new NotFoundException('需求不存在');
|
||||
return req;
|
||||
@@ -110,7 +116,7 @@ export class RequirementService {
|
||||
await this.findOne(productId, id);
|
||||
return this.prisma.requirement.update({
|
||||
where: { id_productId: { id, productId } },
|
||||
data: this.toRequirementData(dto),
|
||||
data: await this.toRequirementData(dto),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -133,10 +139,11 @@ export class RequirementService {
|
||||
return this.prisma.requirement.delete({ where: { id_productId: { id, productId } } });
|
||||
}
|
||||
|
||||
private toRequirementData(dto: CreateRequirementDto | UpdateRequirementDto) {
|
||||
private async toRequirementData(dto: CreateRequirementDto | UpdateRequirementDto) {
|
||||
const type = dto.type ?? dto.typeId;
|
||||
const platform = dto.platform ?? arrayToCsv(dto.platforms);
|
||||
const priority = parsePriorityValue(dto.priority);
|
||||
const productOwnerReference = dto.productOwnerId ?? dto.productOwner;
|
||||
|
||||
return {
|
||||
...(dto.code !== undefined && { code: dto.code }),
|
||||
@@ -148,7 +155,10 @@ export class RequirementService {
|
||||
...(dto.sourceType !== undefined && { sourceType: emptyToNull(dto.sourceType) }),
|
||||
...(dto.sourceTarget !== undefined && { sourceTarget: emptyToNull(dto.sourceTarget) }),
|
||||
...(platform !== undefined && { platform: emptyToNull(platform) }),
|
||||
...(dto.creatorId !== undefined && { creatorId: emptyToNull(dto.creatorId) }),
|
||||
...(dto.creatorId !== undefined && { creatorId: await resolveUserReference(this.prisma, dto.creatorId) }),
|
||||
...(dto.productOwnerId !== undefined || dto.productOwner !== undefined
|
||||
? { productOwnerId: await resolveUserReference(this.prisma, productOwnerReference) }
|
||||
: {}),
|
||||
...(priority !== undefined && { priority }),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -7,6 +7,9 @@ describe('TestCaseService domain writes', () => {
|
||||
record: jest.fn().mockResolvedValue({ id: 'activity-1' }),
|
||||
};
|
||||
const prisma = {
|
||||
user: {
|
||||
findFirst: createUserFindFirstMock(),
|
||||
},
|
||||
version: {
|
||||
findUnique: jest.fn(),
|
||||
},
|
||||
@@ -27,6 +30,49 @@ describe('TestCaseService domain writes', () => {
|
||||
};
|
||||
};
|
||||
|
||||
it('resolves assignee and creator names to user ids before relation writes', async () => {
|
||||
const { prisma, service } = makeService();
|
||||
prisma.user.findFirst = createUserFindFirstMock({ 测试: 'qa-1', 产品经理: 'pm-1' });
|
||||
prisma.version.findUnique.mockResolvedValue({ id: 'version-1', productId: 'product-1', projectId: 'project-1' });
|
||||
prisma.testCase.create.mockResolvedValue({ id: 'tc-1', versionId: 'version-1', title: '登录冒烟' });
|
||||
|
||||
await service.create('version-1', {
|
||||
title: '登录冒烟',
|
||||
assigneeId: '测试',
|
||||
createdBy: '产品经理',
|
||||
} as any);
|
||||
|
||||
expect(prisma.user.findFirst).toHaveBeenCalledTimes(2);
|
||||
expect(prisma.testCase.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
assigneeId: 'qa-1',
|
||||
creatorId: 'pm-1',
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves names while batch creating copied or AI-generated test cases', async () => {
|
||||
const { prisma, service } = makeService();
|
||||
prisma.user.findFirst = createUserFindFirstMock({ 测试: 'qa-1', AI: 'system-ai' });
|
||||
prisma.version.findUnique.mockResolvedValue({ id: 'version-1', productId: 'product-1', projectId: 'project-1' });
|
||||
prisma.testCase.createMany.mockResolvedValue({ count: 1 });
|
||||
prisma.testCase.findMany.mockResolvedValue([{ id: 'tc-copy-1' }]);
|
||||
|
||||
await service.createMany('version-1', [
|
||||
{ caseNo: 'TC-101', title: 'AI 用例', assigneeId: '测试', createdBy: 'AI' },
|
||||
] as any);
|
||||
|
||||
expect(prisma.testCase.createMany).toHaveBeenCalledWith({
|
||||
data: [
|
||||
expect.objectContaining({
|
||||
assigneeId: 'qa-1',
|
||||
creatorId: 'system-ai',
|
||||
}),
|
||||
],
|
||||
skipDuplicates: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('creates test cases directly under a version partition', async () => {
|
||||
const { prisma, workActivity, service } = makeService();
|
||||
prisma.version.findUnique.mockResolvedValue({ id: 'version-1', productId: 'product-1', projectId: 'project-1' });
|
||||
@@ -139,3 +185,13 @@ describe('TestCaseService domain writes', () => {
|
||||
expect(prisma.testCase.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
function createUserFindFirstMock(mapping: Record<string, string> = {}) {
|
||||
return jest.fn(({ where }: any) => {
|
||||
const refs = (where?.OR ?? [])
|
||||
.flatMap((condition: Record<string, string>) => Object.values(condition))
|
||||
.filter(Boolean);
|
||||
const ref = refs[0];
|
||||
return Promise.resolve(ref ? { id: mapping[ref] ?? ref } : null);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { resolveUserReference } from '../../common/user-reference';
|
||||
import { WorkActivityService } from '../work-activity/work-activity.service';
|
||||
import { CreateTestCaseDto } from './dto/create-test-case.dto';
|
||||
import { UpdateTestCaseDto } from './dto/update-test-case.dto';
|
||||
@@ -14,9 +15,10 @@ export class TestCaseService {
|
||||
|
||||
async create(versionId: string, dto: CreateTestCaseDto) {
|
||||
const version = await this.ensureVersion(versionId);
|
||||
const testCaseData = await this.toTestCaseData(dto);
|
||||
const item = await this.prisma.testCase.create({
|
||||
data: {
|
||||
...this.toTestCaseData(dto),
|
||||
...testCaseData,
|
||||
versionId,
|
||||
productId: version.productId,
|
||||
projectId: version.projectId,
|
||||
@@ -32,15 +34,15 @@ export class TestCaseService {
|
||||
async createMany(versionId: string, dtos: CreateTestCaseDto[]) {
|
||||
if (dtos.length === 0) return { items: [], activities: [] };
|
||||
const version = await this.ensureVersion(versionId);
|
||||
const rows = dtos.map((dto) => ({
|
||||
...this.toTestCaseData(dto),
|
||||
const rows = await Promise.all(dtos.map(async (dto) => ({
|
||||
...(await this.toTestCaseData(dto)),
|
||||
versionId,
|
||||
productId: version.productId,
|
||||
projectId: version.projectId,
|
||||
code: dto.code?.trim() || dto.caseNo?.trim() || createFallbackCode('TC'),
|
||||
title: dto.title,
|
||||
status: dto.status ?? 'pending',
|
||||
}));
|
||||
})));
|
||||
await this.prisma.testCase.createMany({ data: rows, skipDuplicates: true });
|
||||
const items = await this.prisma.testCase.findMany({
|
||||
where: { versionId, code: { in: rows.map((row) => row.code) } },
|
||||
@@ -62,7 +64,7 @@ export class TestCaseService {
|
||||
await this.ensureTestCaseInVersion(versionId, id);
|
||||
const item = await this.prisma.testCase.update({
|
||||
where: { id_versionId: { id, versionId } },
|
||||
data: this.toTestCaseData(dto),
|
||||
data: await this.toTestCaseData(dto),
|
||||
});
|
||||
await this.workActivity.markXiaobaoSummaryDirty(versionId);
|
||||
return { item, activities: [] };
|
||||
@@ -90,7 +92,7 @@ export class TestCaseService {
|
||||
return item;
|
||||
}
|
||||
|
||||
private toTestCaseData(dto: Partial<CreateTestCaseDto>) {
|
||||
private async toTestCaseData(dto: Partial<CreateTestCaseDto>) {
|
||||
return {
|
||||
...(dto.requirementId !== undefined && { requirementId: emptyToNull(dto.requirementId) }),
|
||||
...(dto.requirementProductId !== undefined && { requirementProductId: emptyToNull(dto.requirementProductId) }),
|
||||
@@ -101,8 +103,10 @@ export class TestCaseService {
|
||||
...(dto.status !== undefined && { status: dto.status }),
|
||||
...(dto.roundNo !== undefined && { roundNo: normalizeRoundNo(dto.roundNo) }),
|
||||
...(dto.priority !== undefined && { priority: parsePriority(dto.priority) ?? 0 }),
|
||||
...(dto.assigneeId !== undefined && { assigneeId: emptyToNull(dto.assigneeId) }),
|
||||
...(dto.creatorId !== undefined || dto.createdBy !== undefined ? { creatorId: emptyToNull(dto.creatorId ?? dto.createdBy) } : {}),
|
||||
...(dto.assigneeId !== undefined && { assigneeId: await resolveUserReference(this.prisma, dto.assigneeId) }),
|
||||
...(dto.creatorId !== undefined || dto.createdBy !== undefined
|
||||
? { creatorId: await resolveUserReference(this.prisma, dto.creatorId ?? dto.createdBy) }
|
||||
: {}),
|
||||
...(dto.plannedTestAt !== undefined && { plannedTestAt: parseOptionalDate(dto.plannedTestAt) }),
|
||||
...(dto.plannedEndAt !== undefined && { plannedEndAt: parseOptionalDate(dto.plannedEndAt) }),
|
||||
...(dto.startedAt !== undefined && { startedAt: parseOptionalDate(dto.startedAt) }),
|
||||
|
||||
@@ -50,7 +50,10 @@ describe('V22QueryService', () => {
|
||||
expect(result.devTasks).toEqual([{ id: 'dev-1' }]);
|
||||
expect(prisma.requirement.findMany).toHaveBeenCalledWith({
|
||||
where: { versionId: 'version-1' },
|
||||
include: { creator: { select: { id: true, name: true } } },
|
||||
include: {
|
||||
creator: { select: { id: true, name: true } },
|
||||
productOwner: { select: { id: true, name: true } },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
expect(prisma.devTask.findMany).toHaveBeenCalledWith({
|
||||
@@ -95,7 +98,10 @@ describe('V22QueryService', () => {
|
||||
{ title: { contains: 'login', mode: 'insensitive' } },
|
||||
],
|
||||
},
|
||||
include: { creator: { select: { id: true, name: true } } },
|
||||
include: {
|
||||
creator: { select: { id: true, name: true } },
|
||||
productOwner: { select: { id: true, name: true } },
|
||||
},
|
||||
orderBy: { createdAt: 'asc' },
|
||||
take: 2,
|
||||
cursor: { id_productId: { id: 'req-3', productId: 'product-1' } },
|
||||
@@ -129,7 +135,13 @@ describe('V22QueryService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to AppData workspace rows when relation tables are empty', async () => {
|
||||
it('rejects missing workspace userId with a client error instead of a TypeError', async () => {
|
||||
const service = new V22QueryService(buildPrismaMock() as any);
|
||||
|
||||
await expect(service.getWorkspaceData(undefined as any)).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('does not fall back to legacy AppData workspace rows when relation tables are empty', async () => {
|
||||
const prisma = buildPrismaMock();
|
||||
prisma.versionPlan.findMany.mockResolvedValue([]);
|
||||
prisma.devTask.findMany.mockResolvedValue([]);
|
||||
@@ -170,14 +182,8 @@ describe('V22QueryService', () => {
|
||||
|
||||
const result = await service.getWorkspaceData('m-8');
|
||||
|
||||
expect(result.versionPlans).toEqual([
|
||||
expect.objectContaining({
|
||||
id: 'plan-1',
|
||||
ownerId: 'm-8',
|
||||
title: '案例学习 V1.3 产品方案',
|
||||
}),
|
||||
]);
|
||||
expect(prisma.appData.findMany).toHaveBeenCalled();
|
||||
expect(result).toEqual({ versionPlans: [], devTasks: [], testCases: [], bugs: [] });
|
||||
expect(prisma.appData.findMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('loads Xiaobao warning summaries either globally for managers or by user-owned version ids', async () => {
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { APP_DATA_KEYS } from '../data/data-keys';
|
||||
import { mapAppDataToV22Rows } from '../migration/app-data-v22.mapper';
|
||||
|
||||
interface RequirementQuery {
|
||||
productId?: string;
|
||||
@@ -21,8 +19,16 @@ interface XiaobaoWarningQuery {
|
||||
manager?: string;
|
||||
}
|
||||
|
||||
type WorkspaceRows = {
|
||||
versionPlans: unknown[];
|
||||
devTasks: unknown[];
|
||||
testCases: unknown[];
|
||||
bugs: unknown[];
|
||||
};
|
||||
|
||||
const REQUIREMENT_CREATOR_INCLUDE = {
|
||||
creator: { select: { id: true, name: true } },
|
||||
productOwner: { select: { id: true, name: true } },
|
||||
} as const;
|
||||
|
||||
@Injectable()
|
||||
@@ -105,8 +111,8 @@ export class V22QueryService {
|
||||
};
|
||||
}
|
||||
|
||||
async getWorkspaceData(userId: string): Promise<WorkspaceRows> {
|
||||
const normalizedUserId = userId.trim();
|
||||
async getWorkspaceData(userId?: string): Promise<WorkspaceRows> {
|
||||
const normalizedUserId = userId?.trim();
|
||||
if (!normalizedUserId) throw new BadRequestException('userId is required');
|
||||
|
||||
const [versionPlans, devTasks, testCases, bugs] = await Promise.all([
|
||||
@@ -128,39 +134,9 @@ export class V22QueryService {
|
||||
}),
|
||||
]);
|
||||
|
||||
const relationResult = { versionPlans, devTasks, testCases, bugs };
|
||||
if (hasWorkspaceRows(relationResult)) return relationResult;
|
||||
|
||||
const fallback = await this.getWorkspaceDataFromAppData(normalizedUserId);
|
||||
if (hasWorkspaceRows(fallback)) return fallback;
|
||||
|
||||
return { versionPlans, devTasks, testCases, bugs };
|
||||
}
|
||||
|
||||
private async getWorkspaceDataFromAppData(userId: string): Promise<WorkspaceRows> {
|
||||
const rows = await this.prisma.appData.findMany({
|
||||
where: { key: { in: [...APP_DATA_KEYS] } },
|
||||
select: { key: true, value: true },
|
||||
});
|
||||
const snapshot = Object.fromEntries(rows.map((row) => [row.key, row.value]));
|
||||
const mapped = mapAppDataToV22Rows(snapshot);
|
||||
|
||||
return {
|
||||
versionPlans: mapped.versionPlans
|
||||
.filter((plan) => plan.ownerId === userId && plan.status !== 'completed')
|
||||
.sort(comparePlanRows),
|
||||
devTasks: mapped.devTasks
|
||||
.filter((task) => task.assigneeId === userId && task.status !== 'submitted')
|
||||
.sort(comparePriorityThenUpdatedRows),
|
||||
testCases: mapped.testCases
|
||||
.filter((testCase) => testCase.assigneeId === userId && !['passed', 'failed', 'blocked'].includes(testCase.status))
|
||||
.sort(comparePriorityThenUpdatedRows),
|
||||
bugs: mapped.bugs
|
||||
.filter((bug) => bug.assigneeId === userId && ['open', 'fixing', 'fixed', 'verifying'].includes(bug.status))
|
||||
.sort(comparePriorityThenUpdatedRows),
|
||||
};
|
||||
}
|
||||
|
||||
async getXiaobaoWarnings(query: XiaobaoWarningQuery) {
|
||||
if (query.manager === 'true') {
|
||||
const rows = await this.prisma.xiaobaoRiskSummary.findMany({
|
||||
@@ -234,49 +210,6 @@ export class V22QueryService {
|
||||
}
|
||||
}
|
||||
|
||||
type WorkspaceRows = {
|
||||
versionPlans: unknown[];
|
||||
devTasks: unknown[];
|
||||
testCases: unknown[];
|
||||
bugs: unknown[];
|
||||
};
|
||||
|
||||
type PriorityUpdatedRow = {
|
||||
priority?: number;
|
||||
updatedAt?: string;
|
||||
};
|
||||
|
||||
type PlanSortRow = {
|
||||
expectedEndAt?: string;
|
||||
updatedAt?: string;
|
||||
};
|
||||
|
||||
function hasWorkspaceRows(rows: WorkspaceRows): boolean {
|
||||
return rows.versionPlans.length > 0
|
||||
|| rows.devTasks.length > 0
|
||||
|| rows.testCases.length > 0
|
||||
|| rows.bugs.length > 0;
|
||||
}
|
||||
|
||||
function comparePlanRows(a: PlanSortRow, b: PlanSortRow): number {
|
||||
const endDiff = dateSortValue(a.expectedEndAt, Number.POSITIVE_INFINITY)
|
||||
- dateSortValue(b.expectedEndAt, Number.POSITIVE_INFINITY);
|
||||
if (endDiff !== 0) return endDiff;
|
||||
return dateSortValue(b.updatedAt, 0) - dateSortValue(a.updatedAt, 0);
|
||||
}
|
||||
|
||||
function comparePriorityThenUpdatedRows(a: PriorityUpdatedRow, b: PriorityUpdatedRow): number {
|
||||
const priorityDiff = (a.priority ?? 0) - (b.priority ?? 0);
|
||||
if (priorityDiff !== 0) return priorityDiff;
|
||||
return dateSortValue(b.updatedAt, 0) - dateSortValue(a.updatedAt, 0);
|
||||
}
|
||||
|
||||
function dateSortValue(value: string | undefined, fallback: number): number {
|
||||
if (!value) return fallback;
|
||||
const time = new Date(value).getTime();
|
||||
return Number.isFinite(time) ? time : fallback;
|
||||
}
|
||||
|
||||
function parseLimit(raw?: string): number {
|
||||
const parsed = raw ? Number(raw) : 50;
|
||||
if (!Number.isFinite(parsed)) return 50;
|
||||
|
||||
@@ -47,6 +47,9 @@ export class CreateVersionPlanDto {
|
||||
@IsOptional()
|
||||
resultUrl?: string;
|
||||
|
||||
@IsOptional()
|
||||
tasks?: unknown[];
|
||||
|
||||
@IsArray()
|
||||
@IsOptional()
|
||||
linkedRequirementIds?: string[];
|
||||
|
||||
@@ -5,8 +5,12 @@ describe('VersionPlanService domain writes', () => {
|
||||
const makeService = () => {
|
||||
const workActivity = {
|
||||
record: jest.fn().mockResolvedValue({ id: 'activity-1' }),
|
||||
markXiaobaoSummaryDirty: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const prisma = {
|
||||
user: {
|
||||
findFirst: createUserFindFirstMock(),
|
||||
},
|
||||
version: {
|
||||
findUnique: jest.fn(),
|
||||
},
|
||||
@@ -26,6 +30,58 @@ describe('VersionPlanService domain writes', () => {
|
||||
};
|
||||
};
|
||||
|
||||
it('resolves plan owner names to user ids before relation writes', async () => {
|
||||
const { prisma, service } = makeService();
|
||||
prisma.user.findFirst = createUserFindFirstMock({ 张三: 'member-1' });
|
||||
prisma.version.findUnique.mockResolvedValue({ id: 'version-1', productId: 'product-1', projectId: 'project-1' });
|
||||
prisma.versionPlan.create.mockResolvedValue({ id: 'plan-1', versionId: 'version-1', title: '产品方案', ownerId: 'member-1' });
|
||||
|
||||
await service.create('version-1', {
|
||||
type: 'product',
|
||||
title: '产品方案',
|
||||
owner: '张三',
|
||||
} as any);
|
||||
|
||||
expect(prisma.user.findFirst).toHaveBeenCalled();
|
||||
expect(prisma.versionPlan.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
ownerId: 'member-1',
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('clears unresolved plan owner references instead of writing invalid foreign keys', async () => {
|
||||
const { prisma, service } = makeService();
|
||||
prisma.user.findFirst.mockResolvedValue(null);
|
||||
prisma.versionPlan.findFirst.mockResolvedValue({
|
||||
id: 'plan-1',
|
||||
versionId: 'version-1',
|
||||
productId: 'product-1',
|
||||
projectId: 'project-1',
|
||||
status: 'pending',
|
||||
title: '产品方案',
|
||||
ownerId: 'member-1',
|
||||
});
|
||||
prisma.versionPlan.update.mockResolvedValue({
|
||||
id: 'plan-1',
|
||||
versionId: 'version-1',
|
||||
productId: 'product-1',
|
||||
projectId: 'project-1',
|
||||
status: 'pending',
|
||||
title: '产品方案',
|
||||
ownerId: null,
|
||||
});
|
||||
|
||||
await service.update('version-1', 'plan-1', { owner: '不存在的成员' } as any);
|
||||
|
||||
expect(prisma.versionPlan.update).toHaveBeenCalledWith({
|
||||
where: { id: 'plan-1' },
|
||||
data: expect.objectContaining({
|
||||
ownerId: null,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('creates version plans directly under a version partition', async () => {
|
||||
const { prisma, workActivity, service } = makeService();
|
||||
prisma.version.findUnique.mockResolvedValue({ id: 'version-1', productId: 'product-1', projectId: 'project-1' });
|
||||
@@ -61,6 +117,64 @@ describe('VersionPlanService domain writes', () => {
|
||||
}));
|
||||
});
|
||||
|
||||
it('persists research direction tasks on create and update', async () => {
|
||||
const { prisma, service } = makeService();
|
||||
const tasks = [{ id: 'task-1', title: '竞品分析', status: 'pending' }];
|
||||
prisma.version.findUnique.mockResolvedValue({ id: 'version-1', productId: 'product-1', projectId: 'project-1' });
|
||||
prisma.versionPlan.create.mockResolvedValue({
|
||||
id: 'plan-1',
|
||||
versionId: 'version-1',
|
||||
productId: 'product-1',
|
||||
projectId: 'project-1',
|
||||
status: 'pending',
|
||||
title: '调研方案',
|
||||
ownerId: 'member-1',
|
||||
tasks,
|
||||
});
|
||||
prisma.versionPlan.findFirst.mockResolvedValue({
|
||||
id: 'plan-1',
|
||||
versionId: 'version-1',
|
||||
productId: 'product-1',
|
||||
projectId: 'project-1',
|
||||
status: 'pending',
|
||||
title: '调研方案',
|
||||
ownerId: 'member-1',
|
||||
tasks,
|
||||
});
|
||||
prisma.versionPlan.update.mockResolvedValue({
|
||||
id: 'plan-1',
|
||||
versionId: 'version-1',
|
||||
productId: 'product-1',
|
||||
projectId: 'project-1',
|
||||
status: 'pending',
|
||||
title: '调研方案',
|
||||
ownerId: 'member-1',
|
||||
tasks: [{ ...tasks[0], status: 'in_progress' }],
|
||||
});
|
||||
|
||||
await service.create('version-1', {
|
||||
type: 'research',
|
||||
title: '调研方案',
|
||||
owner: 'member-1',
|
||||
tasks,
|
||||
} as any);
|
||||
await service.update('version-1', 'plan-1', {
|
||||
tasks: [{ ...tasks[0], status: 'in_progress' }],
|
||||
} as any);
|
||||
|
||||
expect(prisma.versionPlan.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
tasks,
|
||||
}),
|
||||
});
|
||||
expect(prisma.versionPlan.update).toHaveBeenCalledWith({
|
||||
where: { id: 'plan-1' },
|
||||
data: expect.objectContaining({
|
||||
tasks: [{ ...tasks[0], status: 'in_progress' }],
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('updates plan status inside version scope and records activity evidence', async () => {
|
||||
const { prisma, workActivity, service } = makeService();
|
||||
prisma.versionPlan.findFirst.mockResolvedValue({
|
||||
@@ -107,3 +221,13 @@ describe('VersionPlanService domain writes', () => {
|
||||
expect(prisma.versionPlan.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
function createUserFindFirstMock(mapping: Record<string, string> = {}) {
|
||||
return jest.fn(({ where }: any) => {
|
||||
const refs = (where?.OR ?? [])
|
||||
.flatMap((condition: Record<string, string>) => Object.values(condition))
|
||||
.filter(Boolean);
|
||||
const ref = refs[0];
|
||||
return Promise.resolve(ref ? { id: mapping[ref] ?? ref } : null);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Inject, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { resolveUserReference } from '../../common/user-reference';
|
||||
import { WorkActivityService } from '../work-activity/work-activity.service';
|
||||
import { CreateVersionPlanDto } from './dto/create-version-plan.dto';
|
||||
import { UpdateVersionPlanDto } from './dto/update-version-plan.dto';
|
||||
@@ -14,9 +15,10 @@ export class VersionPlanService {
|
||||
|
||||
async create(versionId: string, dto: CreateVersionPlanDto) {
|
||||
const version = await this.ensureVersion(versionId);
|
||||
const planData = await this.toPlanData(dto);
|
||||
const item = await this.prisma.versionPlan.create({
|
||||
data: {
|
||||
...this.toPlanData(dto),
|
||||
...planData,
|
||||
versionId,
|
||||
productId: version.productId,
|
||||
projectId: version.projectId,
|
||||
@@ -38,7 +40,7 @@ export class VersionPlanService {
|
||||
|
||||
async update(versionId: string, id: string, dto: UpdateVersionPlanDto) {
|
||||
const current = await this.ensurePlanInVersion(versionId, id);
|
||||
const data = this.toPlanData(dto);
|
||||
const data = await this.toPlanData(dto);
|
||||
if (dto.status === 'in_progress' && !current.actualStartAt) {
|
||||
data.actualStartAt = new Date();
|
||||
}
|
||||
@@ -69,12 +71,14 @@ export class VersionPlanService {
|
||||
return item;
|
||||
}
|
||||
|
||||
private toPlanData(dto: Partial<CreateVersionPlanDto>) {
|
||||
private async toPlanData(dto: Partial<CreateVersionPlanDto>) {
|
||||
return {
|
||||
...(dto.type !== undefined && { type: dto.type }),
|
||||
...(dto.title !== undefined && { title: dto.title }),
|
||||
...(dto.status !== undefined && { status: dto.status }),
|
||||
...(dto.owner !== undefined || dto.ownerId !== undefined ? { ownerId: emptyToNull(dto.ownerId ?? dto.owner) } : {}),
|
||||
...(dto.owner !== undefined || dto.ownerId !== undefined
|
||||
? { ownerId: await resolveUserReference(this.prisma, dto.ownerId ?? dto.owner) }
|
||||
: {}),
|
||||
...(dto.startTime !== undefined || dto.expectedStartAt !== undefined
|
||||
? { expectedStartAt: parseOptionalDate(dto.expectedStartAt ?? dto.startTime) }
|
||||
: {}),
|
||||
@@ -84,6 +88,7 @@ export class VersionPlanService {
|
||||
...(dto.actualStartAt !== undefined && { actualStartAt: parseOptionalDate(dto.actualStartAt) }),
|
||||
...(dto.completedAt !== undefined && { completedAt: parseOptionalDate(dto.completedAt) }),
|
||||
...(dto.resultUrl !== undefined && { resultUrl: emptyToNull(dto.resultUrl) }),
|
||||
...(dto.tasks !== undefined && { tasks: toJsonInput(dto.tasks) }),
|
||||
...(dto.requirementCoverage !== undefined || dto.linkedRequirementIds !== undefined
|
||||
? { requirementCoverage: toJsonInput(dto.requirementCoverage ?? buildRequirementCoverage(dto.linkedRequirementIds)) }
|
||||
: {}),
|
||||
|
||||
Reference in New Issue
Block a user