/
diloytd
/
shield
Обзор
Документация
Войти
/
diloytd
/
shield
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
master
src/services/shieldService.js
540 строк
18 KB
diloytd
Перепроектировать shields.json и поддержать ТУ, сертификаты и новые условия.
22 июл 2026, 17:14
22 июл 2026, 17:14
fb8b887
Код
Авторство
О чём код?
/** * Сервис автоматического определения шильдов. * Все правила загружаются из shields.json — в коде нет описания правил. */ const shieldsData = require('../data/shields.json'); const { tuRegistry, rules: shieldRules } = shieldsData; const { getDatabase } = require('../database/database'); const { serializeCertificates } = require('../utils/certificateUtils'); const { buildMarking } = require('./journalService'); const { allocateNextShieldNumber, assignShieldNumbersForOrder, createJournalEntryForOrder, getProductionDateString, } = require('./numberingService'); /** * Возвращает полный номер ТУ по ссылке из shields.json. * @param {string|null|undefined} tuRef - Ключ из tuRegistry. * @returns {string} Номер ТУ или пустая строка. */ const resolveTu = (tuRef) => { if (!tuRef) { return ''; } return tuRegistry[tuRef] ?? ''; }; /** * Проверяет, совпадает ли одно условие правила со значением изделия. * @param {unknown} productValue - Значение поля изделия. * @param {unknown} ruleValue - Ожидаемое значение из правила. * @returns {boolean} true, если значения совпадают. */ const isConditionMatch = (productValue, ruleValue) => { return productValue === ruleValue; }; /** * Проверяет, подходит ли правило для изделия. * Сравниваются только поля, указанные в conditions правила. * @param {Object} rule - Правило из shields.json. * @param {Object} product - Логическое изделие после импорта. * @returns {boolean} true, если все условия правила выполнены. */ const isRuleMatch = (rule, product) => { if (!isConditionMatch(product.type, rule.productType)) { return false; } const conditions = rule.conditions; for (const fieldName of Object.keys(conditions)) { const expectedValue = conditions[fieldName]; const productValue = product[fieldName]; if (!isConditionMatch(productValue, expectedValue)) { return false; } } return true; }; /** * Приводит тип открывания к каноническому значению из shields.json. * @param {string|null|undefined} value - Тип открывания из JSON или базы данных. * @returns {string|null|undefined} Нормализованный тип открывания. */ const normalizeOpeningType = (value) => { if (value === null || value === undefined) { return value; } if (typeof value !== 'string') { return value; } const trimmedValue = value.trim(); if (trimmedValue === 'Открывающаяся') { return 'Открывающееся'; } return trimmedValue; }; /** * Нормализует изделие перед сопоставлением с правилами shields.json. * Неуказанное поле hasFramuga трактуется как отсутствие фрамуги (false). * Неуказанное поле dropThreshold трактуется как отсутствие выпадающего порога (false). * @param {Object} product - Логическое изделие после импорта или чтения из БД. * @returns {Object} Изделие с заполненными значениями по умолчанию для сопоставления. */ const normalizeProductForShieldMatching = (product) => { return { ...product, hasFramuga: product.hasFramuga ?? false, dropThreshold: product.dropThreshold ?? false, openingType: normalizeOpeningType(product.openingType), }; }; /** * Находит подходящий шильд для логического изделия. * Перебирает правила из shields.json по порядку и возвращает result первого совпавшего. * @param {Object} product - Логическое изделие после импорта (mapLogicalProduct). * @returns {Array<Object>|null} Массив шильдов из result или null, если правило не найдено. */ const findShield = (product) => { if (!product || typeof product !== 'object') { return null; } const normalizedProduct = normalizeProductForShieldMatching(product); for (const rule of shieldRules) { if (isRuleMatch(rule, normalizedProduct)) { return rule.result; } } return null; }; /** * Преобразует булево значение из SQLite (0/1/null) в true/false/null. * @param {number|null|undefined} value - Значение из базы данных. * @returns {boolean|null} Булево значение для сравнения с правилами. */ const convertDbBoolean = (value) => { if (value === null || value === undefined) { return null; } return value === 1; }; /** * Преобразует строку логического изделия из базы данных в объект для findShield. * @param {Object} dbRow - Строка с полями логического изделия из SQLite. * @returns {Object} Логическое изделие в формате, совместимом с shields.json. */ const mapDbRowToProduct = (dbRow) => { return { type: dbRow.type, systemName: dbRow.systemName, fireResistance: dbRow.fireResistance, openingType: dbRow.openingType, glassType: dbRow.glassType, doorType: dbRow.doorType, hasFramuga: convertDbBoolean(dbRow.hasFramuga), hasDoor: convertDbBoolean(dbRow.hasDoor), dropThreshold: convertDbBoolean(dbRow.dropThreshold), constructionType: dbRow.constructionType, }; }; /** * Преобразует один элемент result из findShield в поля таблицы Shields. * Маркировка берётся из shields.json; при отсутствии — из типа и класса огнестойкости. * Заводской номер шильда назначается отдельно через numberingService. * @param {Object} shieldResult - Элемент массива result из shields.json. * @param {Object} product - Логическое изделие. * @returns {Object} Поля для INSERT в таблицу Shields. */ const mapShieldResultToDbFields = (shieldResult, product) => { const tu = shieldResult.tu ?? shieldResult.TU ?? resolveTu(shieldResult.tuRef); const fireResistance = shieldResult.fireResistance ?? product.fireResistance; const marking = shieldResult.marking ?? buildMarking(shieldResult.shieldType, fireResistance); return { shieldType: shieldResult.shieldType, marking, fireResistance, TU: tu, certificates: serializeCertificates(shieldResult.certificates), productionDate: getProductionDateString(), }; }; /** * Получает экземпляр изделия вместе с данными логического изделия и заказа. * @param {import('better-sqlite3').Database} db - Подключение к SQLite. * @param {number} instanceId - Идентификатор экземпляра изделия. * @returns {Object|null} Данные экземпляра или null, если не найден. */ const getInstanceWithProduct = (db, instanceId) => { const selectInstance = db.prepare(` SELECT pi.id AS instanceId, op.orderId, op.number AS positionNumber, lp.type, lp.systemName, lp.fireResistance, lp.openingType, lp.glassType, lp.doorType, lp.hasFramuga, lp.hasDoor, lp.dropThreshold, lp.constructionType FROM ProductInstances pi INNER JOIN LogicalProducts lp ON pi.logicalProductId = lp.id INNER JOIN OrderPositions op ON lp.positionId = op.id WHERE pi.id = ? `); return selectInstance.get(instanceId) ?? null; }; /** * Проверяет, есть ли уже шильды у экземпляра изделия. * @param {import('better-sqlite3').Database} db - Подключение к SQLite. * @param {number} instanceId - Идентификатор экземпляра изделия. * @returns {boolean} true, если у экземпляра уже есть шильды. */ const hasShieldsForInstance = (db, instanceId) => { const countRow = db.prepare(` SELECT COUNT(*) AS count FROM Shields WHERE instanceId = ? `).get(instanceId); return Number(countRow.count) > 0; }; /** * Создаёт запись журнала для заказа, если она ещё не существует. * @param {import('better-sqlite3').Database} db - Подключение к SQLite. * @param {number} orderId - Идентификатор заказа. * @param {string} [productionDate] - Дата выпуска в формате YYYY-MM-DD. * @returns {number} Номер записи журнала. */ const ensureJournalEntryForOrder = (db, orderId, productionDate = getProductionDateString()) => { const existingEntry = db.prepare(` SELECT entryNumber FROM JournalEntries WHERE orderId = ? `).get(orderId); if (existingEntry) { return existingEntry.entryNumber; } return createJournalEntryForOrder(db, orderId, productionDate); }; /** * Присваивает шильд(ы) одному экземпляру изделия по правилам из shields.json. * Создаёт записи в Shields, назначает номера и связывает их с экземпляром. * @param {number} instanceId - Идентификатор экземпляра изделия. * @returns {Object} Результат присвоения для передачи в UI. */ const assignShieldToInstance = (instanceId) => { if (!Number.isInteger(instanceId) || instanceId <= 0) { return { success: false, error: 'Не выбран экземпляр изделия.', }; } try { const db = getDatabase(); const instanceRow = getInstanceWithProduct(db, instanceId); if (!instanceRow) { return { success: false, error: 'Экземпляр изделия не найден.', }; } if (hasShieldsForInstance(db, instanceId)) { return { success: false, error: 'Шильд уже присвоен этому экземпляру.', }; } const product = mapDbRowToProduct(instanceRow); const shields = findShield(product); if (!shields || shields.length === 0) { return { success: false, error: 'Не удалось определить шильд для данного изделия.', }; } const insertShield = db.prepare(` INSERT INTO Shields ( instanceId, shieldNumber, shieldType, marking, fireResistance, TU, certificates, productionDate ) VALUES (?, NULL, ?, ?, ?, ?, ?, ?) `); const updateShieldNumber = db.prepare(` UPDATE Shields SET shieldNumber = ? WHERE id = ? `); const productionDate = getProductionDateString(); let shieldsCount = 0; const assignTransaction = db.transaction(() => { ensureJournalEntryForOrder(db, instanceRow.orderId, productionDate); shields.forEach((shieldResult) => { const dbFields = mapShieldResultToDbFields(shieldResult, product); const insertResult = insertShield.run( instanceId, dbFields.shieldType, dbFields.marking, dbFields.fireResistance, dbFields.TU, dbFields.certificates, productionDate, ); const shieldNumber = allocateNextShieldNumber(db); updateShieldNumber.run(shieldNumber, insertResult.lastInsertRowid); shieldsCount += 1; }); }); assignTransaction(); return { success: true, instanceId, shieldsCount, }; } catch (error) { const message = error instanceof Error && error.message ? error.message : 'Ошибка при присвоении шильда.'; return { success: false, error: message, }; } }; /** * Получает все экземпляры изделий заказа вместе с данными логического изделия и позиции. * @param {import('better-sqlite3').Database} db - Подключение к SQLite. * @param {number} orderId - Идентификатор заказа. * @returns {Array<Object>} Список экземпляров с полями для определения шильда. */ const getOrderInstances = (db, orderId) => { const selectInstances = db.prepare(` SELECT pi.id AS instanceId, op.number AS positionNumber, lp.type, lp.systemName, lp.fireResistance, lp.openingType, lp.glassType, lp.doorType, lp.hasFramuga, lp.hasDoor, lp.dropThreshold, lp.constructionType FROM ProductInstances pi INNER JOIN LogicalProducts lp ON pi.logicalProductId = lp.id INNER JOIN OrderPositions op ON lp.positionId = op.id WHERE op.orderId = ? ORDER BY op.number, lp.id, pi.number `); return selectInstances.all(orderId); }; /** * Создаёт записи шильдов для всех экземпляров изделий заказа. * Вызывается внутри транзакции импорта после создания ProductInstances. * @param {import('better-sqlite3').Database} db - Подключение к SQLite. * @param {number} orderId - Идентификатор заказа. * @returns {{ shieldsCount: number, rulesNotFoundCount: number, warnings: string[] }} Статистика создания шильдов. */ const createShieldsForOrder = (db, orderId) => { const insertShield = db.prepare(` INSERT INTO Shields ( instanceId, shieldNumber, shieldType, marking, fireResistance, TU, certificates, productionDate ) VALUES (?, NULL, ?, ?, ?, ?, ?, ?) `); const instances = getOrderInstances(db, orderId); let shieldsCount = 0; let rulesNotFoundCount = 0; const warnings = []; createJournalEntryForOrder(db, orderId); instances.forEach((instanceRow) => { const product = mapDbRowToProduct(instanceRow); const shields = findShield(product); if (!shields || shields.length === 0) { rulesNotFoundCount += 1; const warningMessage = `Не удалось определить шильд для изделия позиции №${instanceRow.positionNumber}.`; warnings.push(warningMessage); console.warn(warningMessage); return; } shields.forEach((shieldResult) => { const dbFields = mapShieldResultToDbFields(shieldResult, product); insertShield.run( instanceRow.instanceId, dbFields.shieldType, dbFields.marking, dbFields.fireResistance, dbFields.TU, dbFields.certificates, dbFields.productionDate, ); shieldsCount += 1; }); }); assignShieldNumbersForOrder(db, orderId); return { shieldsCount, rulesNotFoundCount, warnings, }; }; /** * Запускает тестовые вызовы findShield для проверки работы сервиса. * Выводит результат в консоль. * @returns {void} */ const runShieldTests = () => { const testCases = [ { name: 'Дверь / Остекленная / EIW60', product: { type: 'Дверь', doorType: 'Остекленная', fireResistance: 'EIW60', hasFramuga: false, }, expectedMarking: 'ДАПО EIW60', }, { name: 'Окно / Глухое / E30 / Гель', product: { type: 'Окно', openingType: 'Глухое', fireResistance: 'E30', glassType: 'Гель', }, expectedMarking: 'ПО 4 E30', }, { name: 'Витраж / EIW45', product: { type: 'Витраж', fireResistance: 'EIW45', }, expectedMarking: 'КПАС EIW45', }, { name: 'Дверь / Глухая / EIS30', product: { type: 'Дверь', doorType: 'Глухая', fireResistance: 'EIS30', hasFramuga: false, }, expectedMarking: 'ДАПГ EIS30', }, { name: 'Дверь / Остекленная / EIW60 / hasFramuga не указан', product: { type: 'Дверь', doorType: 'Остекленная', fireResistance: 'EIW60', hasFramuga: null, }, expectedMarking: 'ДАПО EIW60', }, { name: 'Окно / Глухое / E30 / Монолит', product: { type: 'Окно', openingType: 'Глухое', fireResistance: 'E30', glassType: 'Монолит', }, expectedMarking: 'КПАС E30 монолит', }, { name: 'Окно / Открывающаяся / E30 / Гель', product: { type: 'Окно', openingType: 'Открывающаяся', fireResistance: 'E30', glassType: 'Гель', }, expectedMarking: 'ПРО E30', }, ]; console.log('--- Тесты shieldService ---'); testCases.forEach((testCase) => { const result = findShield(testCase.product); const marking = result ? result[0].marking : null; const isSuccess = marking === testCase.expectedMarking; console.log(`${isSuccess ? 'OK' : 'FAIL'}: ${testCase.name}`); console.log(` Ожидалось: ${testCase.expectedMarking}`); console.log(` Получено: ${marking ?? 'null'}`); }); }; if (require.main === module) { runShieldTests(); } module.exports = { findShield, assignShieldToInstance, createShieldsForOrder, normalizeOpeningType, resolveTu, runShieldTests, };