feat: category-tag configurator + tag rename cleanup

- Rename supplier tags MATERIAUX→METAL_PROKAT, METAL→METIZ_KREPEZH
- Add CategoryTagMapping Prisma model for DB-stored mappings
- consultation.service.ts loads mapping from DB (fallback to defaults)
- Backend GET/PUT /admin/category-tag-config endpoints
- Frontend /settings page with Table: categories × tags × suppliers
- Sidebar menu entry Настройки
- Seed 13 category-tag default mappings

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Louis-Andre 2026-03-21 09:31:50 +00:00
parent 9333c2db28
commit 3fe4ef1c75
12 changed files with 384 additions and 12 deletions

View File

@ -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

View File

@ -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() {
<Route path="admin/users" element={<AdminPage />} />
<Route path="admin/audit" element={<AdminPage />} />
<Route path="admin/config" element={<AdminPage />} />
<Route path="settings" element={<SettingsPage />} />
<Route path="*" element={<NotFound />} />
</Route>
</Routes>

View File

@ -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<string, { id: number; name: string }[]>;
}
export async function getCategoryTagConfig() {
const { data } = await client.get<CategoryTagConfigResponse>('/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;
}

View File

@ -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: <SettingOutlined />,
label: 'Администрирование',
},
{
key: '/settings',
icon: <ToolOutlined />,
label: 'Настройки',
},
];
export function AppMenu() {

View File

@ -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<string | null>(null);
const [config, setConfig] = useState<CategoryTagConfigResponse | null>(null);
const [rows, setRows] = useState<RowData[]>([]);
const buildRows = useCallback((cfg: CategoryTagConfigResponse): RowData[] => {
return cfg.categories.map((cat) => {
// Collect suppliers for all mapped tags
const supplierNames = new Set<string>();
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<RowData> = [
{
title: 'Категория материала',
dataIndex: 'label',
key: 'label',
width: 220,
render: (text: string, record: RowData) => (
<span>
<strong>{text}</strong>
<br />
<span style={{ color: '#999', fontSize: 11 }}>{record.code}</span>
</span>
),
},
{
title: 'Теги поставщиков (привязка)',
dataIndex: 'mappedTags',
key: 'mappedTags',
width: 350,
render: (_: string[], record: RowData) => (
<Select
mode="multiple"
style={{ width: '100%' }}
value={record.mappedTags}
options={tagOptions}
onChange={(vals) => handleTagChange(record.code, vals)}
loading={saving === record.code}
placeholder="Выберите теги..."
optionFilterProp="label"
/>
),
},
{
title: 'Поставщики',
dataIndex: 'suppliers',
key: 'suppliers',
render: (suppliers: string[]) =>
suppliers.length > 0
? suppliers.map((s) => (
<Tag key={s} color="blue" style={{ marginBottom: 2 }}>
{s}
</Tag>
))
: <span style={{ color: '#999' }}></span>,
},
];
if (loading) return <Spin size="large" style={{ display: 'block', margin: '40px auto' }} />;
return (
<div>
<Title level={4} style={{ marginBottom: 16 }}>Привязка категорий к тегам поставщиков</Title>
<Table
columns={columns}
dataSource={rows}
pagination={false}
bordered
size="middle"
/>
</div>
);
}

View File

@ -0,0 +1,13 @@
import { Typography } from 'antd';
import { CategoryTagConfigurator } from './CategoryTagConfigurator';
const { Title } = Typography;
export function SettingsPage() {
return (
<div>
<Title level={3}>Настройки</Title>
<CategoryTagConfigurator />
</div>
);
}

View File

@ -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())

View File

@ -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' });
}
});
}

View File

@ -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<string, string> = {
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<string, { id: number; name: string }[]> = {};
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;
}
}

View File

@ -20,11 +20,11 @@ export interface ConsultationDraft {
htmlBody: string;
}
// Mapping MaterialCategory → SupplierTag name(s)
const CATEGORY_TO_TAG: Record<string, string[]> = {
METAL: ['MATERIAUX', 'METAL'],
FASTENERS: ['METAL', 'PIECES_DETACHEES'],
COMPONENTS: ['COMPOSANTS', 'PIECES_DETACHEES'],
// Default mapping (fallback if DB empty)
const DEFAULT_CATEGORY_TO_TAG: Record<string, string[]> = {
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<Record<string, string[]>> {
const mappings = await this.prisma.categoryTagMapping.findMany();
if (mappings.length === 0) return DEFAULT_CATEGORY_TO_TAG;
const result: Record<string, string[]> = {};
for (const m of mappings) {
result[m.category] = m.tagNames;
}
return result;
}
async generateConsultationDrafts(orderId: number): Promise<ConsultationDraft[]> {
// 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<number, { supplier: any; items: ConsultationItem[] }>();
for (const [category, catItems] of byCategory) {
const tagNames = CATEGORY_TO_TAG[category] ?? [];
const tagNames = categoryToTag[category] ?? [];
let suppliers: any[];
if (tagNames.length > 0) {

View File

@ -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<string, number> = {};

View File

@ -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' });