feat: add products module with supplier relation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Louis-Andre 2026-03-09 12:02:11 +00:00
parent b56e371f22
commit 072b747432
5 changed files with 176 additions and 6 deletions

View File

@ -29,16 +29,36 @@ model User {
}
model Supplier {
id Int @id @default(autoincrement())
id Int @id @default(autoincrement())
name String
inn String @unique
inn String @unique
email String?
phone String?
address String?
contactPerson String? @map("contact_person")
active Boolean @default(true)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
contactPerson String? @map("contact_person")
active Boolean @default(true)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
products Product[]
@@map("suppliers")
}
model Product {
id Int @id @default(autoincrement())
name String
sku String @unique
description String?
category String?
unit String @default("шт")
price Decimal
cost Decimal?
weight Decimal?
active Boolean @default(true)
supplierId Int? @map("supplier_id")
supplier Supplier? @relation(fields: [supplierId], references: [id])
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@map("products")
}

View File

@ -0,0 +1,46 @@
import { FastifyInstance } from 'fastify';
import { ProductService } from './products.service.js';
import {
createProductSchema,
updateProductSchema,
productQuerySchema,
} from './products.schemas.js';
export async function productRoutes(server: FastifyInstance) {
server.addHook('preHandler', server.authenticate);
const svc = new ProductService(server);
server.get('/', async (req, rep) => {
const r = productQuerySchema.safeParse(req.query);
if (!r.success) return rep.status(400).send({ error: 'Validation', details: r.error.flatten() });
return rep.send(await svc.list(r.data));
});
server.get('/:id', async (req, rep) => {
const { id } = req.params as { id: string };
try { return rep.send(await svc.getById(Number(id))); }
catch (e: any) { return rep.status(e.statusCode ?? 500).send({ error: e.message }); }
});
server.post('/', async (req, rep) => {
const r = createProductSchema.safeParse(req.body);
if (!r.success) return rep.status(400).send({ error: 'Validation', details: r.error.flatten() });
try { return rep.status(201).send(await svc.create(r.data)); }
catch (e: any) { return rep.status(e.statusCode ?? 500).send({ error: e.message }); }
});
server.put('/:id', async (req, rep) => {
const { id } = req.params as { id: string };
const r = updateProductSchema.safeParse(req.body);
if (!r.success) return rep.status(400).send({ error: 'Validation', details: r.error.flatten() });
try { return rep.send(await svc.update(Number(id), r.data)); }
catch (e: any) { return rep.status(e.statusCode ?? 500).send({ error: e.message }); }
});
server.delete('/:id', async (req, rep) => {
const { id } = req.params as { id: string };
try { await svc.delete(Number(id)); return rep.status(204).send(); }
catch (e: any) { return rep.status(e.statusCode ?? 500).send({ error: e.message }); }
});
}

View File

@ -0,0 +1,31 @@
import { z } from 'zod';
const unitEnum = z.enum(['шт', 'кг', 'м', 'м2', 'м3', 'компл']);
export const createProductSchema = z.object({
name: z.string().min(1, 'Название обязательно'),
sku: z.string().min(1, 'Артикул обязателен'),
description: z.string().optional().nullable(),
category: z.string().optional().nullable(),
unit: unitEnum.default('шт'),
price: z.coerce.number().positive('Цена должна быть положительной'),
cost: z.coerce.number().positive('Себестоимость должна быть положительной').optional().nullable(),
weight: z.coerce.number().positive('Вес должен быть положительным').optional().nullable(),
active: z.boolean().optional(),
supplierId: z.coerce.number().int().positive().optional().nullable(),
});
export const updateProductSchema = createProductSchema.partial();
export const productQuerySchema = z.object({
page: z.coerce.number().int().min(1).default(1),
limit: z.coerce.number().int().min(1).max(100).default(20),
search: z.string().optional(),
category: z.string().optional(),
active: z.enum(['true', 'false']).optional(),
supplierId: z.coerce.number().int().positive().optional(),
});
export type CreateProductInput = z.infer<typeof createProductSchema>;
export type UpdateProductInput = z.infer<typeof updateProductSchema>;
export type ProductQuery = z.infer<typeof productQuerySchema>;

View File

@ -0,0 +1,71 @@
import { FastifyInstance } from 'fastify';
import { CreateProductInput, UpdateProductInput, ProductQuery } from './products.schemas.js';
export class ProductService {
constructor(private server: FastifyInstance) {}
async list(query: ProductQuery) {
const { page, limit, search, category, active, supplierId } = query;
const where: any = {};
if (active !== undefined) where.active = active === 'true';
if (category) where.category = category;
if (supplierId) where.supplierId = supplierId;
if (search) {
where.OR = [
{ name: { contains: search, mode: 'insensitive' } },
{ sku: { contains: search, mode: 'insensitive' } },
];
}
const [data, total] = await Promise.all([
this.server.prisma.product.findMany({
where,
skip: (page - 1) * limit,
take: limit,
orderBy: { createdAt: 'desc' },
include: { supplier: { select: { id: true, name: true } } },
}),
this.server.prisma.product.count({ where }),
]);
return { data, total, page, limit, totalPages: Math.ceil(total / limit) };
}
async getById(id: number) {
const product = await this.server.prisma.product.findUnique({
where: { id },
include: { supplier: { select: { id: true, name: true } } },
});
if (!product) throw { statusCode: 404, message: 'Продукт не найден' };
return product;
}
async create(input: CreateProductInput) {
const existing = await this.server.prisma.product.findUnique({ where: { sku: input.sku } });
if (existing) throw { statusCode: 409, message: 'Продукт с таким артикулом уже существует' };
return this.server.prisma.product.create({
data: input,
include: { supplier: { select: { id: true, name: true } } },
});
}
async update(id: number, input: UpdateProductInput) {
await this.getById(id);
if (input.sku) {
const existing = await this.server.prisma.product.findFirst({
where: { sku: input.sku, NOT: { id } },
});
if (existing) throw { statusCode: 409, message: 'Продукт с таким артикулом уже существует' };
}
return this.server.prisma.product.update({
where: { id },
data: input,
include: { supplier: { select: { id: true, name: true } } },
});
}
async delete(id: number) {
await this.getById(id);
return this.server.prisma.product.delete({ where: { id } });
}
}

View File

@ -3,6 +3,7 @@ import prismaPlugin from './plugins/prisma.js';
import jwtPlugin from './plugins/jwt.js';
import { authRoutes } from './modules/auth/auth.routes.js';
import { supplierRoutes } from './modules/suppliers/suppliers.routes.js';
import { productRoutes } from './modules/products/products.routes.js';
const server = Fastify({ logger: true });
@ -12,6 +13,7 @@ async function start() {
await server.register(jwtPlugin);
await server.register(authRoutes, { prefix: '/api/v1/auth' });
await server.register(supplierRoutes, { prefix: '/api/v1/suppliers' });
await server.register(productRoutes, { prefix: '/api/v1/products' });
server.get('/health', async () => ({ status: 'ok', timestamp: new Date().toISOString(), service: 'metallkart-erp' }));