feat: add products module with supplier relation
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
b56e371f22
commit
072b747432
@ -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")
|
||||
}
|
||||
|
||||
46
src/modules/products/products.routes.ts
Normal file
46
src/modules/products/products.routes.ts
Normal 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 }); }
|
||||
});
|
||||
}
|
||||
31
src/modules/products/products.schemas.ts
Normal file
31
src/modules/products/products.schemas.ts
Normal 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>;
|
||||
71
src/modules/products/products.service.ts
Normal file
71
src/modules/products/products.service.ts
Normal 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 } });
|
||||
}
|
||||
}
|
||||
@ -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' }));
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user