diff --git a/CLAUDE.md b/CLAUDE.md
index 29df8f9..b6bd01d 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -198,6 +198,7 @@ prisma/
- [x] bugfixes-22a (SPEC 22A: 4 bugs achats — Bug1 consultation CONFIRMED only (exclut DRAFT), Bug2 supplier pre-select verifie OK via buildMatrix, Bug3+4 suppression colonne Приоритет, ajustement largeurs colonnes Описание 220/Категория 140/Заказ 100, scroll 1500) - implemente 21/03/2026
- [x] fix-supplier-preselect (SPEC 22C: fix pre-selection fournisseurs dans drawer consultation — backend contactEmail empty string→null, frontend buildMatrix suppression filtre supplierEmail pour pre-remplissage matrix) - implemente 21/03/2026
- [x] frontend-visual-validation (SPEC 22B: outil validation visuelle autonome — validate-frontend.js Puppeteer+Claude Vision, skill SKILL.md, section CLAUDE.md, criteres fichier texte, JSON output pass/issues/suggestions, max 3 iterations) - implemente 21/03/2026
+- [x] category-tag-configurator (SPEC 22E: nettoyage tags MATERIAUX→METAL_PROKAT+METAL→METIZ_KREPEZH, CategoryTagMapping model DB, CATEGORY_TO_TAG dynamique depuis DB, GET/PUT /admin/category-tag-config, frontend /settings page Table Select multi-tags+fournisseurs auto, seed mappings 13 categories, RBAC ADMIN write) - implemente 21/03/2026
- [ ] invoices (facturation)
- [ ] reports (rapports financiers)
@@ -373,6 +374,7 @@ Skill complete: /root/.claude/skills/frontend-visual-validation/SKILL.md
- purchaseRoutes GET / 404: la route globale GET /api/v1/purchase-requirements manquait (seuls /summary, /consolidated, /:id existaient). Ajout listAll method+route. listByOrder reste sous /orders/:orderId/purchase-requirements.
- pdf-parse v2 API: PDFParse est une classe (pas une fonction). Utiliser `new PDFParse({ data: buffer })` puis `parser.getText()` puis `parser.destroy()`. Import: `import { PDFParse } from 'pdf-parse'` (named export, pas default).
- Anthropic SDK import ESM: `import Anthropic from '@anthropic-ai/sdk'` (default import fonctionne). Types: `Anthropic.ContentBlockParam`, `Anthropic.TextBlock`. Env var ANTHROPIC_API_KEY.
+- CategoryTagMapping in DB: le mapping MaterialCategory→SupplierTag est stocke dans la table category_tag_mappings (pas en dur dans le code). consultation.service.ts charge le mapping depuis la DB a chaque appel. Seed defaults dans seed.ts. API admin GET/PUT /admin/category-tag-config.
## Decisions architecturales
- 09/03/2026: Architecture v2 adoptee. n8n = orchestrateur leger.
@@ -489,6 +491,8 @@ Skill complete: /root/.claude/skills/frontend-visual-validation/SKILL.md
- 21/03/2026: Frontend Visual Validation (SPEC 22B) — Outil autonome /opt/erp/validate-frontend.js: Puppeteer screenshot 1280x900 + Claude Sonnet vision API via https.request (pas SDK). Auth JWT via API login + localStorage injection (meme pattern que screenshot-api.js). Criteres dans fichier texte externe. Output JSON {pass, issues, suggestions, screenshot}. Exit code 0=pass, 1=fail, 2=error. Pas de dotenv dependency (lecture .env manuelle). Skill /root/.claude/skills/frontend-visual-validation/SKILL.md. Section CLAUDE.md obligatoire post-modification frontend.
-## Derniere session (21/03/2026, fix-supplier-preselect)
-- SPEC 22C: fix supplier pre-selection in consultation drawer — empty string email caused buildMatrix to skip all suppliers
+- 21/03/2026: Category-Tag Configurator (SPEC 22E) — CategoryTagMapping model en DB remplace le mapping CATEGORY_TO_TAG hardcode dans consultation.service.ts. Tags renommes MATERIAUX→METAL_PROKAT, METAL→METIZ_KREPEZH. Backend: CategoryTagConfigService GET/PUT, routes /admin/category-tag-config, consultation charge mapping depuis DB avec fallback defaults. Frontend: /settings page avec Table 3 colonnes (categorie/tags Select multiple/fournisseurs auto-calcules), menu sidebar Настройки (ToolOutlined). Seed 13 mappings defaults.
+
+## Derniere session (21/03/2026, category-tag-configurator)
+- SPEC 22E: nettoyage tags + configurateur categories-tags frontend
- Prochaine etape: invoices, reports
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 83ffbd2..bed7b88 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -11,6 +11,7 @@ import { PurchasesPage } from '@/pages/purchases/PurchasesPage';
import { FinancePage } from '@/pages/finance/FinancePage';
import { NotificationsPage } from '@/pages/notifications/NotificationsPage';
import { AdminPage } from '@/pages/admin/AdminPage';
+import { SettingsPage } from '@/pages/settings/SettingsPage';
import { NotFound } from '@/pages/NotFound';
function App() {
@@ -50,6 +51,7 @@ function App() {
} />
} />
} />
+ } />
} />
diff --git a/frontend/src/api/categoryTagConfig.ts b/frontend/src/api/categoryTagConfig.ts
new file mode 100644
index 0000000..9b36d23
--- /dev/null
+++ b/frontend/src/api/categoryTagConfig.ts
@@ -0,0 +1,30 @@
+import client from './client';
+
+export interface CategoryConfig {
+ code: string;
+ label: string;
+ mappedTags: string[];
+}
+
+export interface TagInfo {
+ id: number;
+ name: string;
+ label: string;
+ supplierCount: number;
+}
+
+export interface CategoryTagConfigResponse {
+ categories: CategoryConfig[];
+ tags: TagInfo[];
+ suppliersByTag: Record;
+}
+
+export async function getCategoryTagConfig() {
+ const { data } = await client.get('/admin/category-tag-config');
+ return data;
+}
+
+export async function updateCategoryTagMapping(category: string, tagNames: string[]) {
+ const { data } = await client.put('/admin/category-tag-config', { category, tagNames });
+ return data;
+}
diff --git a/frontend/src/components/Layout/AppMenu.tsx b/frontend/src/components/Layout/AppMenu.tsx
index 0117044..a543d5f 100644
--- a/frontend/src/components/Layout/AppMenu.tsx
+++ b/frontend/src/components/Layout/AppMenu.tsx
@@ -7,6 +7,7 @@ import {
DollarOutlined,
BellOutlined,
SettingOutlined,
+ ToolOutlined,
} from '@ant-design/icons';
import { useNavigate, useLocation } from 'react-router-dom';
import type { MenuProps } from 'antd';
@@ -47,6 +48,11 @@ const menuItems: MenuProps['items'] = [
icon: ,
label: 'Администрирование',
},
+ {
+ key: '/settings',
+ icon: ,
+ label: 'Настройки',
+ },
];
export function AppMenu() {
diff --git a/frontend/src/pages/settings/CategoryTagConfigurator.tsx b/frontend/src/pages/settings/CategoryTagConfigurator.tsx
new file mode 100644
index 0000000..5a42ddd
--- /dev/null
+++ b/frontend/src/pages/settings/CategoryTagConfigurator.tsx
@@ -0,0 +1,142 @@
+import { useCallback, useEffect, useState } from 'react';
+import { Table, Select, Tag, message, Typography, Spin } from 'antd';
+import type { ColumnsType } from 'antd/es/table';
+import {
+ getCategoryTagConfig,
+ updateCategoryTagMapping,
+ type CategoryConfig,
+ type TagInfo,
+ type CategoryTagConfigResponse,
+} from '@/api/categoryTagConfig';
+
+const { Title } = Typography;
+
+interface RowData {
+ key: string;
+ code: string;
+ label: string;
+ mappedTags: string[];
+ suppliers: string[];
+}
+
+export function CategoryTagConfigurator() {
+ const [loading, setLoading] = useState(true);
+ const [saving, setSaving] = useState(null);
+ const [config, setConfig] = useState(null);
+ const [rows, setRows] = useState([]);
+
+ const buildRows = useCallback((cfg: CategoryTagConfigResponse): RowData[] => {
+ return cfg.categories.map((cat) => {
+ // Collect suppliers for all mapped tags
+ const supplierNames = new Set();
+ for (const tagName of cat.mappedTags) {
+ const suppliers = cfg.suppliersByTag[tagName] ?? [];
+ for (const s of suppliers) supplierNames.add(s.name);
+ }
+ return {
+ key: cat.code,
+ code: cat.code,
+ label: cat.label,
+ mappedTags: cat.mappedTags,
+ suppliers: Array.from(supplierNames),
+ };
+ });
+ }, []);
+
+ const fetchConfig = useCallback(async () => {
+ try {
+ setLoading(true);
+ const data = await getCategoryTagConfig();
+ setConfig(data);
+ setRows(buildRows(data));
+ } catch {
+ message.error('Ошибка загрузки конфигурации');
+ } finally {
+ setLoading(false);
+ }
+ }, [buildRows]);
+
+ useEffect(() => {
+ fetchConfig();
+ }, [fetchConfig]);
+
+ const handleTagChange = async (categoryCode: string, tagNames: string[]) => {
+ setSaving(categoryCode);
+ try {
+ await updateCategoryTagMapping(categoryCode, tagNames);
+ message.success('Сохранено');
+ await fetchConfig();
+ } catch {
+ message.error('Ошибка сохранения');
+ } finally {
+ setSaving(null);
+ }
+ };
+
+ const tagOptions = (config?.tags ?? []).map((t: TagInfo) => ({
+ label: t.label,
+ value: t.name,
+ }));
+
+ const columns: ColumnsType = [
+ {
+ title: 'Категория материала',
+ dataIndex: 'label',
+ key: 'label',
+ width: 220,
+ render: (text: string, record: RowData) => (
+
+ {text}
+
+ {record.code}
+
+ ),
+ },
+ {
+ title: 'Теги поставщиков (привязка)',
+ dataIndex: 'mappedTags',
+ key: 'mappedTags',
+ width: 350,
+ render: (_: string[], record: RowData) => (
+
+ );
+}
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index f879b36..42d14aa 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -1296,6 +1296,16 @@ model OnecBankAccount {
@@map("onec_bank_accounts")
}
+// Mapping MaterialCategory → SupplierTag names (configurable)
+model CategoryTagMapping {
+ id Int @id @default(autoincrement())
+ category String @unique
+ tagNames String[] @map("tag_names")
+ updatedAt DateTime @updatedAt @map("updated_at")
+
+ @@map("category_tag_mappings")
+}
+
// 1C OData — cashflow categories (статьи ДДС)
model OnecCashflowCategory {
id Int @id @default(autoincrement())
diff --git a/src/modules/admin/admin.category-tag.routes.ts b/src/modules/admin/admin.category-tag.routes.ts
new file mode 100644
index 0000000..eac9577
--- /dev/null
+++ b/src/modules/admin/admin.category-tag.routes.ts
@@ -0,0 +1,38 @@
+import { FastifyInstance } from 'fastify';
+import { Role } from '@prisma/client';
+import { CategoryTagConfigService } from './admin.category-tag.service.js';
+import { authorize } from '../auth/auth.middleware.js';
+
+export async function categoryTagConfigRoutes(server: FastifyInstance) {
+ server.addHook('preHandler', server.authenticate);
+ const svc = new CategoryTagConfigService(server.prisma);
+
+ // GET / — Get full category-tag config
+ server.get('/', {
+ preHandler: [authorize(['ADMIN', 'DIRECTION_FIN', 'DIRECTION_OPS', 'ACHETEUR'] as Role[])],
+ }, async (_req, rep) => {
+ try {
+ return rep.send(await svc.getConfig());
+ } catch (e) {
+ const err = e as { statusCode?: number; message?: string };
+ return rep.status(err.statusCode ?? 500).send({ error: err.message ?? 'Internal error' });
+ }
+ });
+
+ // PUT / — Update a single category mapping
+ server.put('/', {
+ preHandler: [authorize(['ADMIN'] as Role[])],
+ }, async (req, rep) => {
+ const body = req.body as { category?: string; tagNames?: string[] } | undefined;
+ if (!body?.category || !Array.isArray(body?.tagNames)) {
+ return rep.status(400).send({ error: 'category (string) and tagNames (string[]) required' });
+ }
+ try {
+ const result = await svc.updateMapping(body.category, body.tagNames);
+ return rep.send(result);
+ } catch (e) {
+ const err = e as { statusCode?: number; message?: string };
+ return rep.status(err.statusCode ?? 500).send({ error: err.message ?? 'Internal error' });
+ }
+ });
+}
diff --git a/src/modules/admin/admin.category-tag.service.ts b/src/modules/admin/admin.category-tag.service.ts
new file mode 100644
index 0000000..1064602
--- /dev/null
+++ b/src/modules/admin/admin.category-tag.service.ts
@@ -0,0 +1,86 @@
+import type { PrismaClient } from '@prisma/client';
+
+export class CategoryTagConfigService {
+ private prisma: PrismaClient;
+
+ constructor(prisma: PrismaClient) {
+ this.prisma = prisma;
+ }
+
+ async getConfig() {
+ // Load mappings from DB
+ const mappings = await this.prisma.categoryTagMapping.findMany({
+ orderBy: { category: 'asc' },
+ });
+
+ // Load all tags with supplier counts
+ const tags = await this.prisma.supplierTag.findMany({
+ include: {
+ supplierLinks: {
+ include: {
+ supplier: { select: { id: true, companyName: true, status: true, deletedAt: true } },
+ },
+ },
+ },
+ orderBy: { name: 'asc' },
+ });
+
+ const categoryLabels: Record = {
+ METAL: 'Металл/Металлопрокат',
+ FASTENERS: 'Крепёж/Метизы',
+ COMPONENTS: 'Комплектующие',
+ PAINT: 'Покраска',
+ LASER_FLAT: 'Лазерная резка (лист)',
+ LASER_TUBE: 'Лазерная резка (труба)',
+ TUBE_BENDING: 'Гибка труб',
+ ROD_BENDING: 'Гибка прутка',
+ CNC_MILLING: 'Фрезеровка ЧПУ',
+ GALVANIC: 'Гальваника',
+ MACHINING_EXT: 'Мехобработка (внеш.)',
+ MESH_WELDING: 'Сварка сеток',
+ SPRING_COILING: 'Навивка пружин',
+ };
+
+ // Build categories array
+ const categories = mappings.map((m) => ({
+ code: m.category,
+ label: categoryLabels[m.category] ?? m.category,
+ mappedTags: m.tagNames,
+ }));
+
+ // Build tags array
+ const tagsResult = tags.map((t) => {
+ const activeSuppliers = t.supplierLinks.filter(
+ (l) => l.supplier.status === 'ACTIVE' && !l.supplier.deletedAt,
+ );
+ return {
+ id: t.id,
+ name: t.name,
+ label: t.label ?? t.name,
+ supplierCount: activeSuppliers.length,
+ };
+ });
+
+ // Build suppliersByTag
+ const suppliersByTag: Record = {};
+ for (const t of tags) {
+ const activeSuppliers = t.supplierLinks
+ .filter((l) => l.supplier.status === 'ACTIVE' && !l.supplier.deletedAt)
+ .map((l) => ({ id: l.supplier.id, name: l.supplier.companyName }));
+ if (activeSuppliers.length > 0) {
+ suppliersByTag[t.name] = activeSuppliers;
+ }
+ }
+
+ return { categories, tags: tagsResult, suppliersByTag };
+ }
+
+ async updateMapping(category: string, tagNames: string[]) {
+ const result = await this.prisma.categoryTagMapping.upsert({
+ where: { category },
+ update: { tagNames },
+ create: { category, tagNames },
+ });
+ return result;
+ }
+}
diff --git a/src/modules/consultation/consultation.service.ts b/src/modules/consultation/consultation.service.ts
index cd0a6c3..3d5f1e1 100644
--- a/src/modules/consultation/consultation.service.ts
+++ b/src/modules/consultation/consultation.service.ts
@@ -20,11 +20,11 @@ export interface ConsultationDraft {
htmlBody: string;
}
-// Mapping MaterialCategory → SupplierTag name(s)
-const CATEGORY_TO_TAG: Record = {
- METAL: ['MATERIAUX', 'METAL'],
- FASTENERS: ['METAL', 'PIECES_DETACHEES'],
- COMPONENTS: ['COMPOSANTS', 'PIECES_DETACHEES'],
+// Default mapping (fallback if DB empty)
+const DEFAULT_CATEGORY_TO_TAG: Record = {
+ METAL: ['METAL_PROKAT'],
+ FASTENERS: ['METIZ_KREPEZH'],
+ COMPONENTS: ['COMPOSANTS'],
PAINT: ['PEINTURE'],
LASER_FLAT: ['SOUS_TRAITANCE_LASER'],
LASER_TUBE: ['SOUS_TRAITANCE_LASER'],
@@ -76,6 +76,16 @@ export class ConsultationService {
this.prisma = prisma;
}
+ private async loadCategoryToTag(): Promise> {
+ const mappings = await this.prisma.categoryTagMapping.findMany();
+ if (mappings.length === 0) return DEFAULT_CATEGORY_TO_TAG;
+ const result: Record = {};
+ for (const m of mappings) {
+ result[m.category] = m.tagNames;
+ }
+ return result;
+ }
+
async generateConsultationDrafts(orderId: number): Promise {
// Load order for orderCode
const order = await this.prisma.order.findUnique({ where: { id: orderId } });
@@ -121,11 +131,14 @@ export class ConsultationService {
byCategory.get(cat)!.push(item);
}
+ // Load configurable mapping from DB
+ const categoryToTag = await this.loadCategoryToTag();
+
// For each category, find tagged suppliers
const supplierItemsMap = new Map();
for (const [category, catItems] of byCategory) {
- const tagNames = CATEGORY_TO_TAG[category] ?? [];
+ const tagNames = categoryToTag[category] ?? [];
let suppliers: any[];
if (tagNames.length > 0) {
diff --git a/src/seed.ts b/src/seed.ts
index 4e5be07..32ca6bb 100644
--- a/src/seed.ts
+++ b/src/seed.ts
@@ -50,7 +50,7 @@ const SUPPLIERS = [
accountantName: 'Зайцева Ольга Николаевна', accountantPhone: '+7-8202-55-33-12', accountantEmail: 'buh@severstal-metiz.ru',
paymentDelayDays: 14, deliveryDelayDays: 5, discountPercent: 3, status: 'ACTIVE' as const,
notes: 'Основной поставщик метизов и крепежа. Скидка 3% при объёме от 200 000 руб. Доставка ТК до Твери.',
- tags: ['METAL'],
+ tags: ['METIZ_KREPEZH'],
},
{
companyName: 'ООО "ТМК-Маркет"', inn: '7700000102', kpp: '770001001', ogrn: '1027700000102',
@@ -59,7 +59,7 @@ const SUPPLIERS = [
accountantName: 'Попова Марина Сергеевна', accountantPhone: '+7-495-777-11-23', accountantEmail: 'buh@tmk-market.ru',
paymentDelayDays: 7, deliveryDelayDays: 3, discountPercent: 5, status: 'ACTIVE' as const,
notes: 'Трубы, профиль, листовой прокат. Склад в Москве, доставка 2-3 дня. Минимальный заказ 100 000 руб.',
- tags: ['MATERIAUX'],
+ tags: ['METAL_PROKAT'],
},
{
companyName: 'ООО "КраскаПром"', inn: '6900000201', kpp: '690001001', ogrn: '1066900000201',
@@ -103,7 +103,7 @@ const SUPPLIERS = [
// TAGS fournisseurs
// ──────────────────────────────────────────────────────────────
const SUPPLIER_TAGS = [
- { name: 'MATERIAUX', label: 'Материалы' },
+ { name: 'METAL_PROKAT', label: 'Металл/Металлопрокат' },
{ name: 'PIECES_DETACHEES', label: 'Запчасти' },
{ name: 'SOUS_TRAITANCE_LASER', label: 'Субподряд сварка/лазер' },
{ name: 'PEINTURE', label: 'Покраска' },
@@ -111,7 +111,7 @@ const SUPPLIER_TAGS = [
{ name: 'CONSOMMABLES', label: 'Расходные материалы' },
{ name: 'TRANSPORT', label: 'Транспорт' },
{ name: 'OUTILLAGE', label: 'Инструмент/оснастка' },
- { name: 'METAL', label: 'Метизы/крепёж' },
+ { name: 'METIZ_KREPEZH', label: 'Метизы/крепёж' },
{ name: 'PACKAGING', label: 'Упаковка' },
{ name: 'ELECTRICAL', label: 'Электрика' },
{ name: 'WELDING', label: 'Сварка' },
@@ -436,6 +436,32 @@ async function main() {
}
console.log(` ✓ ${SUPPLIER_TAGS.length} supplier tags`);
+ // ── CATEGORY-TAG MAPPINGS ──────────────────────────────────
+ console.log('\nSeeding category-tag mappings...');
+ const CATEGORY_TAG_DEFAULTS: { category: string; tagNames: string[] }[] = [
+ { category: 'METAL', tagNames: ['METAL_PROKAT'] },
+ { category: 'FASTENERS', tagNames: ['METIZ_KREPEZH'] },
+ { category: 'COMPONENTS', tagNames: ['COMPOSANTS'] },
+ { category: 'PAINT', tagNames: ['PEINTURE'] },
+ { category: 'LASER_FLAT', tagNames: ['SOUS_TRAITANCE_LASER'] },
+ { category: 'LASER_TUBE', tagNames: ['SOUS_TRAITANCE_LASER'] },
+ { category: 'TUBE_BENDING', tagNames: ['SOUS_TRAITANCE_LASER'] },
+ { category: 'ROD_BENDING', tagNames: ['SOUS_TRAITANCE_LASER'] },
+ { category: 'CNC_MILLING', tagNames: ['SOUS_TRAITANCE_LASER'] },
+ { category: 'GALVANIC', tagNames: ['COMPOSANTS'] },
+ { category: 'MACHINING_EXT', tagNames: ['SOUS_TRAITANCE_LASER'] },
+ { category: 'MESH_WELDING', tagNames: ['WELDING'] },
+ { category: 'SPRING_COILING', tagNames: ['COMPOSANTS'] },
+ ];
+ for (const m of CATEGORY_TAG_DEFAULTS) {
+ await prisma.categoryTagMapping.upsert({
+ where: { category: m.category },
+ update: { tagNames: m.tagNames },
+ create: m,
+ });
+ }
+ console.log(` ✓ ${CATEGORY_TAG_DEFAULTS.length} category-tag mappings`);
+
// ── SUPPLIERS ──────────────────────────────────────────────
console.log('\nSeeding suppliers...');
const supplierMap: Record = {};
diff --git a/src/server.ts b/src/server.ts
index c3e229e..7d4e9aa 100644
--- a/src/server.ts
+++ b/src/server.ts
@@ -23,6 +23,7 @@ import { notificationRoutes } from './modules/notifications/notifications.routes
import { taskRoutes, dashboardMeRoute } from './modules/tasks/tasks.routes.js';
import { auditRoutes } from './modules/audit/audit.routes.js';
import { adminUserRoutes, adminConfigRoutes } from './modules/admin/admin.routes.js';
+import { categoryTagConfigRoutes } from './modules/admin/admin.category-tag.routes.js';
import { healthRoute, adminSystemRoute } from './modules/admin/admin.system.js';
import { clientRoutes } from './modules/clients/clients.routes.js';
import { documentFileRoutes } from './modules/document-files/document-files.routes.js';
@@ -67,6 +68,7 @@ async function start() {
await server.register(auditRoutes, { prefix: '/api/v1/audit' });
await server.register(adminUserRoutes, { prefix: '/api/v1/admin/users' });
await server.register(adminConfigRoutes, { prefix: '/api/v1/admin/config' });
+ await server.register(categoryTagConfigRoutes, { prefix: '/api/v1/admin/category-tag-config' });
await server.register(adminSystemRoute, { prefix: '/api/v1/admin/system' });
await server.register(clientRoutes, { prefix: '/api/v1/clients' });
await server.register(documentFileRoutes, { prefix: '/api/v1/documents/:documentId/files' });