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:
parent
9ad37a3975
commit
b56e371f22
@ -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)
|
||||
|
||||
@ -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")
|
||||
}
|
||||
|
||||
46
src/modules/suppliers/suppliers.routes.ts
Normal file
46
src/modules/suppliers/suppliers.routes.ts
Normal 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 }); }
|
||||
});
|
||||
}
|
||||
26
src/modules/suppliers/suppliers.schemas.ts
Normal file
26
src/modules/suppliers/suppliers.schemas.ts
Normal 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>;
|
||||
58
src/modules/suppliers/suppliers.service.ts
Normal file
58
src/modules/suppliers/suppliers.service.ts
Normal 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 } });
|
||||
}
|
||||
}
|
||||
@ -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' }));
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user