{
  "id": "P08-C01",
  "title": "Литры, штуки и исключения: нормализация садового каталога",
  "kind": "data",
  "brand": "ЧИСТЫЕ ДАННЫЕ / P08-C01",
  "role": "Проектирование правил, реализация преобразователя и проверка исключений",
  "objective": "Получить воспроизводимое преобразование товарных строк с сохранением исходных данных.",
  "problem": "В исходной выгрузке объём и комплектность записаны одной строкой. Простое удаление единиц превращает 2 × 1 л в один литр.",
  "choice": "Сначала разбираю количество, значение и единицу, затем считаю общий объём. Исходная строка остаётся в записи, а дробь без единицы не получает догадку.",
  "result": "Рабочий преобразователь обработал 8 строк: 6 записей разнесены по полям, 2 отправлены в журнал исключений. Исходные значения сохранены; результат можно пересчитать на странице и сопоставить с JSON.",
  "limit": "Использован собственный ограниченный набор строк и явно заданные справочники. Преобразователь не угадывает отсутствующие свойства; до импорта реального каталога правила нужно согласовать с его схемой.",
  "contribution": "Рабочий преобразователь JavaScript, входной набор, результат, журнал исключений и страница для повторного запуска.",
  "panels": [
    {
      "mode": "table",
      "title": "Нормализованные поля",
      "headers": [
        "id",
        "count",
        "unitLiters",
        "totalLiters"
      ],
      "rows": [
        [
          "G-101",
          "1",
          "0.5",
          "0.5"
        ],
        [
          "G-102",
          "1",
          "5",
          "5"
        ],
        [
          "G-103",
          "2",
          "1",
          "2"
        ],
        [
          "G-104",
          "1",
          "0.75",
          "0.75"
        ],
        [
          "G-106",
          "1",
          "0.25",
          "0.25"
        ],
        [
          "G-107",
          "3",
          "0.5",
          "1.5"
        ]
      ],
      "caption": "6 готовых записей; остальные 2 требуют отдельного решения."
    },
    {
      "mode": "table",
      "title": "Исключения не потеряны",
      "headers": [
        "ID",
        "Исходная строка"
      ],
      "rows": [
        [
          "G-105",
          "1/2"
        ],
        [
          "G-108",
          "0 л"
        ]
      ],
      "caption": "Причины каждого исключения доступны в полном JSON."
    }
  ],
  "table": {
    "headers": [
      "id",
      "count",
      "unitLiters",
      "totalLiters"
    ],
    "rows": [
      [
        "G-101",
        "1",
        "0.5",
        "0.5"
      ],
      [
        "G-102",
        "1",
        "5",
        "5"
      ],
      [
        "G-103",
        "2",
        "1",
        "2"
      ],
      [
        "G-104",
        "1",
        "0.75",
        "0.75"
      ],
      [
        "G-106",
        "1",
        "0.25",
        "0.25"
      ],
      [
        "G-107",
        "3",
        "0.5",
        "1.5"
      ]
    ]
  },
  "extraFiles": [
    {
      "file": "normalizer.js",
      "content": "function normalize(rows){if(!Array.isArray(rows))throw new TypeError(\"Нужен массив строк\");const records=[],exceptions=[];for(const r of rows){if(!r||typeof r.id!==\"string\"||typeof r.raw!==\"string\"){exceptions.push({input:r,reason:\"Нужны текстовые id и raw\"});continue}const s=r.raw.trim().toLowerCase().replace(/,/g,'.').replace(/ml/g,'мл');let m=s.match(/^(?:(\\d+)\\s*[x×*]\\s*)?(\\d+(?:\\.\\d+)?)\\s*(мл|л)$/);if(!m){exceptions.push({...r,reason:'Неоднозначное число или единица'});continue}const count=+(m[1]||1),unitLiters=+m[2]/(m[3]==='мл'?1000:1);if(!Number.isSafeInteger(count)||count<1||!Number.isFinite(unitLiters)||unitLiters<=0||unitLiters>Number.MAX_SAFE_INTEGER||!Number.isFinite(count*unitLiters)||count*unitLiters>Number.MAX_SAFE_INTEGER){exceptions.push({...r,reason:'Количество и объём должны быть положительными'});continue}records.push({id:r.id,raw:r.raw,count,unitLiters,totalLiters:Number((count*unitLiters).toPrecision(15))})}return {records,exceptions}}\nif(typeof module!==\"undefined\")module.exports={normalize};\n",
      "label": "Исходник преобразования",
      "mimeType": "text/javascript"
    },
    {
      "file": "input.json",
      "content": "[\n  {\n    \"id\": \"G-101\",\n    \"raw\": \"500 мл\"\n  },\n  {\n    \"id\": \"G-102\",\n    \"raw\": \"5 л\"\n  },\n  {\n    \"id\": \"G-103\",\n    \"raw\": \"2 × 1 л\"\n  },\n  {\n    \"id\": \"G-104\",\n    \"raw\": \"750ml\"\n  },\n  {\n    \"id\": \"G-105\",\n    \"raw\": \"1/2\"\n  },\n  {\n    \"id\": \"G-106\",\n    \"raw\": \"0,25 л\"\n  },\n  {\n    \"id\": \"G-107\",\n    \"raw\": \"3 x 500 мл\"\n  },\n  {\n    \"id\": \"G-108\",\n    \"raw\": \"0 л\"\n  }\n]",
      "label": "Исходные записи",
      "mimeType": "application/json"
    },
    {
      "file": "output.json",
      "content": "{\n  \"records\": [\n    {\n      \"id\": \"G-101\",\n      \"raw\": \"500 мл\",\n      \"count\": 1,\n      \"unitLiters\": 0.5,\n      \"totalLiters\": 0.5\n    },\n    {\n      \"id\": \"G-102\",\n      \"raw\": \"5 л\",\n      \"count\": 1,\n      \"unitLiters\": 5,\n      \"totalLiters\": 5\n    },\n    {\n      \"id\": \"G-103\",\n      \"raw\": \"2 × 1 л\",\n      \"count\": 2,\n      \"unitLiters\": 1,\n      \"totalLiters\": 2\n    },\n    {\n      \"id\": \"G-104\",\n      \"raw\": \"750ml\",\n      \"count\": 1,\n      \"unitLiters\": 0.75,\n      \"totalLiters\": 0.75\n    },\n    {\n      \"id\": \"G-106\",\n      \"raw\": \"0,25 л\",\n      \"count\": 1,\n      \"unitLiters\": 0.25,\n      \"totalLiters\": 0.25\n    },\n    {\n      \"id\": \"G-107\",\n      \"raw\": \"3 x 500 мл\",\n      \"count\": 3,\n      \"unitLiters\": 0.5,\n      \"totalLiters\": 1.5\n    }\n  ],\n  \"exceptions\": [\n    {\n      \"id\": \"G-105\",\n      \"raw\": \"1/2\",\n      \"reason\": \"Неоднозначное число или единица\"\n    },\n    {\n      \"id\": \"G-108\",\n      \"raw\": \"0 л\",\n      \"reason\": \"Количество и объём должны быть положительными\"\n    }\n  ]\n}",
      "label": "Результат и исключения",
      "mimeType": "application/json"
    },
    {
      "file": "test-report.json",
      "content": "[\n  {\n    \"name\": \"Входные строки сохранены и распределены\",\n    \"pass\": true\n  },\n  {\n    \"name\": \"Исходный raw сохранён\",\n    \"pass\": true\n  },\n  {\n    \"name\": \"Повторный запуск детерминирован\",\n    \"pass\": true\n  },\n  {\n    \"name\": \"Неверная структура не теряется\",\n    \"pass\": true\n  },\n  {\n    \"name\": \"Верхний уровень должен быть массивом\",\n    \"pass\": true\n  },\n  {\n    \"name\": \"Переполнение чисел отклонено без Infinity/null\",\n    \"pass\": true\n  },\n  {\n    \"name\": \"Малый положительный объём не округлён в ноль\",\n    \"pass\": true\n  },\n  {\n    \"name\": \"Все числовые поля конечны\",\n    \"pass\": true\n  }\n]",
      "label": "Протокол проверок",
      "mimeType": "application/json"
    }
  ],
  "interactiveHTML": "<section class=\"box\"><h2>Проверить свою строку</h2><p>Вход — JSON-массив с полями id и raw. Измените пример и выполните преобразование.</p><textarea id=\"source\" aria-label=\"Исходные записи\">[\n  {\n    \"id\": \"G-101\",\n    \"raw\": \"500 мл\"\n  },\n  {\n    \"id\": \"G-102\",\n    \"raw\": \"5 л\"\n  },\n  {\n    \"id\": \"G-103\",\n    \"raw\": \"2 × 1 л\"\n  },\n  {\n    \"id\": \"G-104\",\n    \"raw\": \"750ml\"\n  },\n  {\n    \"id\": \"G-105\",\n    \"raw\": \"1/2\"\n  },\n  {\n    \"id\": \"G-106\",\n    \"raw\": \"0,25 л\"\n  },\n  {\n    \"id\": \"G-107\",\n    \"raw\": \"3 x 500 мл\"\n  },\n  {\n    \"id\": \"G-108\",\n    \"raw\": \"0 л\"\n  }\n]</textarea><button id=\"run\">Преобразовать</button><output id=\"result\" aria-live=\"polite\"></output><script src=\"normalizer.js\"></script><script>document.getElementById('run').onclick=function(){try{const data=JSON.parse(document.getElementById('source').value);if(!Array.isArray(data)||data.some(r=>typeof r.id!=='string'||typeof r.raw!=='string'))throw Error('Нужен массив объектов с текстовыми id и raw');document.getElementById('result').textContent=JSON.stringify(normalize(data),null,2)}catch(e){document.getElementById('result').textContent='Ошибка входных данных: '+e.message}};document.getElementById('run').click();</script></section>",
  "checks": [
    "Запустить набор: должно получиться 6 записей и 2 исключения; G-103 содержит count=2, unitLiters=1, totalLiters=2.",
    "Изменить один входной объект и проверить, что результат пересчитался.",
    "Проверить, что неверный JSON даёт сообщение об ошибке, не стирая поле."
  ],
  "closes": [
    "Вместо описания очистки создан и запущен преобразователь.",
    "Поля реально разделены; исходная строка и исключения сохранены."
  ]
}
