feat: add suppliers module

CRUD REST API for suppliers with INN (Russian tax number) validation,
pagination, search, and JWT authentication.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Louis-Andre 2026-03-09 11:51:29 +00:00
parent 9ad37a3975
commit b56e371f22
6 changed files with 150 additions and 1 deletions

View File

@ -25,6 +25,7 @@ src/
jwt.ts - Plugin authentification
modules/
auth/ - Login, register, refresh token
suppliers/ - CRUD fournisseurs (INN, pagination)
prisma/
schema.prisma - Schema DB (source of truth)
@ -40,9 +41,10 @@ prisma/
## Modules implementes
- [x] auth (login, register, JWT refresh) - squelette cree 08/03/2026
- [x] suppliers (CRUD, INN validation, pagination, JWT auth) - implemente 09/03/2026
## Modules a implementer
- [ ] suppliers (fournisseurs, champ INN obligatoire pour fournisseurs russes)
- [x] suppliers (fournisseurs, champ INN obligatoire pour fournisseurs russes)
- [ ] products (catalogue produits MetallKart)
- [ ] orders (commandes clients)
- [ ] inventory (stock/entrepot)

View File

@ -27,3 +27,18 @@ model User {
@@map("users")
}
model Supplier {
id Int @id @default(autoincrement())
name String
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")
@@map("suppliers")
}

View File

@ -0,0 +1,46 @@
import { FastifyInstance } from 'fastify';
import { SupplierService } from './suppliers.service.js';
import {
createSupplierSchema,
updateSupplierSchema,
supplierQuerySchema,
} from './suppliers.schemas.js';
export async function supplierRoutes(server: FastifyInstance) {
server.addHook('preHandler', server.authenticate);
const svc = new SupplierService(server);
server.get('/', async (req, rep) => {
const r = supplierQuerySchema.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 = createSupplierSchema.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 = updateSupplierSchema.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,26 @@
import { z } from 'zod';
const innRegex = /^\d{10}(\d{2})?$/;
export const createSupplierSchema = z.object({
name: z.string().min(1, 'Название обязательно'),
inn: z.string().regex(innRegex, 'ИНН должен содержать 10 или 12 цифр'),
email: z.string().email('Некорректный email').optional().nullable(),
phone: z.string().optional().nullable(),
address: z.string().optional().nullable(),
contactPerson: z.string().optional().nullable(),
active: z.boolean().optional(),
});
export const updateSupplierSchema = createSupplierSchema.partial();
export const supplierQuerySchema = 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(),
active: z.enum(['true', 'false']).optional(),
});
export type CreateSupplierInput = z.infer<typeof createSupplierSchema>;
export type UpdateSupplierInput = z.infer<typeof updateSupplierSchema>;
export type SupplierQuery = z.infer<typeof supplierQuerySchema>;

View File

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

View File

@ -2,6 +2,7 @@ import Fastify from 'fastify';
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';
const server = Fastify({ logger: true });
@ -10,6 +11,7 @@ async function start() {
await server.register(prismaPlugin);
await server.register(jwtPlugin);
await server.register(authRoutes, { prefix: '/api/v1/auth' });
await server.register(supplierRoutes, { prefix: '/api/v1/suppliers' });
server.get('/health', async () => ({ status: 'ok', timestamp: new Date().toISOString(), service: 'metallkart-erp' }));