feat: align RU validation, error contract, and generator runtime templates

Wire DSL-derived field labels, safe API error JSON (string|string[]), decimal/enum DTO fixes, and client dataProvider without comma-splitting. Add generation/templates/runtime as canonical source copied on generate; extend AID bundle, prompts, validation gate, and docs.
This commit is contained in:
time_
2026-03-29 10:39:54 +03:00
parent 9a1a700efa
commit f6cdeec918
25 changed files with 801 additions and 93 deletions

View File

@@ -0,0 +1,28 @@
/** AUTO-GENERATED from domain DSL (generation/generate.mjs). Do not edit by hand. */
export const FIELD_LABELS: Record<string, string> = {
"code": "Код вида оборудования",
"commissionedAt": "Дата ввода в эксплуатацию",
"completedAt": "Фактическая дата завершения",
"contractor": "Подрядная организация (если внешний ремонт)",
"description": "Описание работ / дефекта",
"engineHoursAtRepair": "Наработка на момент ремонта, моточасов",
"engineHoursSinceLastRepair": "Наработка с последнего ремонта, моточасов",
"equipmentId": "Оборудование",
"equipmentTypeCode": "Вид оборудования",
"id": "Идентификатор",
"inventoryNumber": "Инвентарный номер",
"lastRepairAt": "Дата последнего ремонта",
"location": "Место эксплуатации / скважина / куст",
"maintenanceIntervalHours": "Периодичность ТО, моточасов",
"manufacturer": "Производитель",
"name": "Наименование единицы оборудования",
"notes": "Примечания",
"number": "Номер заявки",
"overhaulIntervalHours": "Периодичность КР, моточасов",
"plannedAt": "Плановая дата начала",
"repairKind": "Вид ремонта",
"serialNumber": "Заводской (серийный) номер",
"startedAt": "Фактическая дата начала",
"status": "Текущий статус",
"totalEngineHours": "Общая наработка, моточасов",
};

View File

