From 072b747432bcb09504dd522fab7a95b74f0bfea0 Mon Sep 17 00:00:00 2001 From: Louis-Andre Date: Mon, 9 Mar 2026 12:02:11 +0000 Subject: [PATCH] feat: add products module with supplier relation Co-Authored-By: Claude Opus 4.6 --- prisma/schema.prisma | 32 +++++++++-- src/modules/products/products.routes.ts | 46 +++++++++++++++ src/modules/products/products.schemas.ts | 31 +++++++++++ src/modules/products/products.service.ts | 71 ++++++++++++++++++++++++ src/server.ts | 2 + 5 files changed, 176 insertions(+), 6 deletions(-) create mode 100644 src/modules/products/products.routes.ts create mode 100644 src/modules/products/products.schemas.ts create mode 100644 src/modules/products/products.service.ts diff --git a/prisma/schema.prisma b/prisma/schema.prisma index c410fa7..879ed61 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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") +} diff --git a/src/modules/products/products.routes.ts b/src/modules/products/products.routes.ts new file mode 100644 index 0000000..4c0a15d --- /dev/null +++ b/src/modules/products/products.routes.ts @@ -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 }); } + }); +} diff --git a/src/modules/products/products.schemas.ts b/src/modules/products/products.schemas.ts new file mode 100644 index 0000000..851e189 --- /dev/null +++ b/src/modules/products/products.schemas.ts @@ -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; +export type UpdateProductInput = z.infer; +export type ProductQuery = z.infer; diff --git a/src/modules/products/products.service.ts b/src/modules/products/products.service.ts new file mode 100644 index 0000000..4e48d0a --- /dev/null +++ b/src/modules/products/products.service.ts @@ -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 } }); + } +} diff --git a/src/server.ts b/src/server.ts index 062cffe..c52bd39 100644 --- a/src/server.ts +++ b/src/server.ts @@ -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' }));