@@ -12,7 +12,7 @@ import { Request, Response } from 'express';
type ErrorResponseBody = {
statusCode: number;
message: string;
message: string | string[];
code: string;
details?: unknown;
path: string;
@@ -39,8 +39,10 @@ export class ApiExceptionFilter implements ExceptionFilter {
};
if (mapped.statusCode >= 500) {
const logDetail =
exception instanceof Error ? exception.message : String(mapped.message);
this.logger.error(
`Unhandled error on ${request.method} ${request.url}: ${mapped.message}`,
`Unhandled error on ${request.method} ${request.url}: ${logDetail}`,
exception instanceof Error ? exception.stack : undefined,
);
}
@@ -50,7 +52,7 @@ export class ApiExceptionFilter implements ExceptionFilter {
private mapException(exception: unknown): {
statusCode: number;
message: string;
message: string | string[];
code: string;
details?: unknown;
} {
@@ -74,9 +76,11 @@ export class ApiExceptionFilter implements ExceptionFilter {
}
const rawMessage = payload?.message ?? exception.message;
const message = Array.isArray(rawMessage)
? rawMessage.join(', ')
: rawMessage || exception.message;
const message: string | string[] = Array.isArray(rawMessage)
? rawMessage.map((m) => String(m))
: typeof rawMessage === 'string' && rawMessage.length > 0
? rawMessage
: String(exception.message ?? 'Bad Request');
return {
statusCode,
@@ -118,21 +122,26 @@ export class ApiExceptionFilter implements ExceptionFilter {
if (exception instanceof Error) {
return {
statusCode: HttpStatus.INTERNAL_SERVER_ERROR,
message: exception.message || 'Internal server error',
message: 'Внутренняя ошибка сервера.',
code: 'INTERNAL_ERROR',
};
}
return {
statusCode: HttpStatus.INTERNAL_SERVER_ERROR,
message: 'Internal server error',
message: 'Внутренняя ошибка сервера.',
code: 'INTERNAL_ERROR',
};
}
private mapPrismaKnownRequestError(
exception: Prisma.PrismaClientKnownRequestError,
) {
): {
statusCode: number;
message: string;
code: string;
details?: unknown;
} {
switch (exception.code) {
case 'P2002': {
const target = Array.isArray(exception.meta?.target)

View File

@@ -8,34 +8,7 @@ import {
import { AppModule } from './app.module';
import { RuntimeEnvironment } from './config/env.validation';
import { ApiExceptionFilter } from './common/filters/api-exception.filter';
const FIELD_LABELS: Record<string, string> = {
code: 'Код',
name: 'Название',
manufacturer: 'Производитель',
maintenanceIntervalHours: 'Интервал ТО (часы)',
overhaulIntervalHours: 'Интервал капремонта (часы)',
inventoryNumber: 'Инвентарный номер',
serialNumber: 'Серийный номер',
equipmentTypeCode: 'Тип оборудования',
equipmentId: 'Оборудование',
status: 'Статус',
location: 'Местоположение',
commissionedAt: 'Дата ввода в эксплуатацию',
totalEngineHours: 'Наработка общая',
engineHoursSinceLastRepair: 'Наработка после ремонта',
lastRepairAt: 'Дата последнего ремонта',
notes: 'Примечание',
number: 'Номер',
repairKind: 'Вид ремонта',
plannedAt: 'Плановая дата',
startedAt: 'Дата начала',
completedAt: 'Дата завершения',
contractor: 'Подрядчик',
engineHoursAtRepair: 'Наработка на момент ремонта',
description: 'Описание',
id: 'Идентификатор',
};
import { FIELD_LABELS } from './common/field-labels.generated';
function prettifyFieldName(field: string): string {
if (FIELD_LABELS[field]) return FIELD_LABELS[field];
@@ -60,9 +33,12 @@ function constraintToRuMessage(field: string, constraint: string): string {
return `Поле "${label}" должно быть UUID`;
case 'isNumberString':
return `Поле "${label}" должно быть числом`;
case 'isNumber':
return `Поле "${label}" должно быть числом`;
case 'isIso8601':
return `Поле "${label}" должно содержать корректную дату`;
case 'isEnum':
case 'isIn':
return `Поле "${label}" содержит недопустимое значение`;
default:
return `Поле "${label}" заполнено некорректно`;

View File

@@ -1,4 +1,5 @@
import { IsISO8601, IsNotEmpty, IsNumberString, IsString } from 'class-validator';
import { IsISO8601, IsIn, IsNotEmpty, IsNumber, IsString } from 'class-validator';
import { Type } from 'class-transformer';
export class CreateEquipmentDto {
@IsString({ message: 'inventoryNumber: должно быть строкой' })
@@ -12,17 +13,19 @@ export class CreateEquipmentDto {
@IsString({ message: 'equipmentTypeCode: должно быть строкой' })
@IsNotEmpty({ message: 'equipmentTypeCode: обязательное поле' })
equipmentTypeCode!: string;
@IsString({ message: 'status: должно быть строкой' })
@IsIn(['Active', 'Repair', 'Reserve', 'WriteOff'], { message: 'status: недопустимое значение' })
@IsNotEmpty({ message: 'status: обязательное поле' })
status!: string;
@IsString({ message: 'location: должно быть строкой' })
location?: string;
@IsISO8601({}, { message: 'commissionedAt: должно содержать корректную дату' })
commissionedAt?: string;
@IsNumberString({}, { message: 'totalEngineHours: должно быть числом' })
totalEngineHours?: string;
@IsNumberString({}, { message: 'engineHoursSinceLastRepair: должно быть числом' })
engineHoursSinceLastRepair?: string;
@Type(() => Number)
@IsNumber({ allowNaN: false, allowInfinity: false }, { message: 'totalEngineHours: должно быть числом' })
totalEngineHours?: number;
@Type(() => Number)
@IsNumber({ allowNaN: false, allowInfinity: false }, { message: 'engineHoursSinceLastRepair: должно быть числом' })
engineHoursSinceLastRepair?: number;
@IsISO8601({}, { message: 'lastRepairAt: должно содержать корректную дату' })
lastRepairAt?: string;
@IsString({ message: 'notes: должно быть строкой' })

View File

@@ -1,4 +1,5 @@
import { IsISO8601, IsNumberString, IsOptional, IsString, IsUUID } from 'class-validator';
import { IsISO8601, IsIn, IsNumber, IsOptional, IsString, IsUUID } from 'class-validator';
import { Type } from 'class-transformer';
export class UpdateEquipmentDto {
@IsOptional()
@@ -17,7 +18,7 @@ export class UpdateEquipmentDto {
@IsString({ message: 'equipmentTypeCode: должно быть строкой' })
equipmentTypeCode?: string;
@IsOptional()
@IsString({ message: 'status: должно быть строкой' })
@IsIn(['Active', 'Repair', 'Reserve', 'WriteOff'], { message: 'status: недопустимое значение' })
status?: string;
@IsOptional()
@IsString({ message: 'location: должно быть строкой' })
@@ -26,11 +27,13 @@ export class UpdateEquipmentDto {
@IsISO8601({}, { message: 'commissionedAt: должно содержать корректную дату' })
commissionedAt?: string;
@IsOptional()
@IsNumberString({}, { message: 'totalEngineHours: должно быть числом' })
totalEngineHours?: string;
@Type(() => Number)
@IsNumber({ allowNaN: false, allowInfinity: false }, { message: 'totalEngineHours: должно быть числом' })
totalEngineHours?: number;
@IsOptional()
@IsNumberString({}, { message: 'engineHoursSinceLastRepair: должно быть числом' })
engineHoursSinceLastRepair?: string;
@Type(() => Number)
@IsNumber({ allowNaN: false, allowInfinity: false }, { message: 'engineHoursSinceLastRepair: должно быть числом' })
engineHoursSinceLastRepair?: number;
@IsOptional()
@IsISO8601({}, { message: 'lastRepairAt: должно содержать корректную дату' })
lastRepairAt?: string;

View File

@@ -1,4 +1,5 @@
import { IsISO8601, IsNotEmpty, IsNumberString, IsString, IsUUID } from 'class-validator';
import { IsISO8601, IsIn, IsNotEmpty, IsNumber, IsString, IsUUID } from 'class-validator';
import { Type } from 'class-transformer';
export class CreateRepairOrderDto {
@IsString({ message: 'number: должно быть строкой' })
@@ -7,10 +8,10 @@ export class CreateRepairOrderDto {
@IsUUID(undefined, { message: 'equipmentId: должно быть UUID' })
@IsNotEmpty({ message: 'equipmentId: обязательное поле' })
equipmentId!: string;
@IsString({ message: 'repairKind: должно быть строкой' })
@IsIn(['TO', 'TR', 'TRE', 'KR', 'AR', 'MP'], { message: 'repairKind: недопустимое значение' })
@IsNotEmpty({ message: 'repairKind: обязательное поле' })
repairKind!: string;
@IsString({ message: 'status: должно быть строкой' })
@IsIn(['Draft', 'Approved', 'InWork', 'Done', 'Cancelled'], { message: 'status: недопустимое значение' })
@IsNotEmpty({ message: 'status: обязательное поле' })
status!: string;
@IsISO8601({}, { message: 'plannedAt: должно содержать корректную дату' })
@@ -22,8 +23,9 @@ export class CreateRepairOrderDto {
completedAt?: string;
@IsString({ message: 'contractor: должно быть строкой' })
contractor?: string;
@IsNumberString({}, { message: 'engineHoursAtRepair: должно быть числом' })
engineHoursAtRepair?: string;
@Type(() => Number)
@IsNumber({ allowNaN: false, allowInfinity: false }, { message: 'engineHoursAtRepair: должно быть числом' })
engineHoursAtRepair?: number;
@IsString({ message: 'description: должно быть строкой' })
description?: string;
@IsString({ message: 'notes: должно быть строкой' })

View File

@@ -1,4 +1,5 @@
import { IsISO8601, IsNumberString, IsOptional, IsString, IsUUID } from 'class-validator';
import { IsISO8601, IsIn, IsNumber, IsOptional, IsString, IsUUID } from 'class-validator';
import { Type } from 'class-transformer';
export class UpdateRepairOrderDto {
@IsOptional()
@@ -11,10 +12,10 @@ export class UpdateRepairOrderDto {
@IsUUID(undefined, { message: 'equipmentId: должно быть UUID' })
equipmentId?: string;
@IsOptional()
@IsString({ message: 'repairKind: должно быть строкой' })
@IsIn(['TO', 'TR', 'TRE', 'KR', 'AR', 'MP'], { message: 'repairKind: недопустимое значение' })
repairKind?: string;
@IsOptional()
@IsString({ message: 'status: должно быть строкой' })
@IsIn(['Draft', 'Approved', 'InWork', 'Done', 'Cancelled'], { message: 'status: недопустимое значение' })
status?: string;
@IsOptional()
@IsISO8601({}, { message: 'plannedAt: должно содержать корректную дату' })
@@ -29,8 +30,9 @@ export class UpdateRepairOrderDto {
@IsString({ message: 'contractor: должно быть строкой' })
contractor?: string;
@IsOptional()
@IsNumberString({}, { message: 'engineHoursAtRepair: должно быть числом' })
engineHoursAtRepair?: string;
@Type(() => Number)
@IsNumber({ allowNaN: false, allowInfinity: false }, { message: 'engineHoursAtRepair: должно быть числом' })
engineHoursAtRepair?: number;
@IsOptional()
@IsString({ message: 'description: должно быть строкой' })
description?: string;