// =============================================
// Recycle Object — Warehouse (Inventory) Page
// =============================================
// =============================================
// SEED DATA (from "Инвентаризация Фурнитуры" Excel)
// Auto-loaded on first visit if warehouse is empty
// =============================================
const WAREHOUSE_SEED_DATA = [
{"category":"carabiners","name":"Карабин-кольцо","sku":"CR-RNG-030-VT","size":"3 см","color":"фиолетовый","qty":30,"price_per_unit":5.0},
{"category":"carabiners","name":"Карабин-кольцо","sku":"CR-RNG-030-PK","size":"3 см","color":"розовый","qty":80,"price_per_unit":5.0},
{"category":"carabiners","name":"Карабин-кольцо","sku":"CR-RNG-030-GR","size":"3 см","color":"зеленый","qty":140,"price_per_unit":5.0},
{"category":"carabiners","name":"Карабин-кольцо","sku":"CR-RNG-030-WH","size":"3 см","color":"белый","qty":300,"price_per_unit":5.0},
{"category":"carabiners","name":"Карабин-кольцо","sku":"CR-RNG-030-LBL","size":"3 см","color":"голубой","price_per_unit":5},
{"category":"carabiners","name":"Карабин-кольцо","sku":"CR-RNG-030-OR","size":"3 см","color":"оранжевый","qty":30,"price_per_unit":5.0},
{"category":"carabiners","name":"Карабин-кольцо","sku":"CR-RNG-030-BK","size":"3 см","color":"черный","qty":120,"price_per_unit":5.0},
{"category":"carabiners","name":"Карабин-кольцо","sku":"CR-RNG-030-BL","size":"3 см","color":"синий","price_per_unit":5},
{"category":"carabiners","name":"Карабин-кольцо","sku":"CR-RNG-030-YL","size":"3 см","color":"желтый","qty":80,"price_per_unit":5.0},
{"category":"carabiners","name":"Карабин-кольцо","sku":"CR-RNG-020-RD","size":"2 см","color":"красный","qty":360,"price_per_unit":5.0},
{"category":"carabiners","name":"Карабин-кольцо","sku":"CR-RNG-020-OR","size":"2 см","color":"оранжевый","qty":200,"price_per_unit":5.0},
{"category":"carabiners","name":"Карабин-кольцо","sku":"CR-RNG-020-LBL","size":"2 см","color":"голубой","qty":150,"price_per_unit":5.0},
{"category":"carabiners","name":"Карабин-кольцо","sku":"CR-RNG-020-BL","size":"2 см","color":"синий","qty":200,"price_per_unit":5.0},
{"category":"carabiners","name":"Карабин-кольцо","sku":"CR-RNG-020-PK","size":"2 см","color":"розовый","qty":14,"price_per_unit":5.0},
{"category":"carabiners","name":"Карабин-кольцо","sku":"CR-RNG-020-YL","size":"2 см","color":"желтый","qty":350,"price_per_unit":5.0},
{"category":"carabiners","name":"Карабин-кольцо","sku":"CR-RNG-020-GR","size":"2 см","color":"зеленый","qty":140,"price_per_unit":5.0},
{"category":"carabiners","name":"Карабин-кольцо","sku":"CR-RNG-020-BLCK","size":"2 см","color":"черный","qty":90,"price_per_unit":5},
{"category":"carabiners","name":"Карабин овальный","sku":"CR-OVL-050-BK","size":"5 см","color":"черный","qty":75,"price_per_unit":10.0},
{"category":"carabiners","name":"Карабин","sku":"CR-STD-050-BK","size":"5 см","color":"черный","qty":270,"price_per_unit":10.0},
{"category":"carabiners","name":"Карабин (образцы Т-банк)","sku":"CR-STD-050-BK-TB","size":"5 см","color":"черный","qty":82,"price_per_unit":10},
{"category":"carabiners","name":"Карабины","sku":"CR-STD-050-RD","size":"5 см","color":"красный","qty":35,"price_per_unit":10.0},
{"category":"carabiners","name":"Карабин","sku":"CR-STD-050-RD+","size":"5 см","color":"красный","qty":100,"price_per_unit":10.0},
{"category":"carabiners","name":"Карабины","sku":"CR-STD-050-OR","size":"5 см","color":"оранжевый","qty":650,"price_per_unit":10.0},
{"category":"carabiners","name":"Карабины","sku":"CR-STD-050-YL","size":"5 см","color":"желтый","qty":120,"price_per_unit":10.0},
{"category":"carabiners","name":"Карабины","sku":"CR-STD-050-YL+","size":"5 см","color":"желтый","qty":120,"price_per_unit":10.0},
{"category":"carabiners","name":"Карабины","sku":"CR-STD-050-GR","size":"5 см","color":"зеленый","price_per_unit":10},
{"category":"carabiners","name":"Карабины","sku":"CR-STD-050-LGR","size":"5 см","color":"салатовый","qty":30,"price_per_unit":10.0},
{"category":"carabiners","name":"Карабины","sku":"CR-STD-050-GR+","size":"5 см","color":"зеленый","qty":70,"price_per_unit":10.0},
{"category":"carabiners","name":"Карабины","sku":"CR-STD-050-LBL+","size":"5 см","color":"голубой","qty":70,"price_per_unit":10.0},
{"category":"carabiners","name":"Карабины","sku":"CR-STD-050-LBL","size":"5 см","color":"голубой","qty":30,"price_per_unit":10.0},
{"category":"carabiners","name":"Карабины","sku":"CR-STD-050-PK","size":"5 см","color":"розовый","qty":90,"price_per_unit":10.0},
{"category":"carabiners","name":"Карабины","sku":"CR-STD-050-PK+","size":"5 см","color":"розовый","qty":5,"price_per_unit":10.0},
{"category":"carabiners","name":"Карабины","sku":"CR-STD-050-PKB","size":"5 см","color":"яркий розовый","qty":50,"price_per_unit":10.0},
{"category":"carabiners","name":"Карабины","sku":"CR-STD-050-VT+","size":"5 см","color":"фиолетовый","qty":200,"price_per_unit":10.0},
{"category":"carabiners","name":"Карабины","sku":"CR-STD-050-BL","size":"5 см","color":"синий","qty":43,"price_per_unit":10.0},
{"category":"carabiners","name":"Карабины","sku":"CR-STD-050-BL+","size":"5 см","color":"синий","qty":160,"price_per_unit":10.0},
{"category":"carabiners","name":"Карабины","sku":"CR-STD-050-BV","size":"5 см","color":"синий-фиолетовый","qty":96,"price_per_unit":10.0},
{"category":"carabiners","name":"Карабины","sku":"CR-STD-050-PNK2+","size":"5 см","color":"розовый","qty":100,"price_per_unit":10.0},
{"category":"carabiners","name":"Карабин с ушком","sku":"CR-STD-BK-H","color":"черный","qty":230,"price_per_unit":10.0},
{"category":"carabiners","name":"Карабин с защелкой","sku":"CR-STD-BLCK+","color":"черный","qty":75,"price_per_unit":10.0},
{"category":"carabiners","name":"Круглый карабин","sku":"CR-RNG-020-SV","size":"2 см","color":"серебряный","qty":90,"price_per_unit":5.0},
{"category":"carabiners","name":"Круглый карабин","sku":"CR-RNG-023-SV","size":"2,3 см","color":"серебряный","price_per_unit":5},
{"category":"carabiners","name":"Круглый карабин","sku":"CR-RNG-025-SV","size":"2,5 см","color":"серебряный","price_per_unit":5},
{"category":"carabiners","name":"Круглый карабин","sku":"CR-RNG-032-SV","size":"3,2 см","color":"серебряный","qty":25,"price_per_unit":5.0},
{"category":"carabiners","name":"Круглый карабин","sku":"CR-RNG-039-SV","size":"3,9 см","color":"серебряный","qty":450,"price_per_unit":5.0},
{"category":"carabiners","name":"Круглый карабин с ушком","sku":"CR-RNG-SV-H","size":"2,3 см","color":"серебряный","qty":880,"price_per_unit":10.0},
{"category":"carabiners","name":"Карабин с плоским кольцом","sku":"CR-RNGFLT","color":"серебряный","qty":280,"price_per_unit":13.0},
{"category":"carabiners","name":"Металлический карабин","sku":"CR-MET-040-SV","size":"4 см","color":"серебряный","qty":90,"price_per_unit":10.0},
{"category":"carabiners","name":"Металлический карабин","sku":"CR-MET-070-SV","size":"7 см","color":"серебряный","qty":93,"price_per_unit":10.0},
{"category":"carabiners","name":"Карабин тонкий (для брелков)","sku":"CR-THN-SV","color":"серебряный","qty":150,"price_per_unit":10.0},
{"category":"carabiners","name":"Карабины с кольцом","sku":"CR-STD-SV-K","color":"серебряный","qty":120,"price_per_unit":15.0},
{"category":"carabiners","name":"Карабины круглые с кольцом","sku":"CR-RNG-SV-K","color":"серебряный","price_per_unit":15},
{"category":"carabiners","name":"Карабины с круглым отверстием","sku":"CR-STD-SV","color":"серебряный","qty":1200,"price_per_unit":10.0},
{"category":"carabiners","name":"Карабин с ушком","sku":"CR-STD-SV-H","color":"серебряный","qty":850,"price_per_unit":10.0},
{"category":"carabiners","name":"Карабин с хупом","sku":"CR-HP","color":"серебряный","qty":100,"price_per_unit":10.0},
{"category":"cables","name":"Тросы","sku":"TR-050-WH","size":"5 см","color":"белые","qty":20,"price_per_unit":5.0},
{"category":"cables","name":"Тросы","sku":"TR-050-GY","size":"5 см","color":"серые","qty":15,"price_per_unit":5.0},
{"category":"cables","name":"Тросы","sku":"TR-38-SV","size":"3,8 см","color":"серебристые","qty":400,"price_per_unit":5.0},
{"category":"cables","name":"Тросы","sku":"TR-50-SV","size":"5 см","color":"серебристые","qty":1250,"price_per_unit":5.0},
{"category":"cables","name":"Тросы","sku":"TR-050-OR","size":"5 см","color":"оранжевые","qty":330,"price_per_unit":5.0},
{"category":"cables","name":"Тросы","sku":"TR-050-GRL","size":"5 см","color":"зеленые светлые","qty":25,"price_per_unit":5.0},
{"category":"cables","name":"Тросы","sku":"TR-050-GRD","size":"5 см","color":"темно-зеленые","qty":370,"price_per_unit":5.0},
{"category":"cables","name":"Тросы","sku":"TR-050-GR","size":"5 см","color":"зеленый","qty":620,"price_per_unit":5.0},
{"category":"cables","name":"Тросы","sku":"TR-050-GR+","size":"5 см","color":"зеленый (темнее)","qty":180,"price_per_unit":5.0},
{"category":"cables","name":"Тросы","sku":"TR-050-AQ","size":"5 см","color":"морская волна","qty":130,"price_per_unit":5.0},
{"category":"cables","name":"Тросы","sku":"TR-050-TBL","size":"5 см","color":"тиффани","qty":30,"price_per_unit":5.0},
{"category":"cables","name":"Тросы","sku":"TR-055-TBD","size":"5 см","color":"светло-голубой","qty":140,"price_per_unit":5.0},
{"category":"cables","name":"Тросы","sku":"TR-050-NV","size":"5 см","color":"темно-синие","price_per_unit":5.0},
{"category":"cables","name":"Тросы","sku":"TR-050-BK","size":"5 см","color":"черный","qty":160,"price_per_unit":5.0},
{"category":"cables","name":"Тросы","sku":"TR-050-YL","size":"5 см","color":"желтый","qty":215,"price_per_unit":5.0},
{"category":"cables","name":"Тросы","sku":"TR-050-VL","size":"5 см","color":"фиолетовый","qty":65,"price_per_unit":5.0},
{"category":"cables","name":"Тросы","sku":"TR-050-VLD","size":"5 см","color":"темный фиолетовый","qty":15,"price_per_unit":5.0},
{"category":"cables","name":"Тросы","sku":"TR-050-LBL","size":"5 см","color":"голубой","price_per_unit":5.0},
{"category":"cables","name":"Тросы","sku":"TR-050-RD","size":"5 см","color":"красный","qty":30,"price_per_unit":5.0},
{"category":"cables","name":"Тросы","sku":"TR-050-PK","size":"5 см","color":"розовый","price_per_unit":5.0},
{"category":"cables","name":"Тросы","sku":"TR-050-PKD","size":"5 см","color":"темный розовый","price_per_unit":5},
{"category":"cables","name":"Тросы","sku":"TR-050-LPK","size":"5 см","color":"поросячий розовый","qty":200,"price_per_unit":5.0},
{"category":"cables","name":"Тросы","sku":"TR-050-LLY","size":"5 см","color":"сиреневый","qty":15,"price_per_unit":5.0},
{"category":"cables","name":"Тросы","sku":"TR-050-LLPK","size":"5 см","color":"светло-розовый","qty":50,"price_per_unit":5.0},
{"category":"cables","name":"Тросы","sku":"TR-195-SV","size":"19,5 см","color":"серебряный","qty":11,"price_per_unit":5.0},
{"category":"cables","name":"Тросы","sku":"TR-65-SV","size":"6,5 см","color":"серебряный","qty":1640,"price_per_unit":5.0},
{"category":"rings","name":"Кольца плоские","sku":"RNG-FLAT-25-SV","size":"2,5 см","color":"серебряные","qty":950,"price_per_unit":2},
{"category":"rings","name":"Кольцо","sku":"RNG-20-SV","size":"2 см","color":"серебряные","qty":1050,"price_per_unit":2},
{"category":"rings","name":"Кольцо","sku":"RNG-35-SV","size":"3,5 см","color":"серебряные","qty":105,"price_per_unit":2},
{"category":"rings","name":"Кольцо плоское с цепочкой","sku":"RNG-CHN","size":"3,2 см","color":"серебряные","qty":980,"price_per_unit":5.0},
{"category":"rings","name":"Соед. кольцо тонкое","sku":"RNG-UN-010-SV-TH","size":"10 мм","color":"серебряные","qty":2900,"price_per_unit":2.0},
{"category":"rings","name":"Соединительное кольцо","sku":"RNG-UN-010-SV","size":"10 мм","color":"серебряные","qty":7200,"price_per_unit":1.0},
{"category":"rings","name":"Соединительное кольцо","sku":"RNG-UN-008-SV","size":"8 мм","color":"серебряные","qty":3500,"price_per_unit":1.0},
{"category":"rings","name":"Соединительное кольцо","sku":"RNG-UN-010-BK","size":"10 мм","color":"черный","qty":1600,"price_per_unit":1.0},
{"category":"rings","name":"Соединительное кольцо","sku":"RNG-UN-008-BK","size":"8 мм","color":"черный","qty":2800,"price_per_unit":1.0},
{"category":"chains","name":"Цепочки металл","sku":"CH-MTL","size":"2 мм","color":"ненарезанные","price_per_unit":4.0},
{"category":"chains","name":"Цепочки металл 10см","sku":"CH-MTL-10CM","size":"2 мм, 10 см","qty":1900,"price_per_unit":4.0},
{"category":"chains","name":"Цепочки металл тонкие","sku":"CH-MTL-THIN","size":"1,6 мм","color":"ненарезанные","price_per_unit":4},
{"category":"chains","name":"Цепочки металл тонкие 10см","sku":"CH-MTLM-10CM","size":"1,6 мм, 10 см","qty":800,"price_per_unit":4.0},
{"category":"chains","name":"Цепочки металл тонкие 15см","sku":"CH-MTL-15CM","size":"1,6 мм, 15 см","qty":280,"price_per_unit":4.0},
{"category":"chains","name":"Цепочки металл моток","sku":"CH-MTL-ROLL","size":"1,6 мм, моток","qty":5,"price_per_unit":4.0},
{"category":"chains","name":"Крепления для тонких цепочек","sku":"CL-THIN","price_per_unit":4.0},
{"category":"chains","name":"Цепочки розовые 10см","sku":"CH-PNK-10CM","size":"10 см","color":"розовый","qty":210,"price_per_unit":4.0},
{"category":"chains","name":"Цепочки темно-розовые 10см","sku":"CH-DPR-10CM","size":"10 см","color":"темно-розовый","qty":220,"price_per_unit":4.0},
{"category":"chains","name":"Цепочки желтые 10см","sku":"CH-YLW-10CM","size":"10 см","color":"желтый","qty":270,"price_per_unit":4.0},
{"category":"chains","name":"Цепочки красные 10см","sku":"CH-RED-10CM","size":"10 см","color":"красный","qty":120,"price_per_unit":4.0},
{"category":"chains","name":"Цепочки черные 10см","sku":"CH-BLK-10CM","size":"10 см","color":"черный","qty":830,"price_per_unit":4.0},
{"category":"cords","name":"Наконечники для шнуров","sku":"CAP-005","size":"5 мм","color":"метал","qty":2200,"price_per_unit":5.0},
{"category":"cords","name":"Наконечники для шнуров","sku":"CAP-006","size":"6 мм","color":"метал","qty":1200,"price_per_unit":5.0},
{"category":"cords","name":"Наконечники матовые","sku":"CAP-005-MT","size":"5 мм","color":"метал","qty":900,"price_per_unit":5.0},
{"category":"cords","name":"Наконечники матовые","sku":"CAP-006-MT","size":"6 мм","color":"метал","qty":190,"price_per_unit":5.0},
{"category":"cords","name":"Миланский шнур","sku":"MSN-GR","color":"зеленый","qty":30900,"unit":"см","price_per_unit":70.0},
{"category":"cords","name":"Миланский шнур","sku":"MSN-BK","color":"черный","qty":2600,"price_per_unit":70.0},
{"category":"cords","name":"Миланский шнур","sku":"MSN-PK","color":"розовый","qty":1200,"price_per_unit":70.0},
{"category":"cords","name":"Миланский шнур","sku":"MSN-OR","color":"оранжевый","qty":2600,"price_per_unit":70.0},
{"category":"cords","name":"Миланский шнур","sku":"MSN-LV","color":"фиолетовый","qty":3800,"price_per_unit":70.0},
{"category":"cords","name":"Шнур с силик. наконечником","sku":"SLS-800-PK-NN","size":"80 см","color":"розовый","qty":85,"price_per_unit":23.0},
{"category":"cords","name":"Шнур с силик. наконечником","sku":"SLS-800-VT-NN","size":"80 см","color":"фиолетовый","qty":1050,"price_per_unit":23.0},
{"category":"cords","name":"Шнур с силик. наконечником","sku":"SLS-800-LPK-NN","size":"80 см","color":"темно-розовый","price_per_unit":23},
{"category":"cords","name":"Шнур с силик. наконечником","sku":"SLS-800-OR-NN","size":"80 см","color":"оранжевый","qty":222,"price_per_unit":23.0},
{"category":"cords","name":"Шнур с силик. наконечником","sku":"SLS-800-GR-NN-GR","size":"80 см","color":"зеленый","price_per_unit":23},
{"category":"cords","name":"Шнур с силик. наконечником","sku":"SLS-800-BL-NN","size":"80 см","color":"синий","qty":1232,"price_per_unit":23.0},
{"category":"cords","name":"Шнур с силик. наконечником","sku":"SLS-800-YL-NN","size":"80 см","color":"желтый","qty":100,"price_per_unit":23.0},
{"category":"cords","name":"Шнур с силик. наконечником","sku":"SLS-800-YL-NN-YL","size":"80 см","color":"яркий желтый","qty":435,"price_per_unit":23.0},
{"category":"cords","name":"Шнур с силик. наконечником","sku":"SLS-800-LBL-NN","size":"80 см","color":"голубой","qty":35,"price_per_unit":23.0},
{"category":"cords","name":"Шнур с силик. наконечником","sku":"SLS-800-GR-NN-DGR","size":"80 см","color":"зеленый (темный)","price_per_unit":23.0},
{"category":"cords","name":"Шнур с силик. наконечником","sku":"SLS-800-RD-NN-RD","size":"80 см","color":"красный","qty":1580,"price_per_unit":23.0},
{"category":"cords","name":"Шнур с силик. наконечником","sku":"SLS-800-BL-NN-LBL","size":"80 см","color":"светло-голубой","price_per_unit":23},
{"category":"cords","name":"Шнур с силик. наконечником","sku":"SLS-800-GR-NN-GR2","size":"80 см","color":"зеленый","price_per_unit":23},
{"category":"cords","name":"Шнур с силик. наконечником","sku":"SLS-800-BCK-NN","size":"80 см","color":"черный","qty":492,"price_per_unit":23.0},
{"category":"cords","name":"Шнур с силик. наконечником","sku":"SLS-800-GRY-NN","size":"80 см","color":"серый","price_per_unit":23},
{"category":"cords","name":"Шнур с силик. наконечником","sku":"SLS-800-SKY-NN","size":"80 см","color":"небесный","qty":42,"price_per_unit":23.0},
{"category":"cords","name":"Шнур с силик. наконечником","sku":"SLS-800-PGY-NN","size":"80 см","color":"поросячий","qty":950,"price_per_unit":23.0},
{"category":"cords","name":"Шнур с силик. наконечником","sku":"SLS-800-FCS-NN","size":"80 см","color":"фуксия","price_per_unit":23},
{"category":"cords","name":"Шнур с силик. наконечником","sku":"SLS-800-PNBG-NN","size":"80 см","color":"розовый беж","price_per_unit":23},
{"category":"cords","name":"Шнур с силик. наконечником","sku":"SLS-800-HNY-NN","size":"80 см","color":"медовый","qty":100,"price_per_unit":23.0},
{"category":"cords","name":"Шнур с силик. наконечником","sku":"SLS-800-WHT-NN","size":"80 см","color":"белый","qty":437,"price_per_unit":23.0},
{"category":"cords","name":"Шнур с силик. наконечником","sku":"SLS-800-SLD-NN","size":"80 см","color":"салатовый","qty":400,"price_per_unit":23},
{"category":"cords","name":"Шнур с силик. наконечником","sku":"SLS-800-LZR-NN","size":"80 см","color":"лазурный","qty":138,"price_per_unit":23.0},
{"category":"cords","name":"Шнур с черн. наконечниками","sku":"SLS-800-BCK-NNBL","size":"80 см","color":"черный","qty":85,"price_per_unit":23.0},
{"category":"cords","name":"Шнур кожаный","sku":"LSN-WH","color":"белый","qty":179,"unit":"м","price_per_unit":25.0},
{"category":"cords","name":"Шнур кожаный","sku":"LSN-BK","color":"черный","qty":79,"price_per_unit":25.0},
{"category":"cords","name":"Шнур кожаный","sku":"LSN-PNK","color":"розовый","qty":136,"price_per_unit":25.0},
{"category":"cords","name":"Петля с карабином","sku":"LP-CR","color":"черный + белый","qty":75,"price_per_unit":25.0},
{"category":"cords","name":"Паракорд розовый 550","sku":"PRKD-PN550","color":"розовый","qty":21,"unit":"м","price_per_unit":23.0},
{"category":"cords","name":"Паракорд фуксия 550","sku":"PRKD-FK550","color":"фуксия","qty":28,"unit":"м","price_per_unit":23.0},
{"category":"cords","name":"Паракорд малиновый 550","sku":"PRKD-RSB550","color":"малиновый","qty":49,"unit":"м","price_per_unit":23.0},
{"category":"cords","name":"Паракорд бирюзовый 550","sku":"PRKD-LZ550","color":"бирюзовый","qty":122,"unit":"м","price_per_unit":23.0},
{"category":"cords","name":"Паракорд с узором","sku":"PRKD-2GLD","color":"золотой + синий","qty":30,"unit":"м","price_per_unit":23.0},
{"category":"cords","name":"Паракорд с узором","sku":"PRKD-2BLCK","color":"графит + черный","qty":41,"unit":"м","price_per_unit":23.0},
{"category":"cords","name":"Паракорд с узором","sku":"PRKD-2VLT","color":"фиолетовый + горчичный","qty":34,"unit":"м","price_per_unit":23.0},
{"category":"cords","name":"Паракорд зеленый неон","sku":"PRKD-NON-LGT-GRN","color":"зеленый неон","qty":46,"unit":"м","price_per_unit":23.0},
{"category":"cords","name":"Паракорд","sku":"PRKD-NON-GRN","color":"зеленый","qty":16,"unit":"м","price_per_unit":23.0},
{"category":"cords","name":"Паракорд","sku":"PRKD-NON-DRK-GRN","color":"болотный","qty":19,"unit":"м","price_per_unit":23.0},
{"category":"cords","name":"Паракорд","sku":"PRKD-NON-GRSS-GRN","color":"зеленый","qty":110,"unit":"м","price_per_unit":23.0},
{"category":"cords","name":"Паракорд","sku":"PRKD-NON-BLCK","color":"черный","qty":9,"unit":"м","price_per_unit":23.0},
{"category":"cords","name":"Паракорд","sku":"PRKD-NON-ORNG","color":"оранжевый","qty":10,"unit":"м","price_per_unit":23.0},
{"category":"cords","name":"Шнур","sku":"STRNG-YLL+WHT","color":"желтый + белый","qty":197,"unit":"м","price_per_unit":23.0},
{"category":"cords","name":"Шнур","sku":"STRNG-BL+WHT","color":"голубой + белый","qty":152,"unit":"м","price_per_unit":23.0},
{"category":"cords","name":"Шнур","sku":"STRNG-DRBL+WHT","color":"синий + белый","qty":112,"unit":"м","price_per_unit":23.0},
{"category":"cords","name":"Шнур","sku":"STRNG-ORNG+WHT","color":"оранжевый + белый","qty":226,"unit":"м","price_per_unit":23.0},
{"category":"cords","name":"Шнур","sku":"STRNG-GRN+WHT","color":"зеленый + белый","qty":471,"unit":"м","price_per_unit":23.0},
{"category":"cords","name":"Паракорд (медуза)","sku":"PRKD-RD","color":"красный","qty":30,"unit":"м","price_per_unit":23.0},
{"category":"cords","name":"Паракорд","sku":"PRKD-BLCK","color":"черный","qty":233,"unit":"м","price_per_unit":23.0},
{"category":"packaging","name":"Конверт","sku":"ENV-65x48","size":"6,5x4,8","color":"калька","qty":151,"price_per_unit":5.0},
{"category":"packaging","name":"Конверт","sku":"ENV-58x58","size":"5,8x5,8","color":"калька","price_per_unit":5},
{"category":"packaging","name":"Конверт","sku":"ENV-95x70","size":"9,5x7","color":"белый","qty":144,"price_per_unit":5.0},
{"category":"packaging","name":"Конверт","sku":"ENV-90x90","size":"9x9","color":"калька","qty":2125,"price_per_unit":5.0},
{"category":"packaging","name":"Конверт","sku":"ENV-100x80","size":"10x8","color":"калька","qty":13,"price_per_unit":5.0},
{"category":"packaging","name":"Конверт","sku":"ENV-130x80","size":"13x8","color":"калька","qty":48,"price_per_unit":5.0},
{"category":"packaging","name":"Конверт","sku":"ENV-150x90","size":"15x9","color":"калька","qty":38,"price_per_unit":5.0},
{"category":"packaging","name":"Конверт","sku":"ENV-100x100","size":"10x10","color":"калька","qty":1240,"price_per_unit":7.0},
{"category":"packaging","name":"Конверт","sku":"ENV-130x130","size":"13x13","color":"калька","qty":288,"price_per_unit":8.0},
{"category":"packaging","name":"Конверт почтовый","sku":"ENV-165x120","size":"16,5x12","color":"калька","qty":28,"price_per_unit":9.0},
{"category":"packaging","name":"Конверт","sku":"ENV-60x60","size":"6x6","color":"калька","qty":1650,"price_per_unit":5.0},
{"category":"packaging","name":"Новая упаковка RO","sku":"BOX-TR"},
{"category":"packaging","name":"Розовая коробка","sku":"BOX-130x80-PK","size":"13x8","color":"розовый","qty":400,"price_per_unit":100.0},
{"category":"packaging","name":"Розовая коробка","sku":"BOX-150x150-PK","size":"15x15","color":"розовый","qty":53,"price_per_unit":100.0},
{"category":"packaging","name":"Розовая коробка","sku":"BOX-130x130-PK","size":"13x13","color":"розовый","qty":47,"price_per_unit":100.0},
{"category":"packaging","name":"Зеркальная коробка","sku":"BOX-MRR","size":"11x11","color":"зеркальная","qty":179,"price_per_unit":150.0},
{"category":"other","name":"Кисточка","sku":"TSC-LBL","size":"5 см","color":"голубой","price_per_unit":15},
{"category":"other","name":"Кисточка","sku":"TSC-WH","size":"5 см","color":"белый","price_per_unit":15},
{"category":"other","name":"Кисточка","sku":"TSC-RD","size":"5 см","color":"красный","price_per_unit":15},
{"category":"other","name":"Кисточка","sku":"TSC-VT","size":"5 см","color":"фиолетовый","qty":109,"price_per_unit":15.0},
{"category":"other","name":"Кисточка","sku":"TSC-PK","size":"5 см","color":"розовый","qty":47,"price_per_unit":15.0},
{"category":"other","name":"Кисточка","sku":"TSC-PKB","size":"5 см","color":"фуксия","qty":101,"price_per_unit":15.0},
{"category":"other","name":"Кисточка","sku":"TSC-LPK","size":"5 см","color":"светло-розовый","qty":103,"price_per_unit":15.0},
{"category":"other","name":"Кисточка","sku":"TSC-LGR","size":"5 см","color":"салатовый","qty":31,"price_per_unit":15.0},
{"category":"other","name":"Зеркало сердце","sku":"MR-HRT","size":"42x36 мм","qty":250,"price_per_unit":26.0},
{"category":"other","name":"Зеркало круг","sku":"MR-CRL","size":"7 см","qty":430,"price_per_unit":26.0},
{"category":"other","name":"Зеркало круг (джуфора)","sku":"MR-9CRL","size":"9 см","qty":350,"price_per_unit":26.0},
{"category":"other","name":"NFC","sku":"NFC","qty":160,"price_per_unit":8.0},
{"category":"other","name":"Ретрактор","sku":"OP-SVL","color":"серебро","qty":128,"price_per_unit":5.0},
{"category":"other","name":"Открывашка","sku":"RET-045-SVL","size":"4,5 см","color":"серебро","qty":530,"price_per_unit":7.0},
{"category":"other","name":"Кисточка","sku":"TSC-BLCK","size":"5 см","color":"черная","qty":28,"price_per_unit":15.0},
{"category":"other","name":"Кисточка","sku":"TSC-DVT","size":"5 см","color":"темно-фиолетовая","qty":10,"price_per_unit":15.0},
{"category":"other","name":"Хуп","sku":"HP-010-SVL","size":"1 см","color":"серебро"},
{"category":"other","name":"Хуп","sku":"HP-020-SVL","size":"2 см","color":"серебро","qty":720},
];
const WAREHOUSE_CATEGORIES = [
{ key: 'carabiners', label: 'Карабины', icon: '🔗', color: '#dbeafe', textColor: '#1d4ed8' },
{ key: 'cables', label: 'Тросы', icon: '⚙', color: '#fef3c7', textColor: '#92400e' },
{ key: 'rings', label: 'Кольца', icon: '⭕', color: '#d1fae5', textColor: '#065f46' },
{ key: 'chains', label: 'Цепочки', icon: '⛓', color: '#e0e7ff', textColor: '#4338ca' },
{ key: 'cords', label: 'Шнуры', icon: '🧵', color: '#fce7f3', textColor: '#9d174d' },
{ key: 'packaging', label: 'Упаковка', icon: '📦', color: '#f3e8ff', textColor: '#7c3aed' },
{ key: 'molds', label: 'Молды', icon: '🧲', color: '#fef2f2', textColor: '#b91c1c' },
{ key: 'other', label: 'Разное', icon: '🔹', color: '#f1f5f9', textColor: '#475569' },
];
const WAREHOUSE_REQUIRED_SEED_SKUS = [
'CR-RNG-025-SV',
];
const DEFAULT_MOLD_CAPACITY_BY_TYPE = {
customer: 1000,
blank: 5000,
};
const BLANK_HARDWARE_FILTER_KEY = 'blank_hardware';
const BLANK_HARDWARE_LOW_STOCK_THRESHOLD = 1000;
const MOLD_USAGE_ALERT_STEP = 1000;
const MOLD_USAGE_ALERT_ASSIGNEE_FALLBACKS = {
lesha: 1772827635013,
anastasia: 1741700002000,
};
const Warehouse = {
allItems: [],
allReservations: [],
editingId: null,
pendingImport: null,
_pendingThumbnail: null,
currentView: 'table',
_viewToken: 0,
_shipmentsLoadedAt: 0,
_viewInitialized: false,
_backgroundSyncPromise: null,
_backgroundSyncScheduled: false,
_projectHardwareMutationQueue: Promise.resolve(),
_projectHardwareMutationDepth: 0,
// Shipment state
allShipments: [],
editingShipmentId: null,
shipmentItems: [],
projectHardwareState: { checks: {}, actual_qtys: {} },
_blankHardwareWarehouseItemIds: new Set(),
auditDraft: null,
inventoryAuditDetailFilters: {},
// ==========================================
// LIFECYCLE
// ==========================================
async load() {
await this._waitForProjectHardwareMutations();
this.allItems = await loadWarehouseItems();
this.allItems = await this._ensureRequiredSeedItems(this.allItems);
await this._loadMoldOrders();
await this._refreshBlankHardwareWarehouseItemIds();
const initialView = this.currentView || 'table';
const shouldDeferHeavySync = initialView !== 'project-hardware';
// Auto-seed on first visit if warehouse is empty
if (this.allItems.length === 0 && WAREHOUSE_SEED_DATA.length > 0) {
await this._seedInitialData();
this.allItems = await loadWarehouseItems();
}
// Legacy photo migrations used localStorage as their rollout flag, which made them
// re-run from every new browser/profile and overwrite already-saved cloud photos.
// We keep the flags for old clients, but the migration itself must now be a no-op.
if (!localStorage.getItem('wh_photo_fix_v3')) {
localStorage.setItem('wh_photo_fix_v3', '1');
console.info('[Warehouse] Skipped legacy photo reset migration (v3) to preserve manual photos');
}
if (!localStorage.getItem('wh_photo_fix_v4')) {
localStorage.setItem('wh_photo_fix_v4', '1');
console.info('[Warehouse] Skipped legacy seed-photo migration (v4) to preserve manual photos');
}
// Migrate: patch prices from WAREHOUSE_SEED_DATA if items have price_per_unit = 0
{
let pricedCount = 0;
this.allItems.forEach(item => {
if (!item.price_per_unit || item.price_per_unit === 0) {
const seed = WAREHOUSE_SEED_DATA.find(s => s.sku === item.sku);
if (seed && seed.price_per_unit > 0) {
item.price_per_unit = seed.price_per_unit;
pricedCount++;
}
}
});
if (pricedCount > 0) {
await saveWarehouseItems(this.allItems);
console.log(`[Warehouse] Patched ${pricedCount} items with seed prices`);
}
}
// Repair untouched seeded rows if the initial quantity in code was wrong or missing.
{
let qtyFixedCount = 0;
this.allItems.forEach(item => {
const seed = WAREHOUSE_SEED_DATA.find(s => s.sku === item.sku);
const seedQty = parseFloat(seed && seed.qty);
if (!Number.isFinite(seedQty)) return;
const currentQty = parseFloat(item.qty);
const createdAt = item.created_at ? String(item.created_at) : '';
const updatedAt = item.updated_at ? String(item.updated_at) : '';
const looksUntouched = !!createdAt && createdAt === updatedAt;
if (!looksUntouched) return;
if (currentQty === seedQty) return;
item.qty = seedQty;
item.updated_at = item.created_at || new Date().toISOString();
qtyFixedCount++;
});
if (qtyFixedCount > 0) {
await saveWarehouseItems(this.allItems);
console.log(`[Warehouse] Repaired ${qtyFixedCount} untouched seed quantities`);
}
}
// Cleanup: remove exact duplicate rows with zero qty
if (this._cleanupZeroDuplicateItems()) {
await saveWarehouseItems(this.allItems);
console.log('[Warehouse] Removed zero-qty duplicate items');
}
this.allReservations = await loadWarehouseReservations();
this.projectHardwareState = await loadProjectHardwareState();
if (!this.projectHardwareState || typeof this.projectHardwareState !== 'object') {
this.projectHardwareState = { checks: {}, actual_qtys: {} };
}
if (!this.projectHardwareState.checks || typeof this.projectHardwareState.checks !== 'object') {
this.projectHardwareState.checks = {};
}
if (!this.projectHardwareState.actual_qtys || typeof this.projectHardwareState.actual_qtys !== 'object') {
this.projectHardwareState.actual_qtys = {};
}
if (!shouldDeferHeavySync) {
await this.reconcileProjectHardwareReservations();
await this._reconcileBlankHardwareLowStockAlerts();
}
this.recalcReservations();
this.populateCategoryFilter();
this.renderStats();
this.setView(initialView, { force: true });
if (shouldDeferHeavySync) {
this._scheduleBackgroundSync();
}
},
_scheduleBackgroundSync() {
if (this._backgroundSyncPromise || this._backgroundSyncScheduled) return;
this._backgroundSyncScheduled = true;
setTimeout(() => {
this._backgroundSyncScheduled = false;
void this._runBackgroundSync();
}, 0);
},
async _runBackgroundSync() {
if (this._backgroundSyncPromise) return this._backgroundSyncPromise;
this._backgroundSyncPromise = (async () => {
try {
await this.reconcileProjectHardwareReservations();
await this._reconcileBlankHardwareLowStockAlerts();
this.recalcReservations();
this.populateCategoryFilter();
this.renderStats();
if (this.currentView === 'table') {
this.filterAndRender();
} else if (this.currentView === 'project-hardware') {
this.renderProjectHardwareView(this._viewToken);
}
} catch (error) {
console.warn('[Warehouse] Background sync failed', error);
} finally {
this._backgroundSyncPromise = null;
}
})();
return this._backgroundSyncPromise;
},
_createRequiredSeedWarehouseItem(raw, id, nowIso = new Date().toISOString()) {
return {
id,
category: raw.category || 'other',
name: raw.name || '',
sku: raw.sku || '',
size: raw.size || '',
color: raw.color || '',
unit: raw.unit || 'шт',
photo_url: '',
photo_thumbnail: (typeof WAREHOUSE_SEED_PHOTOS_BY_SKU !== 'undefined' && WAREHOUSE_SEED_PHOTOS_BY_SKU[raw.sku]) ? WAREHOUSE_SEED_PHOTOS_BY_SKU[raw.sku] : '',
qty: raw.qty || 0,
min_qty: 10,
price_per_unit: raw.price_per_unit || 0,
notes: '',
created_at: nowIso,
updated_at: nowIso,
};
},
async _ensureRequiredSeedItems(items) {
const currentItems = Array.isArray(items) ? [...items] : [];
if (!currentItems.length || !WAREHOUSE_REQUIRED_SEED_SKUS.length) return currentItems;
const requiredSkuSet = new Set(WAREHOUSE_REQUIRED_SEED_SKUS.map(sku => this._normStr(sku)));
const existingSkuSet = new Set(currentItems.map(item => this._normStr(item?.sku)));
const missingSeeds = WAREHOUSE_SEED_DATA.filter(seed =>
requiredSkuSet.has(this._normStr(seed?.sku))
&& !existingSkuSet.has(this._normStr(seed?.sku))
);
if (!missingSeeds.length) return currentItems;
const nowIso = new Date().toISOString();
const baseId = Date.now();
const addedItems = missingSeeds.map((raw, index) => this._createRequiredSeedWarehouseItem(raw, baseId + index, nowIso));
const updatedItems = [...currentItems, ...addedItems];
await saveWarehouseItems(updatedItems);
console.log(`[Warehouse] Added ${addedItems.length} required seed items: ${addedItems.map(item => item.sku).join(', ')}`);
return updatedItems;
},
async _seedInitialData() {
const now = Date.now();
const items = WAREHOUSE_SEED_DATA.map((raw, i) => ({
id: now + i,
category: raw.category || 'other',
name: raw.name || '',
sku: raw.sku || '',
size: raw.size || '',
color: raw.color || '',
unit: raw.unit || 'шт',
photo_url: '',
photo_thumbnail: (typeof WAREHOUSE_SEED_PHOTOS_BY_SKU !== 'undefined' && WAREHOUSE_SEED_PHOTOS_BY_SKU[raw.sku]) ? WAREHOUSE_SEED_PHOTOS_BY_SKU[raw.sku] : '',
qty: raw.qty || 0,
min_qty: 10,
price_per_unit: 0,
notes: '',
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
}));
await saveWarehouseItems(items);
// Record in history
const history = await loadWarehouseHistory();
history.push({
id: Date.now() + 99999,
item_id: 0,
item_name: `Начальная загрузка (${items.length} позиций)`,
item_sku: '',
type: 'import',
qty_change: items.reduce((s, i) => s + (i.qty || 0), 0),
qty_before: 0,
qty_after: 0,
order_name: '',
notes: `Импорт из Excel «Инвентаризация Фурнитуры» — ${items.length} позиций`,
created_at: new Date().toISOString(),
created_by: 'система',
});
await saveWarehouseHistory(history);
console.log(`[Warehouse] Seeded ${items.length} items from WAREHOUSE_SEED_DATA`);
},
recalcReservations() {
this.allItems.forEach(item => {
const activeRes = this.allReservations.filter(
r => r.item_id === item.id && r.status === 'active'
);
item.reserved_qty = activeRes.reduce((s, r) => s + this._parseWarehouseQty(r.qty), 0);
item.available_qty = Math.max(0, this._parseWarehouseQty(item.qty) - item.reserved_qty);
});
},
_parseWarehouseQty(value) {
if (value == null) return 0;
if (typeof value === 'string') {
const cleaned = value
.replace(/[\s\u00A0]/g, '')
.replace(',', '.');
const parsed = parseFloat(cleaned);
return Number.isFinite(parsed) ? parsed : 0;
}
const parsed = parseFloat(value);
return Number.isFinite(parsed) ? parsed : 0;
},
_supportsFractionalWarehouseQty(unitOrItem) {
const rawUnit = typeof unitOrItem === 'object' && unitOrItem !== null
? unitOrItem.unit
: unitOrItem;
const unit = this._normStr(rawUnit || '');
return unit === 'м' || unit === 'см';
},
_warehouseQtyInputStep(unitOrItem) {
return this._supportsFractionalWarehouseQty(unitOrItem) ? 'any' : '1';
},
_formatWarehouseQtyDisplay(value, unitOrItem) {
const qty = this._parseWarehouseQty(value);
const supportsFractions = this._supportsFractionalWarehouseQty(unitOrItem);
return qty.toLocaleString('ru-RU', supportsFractions
? { minimumFractionDigits: 0, maximumFractionDigits: 2 }
: { maximumFractionDigits: 0 });
},
populateCategoryFilter() {
const sel = document.getElementById('wh-filter-category');
if (!sel) return;
sel.innerHTML = 'Все категории ' +
WAREHOUSE_CATEGORIES.map(c =>
`${c.icon} ${c.label} `
).join('') +
`⭐ Бланковая фурнитура `;
},
_auditDraftStorageKey() {
return 'ro_wh_audit_draft_v2';
},
_defaultAuditDraft() {
return {
mode: 'new',
audit_id: null,
baseline: {},
category: '',
search: '',
values: {},
saved_at: '',
};
},
_ensureAuditDraft() {
if (!this.auditDraft || typeof this.auditDraft !== 'object') {
this.auditDraft = this._defaultAuditDraft();
}
if (!this.auditDraft.values || typeof this.auditDraft.values !== 'object') {
this.auditDraft.values = {};
}
if (!this.auditDraft.baseline || typeof this.auditDraft.baseline !== 'object') {
this.auditDraft.baseline = {};
}
if (this.auditDraft.mode !== 'edit') {
this.auditDraft.mode = 'new';
this.auditDraft.audit_id = null;
}
return this.auditDraft;
},
_loadAuditDraft() {
try {
const raw = localStorage.getItem(this._auditDraftStorageKey());
if (!raw) return this._defaultAuditDraft();
const parsed = JSON.parse(raw);
return {
...this._defaultAuditDraft(),
...(parsed && typeof parsed === 'object' ? parsed : {}),
values: parsed && typeof parsed.values === 'object' && parsed.values ? parsed.values : {},
baseline: parsed && typeof parsed.baseline === 'object' && parsed.baseline ? parsed.baseline : {},
mode: parsed && parsed.mode === 'edit' ? 'edit' : 'new',
audit_id: parsed && parsed.mode === 'edit' ? Number(parsed.audit_id || 0) || null : null,
};
} catch (_) {
return this._defaultAuditDraft();
}
},
_isAuditEditMode(draft = null) {
const current = draft && typeof draft === 'object' ? draft : this._ensureAuditDraft();
return current.mode === 'edit' && Number(current.audit_id || 0) > 0;
},
_getAuditBaselineQty(itemId, fallbackQty) {
const draft = this._ensureAuditDraft();
const key = String(Number(itemId || 0) || itemId || '');
if (this._isAuditEditMode(draft) && key && Object.prototype.hasOwnProperty.call(draft.baseline || {}, key)) {
return this._parseWarehouseQty(draft.baseline[key]);
}
return this._parseWarehouseQty(fallbackQty);
},
_updateAuditFormMode() {
const titleEl = document.getElementById('wh-audit-title');
const descriptionEl = document.getElementById('wh-audit-description');
const submitEl = document.getElementById('wh-audit-submit-btn');
const isEdit = this._isAuditEditMode();
if (titleEl) {
titleEl.textContent = isEdit ? 'Редактирование инвентаризации' : 'Инвентаризация';
}
if (descriptionEl) {
descriptionEl.textContent = isEdit
? 'Исправьте фактические остатки и сохраните — система пересчитает расхождения, перепишет эту инвентаризацию и обновит текущие остатки на складе.'
: 'Вводите фактические остатки с автосохранением в черновик. В конце подтвердите приёмку инвентаризации — система скорректирует остатки и запишет сумму расхождений.';
}
if (submitEl) {
submitEl.textContent = isEdit ? 'Сохранить изменения' : 'Принять инвентаризацию';
}
},
_persistAuditDraft() {
const draft = this._ensureAuditDraft();
draft.saved_at = new Date().toISOString();
localStorage.setItem(this._auditDraftStorageKey(), JSON.stringify(draft));
this._updateAuditDraftStatus();
this._updateAuditSummary();
},
saveAuditDraft(showToast = false) {
this._persistAuditDraft();
if (showToast) App.toast('Черновик инвентаризации сохранён');
},
clearAuditDraft() {
const hasEntries = Object.keys((this.auditDraft && this.auditDraft.values) || {}).length > 0 || !!(this.auditDraft && this.auditDraft.search);
if (hasEntries && !confirm('Очистить черновик инвентаризации? Введённые фактические остатки будут сброшены.')) return;
this.auditDraft = this._defaultAuditDraft();
localStorage.removeItem(this._auditDraftStorageKey());
const categoryEl = document.getElementById('wh-audit-category');
if (categoryEl) categoryEl.value = '';
const searchEl = document.getElementById('wh-audit-search');
if (searchEl) searchEl.value = '';
this._updateAuditFormMode();
this.renderAuditTable('');
this._updateAuditDraftStatus();
this._updateAuditSummary();
App.toast('Черновик инвентаризации очищен');
},
_populateAuditCategoryFilter() {
const sel = document.getElementById('wh-audit-category');
if (!sel) return;
const counts = new Map();
(this.allItems || []).forEach(item => {
const key = String(item && item.category || '');
counts.set(key, (counts.get(key) || 0) + 1);
});
const blankCount = (this.allItems || []).filter(item => this._isBlankHardwareWarehouseItem(item)).length;
const options = ['Все категории '];
WAREHOUSE_CATEGORIES.forEach(cat => {
const count = counts.get(cat.key) || 0;
options.push(`${cat.icon} ${cat.label}${count ? ` (${count})` : ''} `);
});
options.push(`⭐ Бланковая фурнитура${blankCount ? ` (${blankCount})` : ''} `);
sel.innerHTML = options.join('');
sel.value = (this.auditDraft && this.auditDraft.category) || '';
if (sel.value !== ((this.auditDraft && this.auditDraft.category) || '')) {
sel.value = '';
}
},
_getAuditFilteredItems(category, search) {
let items = [...(this.allItems || [])];
if (category) {
if (category === BLANK_HARDWARE_FILTER_KEY) {
items = items.filter(item => this._isBlankHardwareWarehouseItem(item));
} else {
items = items.filter(item => String(item.category || '') === category);
}
}
const query = String(search || '').trim().toLowerCase();
if (query) {
items = items.filter(item =>
String(item.name || '').toLowerCase().includes(query)
|| String(item.sku || '').toLowerCase().includes(query)
|| String(item.color || '').toLowerCase().includes(query)
);
}
items.sort((a, b) => {
const catA = String(a && a.category || '');
const catB = String(b && b.category || '');
if (catA !== catB) return catA.localeCompare(catB, 'ru');
return String(a && a.name || '').localeCompare(String(b && b.name || ''), 'ru');
});
return items;
},
_getAuditStoredValue(itemId) {
const draft = this._ensureAuditDraft();
const key = String(Number(itemId || 0) || itemId || '');
return Object.prototype.hasOwnProperty.call(draft.values, key) ? String(draft.values[key]) : '';
},
_getAuditDiffMeta(item, actualValue) {
const systemQty = this._getAuditBaselineQty(item && item.id, item && item.qty);
const actualQty = actualValue === '' || actualValue == null ? NaN : this._parseWarehouseQty(actualValue);
if (!Number.isFinite(actualQty)) {
return {
systemQty,
actualQty: null,
diff: null,
valueDiff: null,
};
}
const diff = actualQty - systemQty;
const unitPrice = Math.max(0, parseFloat(item && item.price_per_unit) || 0);
return {
systemQty,
actualQty,
diff,
valueDiff: diff * unitPrice,
};
},
_renderAuditDiffMarkup(item, actualValue) {
const meta = this._getAuditDiffMeta(item, actualValue);
if (meta.diff == null) {
return {
qty: '—',
qtyClass: 'text-right audit-diff',
money: '—',
moneyClass: 'text-right audit-diff',
};
}
if (Math.abs(meta.diff) < 0.000001) {
return {
qty: '0',
qtyClass: 'text-right audit-diff audit-zero',
money: this._formatMoney(0),
moneyClass: 'text-right audit-diff audit-zero',
};
}
if (meta.diff > 0) {
return {
qty: `+${meta.diff}`,
qtyClass: 'text-right audit-diff audit-positive',
money: `+${this._formatMoney(meta.valueDiff)}`,
moneyClass: 'text-right audit-diff audit-positive',
};
}
return {
qty: String(meta.diff),
qtyClass: 'text-right audit-diff audit-negative',
money: `−${this._formatMoney(Math.abs(meta.valueDiff))}`,
moneyClass: 'text-right audit-diff audit-negative',
};
},
_updateAuditRowDiff(itemId) {
const numericId = Number(itemId || 0);
const item = (this.allItems || []).find(entry => Number(entry && entry.id || 0) === numericId);
if (!item) return;
const rendered = this._renderAuditDiffMarkup(item, this._getAuditStoredValue(numericId));
const diffEl = document.getElementById(`audit-diff-${numericId}`);
if (diffEl) {
diffEl.textContent = rendered.qty;
diffEl.className = rendered.qtyClass;
}
const moneyEl = document.getElementById(`audit-money-${numericId}`);
if (moneyEl) {
moneyEl.textContent = rendered.money;
moneyEl.className = rendered.moneyClass;
}
},
_getAuditSummaryStats() {
const draft = this._ensureAuditDraft();
const stats = {
enteredPositions: 0,
changedPositions: 0,
shortageQty: 0,
shortageValue: 0,
surplusQty: 0,
surplusValue: 0,
netQty: 0,
netValue: 0,
};
Object.entries(draft.values || {}).forEach(([rawId, rawValue]) => {
if (rawValue === '' || rawValue == null) return;
const itemId = Number(rawId || 0);
const item = (this.allItems || []).find(entry => Number(entry && entry.id || 0) === itemId);
if (!item) return;
stats.enteredPositions += 1;
const meta = this._getAuditDiffMeta(item, rawValue);
if (meta.diff == null || Math.abs(meta.diff) < 0.000001) return;
stats.changedPositions += 1;
stats.netQty += meta.diff;
stats.netValue += meta.valueDiff || 0;
if (meta.diff < 0) {
stats.shortageQty += Math.abs(meta.diff);
stats.shortageValue += Math.abs(meta.valueDiff || 0);
} else {
stats.surplusQty += meta.diff;
stats.surplusValue += Math.abs(meta.valueDiff || 0);
}
});
stats.shortageValue = Math.round(stats.shortageValue * 100) / 100;
stats.surplusValue = Math.round(stats.surplusValue * 100) / 100;
stats.netValue = Math.round(stats.netValue * 100) / 100;
return stats;
},
_updateAuditDraftStatus() {
const el = document.getElementById('wh-audit-draft-status');
if (!el) return;
const draft = this._ensureAuditDraft();
const count = Object.values(draft.values || {}).filter(value => String(value || '') !== '').length;
const draftLabel = this._isAuditEditMode(draft) ? 'Черновик правок' : 'Черновик';
if (!draft.saved_at) {
el.textContent = count > 0
? `${draftLabel} готов к автосохранению · ${count} поз.`
: `${draftLabel} будет сохраняться автоматически`;
return;
}
const stamp = new Date(draft.saved_at);
const safeStamp = Number.isNaN(stamp.getTime()) ? String(draft.saved_at) : stamp.toLocaleString('ru-RU');
el.textContent = `${draftLabel} автосохранён · ${safeStamp} · ${count} поз.`;
},
_updateAuditSummary() {
const el = document.getElementById('wh-audit-summary');
if (!el) return;
const stats = this._getAuditSummaryStats();
if (stats.changedPositions === 0) {
el.textContent = stats.enteredPositions > 0
? `Проверено ${stats.enteredPositions} поз. · Расхождений пока нет`
: 'Изменений пока нет';
return;
}
el.textContent = [
`Позиции с расхождением: ${stats.changedPositions}`,
`Недостача: ${this._formatMoney(stats.shortageValue)}`,
`Излишек: ${this._formatMoney(stats.surplusValue)}`,
`Нетто: ${stats.netValue >= 0 ? '+' : '−'}${this._formatMoney(Math.abs(stats.netValue))}`,
].join(' · ');
},
_roundInventoryNumber(value) {
return Math.round((parseFloat(value) || 0) * 100) / 100;
},
_formatInventoryQty(value, unit) {
const rounded = this._roundInventoryNumber(value);
const normalizedUnit = String(unit || 'шт').trim();
return normalizedUnit
? `${rounded.toLocaleString('ru-RU')} ${this.esc(normalizedUnit)}`
: rounded.toLocaleString('ru-RU');
},
_formatInventoryDateTime(value) {
if (!value) return '—';
try {
return new Intl.DateTimeFormat('ru-RU', {
dateStyle: 'medium',
timeStyle: 'short',
}).format(new Date(value));
} catch (e) {
return String(value || '—');
}
},
_normalizeInventoryAuditCount(value) {
const numeric = Number(value);
return Number.isFinite(numeric) ? Math.max(0, Math.round(numeric)) : null;
},
_normalizeInventoryAuditDetail(detail, currentItemsById) {
const raw = detail && typeof detail === 'object' ? detail : {};
const itemId = Number(raw.item_id || raw.id || 0);
const currentItem = itemId ? currentItemsById.get(itemId) || null : null;
const systemQtyRaw = raw.system_qty_before ?? raw.qty_before ?? raw.system_qty ?? raw.before_qty;
const actualQtyRaw = raw.actual_qty ?? raw.qty_after ?? raw.fact_qty ?? raw.actual;
const explicitDiff = raw.diff;
const unitPriceRaw = raw.price_per_unit ?? raw.unit_price ?? (currentItem && currentItem.price_per_unit);
const valueDiffRaw = raw.value_diff ?? raw.total_cost_change;
const systemQty = this._roundInventoryNumber(systemQtyRaw);
const actualQtyParsed = actualQtyRaw === '' || actualQtyRaw == null ? null : parseFloat(actualQtyRaw);
const actualQty = Number.isFinite(actualQtyParsed) ? this._roundInventoryNumber(actualQtyParsed) : null;
const parsedDiff = explicitDiff === '' || explicitDiff == null ? NaN : parseFloat(explicitDiff);
const diff = Number.isFinite(parsedDiff)
? this._roundInventoryNumber(parsedDiff)
: (actualQty == null ? 0 : this._roundInventoryNumber(actualQty - systemQty));
const unitPrice = this._roundInventoryNumber(unitPriceRaw);
const parsedValueDiff = valueDiffRaw === '' || valueDiffRaw == null ? NaN : parseFloat(valueDiffRaw);
const valueDiff = Number.isFinite(parsedValueDiff)
? this._roundInventoryNumber(parsedValueDiff)
: this._roundInventoryNumber(diff * unitPrice);
const currentQty = currentItem ? this._roundInventoryNumber(currentItem.qty) : null;
const matchesCurrent = actualQty != null && currentQty != null
? Math.abs(currentQty - actualQty) < 0.000001
: null;
return {
item_id: itemId || null,
item_name: String(raw.item_name || raw.name || (currentItem && currentItem.name) || ''),
item_sku: String(raw.item_sku || raw.sku || (currentItem && currentItem.sku) || ''),
item_category: String(raw.item_category || raw.category || (currentItem && currentItem.category) || ''),
unit: String(raw.unit || (currentItem && currentItem.unit) || 'шт'),
system_qty_before: systemQty,
actual_qty: actualQty,
diff,
value_diff: valueDiff,
price_per_unit: unitPrice,
current_qty: currentQty,
matches_current: matchesCurrent,
is_changed: Math.abs(diff) >= 0.000001,
};
},
_getInventoryAuditAdjustments(auditEntry, history) {
const auditId = Number(auditEntry && auditEntry.id || 0);
const auditTime = new Date(auditEntry && auditEntry.created_at || 0).getTime();
const auditManager = String(auditEntry && auditEntry.created_by || '').trim();
return (history || []).filter(entry => {
if (!entry || entry.type !== 'adjustment') return false;
if (auditId && Number(entry.inventory_audit_id || 0) === auditId) return true;
if (!/Инвентаризация:/i.test(String(entry.notes || ''))) return false;
const entryTime = new Date(entry.created_at || 0).getTime();
if (!Number.isFinite(auditTime) || !Number.isFinite(entryTime) || Math.abs(entryTime - auditTime) > 5 * 60 * 1000) {
return false;
}
if (auditManager) {
const entryManager = String(entry.created_by || '').trim();
if (entryManager && entryManager !== auditManager) return false;
}
return true;
}).sort((a, b) => {
const catA = String(a && a.item_category || '');
const catB = String(b && b.item_category || '');
if (catA !== catB) return catA.localeCompare(catB, 'ru');
const nameA = String(a && a.item_name || '');
const nameB = String(b && b.item_name || '');
if (nameA !== nameB) return nameA.localeCompare(nameB, 'ru');
return Number(a && a.item_id || 0) - Number(b && b.item_id || 0);
});
},
_buildLegacyInventoryAuditDetails(auditEntry, history, currentItemsById) {
return this._getInventoryAuditAdjustments(auditEntry, history).map(entry => this._normalizeInventoryAuditDetail({
item_id: entry.item_id,
item_name: entry.item_name,
item_sku: entry.item_sku,
item_category: entry.item_category,
unit: (currentItemsById.get(Number(entry.item_id || 0)) || {}).unit || 'шт',
system_qty_before: entry.qty_before,
actual_qty: entry.qty_after,
diff: entry.qty_change,
price_per_unit: entry.unit_price,
value_diff: (parseFloat(entry.qty_change) || 0) * (parseFloat(entry.unit_price) || 0),
}, currentItemsById));
},
_getInventoryAuditEntries(history) {
const currentItemsById = new Map((this.allItems || []).map(item => [Number(item.id || 0), item]));
return (history || [])
.filter(entry => entry && entry.type === 'inventory_audit')
.sort((a, b) => new Date(b.created_at) - new Date(a.created_at))
.map(entry => {
const storedDetails = Array.isArray(entry.inventory_details) ? entry.inventory_details : [];
const detailSource = storedDetails.length > 0 ? 'stored' : 'derived';
const details = (storedDetails.length > 0
? storedDetails.map(detail => this._normalizeInventoryAuditDetail(detail, currentItemsById))
: this._buildLegacyInventoryAuditDetails(entry, history, currentItemsById))
.filter(Boolean);
const comparableDetails = details.filter(detail => detail.actual_qty != null && detail.current_qty != null);
const matchedCurrentCount = comparableDetails.filter(detail => detail.matches_current).length;
const changedPositions = this._normalizeInventoryAuditCount(entry.inventory_positions_changed)
?? details.filter(detail => detail.is_changed).length;
const enteredPositions = this._normalizeInventoryAuditCount(entry.inventory_entered_positions)
?? (storedDetails.length > 0 ? details.length : null);
const totalPositions = this._normalizeInventoryAuditCount(entry.inventory_total_positions);
const unchangedPositions = this._normalizeInventoryAuditCount(entry.inventory_positions_unchanged)
?? (enteredPositions != null ? Math.max(0, enteredPositions - changedPositions) : null);
const omittedPositions = this._normalizeInventoryAuditCount(entry.inventory_positions_omitted)
?? (enteredPositions != null && totalPositions != null ? Math.max(0, totalPositions - enteredPositions) : null);
return {
...entry,
details,
detailSource,
changedPositions,
enteredPositions,
totalPositions,
unchangedPositions,
omittedPositions,
shortageValue: this._roundInventoryNumber(entry.inventory_shortage_value),
surplusValue: this._roundInventoryNumber(entry.inventory_surplus_value),
netValue: this._roundInventoryNumber(entry.inventory_net_value),
currentCheckCount: comparableDetails.length,
currentMatchCount: matchedCurrentCount,
};
});
},
_findInventoryAuditSummaryEntry(history, auditId) {
const numericAuditId = Number(auditId || 0);
if (!numericAuditId) return null;
return (history || []).find(entry =>
entry
&& entry.type === 'inventory_audit'
&& Number(entry.id || 0) === numericAuditId
) || null;
},
_getInventoryAuditMutationContext(auditId, history, audits = null) {
const auditEntry = this._findInventoryAuditSummaryEntry(history, auditId);
if (!auditEntry) return null;
const auditList = Array.isArray(audits) ? audits : this._getInventoryAuditEntries(history);
const audit = auditList.find(entry => Number(entry && entry.id || 0) === Number(auditId || 0)) || null;
const adjustments = this._getInventoryAuditAdjustments(auditEntry, history);
const relatedEntries = [auditEntry, ...adjustments];
const relatedEntrySet = new Set(relatedEntries);
const relatedIndexes = new Set();
let lastRelatedIndex = -1;
(history || []).forEach((entry, index) => {
if (relatedEntrySet.has(entry)) {
relatedIndexes.add(index);
lastRelatedIndex = index;
}
});
const laterEntries = lastRelatedIndex >= 0
? (history || []).slice(lastRelatedIndex + 1).filter(Boolean)
: [];
const canMutate = laterEntries.length === 0;
return {
auditEntry,
audit,
adjustments,
relatedEntries,
relatedIndexes,
laterEntries,
canMutate,
blockedReason: canMutate
? ''
: 'Эту инвентаризацию уже нельзя безопасно менять: после неё были другие движения по складу.',
};
},
_buildInventoryAuditDraftFromAudit(audit) {
const draft = this._defaultAuditDraft();
draft.mode = 'edit';
draft.audit_id = Number(audit && audit.id || 0) || null;
draft.values = {};
draft.baseline = {};
(audit && Array.isArray(audit.details) ? audit.details : []).forEach(detail => {
const itemId = Number(detail && detail.item_id || 0);
if (!itemId) return;
draft.baseline[String(itemId)] = String(this._roundInventoryNumber(detail.system_qty_before));
if (detail.actual_qty != null) {
draft.values[String(itemId)] = String(this._roundInventoryNumber(detail.actual_qty));
}
});
return draft;
},
_inventoryAuditDetailsChanged(originalDetails, nextDetails) {
const normalize = (details) => (Array.isArray(details) ? details : [])
.map(detail => ({
item_id: Number(detail && detail.item_id || 0),
system_qty_before: this._roundInventoryNumber(detail && detail.system_qty_before),
actual_qty: detail && detail.actual_qty == null ? null : this._roundInventoryNumber(detail.actual_qty),
}))
.filter(detail => detail.item_id > 0 && detail.actual_qty != null)
.sort((a, b) => a.item_id - b.item_id);
return JSON.stringify(normalize(originalDetails)) !== JSON.stringify(normalize(nextDetails));
},
_buildInventoryAuditSummaryEntry(auditId, createdAt, createdBy, stats, enteredRows, detailLines) {
return {
id: auditId,
item_id: 0,
item_name: 'Инвентаризация склада',
item_sku: '',
item_category: '',
type: 'inventory_audit',
qty_change: Math.round(stats.netQty * 100) / 100,
requested_qty_change: Math.round(stats.netQty * 100) / 100,
qty_before: 0,
qty_after: 0,
unit_price: 0,
total_cost_change: Math.round(stats.shortageValue * 100) / 100,
order_id: null,
order_name: '',
notes: `Скорректировано ${stats.changedPositions} поз.; недостача ${this._formatMoney(stats.shortageValue)}, излишек ${this._formatMoney(stats.surplusValue)}, нетто ${stats.netValue >= 0 ? '+' : '−'}${this._formatMoney(Math.abs(stats.netValue))}. Детали: ${detailLines.length ? detailLines.slice(0, 12).join('; ') : 'без расхождений'}`,
clamped: false,
created_at: createdAt,
created_by: createdBy || '',
updated_at: new Date().toISOString(),
inventory_shortage_value: Math.round(stats.shortageValue * 100) / 100,
inventory_surplus_value: Math.round(stats.surplusValue * 100) / 100,
inventory_net_value: Math.round(stats.netValue * 100) / 100,
inventory_positions_changed: stats.changedPositions,
inventory_entered_positions: stats.enteredPositions,
inventory_positions_unchanged: Math.max(0, stats.enteredPositions - stats.changedPositions),
inventory_total_positions: (this.allItems || []).length,
inventory_positions_omitted: Math.max(0, (this.allItems || []).length - stats.enteredPositions),
inventory_details: enteredRows,
};
},
_getInventoryAuditDetailFilterState(auditId) {
const numericAuditId = Number(auditId || 0);
if (!numericAuditId) {
return {
onlyChanged: false,
onlyCurrentMismatch: false,
};
}
const rawState = this.inventoryAuditDetailFilters && this.inventoryAuditDetailFilters[String(numericAuditId)];
if (rawState && typeof rawState === 'object') {
return {
onlyChanged: Boolean(rawState.onlyChanged),
onlyCurrentMismatch: Boolean(rawState.onlyCurrentMismatch),
};
}
return {
onlyChanged: Boolean(rawState),
onlyCurrentMismatch: false,
};
},
_setInventoryAuditDetailFilterState(auditId, patch) {
const numericAuditId = Number(auditId || 0);
if (!numericAuditId) return;
if (!this.inventoryAuditDetailFilters || typeof this.inventoryAuditDetailFilters !== 'object') {
this.inventoryAuditDetailFilters = {};
}
const nextState = {
...this._getInventoryAuditDetailFilterState(numericAuditId),
...(patch || {}),
};
if (!nextState.onlyChanged && !nextState.onlyCurrentMismatch) {
delete this.inventoryAuditDetailFilters[String(numericAuditId)];
return;
}
this.inventoryAuditDetailFilters[String(numericAuditId)] = nextState;
},
_isInventoryAuditOnlyChanged(auditId) {
return this._getInventoryAuditDetailFilterState(auditId).onlyChanged;
},
_isInventoryAuditOnlyCurrentMismatch(auditId) {
return this._getInventoryAuditDetailFilterState(auditId).onlyCurrentMismatch;
},
async toggleInventoryAuditOnlyChanged(auditId, enabled) {
const numericAuditId = Number(auditId || 0);
if (!numericAuditId) return;
this._setInventoryAuditDetailFilterState(numericAuditId, { onlyChanged: Boolean(enabled) });
await this.renderInventoryView();
const detailsEl = document.getElementById(`wh-inventory-details-${numericAuditId}`);
if (detailsEl) detailsEl.open = true;
},
async toggleInventoryAuditOnlyCurrentMismatch(auditId, enabled) {
const numericAuditId = Number(auditId || 0);
if (!numericAuditId) return;
this._setInventoryAuditDetailFilterState(numericAuditId, { onlyCurrentMismatch: Boolean(enabled) });
await this.renderInventoryView();
const detailsEl = document.getElementById(`wh-inventory-details-${numericAuditId}`);
if (detailsEl) detailsEl.open = true;
},
_renderInventoryAuditCard(audit, isLatest, mutationContext = null) {
const chips = [
`
С расхождением в инвентаризации ${audit.changedPositions}
`,
audit.enteredPositions != null
? `Вписано ${audit.enteredPositions}
`
: '',
audit.unchangedPositions != null
? `Совпало в инвентаризации ${audit.unchangedPositions}
`
: '',
audit.omittedPositions != null
? `Не вписали в инвентаризации ${audit.omittedPositions}
`
: '',
`Недостача ${this._formatMoney(audit.shortageValue)}
`,
`Излишек ${this._formatMoney(audit.surplusValue)}
`,
`Нетто ${audit.netValue >= 0 ? '+' : '−'}${this._formatMoney(Math.abs(audit.netValue))}
`,
].filter(Boolean).join('');
const noteLines = [];
noteLines.push('Верхние цифры показывают состояние на момент инвентаризации. Колонка "Сейчас на складе" и проверка ниже показывают уже текущее состояние после применённых корректировок.');
if (audit.detailSource === 'derived') {
noteLines.push('Подробные строки восстановлены из истории корректировок. В первой версии инвентаризации система сохраняла только позиции с расхождением, без строк "совпало" и без списка невнесённых позиций.');
}
if (audit.currentCheckCount > 0) {
noteLines.push(`Проверка сейчас на складе: совпадает ${audit.currentMatchCount} из ${audit.currentCheckCount} детализированных строк.`);
}
if (!noteLines.length && audit.notes) {
noteLines.push(String(audit.notes));
}
if (mutationContext && !mutationContext.canMutate && mutationContext.blockedReason) {
noteLines.push(mutationContext.blockedReason);
}
const actionButtons = mutationContext && mutationContext.canMutate
? `
Редактировать
Удалить
`
: '';
const orderedDetails = [...audit.details].sort((a, b) => {
const changedDiff = Number(Boolean(b && b.is_changed)) - Number(Boolean(a && a.is_changed));
if (changedDiff !== 0) return changedDiff;
const currentMismatchDiff = Number(b && b.matches_current === false) - Number(a && a.matches_current === false);
if (currentMismatchDiff !== 0) return currentMismatchDiff;
const categoryDiff = String(a && a.item_category || '').localeCompare(String(b && b.item_category || ''), 'ru');
if (categoryDiff !== 0) return categoryDiff;
const nameDiff = String(a && a.item_name || '').localeCompare(String(b && b.item_name || ''), 'ru');
if (nameDiff !== 0) return nameDiff;
return Number(a && a.item_id || 0) - Number(b && b.item_id || 0);
});
const onlyChanged = this._isInventoryAuditOnlyChanged(audit.id);
const onlyCurrentMismatch = this._isInventoryAuditOnlyCurrentMismatch(audit.id);
const currentMismatchCount = orderedDetails.filter(detail => detail && detail.matches_current === false).length;
const visibleDetails = orderedDetails.filter(detail => {
if (onlyChanged && !(detail && detail.is_changed)) return false;
if (onlyCurrentMismatch && !(detail && detail.matches_current === false)) return false;
return true;
});
const detailsRows = visibleDetails.map(detail => {
const cat = WAREHOUSE_CATEGORIES.find(entry => entry.key === detail.item_category);
const diffClass = detail.diff > 0
? 'audit-positive'
: (detail.diff < 0 ? 'audit-negative' : 'audit-zero');
const diffText = detail.diff > 0
? `+${this._formatInventoryQty(detail.diff, detail.unit)}`
: (detail.diff < 0
? `−${this._formatInventoryQty(Math.abs(detail.diff), detail.unit)}`
: this._formatInventoryQty(0, detail.unit));
const valueText = detail.value_diff > 0
? `+${this._formatMoney(detail.value_diff)}`
: (detail.value_diff < 0
? `−${this._formatMoney(Math.abs(detail.value_diff))}`
: this._formatMoney(0));
const verifyClass = detail.matches_current == null
? 'wh-inventory-status-muted'
: (detail.matches_current ? 'wh-inventory-status-ok' : 'wh-inventory-status-warn');
const verifyLabel = detail.matches_current == null
? 'Нет данных'
: (detail.matches_current ? 'Совпадает' : 'Изменилось');
return `
${cat?.label || '—'}
${this.esc(detail.item_name || '—')}
${detail.item_sku ? `${this.esc(detail.item_sku)}
` : ''}
${this._formatInventoryQty(detail.system_qty_before, detail.unit)}
${detail.actual_qty == null ? '—' : this._formatInventoryQty(detail.actual_qty, detail.unit)}
${diffText}
${valueText}
${detail.current_qty == null ? '—' : this._formatInventoryQty(detail.current_qty, detail.unit)}
${verifyLabel}
`;
}).join('');
const filterControls = audit.details.length > 0
? `
Только расхождения (${audit.changedPositions})
Только несовпадающие сейчас (${currentMismatchCount})
`
: '';
return `
Инвентаризация от ${this._formatInventoryDateTime(audit.created_at)}
${isLatest ? 'Последняя ' : ''}
Провёл: ${this.esc(audit.created_by || '—')}
${audit.totalPositions != null ? ` · Всего позиций на складе: ${audit.totalPositions}` : ''}
${actionButtons}
${chips}
${noteLines.map(line => `
${this.esc(line)}
`).join('')}
${audit.details.length > 0 ? `
Показать детали (${audit.details.length})
${filterControls}
Категория
Позиция
Было в системе
Факт в инвентаризации
Разница в инвентаризации
Расхождение ₽
Сейчас на складе
${detailsRows || `В этой инвентаризации нет строк с расхождением. `}
` : `
Для этой инвентаризации в истории нет детализированных строк.
`}
`;
},
async renderInventoryView() {
const container = document.getElementById('wh-content');
if (!container) return;
const history = await loadWarehouseHistory();
const audits = this._getInventoryAuditEntries(history);
const mutationContexts = new Map(audits.map(audit => [
Number(audit && audit.id || 0),
this._getInventoryAuditMutationContext(audit && audit.id, history, audits),
]));
if (audits.length === 0) {
container.innerHTML = `
Инвентаризаций пока нет
☑ Провести первую инвентаризацию
`;
return;
}
container.innerHTML = `
${audits.map((audit, index) => this._renderInventoryAuditCard(
audit,
index === 0,
mutationContexts.get(Number(audit && audit.id || 0)) || null
)).join('')}
`;
},
// ==========================================
// STATS
// ==========================================
async renderStats() {
const items = this.allItems;
const totalItems = items.length;
const totalQty = items.reduce((s, i) => s + this._parseWarehouseQty(i.qty), 0);
const totalReserved = items.reduce((s, i) => s + this._parseWarehouseQty(i.reserved_qty), 0);
const lowStock = items.filter(i => i.min_qty > 0 && i.qty < i.min_qty).length;
const frozenHardware = items.reduce((s, i) => {
const qty = Math.max(0, parseFloat(i.qty) || 0);
const unitCost = Math.max(0, parseFloat(i.price_per_unit) || 0);
return s + qty * unitCost;
}, 0);
const frozenReadyGoods = await this._getReadyGoodsFrozenAmount();
const frozenTotal = frozenHardware + frozenReadyGoods;
const el = (id, val) => { const e = document.getElementById(id); if (e) e.textContent = val; };
el('wh-total-items', totalItems);
el('wh-total-qty', totalQty.toLocaleString('ru-RU'));
el('wh-total-reserved', totalReserved.toLocaleString('ru-RU'));
el('wh-low-stock', lowStock);
el('wh-frozen-total', this._formatMoney(frozenTotal));
el('wh-frozen-hw', this._formatMoney(frozenHardware));
// Ready goods stats
const rg = await loadReadyGoods();
const rgTotalQty = rg.reduce((s, i) => s + (parseFloat(i.qty) || 0), 0);
const rgFrozen = rg.reduce((s, i) => s + (parseFloat(i.qty) || 0) * (parseFloat(i.cost_per_unit) || 0), 0);
el('wh-ready-goods-count', rgTotalQty.toLocaleString('ru-RU'));
el('wh-frozen-rg', this._formatMoney(rgFrozen));
},
// ==========================================
// FILTERING & RENDERING
// ==========================================
filterAndRender() {
let items = [...this.allItems];
// Category filter
const cat = document.getElementById('wh-filter-category');
if (cat && cat.value) {
if (cat.value === BLANK_HARDWARE_FILTER_KEY) {
items = items.filter(i => this._isBlankHardwareWarehouseItem(i));
} else {
items = items.filter(i => i.category === cat.value);
}
}
// Stock filter
const stock = document.getElementById('wh-filter-stock');
if (stock && stock.value) {
switch (stock.value) {
case 'in_stock': items = items.filter(i => i.qty > 0); break;
case 'low': items = items.filter(i => i.min_qty > 0 && i.qty > 0 && i.qty < i.min_qty); break;
case 'out': items = items.filter(i => i.qty <= 0); break;
case 'reserved': items = items.filter(i => i.reserved_qty > 0); break;
}
}
// Search
const search = document.getElementById('wh-search');
if (search && search.value.trim()) {
const q = search.value.trim().toLowerCase();
items = items.filter(i =>
(i.name || '').toLowerCase().includes(q)
|| (i.sku || '').toLowerCase().includes(q)
|| (i.color || '').toLowerCase().includes(q)
);
}
// Sort
const sort = document.getElementById('wh-sort');
const sortVal = sort ? sort.value : 'name';
switch (sortVal) {
case 'name': items.sort((a, b) => (a.name || '').localeCompare(b.name || '', 'ru')); break;
case 'qty_desc': items.sort((a, b) => (b.qty || 0) - (a.qty || 0)); break;
case 'qty_asc': items.sort((a, b) => (a.qty || 0) - (b.qty || 0)); break;
case 'category': items.sort((a, b) => (a.category || '').localeCompare(b.category || '')); break;
}
this.renderTable(items);
},
/** Collect unique color values from all warehouse items */
_getUniqueColors() {
const colors = new Set();
(this.allItems || []).forEach(item => {
if (item.color && item.color.trim()) colors.add(item.color.trim());
});
return [...colors].sort((a, b) => a.localeCompare(b, 'ru'));
},
_isMoldCategory(category) {
return String(category || '').toLowerCase() === 'molds';
},
_normalizeMoldType(value) {
return String(value || '').toLowerCase() === 'blank' ? 'blank' : 'customer';
},
_defaultMoldCapacityTotal(moldType) {
return DEFAULT_MOLD_CAPACITY_BY_TYPE[this._normalizeMoldType(moldType)] || 0;
},
_normalizeSimpleText(value) {
return String(value || '')
.trim()
.toLowerCase()
.replace(/\s+/g, ' ');
},
_isWarehouseBackedHwBlank(blank) {
if (!blank || typeof blank !== 'object') return false;
if (blank.hw_form_source) return String(blank.hw_form_source) === 'warehouse';
return Number(blank.warehouse_item_id || 0) > 0;
},
async _refreshBlankHardwareWarehouseItemIds() {
if (typeof loadHwBlanks !== 'function') {
this._blankHardwareWarehouseItemIds = new Set();
return this._blankHardwareWarehouseItemIds;
}
try {
const blanks = await loadHwBlanks();
const ids = new Set(
(Array.isArray(blanks) ? blanks : [])
.filter(blank => this._isWarehouseBackedHwBlank(blank))
.map(blank => Number(blank.warehouse_item_id || 0))
.filter(id => Number.isFinite(id) && id > 0)
);
this._blankHardwareWarehouseItemIds = ids;
return ids;
} catch (error) {
console.warn('[Warehouse] Failed to load blank hardware ids', error);
this._blankHardwareWarehouseItemIds = new Set();
return this._blankHardwareWarehouseItemIds;
}
},
_isBlankHardwareWarehouseItem(item) {
const itemId = Number(item && item.id || 0);
return Number.isFinite(itemId)
&& itemId > 0
&& this._blankHardwareWarehouseItemIds instanceof Set
&& this._blankHardwareWarehouseItemIds.has(itemId);
},
_buildBlankHardwareLowStockTaskDraft(item, people, areaIds) {
const qty = Math.max(0, parseFloat(item && item.qty) || 0);
const unit = String(item && item.unit || 'шт').trim() || 'шт';
const category = WAREHOUSE_CATEGORIES.find(entry => entry.key === item.category);
const categoryLabel = category ? category.label : 'Склад';
const reporterId = Number(App && App.currentEmployeeId || 0) || null;
const reporterName = (App && typeof App.getCurrentEmployeeName === 'function'
? App.getCurrentEmployeeName()
: '') || 'Система';
return {
title: `Заказать бланковую фурнитуру «${String(item && item.name || 'Без названия').trim()}»`,
description: [
'На складе осталось меньше 1000 шт бланковой фурнитуры. Нужно оформить повторный заказ.',
`Позиция: ${String(item && item.name || 'Без названия').trim()}`,
item && item.sku ? `SKU: ${String(item.sku).trim()}` : '',
`Категория: ${categoryLabel}`,
`Текущий остаток: ${qty.toLocaleString('ru-RU')} ${unit}`,
`Порог: ${BLANK_HARDWARE_LOW_STOCK_THRESHOLD.toLocaleString('ru-RU')} ${unit}`,
].filter(Boolean).join('\n'),
status: 'incoming',
priority: 'high',
reporter_id: reporterId,
reporter_name: reporterName,
assignee_id: Number(people && people.anastasia && people.anastasia.id || 0) || null,
assignee_name: String(people && people.anastasia && people.anastasia.name || 'Анастасия'),
reviewer_id: null,
reviewer_name: '',
area_id: areaIds.warehouse || areaIds.general || null,
order_id: null,
project_id: null,
china_purchase_id: null,
warehouse_item_id: Number(item && item.id || 0) || null,
primary_context_kind: 'area',
due_date: this._todayYMD(),
due_time: null,
waiting_for_text: '',
};
},
async _createBlankHardwareLowStockTasks(items) {
if (!Array.isArray(items) || items.length === 0) return [];
if (typeof saveWorkTask !== 'function') return [];
const [employees, areas] = await Promise.all([
typeof loadEmployees === 'function' ? loadEmployees().catch(() => []) : [],
typeof loadWorkAreas === 'function' ? loadWorkAreas().catch(() => []) : [],
]);
const people = this._resolveMoldUsageAlertPeople(employees);
const areaIds = {
warehouse: this._findAreaIdBySlug(areas, 'warehouse'),
general: this._findAreaIdBySlug(areas, 'general'),
};
const createdTasks = [];
for (const item of items) {
const draft = this._buildBlankHardwareLowStockTaskDraft(item, people, areaIds);
const saved = await saveWorkTask(draft, {
actor_id: App && App.currentEmployeeId || null,
actor_name: (App && typeof App.getCurrentEmployeeName === 'function'
? App.getCurrentEmployeeName()
: '') || 'Система',
});
createdTasks.push(saved);
if (saved && saved.assignee_id && typeof TaskEvents !== 'undefined' && TaskEvents && typeof TaskEvents.emit === 'function') {
await TaskEvents.emit('task_assigned', {
task_id: saved.id,
project_id: saved.project_id || null,
assignee_id: saved.assignee_id,
});
}
}
return createdTasks;
},
async _reconcileBlankHardwareLowStockAlerts() {
if (!Array.isArray(this.allItems) || this.allItems.length === 0) {
return { changed: false, alertsCreated: 0 };
}
const itemsToAlert = [];
let changed = false;
this.allItems = this.allItems.map(rawItem => {
if (!rawItem || typeof rawItem !== 'object') return rawItem;
const item = { ...rawItem };
const isBlankHardware = this._isBlankHardwareWarehouseItem(item);
const qty = Math.max(0, parseFloat(item.qty) || 0);
const isLow = isBlankHardware && qty < BLANK_HARDWARE_LOW_STOCK_THRESHOLD;
const alreadyAlerted = item.blank_hardware_low_stock_alerted === true;
if (isLow && !alreadyAlerted) {
item.blank_hardware_low_stock_alerted = true;
item.blank_hardware_low_stock_alert_qty = qty;
item.blank_hardware_low_stock_alerted_at = new Date().toISOString();
itemsToAlert.push(item);
changed = true;
return item;
}
if (!isLow && (
alreadyAlerted
|| item.blank_hardware_low_stock_alert_qty != null
|| item.blank_hardware_low_stock_alerted_at
)) {
delete item.blank_hardware_low_stock_alerted;
delete item.blank_hardware_low_stock_alert_qty;
delete item.blank_hardware_low_stock_alerted_at;
changed = true;
}
return item;
});
const createdTasks = await this._createBlankHardwareLowStockTasks(itemsToAlert);
if (changed) {
await saveWarehouseItems(this.allItems);
}
return {
changed,
alertsCreated: createdTasks.length,
};
},
_parseMoldAlertedThresholds(item) {
const raw = item && item.mold_alerted_thresholds;
let values = [];
if (Array.isArray(raw)) {
values = raw;
} else if (typeof raw === 'string') {
const trimmed = raw.trim();
if (trimmed) {
try {
const parsed = JSON.parse(trimmed);
values = Array.isArray(parsed) ? parsed : trimmed.split(',');
} catch (e) {
values = trimmed.split(',');
}
}
}
return Array.from(new Set(
(values || [])
.map(value => parseInt(value, 10))
.filter(value => Number.isFinite(value) && value > 0)
)).sort((a, b) => a - b);
},
_getCrossedMoldUsageThresholds(beforeUsed, afterUsed, alreadyAlerted) {
const safeBefore = Math.max(0, parseFloat(beforeUsed) || 0);
const safeAfter = Math.max(0, parseFloat(afterUsed) || 0);
if (safeAfter <= safeBefore) return [];
const alerted = new Set((alreadyAlerted || []).map(value => parseInt(value, 10)).filter(Boolean));
const thresholds = [];
const startStep = Math.floor(safeBefore / MOLD_USAGE_ALERT_STEP) + 1;
const endStep = Math.floor(safeAfter / MOLD_USAGE_ALERT_STEP);
for (let step = startStep; step <= endStep; step += 1) {
const threshold = step * MOLD_USAGE_ALERT_STEP;
if (!alerted.has(threshold)) thresholds.push(threshold);
}
return thresholds;
},
_findEmployeeByNames(employees, variants) {
const normalizedVariants = (variants || []).map(value => this._normalizeSimpleText(value)).filter(Boolean);
return (employees || []).find(employee => {
const name = this._normalizeSimpleText(employee && employee.name);
return normalizedVariants.some(variant => name.includes(variant));
}) || null;
},
_resolveMoldUsageAlertPeople(employees) {
const lesha = this._findEmployeeByNames(employees, ['леша', 'алеша', 'алексей'])
|| { id: MOLD_USAGE_ALERT_ASSIGNEE_FALLBACKS.lesha, name: 'Леша' };
const anastasia = this._findEmployeeByNames(employees, ['анастасия'])
|| { id: MOLD_USAGE_ALERT_ASSIGNEE_FALLBACKS.anastasia, name: 'Анастасия' };
return { lesha, anastasia };
},
_findAreaIdBySlug(areas, slug) {
const normalizedSlug = this._normalizeSimpleText(slug);
const area = (areas || []).find(entry => this._normalizeSimpleText(entry && entry.slug) === normalizedSlug);
return Number(area && area.id || 0) || null;
},
_buildMoldUsageAlertContext(whItem, threshold, options = {}) {
const moldName = String(whItem && whItem.name || 'Без названия').trim();
const sku = String(whItem && whItem.sku || '').trim();
const moldType = this._normalizeMoldType(whItem && whItem.mold_type);
const total = parseFloat(whItem && whItem.mold_capacity_total) || 0;
const used = parseFloat(whItem && whItem.mold_capacity_used) || 0;
const remaining = total > 0 ? Math.max(0, total - used) : null;
const linkedOrderId = Number(whItem && whItem.linked_order_id || 0) || null;
const linkedOrderName = String(whItem && whItem.linked_order_name || '').trim() || this._getOrderNameById(linkedOrderId);
const triggerOrderId = Number(options.orderId || 0) || null;
const triggerOrderName = String(options.orderName || '').trim();
const typeLabel = moldType === 'blank' ? 'Бланк / stock' : 'Клиентский';
const thresholdText = Number(threshold || 0).toLocaleString('ru-RU');
const totalText = total > 0 ? total.toLocaleString('ru-RU') : '—';
const usedText = used.toLocaleString('ru-RU');
const remainingText = remaining != null ? remaining.toLocaleString('ru-RU') : '—';
const orderText = linkedOrderId ? `#${linkedOrderId}${linkedOrderName ? ` — ${linkedOrderName}` : ''}` : 'не привязан';
const triggerOrderText = triggerOrderId
? `#${triggerOrderId}${triggerOrderName ? ` — ${triggerOrderName}` : ''}`
: '';
return {
moldName,
sku,
moldType,
typeLabel,
total,
used,
remaining,
linkedOrderId,
linkedOrderName,
thresholdText,
totalText,
usedText,
remainingText,
orderText,
triggerOrderId,
triggerOrderName,
triggerOrderText,
};
},
_buildMoldUsageAlertTaskDrafts(whItem, threshold, context, people, areaIds) {
const commonLines = [
`Молд: ${context.moldName}`,
`Тип: ${context.typeLabel}`,
context.sku ? `SKU: ${context.sku}` : '',
`Использовано: ${context.usedText} / ${context.totalText}`,
context.remaining != null ? `Осталось ресурса: ${context.remainingText}` : '',
`Пересечён порог: ${context.thresholdText}`,
`Связанный заказ: ${context.orderText}`,
context.triggerOrderText ? `Триггерный заказ: ${context.triggerOrderText}` : '',
].filter(Boolean);
const warehouseItemId = Number(whItem && whItem.id || 0) || null;
const reporterId = Number(App && App.currentEmployeeId || 0) || null;
const reporterName = (App && typeof App.getCurrentEmployeeName === 'function'
? App.getCurrentEmployeeName()
: '') || 'Система';
const orderId = context.linkedOrderId || context.triggerOrderId || null;
const primaryContextKind = orderId ? 'order' : 'area';
return [
{
title: `Проверить пригодность молда «${context.moldName}» · ${context.thresholdText}/${context.totalText}`,
description: [
'Проверь, подходит ли mold_type для дальнейшего производства после очередного порога использования.',
...commonLines,
].join('\n'),
status: 'incoming',
priority: 'high',
reporter_id: reporterId,
reporter_name: reporterName,
assignee_id: Number(people && people.lesha && people.lesha.id || 0) || null,
assignee_name: String(people && people.lesha && people.lesha.name || 'Леша'),
reviewer_id: null,
reviewer_name: '',
area_id: areaIds.warehouse || areaIds.general || null,
order_id: orderId,
project_id: null,
china_purchase_id: null,
warehouse_item_id: warehouseItemId,
primary_context_kind: primaryContextKind,
due_date: this._todayYMD(),
due_time: null,
waiting_for_text: '',
},
{
title: `Согласовать повтор молда «${context.moldName}» · ${context.thresholdText}/${context.totalText}`,
description: [
'Согласуй с Лешей необходимость повтора молда и запусти заказ на новый mold, если текущий ресурс подходит к лимиту.',
...commonLines,
].join('\n'),
status: 'incoming',
priority: 'high',
reporter_id: reporterId,
reporter_name: reporterName,
assignee_id: Number(people && people.anastasia && people.anastasia.id || 0) || null,
assignee_name: String(people && people.anastasia && people.anastasia.name || 'Анастасия'),
reviewer_id: Number(people && people.lesha && people.lesha.id || 0) || null,
reviewer_name: String(people && people.lesha && people.lesha.name || 'Леша'),
area_id: areaIds.china || areaIds.general || null,
order_id: orderId,
project_id: null,
china_purchase_id: null,
warehouse_item_id: warehouseItemId,
primary_context_kind: primaryContextKind,
due_date: this._todayYMD(),
due_time: null,
waiting_for_text: '',
},
];
},
async _createMoldUsageAlertTasks(alerts) {
if (!Array.isArray(alerts) || alerts.length === 0) return [];
if (typeof saveWorkTask !== 'function') return [];
const [employees, areas] = await Promise.all([
typeof loadEmployees === 'function' ? loadEmployees().catch(() => []) : [],
typeof loadWorkAreas === 'function' ? loadWorkAreas().catch(() => []) : [],
]);
const people = this._resolveMoldUsageAlertPeople(employees);
const areaIds = {
warehouse: this._findAreaIdBySlug(areas, 'warehouse'),
china: this._findAreaIdBySlug(areas, 'china'),
general: this._findAreaIdBySlug(areas, 'general'),
};
const createdTasks = [];
for (const alert of alerts) {
const context = this._buildMoldUsageAlertContext(alert.item, alert.threshold, {
orderId: alert.orderId,
orderName: alert.orderName,
});
const drafts = this._buildMoldUsageAlertTaskDrafts(alert.item, alert.threshold, context, people, areaIds);
for (const draft of drafts) {
const saved = await saveWorkTask(draft, {
actor_id: App && App.currentEmployeeId || null,
actor_name: (App && typeof App.getCurrentEmployeeName === 'function'
? App.getCurrentEmployeeName()
: '') || 'Система',
});
createdTasks.push(saved);
if (saved && saved.assignee_id && typeof TaskEvents !== 'undefined' && TaskEvents && typeof TaskEvents.emit === 'function') {
await TaskEvents.emit('task_assigned', {
task_id: saved.id,
project_id: saved.project_id || null,
assignee_id: saved.assignee_id,
});
}
}
}
return createdTasks;
},
_normalizeMoldLookupText(value) {
return String(value || '')
.toLowerCase()
.trim()
.replace(/[_-]+/g, ' ')
.replace(/\s+/g, ' ');
},
_buildAutoMoldSku(name, moldType, linkedOrderId) {
const normalizedType = this._normalizeMoldType(moldType);
const normalizedOrderId = Number(linkedOrderId || 0) || 0;
if (normalizedType === 'customer' && normalizedOrderId) {
return `MOLD-CUSTOM-${normalizedOrderId}`;
}
const slug = String(name || '')
.trim()
.toUpperCase()
.replace(/\s+/g, '-')
.replace(/[^\p{L}\p{N}-]+/gu, '')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '');
const prefix = normalizedType === 'blank' ? 'MOLD-BLANK' : 'MOLD-CUSTOM';
return slug ? `${prefix}-${slug}` : prefix;
},
_applyAutoMoldSku(item) {
if (!item || !this._isMoldCategory(item.category)) return item;
const currentSku = String(item.sku || '').trim();
const isAutoSku = /^MOLD-(BLANK|CUSTOM)(-|$)/i.test(currentSku);
if (currentSku && !isAutoSku) return item;
item.sku = this._buildAutoMoldSku(item.name || '', item.mold_type, item.linked_order_id);
return item;
},
_syncWarehouseFormMoldDerivedFields() {
const categoryEl = document.getElementById('wh-f-category');
const skuEl = document.getElementById('wh-f-sku');
const nameEl = document.getElementById('wh-f-name');
const typeEl = document.getElementById('wh-f-mold-type');
const orderEl = document.getElementById('wh-f-mold-linked-order-id');
if (!categoryEl || !skuEl) return;
const isMold = this._isMoldCategory(categoryEl.value);
skuEl.readOnly = isMold;
skuEl.placeholder = isMold ? 'SKU назначится автоматически' : 'CR-RNG-030-VT';
skuEl.title = isMold ? 'Для молдов SKU формируется автоматически' : '';
if (!isMold) return;
const normalizedType = this._normalizeMoldType(typeEl && typeEl.value);
const linkedOrderId = normalizedType === 'customer'
? String(orderEl && orderEl.value || '').trim()
: '';
skuEl.value = this._buildAutoMoldSku(nameEl && nameEl.value || '', normalizedType, linkedOrderId);
},
_findBlankTemplateByMold(item) {
const templates = Array.isArray(App && App.templates) ? App.templates : [];
const explicitId = String(item && item.template_id || '').trim();
if (explicitId) {
const byId = templates.find(t => String(t && t.id || '') === explicitId);
if (byId) return byId;
}
const normalizedName = this._normalizeMoldLookupText(item && item.name);
if (!normalizedName) return null;
return templates.find(t =>
String(t && t.category || '').toLowerCase() === 'blank'
&& this._normalizeMoldLookupText(t && t.name) === normalizedName
) || null;
},
_resolveBlankMoldTemplateId(item) {
const match = this._findBlankTemplateByMold(item);
return match ? String(match.id) : '';
},
async _loadMoldOrders() {
const orders = typeof loadOrders === 'function'
? await loadOrders({}).catch(() => [])
: [];
this.moldOrders = (orders || [])
.filter(order => order && String(order.status || '') !== 'deleted')
.sort((a, b) => Number(b && b.id || 0) - Number(a && a.id || 0));
return this.moldOrders;
},
_getMoldOrders() {
return Array.isArray(this.moldOrders) ? this.moldOrders : [];
},
_getOrderNameById(orderId) {
const normalizedId = Number(orderId || 0) || 0;
if (!normalizedId) return '';
const order = this._getMoldOrders().find(entry => Number(entry && entry.id || 0) === normalizedId);
return String(order && order.order_name || '').trim();
},
_buildMoldOrderOptionsHtml(selectedId) {
const normalizedSelected = Number(selectedId || 0) || 0;
const options = ['Выберите заказ '];
let selectedPresent = false;
this._getMoldOrders().forEach(order => {
const id = Number(order && order.id || 0) || 0;
if (!id) return;
const label = `#${id} — ${this.esc(order.order_name || 'Без названия')}`;
if (id === normalizedSelected) selectedPresent = true;
options.push(`${label} `);
});
if (normalizedSelected && !selectedPresent) {
options.push(`#${normalizedSelected} `);
}
return options.join('');
},
_syncShipmentMoldDerivedFields(row) {
if (!row || !this._isMoldCategory(row.category)) return row;
row.mold_type = String(row.mold_type || '').trim()
? this._normalizeMoldType(row.mold_type)
: (Number(row.linked_order_id || 0) ? 'customer' : 'blank');
const currentTotal = parseFloat(row.mold_capacity_total || 0) || 0;
const shouldResetToBlank = row.mold_type === 'blank' && currentTotal === this._defaultMoldCapacityTotal('customer');
if (!currentTotal || shouldResetToBlank) {
row.mold_capacity_total = this._defaultMoldCapacityTotal(row.mold_type);
}
if (row.mold_type === 'blank') {
row.linked_order_id = '';
row.linked_order_name = '';
row.template_id = this._resolveBlankMoldTemplateId(row);
} else if (row.linked_order_id) {
row.linked_order_name = this._getOrderNameById(row.linked_order_id) || row.linked_order_name || '';
row.template_id = '';
} else {
row.template_id = '';
}
this._applyAutoMoldSku(row);
return row;
},
_todayYMD() {
if (typeof App !== 'undefined' && App && typeof App.todayLocalYMD === 'function') {
return App.todayLocalYMD();
}
return new Date().toISOString().slice(0, 10);
},
_plusDaysYMD(baseYmd, days) {
const base = String(baseYmd || this._todayYMD());
const parsed = new Date(`${base}T12:00:00`);
if (Number.isNaN(parsed.getTime())) return '';
parsed.setDate(parsed.getDate() + (parseInt(days, 10) || 0));
return parsed.toISOString().slice(0, 10);
},
_formatDateCompact(value) {
if (!value) return '—';
try {
return new Intl.DateTimeFormat('ru-RU').format(new Date(`${String(value).slice(0, 10)}T12:00:00`));
} catch (e) {
return String(value || '—');
}
},
_buildMoldMeta(item, options) {
const opts = options && typeof options === 'object' ? options : {};
const isMold = this._isMoldCategory(item && item.category);
if (!isMold) return null;
const moldTypeRaw = opts.mold_type ?? item.mold_type;
const moldType = String(moldTypeRaw || '').trim()
? this._normalizeMoldType(moldTypeRaw)
: (Number(opts.linked_order_id ?? item.linked_order_id ?? 0) ? 'customer' : 'blank');
const linkedOrderId = moldType === 'customer'
? (Number(opts.linked_order_id ?? item.linked_order_id ?? 0) || null)
: null;
const arrivedAt = String(opts.mold_arrived_at ?? item.mold_arrived_at ?? opts.receiptDate ?? this._todayYMD()).trim();
const capacityTotalRaw = parseFloat(opts.mold_capacity_total ?? item.mold_capacity_total);
const capacityUsedRaw = parseFloat(opts.mold_capacity_used ?? item.mold_capacity_used);
const capacityTotal = capacityTotalRaw > 0 ? capacityTotalRaw : this._defaultMoldCapacityTotal(moldType);
const capacityUsed = Math.max(0, capacityUsedRaw || 0);
const storageUntilFallback = moldType === 'customer'
? this._plusDaysYMD(arrivedAt, 365)
: '';
const storageUntil = String(opts.mold_storage_until ?? item.mold_storage_until ?? storageUntilFallback).trim();
const linkedOrderNameSource = opts.linked_order_name
?? item.linked_order_name
?? this._getOrderNameById(linkedOrderId)
?? '';
const linkedOrderName = linkedOrderId ? String(linkedOrderNameSource).trim() : '';
const templateId = moldType === 'blank'
? this._resolveBlankMoldTemplateId({ ...item, ...opts, mold_type: moldType })
: '';
return {
mold_type: moldType,
linked_order_id: linkedOrderId,
linked_order_name: linkedOrderName,
template_id: templateId,
mold_capacity_total: capacityTotal,
mold_capacity_used: capacityUsed,
mold_arrived_at: arrivedAt,
mold_storage_until: storageUntil,
};
},
_renderMoldMeta(item) {
const meta = this._buildMoldMeta(item);
if (!meta) return '';
const typeLabel = meta.mold_type === 'blank' ? 'Бланк / stock' : 'Клиентский';
const total = parseFloat(meta.mold_capacity_total) || 0;
const used = parseFloat(meta.mold_capacity_used) || 0;
const percent = total > 0 ? Math.min(100, Math.round((used / total) * 100)) : 0;
const remaining = total > 0 ? total - used : null;
const progressColor = total > 0 && used >= total
? '#dc2626'
: (percent >= 75 ? '#f59e0b' : '#10b981');
const linkedBits = [];
linkedBits.push(`${typeLabel} `);
if (meta.linked_order_id) {
linkedBits.push(`Заказ #${meta.linked_order_id} `);
}
if (meta.mold_storage_until) {
linkedBits.push(`Хранить до: ${this._formatDateCompact(meta.mold_storage_until)} `);
}
const capacityHtml = total > 0
? `
Ресурс
${used.toLocaleString('ru-RU')} / ${total.toLocaleString('ru-RU')}${remaining != null ? ` · остаток ${Math.max(0, remaining).toLocaleString('ru-RU')}` : ''}
`
: '';
return `${linkedBits.join('')}
${capacityHtml}`;
},
renderTable(items) {
const container = document.getElementById('wh-content');
if (!container) return;
if (items.length === 0) {
container.innerHTML = `
📦
Нет позиций
Добавьте вручную или импортируйте из Excel
`;
return;
}
const uniqueColors = this._getUniqueColors();
const rows = items.map(item => {
const cat = WAREHOUSE_CATEGORIES.find(c => c.key === item.category) || WAREHOUSE_CATEGORIES[WAREHOUSE_CATEGORIES.length - 1];
const isLow = item.min_qty > 0 && item.qty < item.min_qty;
const isOut = item.qty <= 0;
const moldMetaHtml = this._renderMoldMeta(item);
// Photo or placeholder
const photoSrc = item.photo_thumbnail || item.photo_url;
const photo = photoSrc
? `${cat.icon} `
: `${cat.icon} `;
// Qty badge class
const qtyClass = isOut ? 'wh-qty-out' : (isLow ? 'wh-qty-low' : 'wh-qty-ok');
// Category badge
const catBadge = `${cat.label} `;
// Available qty
const availInfo = item.reserved_qty > 0
? `${item.available_qty} `
: `— `;
const qtyStep = this._warehouseQtyInputStep(item);
const reservePillsHtml = item.reserved_qty > 0
? this._renderReservationPills(item.id, { compact: true, limit: 2 })
: '';
const reserveLocked = this._hasLockedProjectReservations(item.id);
const reserveHint = reserveLocked
? `через заказ
`
: '';
// Color dropdown options
const colorOpts = uniqueColors.map(c =>
`${this.esc(c)} `
).join('');
return `
${photo}
${this.esc(item.name)}
${this.esc(item.sku || '')}
${moldMetaHtml}
${catBadge}
${this.esc(item.size || '—')}
—
${colorOpts}
${reservePillsHtml}
${reserveHint}
${availInfo}
✎
`;
}).join('');
container.innerHTML = `
Название / Артикул
Категория
Размер
Цвет
Цена
Кол-во
Резерв
Доступно
${rows}
`;
},
// ==========================================
// ADD / EDIT FORM
// ==========================================
onCategoryChange(categoryValue) {
this._syncMoldFieldsVisibility(categoryValue);
this._syncWarehouseFormMoldDerivedFields();
},
_syncMoldFieldsVisibility(categoryValue) {
const wrapper = document.getElementById('wh-mold-fields');
if (!wrapper) return;
const isMold = this._isMoldCategory(categoryValue);
wrapper.style.display = isMold ? '' : 'none';
if (!isMold) return;
const typeEl = document.getElementById('wh-f-mold-type');
const orderWrap = document.getElementById('wh-f-mold-linked-order-wrap');
const arrivedEl = document.getElementById('wh-f-mold-arrived-at');
const storageEl = document.getElementById('wh-f-mold-storage-until');
const storageWrap = document.getElementById('wh-f-mold-storage-until-wrap');
const totalEl = document.getElementById('wh-f-mold-capacity-total');
if (typeEl && !typeEl.value) typeEl.value = 'blank';
const normalizedType = this._normalizeMoldType(typeEl && typeEl.value);
if (arrivedEl && !arrivedEl.value) arrivedEl.value = this._todayYMD();
if (orderWrap) orderWrap.style.display = normalizedType === 'customer' ? '' : 'none';
if (storageWrap) storageWrap.style.display = normalizedType === 'customer' ? '' : 'none';
if (totalEl) {
const currentTotal = parseFloat(totalEl.value || 0) || 0;
const customerDefault = this._defaultMoldCapacityTotal('customer');
const shouldResetToBlank = normalizedType === 'blank' && (!currentTotal || currentTotal === customerDefault);
if (!currentTotal || shouldResetToBlank) {
totalEl.value = String(this._defaultMoldCapacityTotal(normalizedType));
}
}
if (storageEl && !storageEl.value && normalizedType === 'customer') {
storageEl.value = this._plusDaysYMD(arrivedEl && arrivedEl.value, 365);
}
if (storageEl && normalizedType !== 'customer') {
storageEl.value = '';
}
this._syncWarehouseFormMoldDerivedFields();
},
async showAddForm() {
this.editingId = null;
this.clearForm();
await this._loadMoldOrders();
const orderSelect = document.getElementById('wh-f-mold-linked-order-id');
if (orderSelect) orderSelect.innerHTML = this._buildMoldOrderOptionsHtml('');
document.getElementById('wh-form-title').textContent = 'Новая позиция';
document.getElementById('wh-delete-btn').style.display = 'none';
document.getElementById('wh-stock-truth-section').innerHTML = '';
document.getElementById('wh-reservations-section').innerHTML = '';
this._syncMoldFieldsVisibility(document.getElementById('wh-f-category').value);
this._syncWarehouseFormMoldDerivedFields();
document.getElementById('wh-edit-form').style.display = '';
document.getElementById('wh-edit-form').scrollIntoView({ behavior: 'smooth' });
},
async editItem(id) {
const item = this.allItems.find(i => i.id === id);
if (!item) return;
await this._loadMoldOrders();
this.editingId = id;
document.getElementById('wh-form-title').textContent = 'Редактирование';
document.getElementById('wh-f-category').value = item.category || 'other';
document.getElementById('wh-f-name').value = item.name || '';
document.getElementById('wh-f-sku').value = item.sku || '';
document.getElementById('wh-f-size').value = item.size || '';
document.getElementById('wh-f-color').value = item.color || '';
document.getElementById('wh-f-unit').value = item.unit || 'шт';
document.getElementById('wh-f-photo-url').value = item.photo_url || '';
document.getElementById('wh-f-qty').value = item.qty || 0;
document.getElementById('wh-f-min-qty').value = item.min_qty || 0;
document.getElementById('wh-f-price').value = item.price_per_unit || 0;
document.getElementById('wh-f-notes').value = item.notes || '';
document.getElementById('wh-f-mold-type').value = this._normalizeMoldType(item.mold_type);
const orderSelect = document.getElementById('wh-f-mold-linked-order-id');
if (orderSelect) {
orderSelect.innerHTML = this._buildMoldOrderOptionsHtml(item.linked_order_id || '');
orderSelect.value = item.linked_order_id || '';
}
document.getElementById('wh-f-mold-capacity-total').value = item.mold_capacity_total || '';
document.getElementById('wh-f-mold-capacity-used').value = item.mold_capacity_used || 0;
document.getElementById('wh-f-mold-arrived-at').value = item.mold_arrived_at || '';
document.getElementById('wh-f-mold-storage-until').value = item.mold_storage_until || '';
// Photo preview
this._pendingThumbnail = item.photo_thumbnail || null;
const photoFileInput = document.getElementById('wh-f-photo-file');
if (photoFileInput) photoFileInput.value = '';
this.updatePhotoPreview(item.photo_thumbnail || item.photo_url || '');
document.getElementById('wh-delete-btn').style.display = '';
await this.renderItemStockTruth(id);
this.renderItemReservations(id);
this._syncMoldFieldsVisibility(item.category || 'other');
this._syncWarehouseFormMoldDerivedFields();
document.getElementById('wh-edit-form').style.display = '';
document.getElementById('wh-edit-form').scrollIntoView({ behavior: 'smooth' });
},
hideForm() {
document.getElementById('wh-edit-form').style.display = 'none';
},
clearForm() {
['wh-f-name', 'wh-f-sku', 'wh-f-size', 'wh-f-color', 'wh-f-photo-url', 'wh-f-notes'].forEach(id => {
const el = document.getElementById(id);
if (el) el.value = '';
});
document.getElementById('wh-f-category').value = 'carabiners';
document.getElementById('wh-f-unit').value = 'шт';
document.getElementById('wh-f-qty').value = 0;
document.getElementById('wh-f-min-qty').value = 0;
document.getElementById('wh-f-price').value = 0;
document.getElementById('wh-f-mold-type').value = 'blank';
const orderSelect = document.getElementById('wh-f-mold-linked-order-id');
if (orderSelect) {
orderSelect.innerHTML = this._buildMoldOrderOptionsHtml('');
orderSelect.value = '';
}
document.getElementById('wh-f-mold-capacity-total').value = '';
document.getElementById('wh-f-mold-capacity-used').value = 0;
document.getElementById('wh-f-mold-arrived-at').value = this._todayYMD();
document.getElementById('wh-f-mold-storage-until').value = '';
// Reset photo
this._pendingThumbnail = null;
const photoFileInput = document.getElementById('wh-f-photo-file');
if (photoFileInput) photoFileInput.value = '';
const preview = document.getElementById('wh-f-photo-preview');
if (preview) preview.innerHTML = '📷 ';
this._syncMoldFieldsVisibility('carabiners');
this._syncWarehouseFormMoldDerivedFields();
},
async saveItem() {
const name = document.getElementById('wh-f-name').value.trim();
if (!name) { App.toast('Укажите название'); return; }
const existingItem = this.editingId ? this.allItems.find(i => i.id === this.editingId) : null;
const previousQty = this._parseWarehouseQty(existingItem ? existingItem.qty : 0);
const newQty = this._parseWarehouseQty(document.getElementById('wh-f-qty').value);
const qtyDelta = newQty - previousQty;
const qtyChanged = this.editingId && existingItem && Math.abs(qtyDelta) > 1e-9;
const item = {
id: this.editingId || undefined,
category: document.getElementById('wh-f-category').value,
name: name,
sku: document.getElementById('wh-f-sku').value.trim(),
size: document.getElementById('wh-f-size').value.trim(),
color: document.getElementById('wh-f-color').value.trim(),
unit: document.getElementById('wh-f-unit').value || 'шт',
photo_url: document.getElementById('wh-f-photo-url').value.trim(),
photo_thumbnail: this._pendingThumbnail || (this.editingId ? (this.allItems.find(i => i.id === this.editingId) || {}).photo_thumbnail : '') || '',
qty: newQty,
min_qty: parseFloat(document.getElementById('wh-f-min-qty').value) || 0,
price_per_unit: parseFloat(document.getElementById('wh-f-price').value) || 0,
notes: document.getElementById('wh-f-notes').value.trim(),
};
if (this._isMoldCategory(item.category)) {
const moldMeta = this._buildMoldMeta(item, {
mold_type: document.getElementById('wh-f-mold-type').value,
linked_order_id: document.getElementById('wh-f-mold-linked-order-id').value,
mold_capacity_total: document.getElementById('wh-f-mold-capacity-total').value,
mold_capacity_used: document.getElementById('wh-f-mold-capacity-used').value,
mold_arrived_at: document.getElementById('wh-f-mold-arrived-at').value,
mold_storage_until: document.getElementById('wh-f-mold-storage-until').value,
});
Object.assign(item, moldMeta);
this._applyAutoMoldSku(item);
}
if (qtyChanged) {
await this.adjustStock(
this.editingId,
qtyDelta,
qtyDelta > 0 ? 'addition' : 'deduction',
'',
'Ручная правка',
App.getCurrentEmployeeName ? App.getCurrentEmployeeName() : ''
);
}
await saveWarehouseItem(item);
App.toast(this.editingId ? 'Позиция обновлена' : 'Позиция добавлена');
this.hideForm();
await this.load();
},
async deleteFromForm() {
if (!this.editingId) return;
const item = this.allItems.find(i => i.id === this.editingId);
if (!confirm(`Удалить "${item ? item.name : ''}"?`)) return;
await deleteWarehouseItem(this.editingId);
App.toast('Позиция удалена');
this.hideForm();
await this.load();
},
// ==========================================
// STOCK ADJUSTMENTS
// ==========================================
async adjustStock(itemId, qtyChange, reason, orderName, notes, manager, meta) {
const items = await loadWarehouseItems();
const normalizedItemId = Number(itemId || 0);
const idx = items.findIndex(i => Number(i && i.id || 0) === normalizedItemId);
if (idx < 0) {
return {
ok: false,
requestedQtyChange: parseFloat(qtyChange) || 0,
appliedQtyChange: 0,
qtyBefore: null,
qtyAfter: null,
clamped: false,
};
}
const item = items[idx];
const requestedQtyChange = this._parseWarehouseQty(qtyChange);
const qtyBefore = this._parseWarehouseQty(item.qty);
const qtyAfter = Math.max(0, qtyBefore + requestedQtyChange);
const appliedQtyChange = qtyAfter - qtyBefore;
const clamped = Math.abs(appliedQtyChange - requestedQtyChange) > 1e-9;
item.qty = qtyAfter;
item.updated_at = new Date().toISOString();
items[idx] = item;
if (typeof saveWarehouseItem === 'function') {
await saveWarehouseItem(item);
} else {
await saveWarehouseItems(items);
}
if (Array.isArray(this.allItems)) {
const loadedIdx = this.allItems.findIndex(i => Number(i && i.id || 0) === normalizedItemId);
if (loadedIdx >= 0) this.allItems[loadedIdx] = { ...this.allItems[loadedIdx], ...item };
}
// Record in history
const history = await loadWarehouseHistory();
const extraMeta = meta && typeof meta === 'object' ? { ...meta } : {};
const historyOrderId = extraMeta && extraMeta.order_id ? extraMeta.order_id : null;
if (extraMeta && Object.prototype.hasOwnProperty.call(extraMeta, 'order_id')) {
delete extraMeta.order_id;
}
const historyUnitPrice = parseFloat(extraMeta && extraMeta.history_unit_price) || 0;
if (extraMeta && Object.prototype.hasOwnProperty.call(extraMeta, 'history_unit_price')) {
delete extraMeta.history_unit_price;
}
const effectiveUnitPrice = historyUnitPrice > 0 ? historyUnitPrice : (parseFloat(item.price_per_unit) || 0);
history.push({
id: Date.now(),
item_id: itemId,
item_name: item.name || '',
item_sku: item.sku || '',
item_category: item.category || '',
type: reason || 'adjustment',
qty_change: appliedQtyChange,
requested_qty_change: requestedQtyChange,
qty_before: qtyBefore,
qty_after: item.qty,
unit_price: effectiveUnitPrice,
total_cost_change: round2(Math.abs(appliedQtyChange) * effectiveUnitPrice),
order_id: historyOrderId,
order_name: orderName || '',
notes: notes || '',
clamped,
created_at: new Date().toISOString(),
created_by: manager || '',
...extraMeta,
});
await saveWarehouseHistory(history);
return {
ok: true,
requestedQtyChange,
appliedQtyChange,
qtyBefore,
qtyAfter: item.qty,
clamped,
};
},
async quickAdjust(itemId, delta) {
await this.adjustStock(itemId, delta, delta > 0 ? 'addition' : 'deduction', '', 'Быстрая корректировка', '');
await this.load();
},
async promptAdjust(itemId) {
const normalizedItemId = Number(itemId || 0);
const item = this.allItems.find(i => Number(i && i.id || 0) === normalizedItemId);
if (!item) return;
const input = prompt(`Корректировка "${item.name}" (текущее: ${item.qty})\nВведите изменение (+10 или -5):`);
if (input === null) return;
const delta = parseFloat(input);
if (isNaN(delta) || delta === 0) { App.toast('Неверное значение'); return; }
const reason = prompt('Причина корректировки:') || '';
await this.adjustStock(itemId, delta, delta > 0 ? 'addition' : 'deduction', '', reason, '');
App.toast(`${item.name}: ${delta > 0 ? '+' : ''}${delta}`);
await this.load();
},
// ==========================================
// INLINE EDITING (directly in table)
// ==========================================
// Visual feedback: flash green + toast
_inlineSaved(inputEl) {
// Green flash on the input
if (inputEl) {
inputEl.style.transition = 'background 0.2s';
inputEl.style.background = '#bbf7d0';
setTimeout(() => { inputEl.style.background = ''; }, 900);
}
// Toast notification
this._showSaveToast();
},
_showSaveToast() {
let toast = document.getElementById('wh-save-toast');
if (!toast) {
toast = document.createElement('div');
toast.id = 'wh-save-toast';
toast.style.cssText = 'position:fixed;bottom:24px;right:24px;background:#16a34a;color:#fff;padding:8px 18px;border-radius:8px;font-size:14px;font-weight:600;z-index:9999;opacity:0;transition:opacity 0.3s;pointer-events:none;box-shadow:0 4px 12px rgba(0,0,0,0.15)';
document.body.appendChild(toast);
}
toast.textContent = '✓ Сохранено в облако';
toast.style.opacity = '1';
clearTimeout(this._toastTimer);
this._toastTimer = setTimeout(() => { toast.style.opacity = '0'; }, 2000);
},
async inlineQty(itemId, newValueStr, oldQty) {
const newQty = Math.max(0, this._parseWarehouseQty(newValueStr));
const delta = newQty - (oldQty || 0);
if (delta === 0) return;
const inputEl = document.activeElement;
await this.adjustStock(itemId, delta, delta > 0 ? 'addition' : 'deduction', '', 'Ручная правка', '');
this._inlineSaved(inputEl);
await this.load();
},
async inlinePrice(itemId, newValueStr) {
const normalizedItemId = Number(itemId || 0);
const item = this.allItems.find(i => Number(i && i.id || 0) === normalizedItemId);
if (!item) return;
const newPrice = Math.max(0, parseFloat(newValueStr) || 0);
if (newPrice === (item.price_per_unit || 0)) return;
const inputEl = document.activeElement;
item.price_per_unit = newPrice;
item.updated_at = new Date().toISOString();
await saveWarehouseItem(item);
this._inlineSaved(inputEl);
await this.load();
},
async inlineColor(itemId, newColor) {
const normalizedItemId = Number(itemId || 0);
const item = this.allItems.find(i => Number(i && i.id || 0) === normalizedItemId);
if (!item) return;
if (newColor === (item.color || '')) return;
const inputEl = document.activeElement;
item.color = newColor;
item.updated_at = new Date().toISOString();
await saveWarehouseItem(item);
this._inlineSaved(inputEl);
await this.load();
},
async inlineReserve(itemId, newValueStr, oldReserved) {
const normalizedItemId = Number(itemId || 0);
const item = this.allItems.find(i => Number(i && i.id || 0) === normalizedItemId);
if (!item) return;
if (this._hasLockedProjectReservations(normalizedItemId)) {
App.toast('Этот резерв создан из заказа. Меняйте его в карточке заказа или во вкладке «Фурнитура для проектов».');
await this.load();
return;
}
const newReserved = Math.max(0, this._parseWarehouseQty(newValueStr));
const maxReserve = this._parseWarehouseQty(item.qty);
const clampedReserve = Math.min(newReserved, maxReserve);
const diff = clampedReserve - (oldReserved || 0);
if (diff === 0) return;
const inputEl = document.activeElement;
const reservations = await loadWarehouseReservations();
if (diff > 0) {
// Add a manual reservation
reservations.push({
id: Date.now(),
item_id: normalizedItemId,
order_name: 'Ручной резерв',
source: 'manual',
qty: diff,
status: 'active',
created_at: new Date().toISOString(),
});
} else {
// Release: reduce manual reservations first, then any others
let toRelease = Math.abs(diff);
// Sort: manual first, then by date descending
const itemRes = reservations
.filter(r => Number(r.item_id || 0) === normalizedItemId && r.status === 'active')
.sort((a, b) => {
const aManual = Warehouse._isManualReservationRecord(a);
const bManual = Warehouse._isManualReservationRecord(b);
if (aManual && !bManual) return -1;
if (bManual && !aManual) return 1;
return new Date(b.created_at) - new Date(a.created_at);
});
for (const res of itemRes) {
if (toRelease <= 0) break;
const resIdx = reservations.findIndex(r => r.id === res.id);
if (resIdx < 0) continue;
if (res.qty <= toRelease) {
toRelease -= res.qty;
reservations[resIdx].status = 'released';
} else {
reservations[resIdx].qty -= toRelease;
toRelease = 0;
}
}
}
await saveWarehouseReservations(reservations);
this._inlineSaved(inputEl);
await this.load();
},
// ==========================================
// RESERVATIONS
// ==========================================
async addReservation(itemId) {
const normalizedItemId = Number(itemId || 0);
const item = this.allItems.find(i => Number(i && i.id || 0) === normalizedItemId);
if (!item) return;
const available = this.getAvailableQty(item);
const orderName = prompt(`Резерв "${item.name}" (доступно: ${available})\nДля какого проекта/заказа?`);
if (!orderName) return;
const qtyStr = prompt(`Количество для резерва (макс: ${available}):`);
const qty = this._parseWarehouseQty(qtyStr);
if (!qty || qty <= 0) { App.toast('Неверное количество'); return; }
if (qty > available) { App.toast(`Недостаточно! Доступно: ${available}`); return; }
const reservations = await loadWarehouseReservations();
reservations.push({
id: Date.now(),
item_id: normalizedItemId,
order_name: orderName,
source: 'manual',
qty: qty,
status: 'active',
created_at: new Date().toISOString(),
created_by: '',
});
await saveWarehouseReservations(reservations);
App.toast(`Зарезервировано: ${qty} шт для "${orderName}"`);
await this.load();
},
async cancelReservation(resId) {
const reservations = await loadWarehouseReservations();
const idx = reservations.findIndex(r => r.id === resId);
if (idx < 0) return;
reservations[idx].status = 'cancelled';
await saveWarehouseReservations(reservations);
App.toast('Резерв отменён');
await this.load();
// Re-render reservations if editing
if (this.editingId) {
await this.renderItemStockTruth(this.editingId);
this.renderItemReservations(this.editingId);
}
},
getAvailableQty(item) {
const normalizedItemId = Number(item && item.id || 0);
const activeRes = this.allReservations.filter(
r => Number(r.item_id || 0) === normalizedItemId && r.status === 'active'
);
const reserved = activeRes.reduce((s, r) => s + this._parseWarehouseQty(r.qty), 0);
return Math.max(0, this._parseWarehouseQty(item.qty) - reserved);
},
renderItemReservations(itemId) {
const container = document.getElementById('wh-reservations-section');
if (!container) return;
const normalizedItemId = Number(itemId || 0);
const activeRes = this._getActiveReservationsForItem(normalizedItemId);
if (activeRes.length === 0) {
container.innerHTML = 'Нет активных резервов
';
return;
}
container.innerHTML = 'Активные резервы: ' +
activeRes.map(r => {
const meta = this._getReservationDisplayMeta(r);
const openButton = meta.orderId > 0
? `Открыть `
: '';
const authorText = r.created_by ? ` · ${this.esc(r.created_by)}` : '';
return `
${this.esc(meta.sourceLabel)}
${this.esc(meta.primaryLabel)}
${meta.qty.toLocaleString('ru-RU')} шт
${App.formatDate(r.created_at)}${authorText}
${openButton}
Отменить
`;
}).join('');
},
_isStockCorrectionHistoryEntry(entry) {
if (!entry || typeof entry !== 'object') return false;
const type = this._normStr(entry.type || '');
const notes = this._normStr(entry.notes || '');
if (notes.includes('ручн') || notes.includes('инвентар') || notes.includes('коррект')) return true;
if (type === 'inventory_adjustment' || type === 'inventory_apply' || type === 'adjustment') return true;
return !Number(entry.order_id || 0) && !String(entry.order_name || '').trim() && (type === 'addition' || type === 'deduction');
},
_describeWarehouseHistoryEntry(entry, item) {
const qty = this._parseWarehouseQty(entry && entry.qty_change);
const type = this._normStr(entry && entry.type);
const notes = String(entry && entry.notes || '').trim();
const orderName = String(entry && entry.order_name || '').trim();
const createdBy = String(entry && entry.created_by || '').trim();
let title = notes || orderName || 'Движение';
if (!title) {
if (type === 'inventory_adjustment' || type === 'inventory_apply') title = 'Инвентаризация';
else if (type === 'addition') title = 'Пополнение';
else if (type === 'deduction') title = 'Списание';
else if (type === 'import') title = 'Импорт';
else title = type || 'Движение';
}
const metaBits = [];
if (orderName && title !== orderName) metaBits.push(orderName);
if (createdBy) metaBits.push(createdBy);
metaBits.push(this._formatDateCompact(entry && entry.created_at));
const sign = qty > 0 ? '+' : (qty < 0 ? '−' : '');
return {
title,
meta: metaBits.filter(Boolean).join(' · '),
qtyLabel: `${sign}${this._formatWarehouseQtyDisplay(Math.abs(qty), item)} ${String(item && item.unit || 'шт').trim() || 'шт'}`,
toneClass: qty > 0 ? 'is-positive' : (qty < 0 ? 'is-negative' : ''),
};
},
async renderItemStockTruth(itemId) {
const container = document.getElementById('wh-stock-truth-section');
if (!container) return;
const normalizedItemId = Number(itemId || 0);
const item = (this.allItems || []).find(entry => Number(entry && entry.id || 0) === normalizedItemId);
if (!item) {
container.innerHTML = '';
return;
}
const activeReservations = this._getActiveReservationsForItem(normalizedItemId);
const reservedQty = activeReservations.reduce((sum, reservation) => sum + this._parseWarehouseQty(reservation.qty), 0);
const projectReservedQty = activeReservations
.filter(reservation => this._isProjectHardwareReservationSource(reservation && reservation.source) || Number(reservation && reservation.order_id || 0) > 0)
.reduce((sum, reservation) => sum + this._parseWarehouseQty(reservation.qty), 0);
const manualReservedQty = activeReservations
.filter(reservation => this._isManualReservationRecord(reservation))
.reduce((sum, reservation) => sum + this._parseWarehouseQty(reservation.qty), 0);
const totalQty = this._parseWarehouseQty(item.qty);
const availableQty = Math.max(0, totalQty - reservedQty);
const history = (await loadWarehouseHistory())
.filter(entry => Number(entry && entry.item_id || 0) === normalizedItemId)
.sort((a, b) => {
const byDate = new Date(String(b && b.created_at || '')).getTime() - new Date(String(a && a.created_at || '')).getTime();
if (byDate !== 0) return byDate;
return Number(b && b.id || 0) - Number(a && a.id || 0);
});
const recentHistory = history.slice(0, 6);
const correctionEntries = history.filter(entry => this._isStockCorrectionHistoryEntry(entry)).slice(0, 12);
const correctionNet = correctionEntries.reduce((sum, entry) => sum + this._parseWarehouseQty(entry && entry.qty_change), 0);
const correctionSign = correctionNet > 0 ? '+' : (correctionNet < 0 ? '−' : '');
const reserveRowsHtml = activeReservations.length
? activeReservations.map(reservation => {
const meta = this._getReservationDisplayMeta(reservation);
const detailBits = [meta.sourceLabel];
if (reservation.created_by) detailBits.push(String(reservation.created_by).trim());
detailBits.push(this._formatDateCompact(reservation.created_at));
return `
${this.esc(meta.primaryLabel)}
${this.esc(detailBits.filter(Boolean).join(' · '))}
${this._formatWarehouseQtyDisplay(meta.qty, item)} ${this.esc(item.unit || 'шт')}
`;
}).join('')
: 'Сейчас эта позиция никем не удерживается в активном резерве.
';
const historyRowsHtml = recentHistory.length
? recentHistory.map(entry => {
const meta = this._describeWarehouseHistoryEntry(entry, item);
return `
${this.esc(meta.title)}
${this.esc(meta.meta)}
${meta.qtyLabel}
`;
}).join('')
: 'У этой позиции пока нет истории движений.
';
const noteParts = [
`Что значит "Доступно": ${this._formatWarehouseQtyDisplay(totalQty, item)} ${this.esc(item.unit || 'шт')} на руках минус ${this._formatWarehouseQtyDisplay(reservedQty, item)} ${this.esc(item.unit || 'шт')} в активном резерве = ${this._formatWarehouseQtyDisplay(availableQty, item)} ${this.esc(item.unit || 'шт')}.`,
];
if (projectReservedQty > 0) {
noteParts.push(`Из резерва сейчас ${this._formatWarehouseQtyDisplay(projectReservedQty, item)} ${this.esc(item.unit || 'шт')} удерживают проекты и заказы.`);
}
if (manualReservedQty > 0) {
noteParts.push(`Ещё ${this._formatWarehouseQtyDisplay(manualReservedQty, item)} ${this.esc(item.unit || 'шт')} стоит в ручном резерве.`);
}
if (correctionEntries.length > 0) {
noteParts.push(`В истории есть ${correctionEntries.length} ручн./корректирующих движений с нетто ${correctionSign}${this._formatWarehouseQtyDisplay(Math.abs(correctionNet), item)} ${this.esc(item.unit || 'шт')} .`);
}
container.innerHTML = `
Разбор остатка
${noteParts.join(' ')}
Всего на складе
${this._formatWarehouseQtyDisplay(totalQty, item)}
${this.esc(item.unit || 'шт')}
Активный резерв
${this._formatWarehouseQtyDisplay(reservedQty, item)}
проекты + ручной резерв
Свободно сейчас
${this._formatWarehouseQtyDisplay(availableQty, item)}
это и видно в таблице
Корректировки
${correctionSign}${this._formatWarehouseQtyDisplay(Math.abs(correctionNet), item)}
${correctionEntries.length ? `${correctionEntries.length} движений в истории` : 'без ручных правок'}
Кто держит резерв
${reserveRowsHtml}
Последние движения
${historyRowsHtml}
`;
},
// ==========================================
// IMPORT FROM CSV
// ==========================================
showImport() {
document.getElementById('wh-import-form').style.display = '';
document.getElementById('wh-import-preview').innerHTML = '';
document.getElementById('wh-import-file').value = '';
document.getElementById('wh-import-form').scrollIntoView({ behavior: 'smooth' });
},
hideImport() {
document.getElementById('wh-import-form').style.display = 'none';
this.pendingImport = null;
},
processImport() {
const fileInput = document.getElementById('wh-import-file');
const category = document.getElementById('wh-import-category').value;
if (!fileInput.files.length) { App.toast('Выберите файл'); return; }
const reader = new FileReader();
reader.onload = (e) => {
try {
const text = e.target.result;
const items = this.parseCSV(text, category);
if (items.length === 0) {
App.toast('Не удалось распознать данные');
return;
}
this.pendingImport = { items, category };
this.showImportPreview(items);
} catch (err) {
App.toast('Ошибка чтения файла: ' + err.message);
}
};
reader.readAsText(fileInput.files[0], 'utf-8');
},
parseCSV(text, category) {
const lines = text.split('\n').map(l => l.trim()).filter(l => l);
if (lines.length < 2) return [];
const items = [];
// Try to detect separator: tab or semicolon
const sep = lines[0].includes('\t') ? '\t' : ';';
for (let i = 1; i < lines.length; i++) {
const cols = lines[i].split(sep).map(c => c.trim().replace(/^"|"$/g, ''));
// Skip section headers (rows where only col A has text), empty rows, date rows
if (cols.length < 2) continue;
if (cols[0] && !cols[1] && !cols[2]) continue; // Section header
if (cols[0] && cols[0].toLowerCase().includes('дата обновления')) continue;
if (!cols[0]) continue; // Empty name
const name = cols[0] || '';
const sku = cols[1] || '';
// Determine qty column (varies by sheet structure)
// Standard: A=name, B=sku, C=size, D=color, E=photo, F=qty
// Rings/Packaging: A=name, B=sku, C=size, D=color, E=qty
// Try to find the qty (first numeric column after column 3)
let qty = 0;
let size = cols[2] || '';
let color = cols[3] || '';
for (let c = 4; c < cols.length; c++) {
const val = parseFloat(cols[c]);
if (!isNaN(val) && val >= 0) {
qty = val;
break;
}
}
// Skip if name looks like a header or section divider
if (name.toLowerCase() === 'наименование') continue;
items.push({
category: category,
name: name,
sku: sku,
size: size,
color: color,
unit: 'шт',
photo_url: '',
qty: qty,
min_qty: 0,
price_per_unit: 0,
notes: '',
});
}
return items;
},
async clearAllPhotos() {
if (!confirm('Удалить ВСЕ фото во всех позициях склада? Это можно будет откатить кнопкой "Восстановить фото по SKU".')) return;
let changed = 0;
this.allItems = this.allItems.map(item => {
const hasPhoto = !!(item.photo_thumbnail || item.photo_url);
if (!hasPhoto) return item;
changed++;
return { ...item, photo_thumbnail: '', photo_url: '' };
});
if (changed === 0) {
App.toast('Фото уже отсутствуют');
return;
}
await saveWarehouseItems(this.allItems);
this.setView(this.currentView || 'table', { force: true });
App.toast(`Фото очищены: ${changed}`);
},
async restorePhotosBySku() {
const photoBySku = this._getSeedPhotoMapBySku();
const totalSeedPhotos = Object.keys(photoBySku).length;
if (totalSeedPhotos === 0) {
App.toast('Не найден источник фото для восстановления');
return;
}
let restored = 0;
this.allItems = this.allItems.map(item => {
const skuKey = this._normStr(item.sku);
const photo = photoBySku[skuKey];
if (!photo) return item;
if (item.photo_thumbnail === photo && !item.photo_url) return item;
restored++;
return { ...item, photo_thumbnail: photo, photo_url: '' };
});
if (restored === 0) {
App.toast('Фото уже соответствуют SKU');
return;
}
await saveWarehouseItems(this.allItems);
this.setView(this.currentView || 'table', { force: true });
App.toast(`Фото восстановлены по SKU: ${restored}`);
},
showImportPreview(items) {
const container = document.getElementById('wh-import-preview');
const cat = WAREHOUSE_CATEGORIES.find(c => c.key === items[0]?.category);
container.innerHTML = `
Найдено позиций: ${items.length} ${cat ? '(' + cat.label + ')' : ''}
Название Артикул Размер Цвет Кол-во
${items.map(it => `
${this.esc(it.name)}
${this.esc(it.sku)}
${this.esc(it.size)}
${this.esc(it.color)}
${it.qty}
`).join('')}
Импортировать ${items.length} позиций
Отмена
`;
},
async confirmImport() {
if (!this.pendingImport) return;
const { items } = this.pendingImport;
for (const item of items) {
await saveWarehouseItem(item);
// Small delay to get unique IDs
await new Promise(r => setTimeout(r, 2));
}
// Record in history
const history = await loadWarehouseHistory();
history.push({
id: Date.now(),
item_id: 0,
item_name: `Импорт (${items.length} позиций)`,
item_sku: '',
type: 'import',
qty_change: items.reduce((s, i) => s + this._parseWarehouseQty(i.qty), 0),
qty_before: 0,
qty_after: 0,
order_name: '',
notes: `Импортировано ${items.length} позиций из CSV`,
created_at: new Date().toISOString(),
created_by: '',
});
await saveWarehouseHistory(history);
App.toast(`Импортировано: ${items.length} позиций`);
this.hideImport();
await this.load();
},
// ==========================================
// INVENTORY AUDIT (Инвентаризация)
// ==========================================
async showAudit() {
await this._refreshBlankHardwareWarehouseItemIds();
this.auditDraft = this._loadAuditDraft();
this._populateAuditCategoryFilter();
const searchEl = document.getElementById('wh-audit-search');
if (searchEl) searchEl.value = this.auditDraft.search || '';
const form = document.getElementById('wh-audit-form');
if (form) form.style.display = '';
this._updateAuditFormMode();
this.renderAuditTable(this.auditDraft.category || '');
this._updateAuditDraftStatus();
this._updateAuditSummary();
},
hideAudit() {
const form = document.getElementById('wh-audit-form');
if (form) form.style.display = 'none';
},
onAuditCategoryChange(category) {
const draft = this._ensureAuditDraft();
draft.category = String(category || '');
this.saveAuditDraft(false);
this.renderAuditTable(draft.category);
},
onAuditSearchChange(search) {
const draft = this._ensureAuditDraft();
draft.search = String(search || '');
this.saveAuditDraft(false);
this.renderAuditTable(draft.category || '');
},
renderAuditTable(category) {
const draft = this._ensureAuditDraft();
if (typeof category === 'string') {
draft.category = category;
}
const selectedCategory = draft.category || '';
const search = draft.search || '';
const items = this._getAuditFilteredItems(selectedCategory, search);
const container = document.getElementById('wh-audit-table');
if (!container) return;
if (items.length === 0) {
container.innerHTML = 'Нет позиций для инвентаризации по текущему фильтру
';
this._updateAuditSummary();
return;
}
container.innerHTML = ``;
this._updateAuditSummary();
},
onAuditInput(el) {
const draft = this._ensureAuditDraft();
const key = String(Number(el && el.dataset && el.dataset.id || 0) || (el && el.dataset && el.dataset.id) || '');
if (!key) return;
const rawValue = String(el && typeof el.value !== 'undefined' ? el.value : '').trim();
if (rawValue === '') {
delete draft.values[key];
} else {
draft.values[key] = rawValue;
}
this._persistAuditDraft();
this._updateAuditRowDiff(key);
},
async editInventoryAudit(auditId) {
const numericAuditId = Number(auditId || 0);
if (!numericAuditId) return;
const history = await loadWarehouseHistory();
const audits = this._getInventoryAuditEntries(history);
const mutationContext = this._getInventoryAuditMutationContext(numericAuditId, history, audits);
if (!mutationContext || !mutationContext.audit) {
App.toast('Инвентаризация не найдена');
return;
}
if (!mutationContext.canMutate) {
App.toast(mutationContext.blockedReason || 'Эту инвентаризацию уже нельзя безопасно менять');
return;
}
this.allItems = await loadWarehouseItems();
await this._refreshBlankHardwareWarehouseItemIds();
this.auditDraft = this._buildInventoryAuditDraftFromAudit(mutationContext.audit);
this._persistAuditDraft();
await this.showAudit();
App.toast('Инвентаризация открыта для редактирования');
},
async deleteInventoryAudit(auditId) {
const numericAuditId = Number(auditId || 0);
if (!numericAuditId) return;
const history = await loadWarehouseHistory();
const audits = this._getInventoryAuditEntries(history);
const mutationContext = this._getInventoryAuditMutationContext(numericAuditId, history, audits);
if (!mutationContext || !mutationContext.audit) {
App.toast('Инвентаризация не найдена');
return;
}
if (!mutationContext.canMutate) {
App.toast(mutationContext.blockedReason || 'Эту инвентаризацию уже нельзя безопасно менять');
return;
}
const confirmText = [
'Удалить инвентаризацию?',
'Все её изменения будут отменены, а остатки вернутся к состоянию "было" на момент этой инвентаризации.',
].join('\n');
if (!confirm(confirmText)) return;
const items = await loadWarehouseItems();
const itemsById = new Map((items || []).map(item => [Number(item && item.id || 0), { ...item }]));
(mutationContext.audit.details || []).forEach(detail => {
const itemId = Number(detail && detail.item_id || 0);
if (!itemId) return;
const item = itemsById.get(itemId);
if (!item) return;
item.qty = this._roundInventoryNumber(detail.system_qty_before);
item.updated_at = new Date().toISOString();
itemsById.set(itemId, item);
});
const nextItems = (items || []).map(item => itemsById.get(Number(item && item.id || 0)) || item);
await saveWarehouseItems(nextItems);
this.allItems = nextItems.map(item => ({ ...item }));
const nextHistory = (history || []).filter((_, index) => !mutationContext.relatedIndexes.has(index));
await saveWarehouseHistory(nextHistory);
if (this._isAuditEditMode() && Number(this.auditDraft && this.auditDraft.audit_id || 0) === numericAuditId) {
this.auditDraft = this._defaultAuditDraft();
localStorage.removeItem(this._auditDraftStorageKey());
this._updateAuditFormMode();
this._updateAuditDraftStatus();
this._updateAuditSummary();
this.hideAudit();
}
App.toast('Инвентаризация удалена, остатки откатились к прежним значениям');
await this.load();
},
async saveAuditResults() {
const draft = this._ensureAuditDraft();
const isEditMode = this._isAuditEditMode(draft);
const enteredRows = [];
const changes = [];
Object.entries(draft.values || {}).forEach(([rawId, rawValue]) => {
if (rawValue === '' || rawValue == null) return;
const itemId = Number(rawId || 0);
const item = (this.allItems || []).find(entry => Number(entry && entry.id || 0) === itemId);
if (!item) return;
const meta = this._getAuditDiffMeta(item, rawValue);
if (meta.diff == null) return;
enteredRows.push({
item_id: item.id,
item_name: item.name || '',
item_sku: item.sku || '',
item_category: item.category || '',
unit: item.unit || 'шт',
system_qty_before: this._roundInventoryNumber(meta.systemQty),
actual_qty: this._roundInventoryNumber(meta.actualQty),
diff: this._roundInventoryNumber(meta.diff),
value_diff: this._roundInventoryNumber(meta.valueDiff || 0),
price_per_unit: this._roundInventoryNumber(item.price_per_unit),
});
if (Math.abs(meta.diff) < 0.000001) return;
changes.push({
item,
actualQty: meta.actualQty,
diff: meta.diff,
valueDiff: meta.valueDiff || 0,
});
});
if (!isEditMode && changes.length === 0) {
App.toast('Нет изменений для сохранения');
return;
}
const stats = this._getAuditSummaryStats();
if (isEditMode && enteredRows.length === 0) {
App.toast('Для пустой инвентаризации используйте удаление');
return;
}
let auditId = Date.now() + Math.floor(Math.random() * 1000);
let auditCreatedAt = new Date().toISOString();
const confirmText = [
isEditMode ? 'Сохранить изменения инвентаризации?' : 'Принять инвентаризацию?',
`Позиции с расхождением: ${stats.changedPositions}`,
`Недостача: ${this._formatMoney(stats.shortageValue)}`,
`Излишек: ${this._formatMoney(stats.surplusValue)}`,
`Нетто: ${stats.netValue >= 0 ? '+' : '−'}${this._formatMoney(Math.abs(stats.netValue))}`,
].join('\n');
if (!confirm(confirmText)) return;
const createdBy = App.getCurrentEmployeeName ? App.getCurrentEmployeeName() : '';
const history = await loadWarehouseHistory();
const detailLines = changes.map(change =>
`${change.item.name}: ${change.diff > 0 ? '+' : ''}${change.diff} ${change.item.unit || 'шт'} (${change.valueDiff >= 0 ? '+' : '−'}${this._formatMoney(Math.abs(change.valueDiff))})`
);
if (isEditMode) {
const mutationContext = this._getInventoryAuditMutationContext(draft.audit_id, history);
if (!mutationContext || !mutationContext.audit) {
App.toast('Инвентаризация не найдена');
return;
}
if (!mutationContext.canMutate) {
App.toast(mutationContext.blockedReason || 'Эту инвентаризацию уже нельзя безопасно менять');
return;
}
auditId = Number(mutationContext.auditEntry && mutationContext.auditEntry.id || 0) || auditId;
auditCreatedAt = mutationContext.auditEntry && mutationContext.auditEntry.created_at
? mutationContext.auditEntry.created_at
: auditCreatedAt;
const originalDetails = Array.isArray(mutationContext.audit.details) ? mutationContext.audit.details : [];
const originalById = new Map(originalDetails.map(detail => [Number(detail && detail.item_id || 0), detail]));
const nextById = new Map(enteredRows.map(detail => [Number(detail && detail.item_id || 0), detail]));
const items = await loadWarehouseItems();
const itemsById = new Map((items || []).map(item => [Number(item && item.id || 0), { ...item }]));
const targetItemIds = new Set([...originalById.keys(), ...nextById.keys()]);
let stockChanged = false;
targetItemIds.forEach(itemId => {
if (!itemId) return;
const item = itemsById.get(itemId);
if (!item) return;
const nextDetail = nextById.get(itemId) || null;
const originalDetail = originalById.get(itemId) || null;
const desiredQty = nextDetail
? this._roundInventoryNumber(nextDetail.actual_qty)
: this._roundInventoryNumber(originalDetail && originalDetail.system_qty_before);
if (Math.abs(this._parseWarehouseQty(item.qty) - desiredQty) >= 0.000001) {
stockChanged = true;
}
item.qty = desiredQty;
item.updated_at = new Date().toISOString();
itemsById.set(itemId, item);
});
const detailsChanged = this._inventoryAuditDetailsChanged(originalDetails, enteredRows);
if (!stockChanged && !detailsChanged) {
App.toast('Изменений для сохранения нет');
return;
}
const nextItems = (items || []).map(item => itemsById.get(Number(item && item.id || 0)) || item);
await saveWarehouseItems(nextItems);
this.allItems = nextItems.map(item => ({ ...item }));
const nextHistory = (history || []).filter((_, index) => !mutationContext.relatedIndexes.has(index));
changes.forEach(change => {
nextHistory.push({
id: Date.now() + Math.floor(Math.random() * 1000),
item_id: change.item.id,
item_name: change.item.name || '',
item_sku: change.item.sku || '',
item_category: change.item.category || '',
type: 'adjustment',
qty_change: this._roundInventoryNumber(change.diff),
requested_qty_change: this._roundInventoryNumber(change.diff),
qty_before: this._roundInventoryNumber(this._getAuditBaselineQty(change.item.id, change.item.qty)),
qty_after: this._roundInventoryNumber(change.actualQty),
unit_price: this._roundInventoryNumber(change.item.price_per_unit),
total_cost_change: this._roundInventoryNumber(Math.abs(change.diff) * (parseFloat(change.item.price_per_unit) || 0)),
order_id: null,
order_name: '',
notes: `Инвентаризация: факт ${change.actualQty}, было ${this._roundInventoryNumber(this._getAuditBaselineQty(change.item.id, change.item.qty))}`,
clamped: false,
created_at: auditCreatedAt,
created_by: createdBy || '',
inventory_audit: true,
inventory_audit_id: auditId,
});
});
nextHistory.push(this._buildInventoryAuditSummaryEntry(
auditId,
auditCreatedAt,
createdBy || (mutationContext.auditEntry && mutationContext.auditEntry.created_by) || '',
stats,
enteredRows,
detailLines
));
await saveWarehouseHistory(nextHistory);
} else {
for (const change of changes) {
await this.adjustStock(
change.item.id,
change.diff,
'adjustment',
'',
`Инвентаризация: факт ${change.actualQty}, было ${this._roundInventoryNumber(this._getAuditBaselineQty(change.item.id, change.item.qty))}`,
createdBy,
{
inventory_audit: true,
inventory_audit_id: auditId,
}
);
}
const nextHistory = await loadWarehouseHistory();
nextHistory.push(this._buildInventoryAuditSummaryEntry(
auditId,
auditCreatedAt,
createdBy,
stats,
enteredRows,
detailLines
));
await saveWarehouseHistory(nextHistory);
}
localStorage.removeItem(this._auditDraftStorageKey());
this.auditDraft = this._defaultAuditDraft();
this._updateAuditFormMode();
this._updateAuditDraftStatus();
this._updateAuditSummary();
App.toast(isEditMode
? `Инвентаризация обновлена: ${stats.changedPositions} поз., недостача ${this._formatMoney(stats.shortageValue)}`
: `Инвентаризация принята: ${stats.changedPositions} поз., недостача ${this._formatMoney(stats.shortageValue)}`);
this.hideAudit();
await this.load();
},
// ==========================================
// HISTORY VIEW
// ==========================================
async renderHistory() {
const container = document.getElementById('wh-content');
if (!container) return;
const history = await loadWarehouseHistory();
const sorted = history.sort((a, b) => new Date(b.created_at) - new Date(a.created_at)).slice(0, 200);
if (sorted.length === 0) {
container.innerHTML = '';
return;
}
const typeIcons = {
deduction: '📤', addition: '📥', adjustment: '🔧',
import: '📋', reservation: '📌', unreserve: '🔓',
};
container.innerHTML = `
Дата
Позиция
Изменение
Остаток
Причина
${sorted.map(h => {
const icon = typeIcons[h.type] || '📋';
const changeClass = (h.qty_change || 0) > 0 ? 'text-green' : ((h.qty_change || 0) < 0 ? 'text-red' : '');
const changeStr = (h.qty_change || 0) > 0 ? '+' + h.qty_change : String(h.qty_change || 0);
return `
${App.formatDate(h.created_at)}
${icon}
${this.esc(h.item_name || '')}
${h.item_sku ? `${this.esc(h.item_sku)}
` : ''}
${changeStr}
${h.qty_after ?? '—'}
${this.esc(h.notes || h.order_name || '')}
`;
}).join('')}
`;
},
_isSampleStatus(status) {
return status === 'sample';
},
_isProjectHardwareReserveStatus(status) {
return ['sample', 'production_casting', 'production_printing', 'production_hardware', 'production_packaging', 'in_production', 'delivery'].includes(status);
},
_isProjectHardwareActionStatus(status) {
return ['production_casting', 'production_printing', 'production_hardware', 'production_packaging', 'in_production', 'delivery', 'completed'].includes(status);
},
_isProjectHardwareTrackedStatus(status) {
return this._isProjectHardwareReserveStatus(status) || this._isProjectHardwareActionStatus(status);
},
_isProjectHardwareReservationSource(source) {
return source === 'project_hardware' || source === 'order_calc';
},
_isManualReservationSource(source) {
const normalized = this._normStr(source || '');
return normalized === 'manual' || normalized === 'manual_reserve';
},
_isManualReservationRecord(reservation) {
if (!reservation || typeof reservation !== 'object') return false;
if (this._isManualReservationSource(reservation.source)) return true;
if (String(reservation.order_name || '').trim() === 'Ручной резерв') return true;
const hasLinkedOrder = Number(reservation.order_id || 0) > 0;
return !hasLinkedOrder && !this._isProjectHardwareReservationSource(reservation.source);
},
_getActiveReservationsForItem(itemId) {
const normalizedItemId = Number(itemId || 0);
return (this.allReservations || []).filter(r =>
Number(r && r.item_id || 0) === normalizedItemId && String(r && r.status || '') === 'active'
);
},
_hasLockedProjectReservations(itemId) {
return this._getActiveReservationsForItem(itemId).some(r =>
this._isProjectHardwareReservationSource(r && r.source) || Number(r && r.order_id || 0) > 0
);
},
_getReservationDisplayMeta(reservation) {
const orderName = String(reservation && reservation.order_name || '').trim();
const orderId = Number(reservation && reservation.order_id || 0);
const qty = this._parseWarehouseQty(reservation && reservation.qty);
const isProject = this._isProjectHardwareReservationSource(reservation && reservation.source) || orderId > 0;
const isManual = this._isManualReservationRecord(reservation);
const sourceLabel = isProject ? 'проект' : (isManual ? 'вручную' : 'резерв');
const primaryLabel = orderName && orderName !== 'Ручной резерв'
? orderName
: (isProject
? (orderId > 0 ? `Заказ #${orderId}` : 'Связанный заказ')
: 'Ручной резерв');
const titleParts = [primaryLabel, `${sourceLabel}, ${qty.toLocaleString('ru-RU')} шт`];
if (reservation && reservation.created_by) titleParts.push(`создал ${reservation.created_by}`);
return {
qty,
orderId,
isProject,
isManual,
sourceLabel,
primaryLabel,
title: titleParts.join(' · '),
};
},
_renderReservationPills(itemId, { compact = false, limit = 2 } = {}) {
const activeReservations = this._getActiveReservationsForItem(itemId);
if (!activeReservations.length) return '';
const shownReservations = activeReservations.slice(0, Math.max(0, limit));
const pillStyle = [
'display:inline-flex',
'align-items:center',
'gap:4px',
'max-width:100%',
'padding:2px 8px',
'border:1px solid var(--border)',
'border-radius:999px',
'background:#fff',
'font-size:11px',
'line-height:1.2',
compact ? 'justify-content:flex-end' : '',
].filter(Boolean).join(';');
const wrapStyle = compact
? 'display:flex;flex-wrap:wrap;gap:4px;justify-content:flex-end;margin-top:6px;'
: 'display:flex;flex-wrap:wrap;gap:6px;';
const pills = shownReservations.map(reservation => {
const meta = this._getReservationDisplayMeta(reservation);
const content = `
${this.esc(meta.primaryLabel)}
${this.esc(meta.sourceLabel)} · ${meta.qty.toLocaleString('ru-RU')} шт
`;
if (meta.orderId > 0) {
return `
${content}
`;
}
return `${content} `;
});
const hiddenCount = activeReservations.length - shownReservations.length;
if (hiddenCount > 0) {
pills.push(`+${hiddenCount} `);
}
return `${pills.join('')}
`;
},
_projectHardwareKey(orderId, itemId) {
return `${Number(orderId) || 0}:${Number(itemId) || 0}`;
},
async _waitForProjectHardwareMutations() {
// Allow nested refreshes (for example, Warehouse.load() at the end of a mutation)
// to continue immediately instead of waiting on the currently running mutation.
if (this._projectHardwareMutationDepth > 0) return;
const pending = this._projectHardwareMutationQueue;
if (!pending || typeof pending.then !== 'function') return;
await pending;
},
async _runProjectHardwareMutation(task) {
if (this._projectHardwareMutationDepth > 0) {
return await task();
}
const previous = this._projectHardwareMutationQueue || Promise.resolve();
const next = previous
.catch(() => undefined)
.then(async () => {
this._projectHardwareMutationDepth += 1;
try {
return await task();
} finally {
this._projectHardwareMutationDepth = Math.max(0, this._projectHardwareMutationDepth - 1);
}
});
this._projectHardwareMutationQueue = next.then(
() => undefined,
() => undefined
);
return await next;
},
async _ensureProjectHardwareStateLoaded() {
if (!this.projectHardwareState || typeof this.projectHardwareState !== 'object') {
this.projectHardwareState = await loadProjectHardwareState();
}
if (!this.projectHardwareState || typeof this.projectHardwareState !== 'object') {
this.projectHardwareState = { checks: {}, actual_qtys: {} };
}
if (!this.projectHardwareState.checks || typeof this.projectHardwareState.checks !== 'object') {
this.projectHardwareState.checks = {};
}
if (!this.projectHardwareState.actual_qtys || typeof this.projectHardwareState.actual_qtys !== 'object') {
this.projectHardwareState.actual_qtys = {};
}
},
_isProjectHardwareReady(orderId, itemId) {
const checks = (this.projectHardwareState && this.projectHardwareState.checks) || {};
return !!checks[this._projectHardwareKey(orderId, itemId)];
},
_hasProjectHardwareActualQty(orderId, itemId) {
const actuals = (this.projectHardwareState && this.projectHardwareState.actual_qtys) || {};
return Object.prototype.hasOwnProperty.call(actuals, this._projectHardwareKey(orderId, itemId));
},
_getProjectHardwareActualQty(orderId, itemId) {
if (!this._hasProjectHardwareActualQty(orderId, itemId)) return null;
const actuals = (this.projectHardwareState && this.projectHardwareState.actual_qtys) || {};
const raw = actuals[this._projectHardwareKey(orderId, itemId)];
const value = round2(Math.max(0, parseFloat(raw) || 0));
return Number.isFinite(value) ? value : null;
},
_setProjectHardwareActualQtyValue(orderId, itemId, value) {
if (!this.projectHardwareState || typeof this.projectHardwareState !== 'object') {
this.projectHardwareState = { checks: {}, actual_qtys: {} };
}
if (!this.projectHardwareState.actual_qtys || typeof this.projectHardwareState.actual_qtys !== 'object') {
this.projectHardwareState.actual_qtys = {};
}
const key = this._projectHardwareKey(orderId, itemId);
const hadValue = Object.prototype.hasOwnProperty.call(this.projectHardwareState.actual_qtys, key);
if (value === null || value === undefined || value === '') {
if (!hadValue) return false;
delete this.projectHardwareState.actual_qtys[key];
return true;
}
const normalized = round2(Math.max(0, parseFloat(value) || 0));
if (hadValue && Math.abs((parseFloat(this.projectHardwareState.actual_qtys[key]) || 0) - normalized) <= 0.000001) {
return false;
}
this.projectHardwareState.actual_qtys[key] = normalized;
return true;
},
async _persistProjectHardwareRowState(orderId, itemId, { actualQty = undefined, ready = undefined, updatedAt = '', updatedBy = '' } = {}) {
const normalizedOrderId = Number(orderId || 0);
const normalizedItemId = Number(itemId || 0);
if (!normalizedOrderId || !normalizedItemId) return;
const key = this._projectHardwareKey(normalizedOrderId, normalizedItemId);
let latest = null;
try {
latest = await loadProjectHardwareState();
} catch (error) {
console.warn('Warehouse._persistProjectHardwareRowState loadProjectHardwareState failed:', error);
latest = null;
}
const merged = latest && typeof latest === 'object'
? {
...latest,
checks: { ...((latest && latest.checks) || {}) },
actual_qtys: { ...((latest && latest.actual_qtys) || {}) },
}
: { checks: {}, actual_qtys: {} };
if (ready !== undefined) {
if (ready) merged.checks[key] = true;
else delete merged.checks[key];
}
if (actualQty !== undefined) {
if (actualQty === null || actualQty === '' || actualQty === undefined) delete merged.actual_qtys[key];
else merged.actual_qtys[key] = round2(Math.max(0, parseFloat(actualQty) || 0));
}
merged.updated_at = updatedAt || new Date().toISOString();
merged.updated_by = updatedBy || '';
this.projectHardwareState = merged;
await saveProjectHardwareState(merged);
},
_getProjectHardwareDisplayActualQty(orderId, itemId, plannedQty, historyDeltaMap, history) {
const explicit = this._getProjectHardwareActualQty(orderId, itemId);
if (explicit !== null) return explicit;
const historicalTarget = this._getProjectHardwareHistoricalTargetQty(orderId, itemId, history, historyDeltaMap);
if (historicalTarget !== null) return historicalTarget;
const consumedQty = this._getProjectHardwareConsumedQty(orderId, itemId, historyDeltaMap);
if (consumedQty > 0) return round2(consumedQty);
if (this._isProjectHardwareReady(orderId, itemId)) return round2(Math.max(0, parseFloat(plannedQty) || 0));
return null;
},
_buildProjectHardwareTargetQty(orderId, itemId, plannedQty, historyDeltaMap) {
const explicit = this._getProjectHardwareActualQty(orderId, itemId);
if (explicit !== null) return explicit;
return round2(Math.max(0, parseFloat(plannedQty) || 0));
},
_getProjectHardwareReservedQtyForOrderItem(reservations, orderId, itemId) {
return round2((reservations || [])
.filter(r =>
String(r && r.status || '') === 'active'
&& Number(r && r.order_id || 0) === Number(orderId || 0)
&& Number(r && r.item_id || 0) === Number(itemId || 0)
&& this._isProjectHardwareReservationSource(r && r.source)
)
.reduce((sum, r) => sum + (parseFloat(r.qty) || 0), 0));
},
_getProjectHardwareBlockingReservations(reservations, currentOrderId, itemId) {
const grouped = new Map();
(reservations || []).forEach(r => {
if (String(r && r.status || '') !== 'active') return;
if (!this._isProjectHardwareReservationSource(r && r.source)) return;
if (Number(r && r.item_id || 0) !== Number(itemId || 0)) return;
if (Number(r && r.order_id || 0) === Number(currentOrderId || 0)) return;
const key = String(r.order_id || '') || `manual:${r.id || ''}`;
const current = grouped.get(key) || {
orderId: Number(r.order_id || 0) || null,
orderName: String(r.order_name || '').trim() || 'другой заказ',
qty: 0,
};
current.qty += parseFloat(r.qty) || 0;
grouped.set(key, current);
});
return Array.from(grouped.values())
.map(entry => ({ ...entry, qty: round2(entry.qty) }))
.sort((a, b) => (b.qty || 0) - (a.qty || 0));
},
_formatProjectHardwareBlockers(blockers) {
const list = Array.isArray(blockers) ? blockers.filter(Boolean) : [];
if (!list.length) return '';
const head = list.slice(0, 2).map(entry => `${entry.orderName} — ${entry.qty} шт`);
const suffix = list.length > 2 ? `; +ещё ${list.length - 2}` : '';
return `; занято: ${head.join('; ')}${suffix}`;
},
_releaseProjectHardwareReservationsForRow(reservations, orderId, itemId, nowIso) {
let changed = false;
(reservations || []).forEach(r => {
if (r.status !== 'active') return;
if (Number(r.order_id) !== Number(orderId || 0)) return;
if (Number(r.item_id) !== Number(itemId || 0)) return;
if (!this._isProjectHardwareReservationSource(r.source)) return;
r.status = 'released';
r.released_at = nowIso;
changed = true;
});
return changed;
},
_projectSupplyKindLabel(kind) {
return String(kind || '').toLowerCase() === 'packaging'
? 'Упаковка'
: 'Фурнитура';
},
async _syncProjectHardwareConsumedQty({ orderId, itemId, targetQty, orderName, managerName, historyDeltaMap, flow = 'ready_delta' }) {
const normalizedTarget = round2(Math.max(0, parseFloat(targetQty) || 0));
const consumedQty = this._getProjectHardwareConsumedQty(orderId, itemId, historyDeltaMap);
const delta = round2(normalizedTarget - consumedQty);
if (Math.abs(delta) <= 0.000001) {
return { ok: true, changed: false, targetQty: normalizedTarget };
}
if (delta > 0) {
const items = await loadWarehouseItems();
const whItem = (items || []).find(i => Number(i.id) === Number(itemId)) || null;
const stockQty = parseFloat(whItem && whItem.qty) || 0;
if (stockQty + 0.000001 < delta) {
return { ok: false, reason: 'insufficient_stock', targetQty: normalizedTarget };
}
const notes = flow === 'ready_toggle'
? `Списание собранной позиции со склада: ${delta} шт`
: `Корректировка собранной позиции: +${delta} шт`;
const result = await this.adjustStock(
itemId,
-delta,
'deduction',
orderName || 'Заказ',
notes,
managerName || '',
{
order_id: Number(orderId || 0),
project_hardware_flow: flow,
project_hardware_target_qty: normalizedTarget,
}
);
const appliedQty = Math.max(0, -(parseFloat(result && result.appliedQtyChange) || 0));
if (!result || result.ok === false || appliedQty + 0.000001 < delta) {
if (appliedQty > 0) {
await this.adjustStock(
itemId,
appliedQty,
'addition',
orderName || 'Заказ',
`Откат неполной корректировки собранной позиции: ${appliedQty} шт`,
managerName || '',
{
order_id: Number(orderId || 0),
project_hardware_flow: 'ready_adjustment',
project_hardware_target_qty: consumedQty,
}
);
}
return { ok: false, reason: 'insufficient_stock', targetQty: normalizedTarget };
}
return { ok: true, changed: true, targetQty: normalizedTarget };
}
const notes = flow === 'ready_toggle'
? `Возврат собранной позиции на склад: ${Math.abs(delta)} шт`
: `Корректировка собранной позиции: -${Math.abs(delta)} шт`;
await this.adjustStock(
itemId,
Math.abs(delta),
'addition',
orderName || 'Заказ',
notes,
managerName || '',
{
order_id: Number(orderId || 0),
project_hardware_flow: flow,
project_hardware_target_qty: normalizedTarget,
}
);
return { ok: true, changed: true, targetQty: normalizedTarget };
},
async toggleProjectHardwareReady(orderId, itemId, checked) {
return await this._runProjectHardwareMutation(async () => {
const normalizedOrderId = Number(orderId || 0);
const normalizedItemId = Number(itemId || 0);
if (!normalizedOrderId || !normalizedItemId) return;
await this._ensureProjectHardwareStateLoaded();
const key = this._projectHardwareKey(normalizedOrderId, normalizedItemId);
const wasSavedChecked = !!this.projectHardwareState.checks[key];
const data = await loadOrder(normalizedOrderId);
if (!data || !data.order) return;
const order = data.order || {};
const demand = this._getProjectHardwareDemandMap(data.items || []);
const qty = demand.get(normalizedItemId) || 0;
const managerName = App.getCurrentEmployeeName() || order.manager_name || '';
const nowIso = new Date().toISOString();
let reservations = await loadWarehouseReservations();
const history = await loadWarehouseHistory();
const historyDeltaMap = this._buildProjectHardwareHistoryDeltaMap(history);
const wasChecked = wasSavedChecked || this._computeProjectHardwareReadyState(
normalizedOrderId,
normalizedItemId,
qty,
history,
historyDeltaMap
);
if (wasChecked === !!checked) return;
if (checked) {
const targetQty = this._buildProjectHardwareTargetQty(normalizedOrderId, normalizedItemId, qty, historyDeltaMap);
const syncResult = await this._syncProjectHardwareConsumedQty({
orderId: normalizedOrderId,
itemId: normalizedItemId,
targetQty,
orderName: order.order_name || 'Заказ',
managerName,
historyDeltaMap,
flow: 'ready_toggle',
});
if (!syncResult.ok) {
App.toast('Не удалось отметить как собрано: недостаточно остатка');
return;
}
this._releaseProjectHardwareReservationsForRow(reservations, normalizedOrderId, normalizedItemId, nowIso);
this.projectHardwareState.checks[key] = true;
App.toast(syncResult.targetQty > 0 ? 'Позиция со склада списана' : 'Позиция отмечена как собранная');
} else {
delete this.projectHardwareState.checks[key];
const returnQty = this._getProjectHardwareConsumedQty(normalizedOrderId, normalizedItemId, historyDeltaMap);
if (returnQty > 0) {
await this.adjustStock(
normalizedItemId,
returnQty,
'addition',
order.order_name || 'Заказ',
`Возврат собранной позиции на склад: ${returnQty} шт`,
managerName,
{
order_id: normalizedOrderId,
project_hardware_flow: 'ready_toggle',
project_hardware_target_qty: 0,
}
);
}
if (qty > 0 && this._isProjectHardwareReserveStatus(order.status)) {
const items = await loadWarehouseItems();
const activeByItem = new Map();
reservations.forEach(r => {
if (r.status !== 'active') return;
const resItemId = Number(r.item_id || 0);
if (!resItemId) return;
activeByItem.set(resItemId, (activeByItem.get(resItemId) || 0) + (parseFloat(r.qty) || 0));
});
const whItem = items.find(i => Number(i.id) === normalizedItemId);
const stockQty = parseFloat(whItem && whItem.qty) || 0;
const alreadyReserved = activeByItem.get(normalizedItemId) || 0;
const available = Math.max(0, stockQty - alreadyReserved);
const reserveQty = Math.min(qty, available);
if (reserveQty > 0) {
reservations.push({
id: Date.now() + Math.floor(Math.random() * 1000),
item_id: normalizedItemId,
order_id: normalizedOrderId,
order_name: order.order_name || 'Заказ',
qty: reserveQty,
status: 'active',
source: 'project_hardware',
created_at: nowIso,
created_by: managerName || '',
});
}
if (reserveQty < qty) {
App.toast('Позиция возвращена не в полный резерв: недостаточно остатка');
} else {
App.toast('Позиция возвращена в резерв');
}
} else if (returnQty > 0) {
App.toast('Позиция возвращена на склад');
} else {
App.toast('Флаг сборки снят');
}
}
this.projectHardwareState.updated_at = nowIso;
this.projectHardwareState.updated_by = managerName;
await this._persistProjectHardwareRowState(normalizedOrderId, normalizedItemId, {
ready: !!this.projectHardwareState.checks[key],
updatedAt: nowIso,
updatedBy: managerName,
});
await saveWarehouseReservations(reservations);
if (typeof Calculator !== 'undefined') {
Calculator._whPickerData = null;
}
await this.load();
});
},
async setProjectHardwareActualQty(orderId, itemId, rawValue) {
return await this._runProjectHardwareMutation(async () => {
const normalizedOrderId = Number(orderId || 0);
const normalizedItemId = Number(itemId || 0);
if (!normalizedOrderId || !normalizedItemId) return;
await this._ensureProjectHardwareStateLoaded();
const data = await loadOrder(normalizedOrderId);
if (!data || !data.order) return;
const order = data.order || {};
const plannedQty = this._getProjectHardwareDemandMap(data.items || []).get(normalizedItemId) || 0;
const nextActual = String(rawValue ?? '').trim() === ''
? null
: round2(Math.max(0, parseFloat(rawValue) || 0));
let stateChanged = this._setProjectHardwareActualQtyValue(normalizedOrderId, normalizedItemId, nextActual);
const nowIso = new Date().toISOString();
const managerName = App.getCurrentEmployeeName() || order.manager_name || '';
let reservations = await loadWarehouseReservations();
let reservationsChanged = false;
const history = await loadWarehouseHistory();
const historyDeltaMap = this._buildProjectHardwareHistoryDeltaMap(history);
const isReady = this._computeProjectHardwareReadyState(
normalizedOrderId,
normalizedItemId,
plannedQty,
history,
historyDeltaMap
);
if (isReady) {
const targetQty = nextActual !== null ? nextActual : round2(Math.max(0, plannedQty));
const syncResult = await this._syncProjectHardwareConsumedQty({
orderId: normalizedOrderId,
itemId: normalizedItemId,
targetQty,
orderName: order.order_name || 'Заказ',
managerName,
historyDeltaMap,
flow: 'ready_delta',
});
if (!syncResult.ok) {
App.toast('Не удалось сохранить фактическое количество: недостаточно остатка');
return;
}
if (!this._isProjectHardwareReady(normalizedOrderId, normalizedItemId)) {
this.projectHardwareState.checks[this._projectHardwareKey(normalizedOrderId, normalizedItemId)] = true;
stateChanged = true;
}
reservationsChanged = this._releaseProjectHardwareReservationsForRow(
reservations,
normalizedOrderId,
normalizedItemId,
nowIso
) || reservationsChanged;
} else if (this._isProjectHardwareReserveStatus(order.status) && plannedQty > 0) {
const syncResult = await this.syncProjectHardwareOrderState({
orderId: normalizedOrderId,
orderName: order.order_name || 'Заказ',
managerName,
status: order.status || '',
currentItems: data.items || [],
previousItems: data.items || [],
});
reservations = await loadWarehouseReservations();
reservationsChanged = reservationsChanged || !!syncResult.reservationsChanged;
}
if (stateChanged) {
await this._persistProjectHardwareRowState(normalizedOrderId, normalizedItemId, {
actualQty: nextActual,
ready: this._isProjectHardwareReady(normalizedOrderId, normalizedItemId) ? true : undefined,
updatedAt: nowIso,
updatedBy: managerName,
});
}
if (reservationsChanged) {
await saveWarehouseReservations(reservations);
}
if (typeof Calculator !== 'undefined') {
Calculator._whPickerData = null;
}
App.toast(nextActual === null ? 'Фактическое количество сброшено к плановому' : 'Фактическое количество сохранено');
await this.load();
});
},
_collectWarehouseDemandFromOrderItems(items) {
const grouped = new Map();
const explicitHardwareWarehouseIds = new Set();
const addDemandRow = (itemId, qty, name, materialType = 'hardware') => {
const normalizedItemId = Number(itemId || 0);
const normalizedQty = parseFloat(qty) || 0;
if (!normalizedItemId || normalizedQty <= 0) return;
const key = String(normalizedItemId);
const prev = grouped.get(key);
const normalizedName = name || '';
if (!prev) {
grouped.set(key, {
warehouse_item_id: normalizedItemId,
qty: normalizedQty,
names: normalizedName ? [normalizedName] : [],
material_type: materialType,
});
return;
}
prev.qty += normalizedQty;
if (normalizedName && !prev.names.includes(normalizedName)) prev.names.push(normalizedName);
if (prev.material_type !== materialType) prev.material_type = 'mixed';
grouped.set(key, prev);
};
(items || []).forEach(item => {
const itemType = String(item.item_type || '').toLowerCase();
if (itemType !== 'hardware') return;
const src = String(item.source || item.hardware_source || '').toLowerCase();
const itemId = Number(item.warehouse_item_id ?? item.hardware_warehouse_item_id ?? 0);
const qty = parseFloat(item.quantity ?? item.hardware_qty ?? item.qty ?? 0) || 0;
if (src === 'warehouse' && itemId && qty > 0) {
explicitHardwareWarehouseIds.add(itemId);
}
});
(items || []).forEach(item => {
const itemType = String(item.item_type || '').toLowerCase();
if (itemType === 'pendant' && typeof getPendantWarehouseDemandRows === 'function') {
getPendantWarehouseDemandRows(item).forEach(row => {
addDemandRow(row.warehouse_item_id, row.qty, row.name, row.material_type || 'hardware');
});
return;
}
if (itemType === 'product' && typeof getProductWarehouseDemandRows === 'function') {
getProductWarehouseDemandRows(item, this.allItems || []).forEach(row => {
if (explicitHardwareWarehouseIds.has(Number(row.warehouse_item_id || 0))) return;
addDemandRow(row.warehouse_item_id, row.qty, row.name, row.material_type || 'hardware');
});
return;
}
const isHardware = itemType === 'hardware';
const isPackaging = itemType === 'packaging';
if (!isHardware && !isPackaging) return;
const src = String(
item.source
|| (isHardware ? item.hardware_source : item.packaging_source)
|| ''
).toLowerCase();
if (src !== 'warehouse') return;
const itemId = Number(
(
item.warehouse_item_id
?? (isHardware ? item.hardware_warehouse_item_id : item.packaging_warehouse_item_id)
?? 0
)
);
const qty = parseFloat(
item.quantity
?? (isHardware ? item.hardware_qty : item.packaging_qty)
?? item.qty
?? 0
) || 0;
const name = item.product_name || item.name || '';
const materialType = isPackaging ? 'packaging' : 'hardware';
addDemandRow(itemId, qty, name, materialType);
});
return Array.from(grouped.values());
},
_getProjectHardwareDemandMap(items) {
const demand = new Map();
this._collectWarehouseDemandFromOrderItems(items).forEach(row => {
const itemId = Number(row.warehouse_item_id || 0);
const qty = parseFloat(row.qty) || 0;
if (!itemId || qty <= 0) return;
demand.set(itemId, qty);
});
return demand;
},
_getProjectHardwareHistoryFlow(entry) {
const explicitFlow = String(entry && entry.project_hardware_flow || '').trim().toLowerCase();
if (explicitFlow) return explicitFlow;
const notes = String(entry && entry.notes || '').trim();
if (!notes) return '';
if (/^(Списание|Возврат на склад) при смене статуса:/i.test(notes)) return 'legacy_status';
if (/^Автоисправление legacy-(?:списания|возврата) проектной позиции:/i.test(notes)) return 'legacy_status_repair';
if (/^Откат неполного списания собранной позиции со склада:/i.test(notes)) return 'ready_adjustment';
if (/^Корректировка собранной (?:фурнитуры|позиции):/i.test(notes)) return 'ready_delta';
if (/^(Списание|Возврат(?: собранной)?(?: позиции| фурнитуры)?|Возврат собранной фурнитуры|Списание собранной фурнитуры)/i.test(notes)) {
if (/собранн/i.test(notes)) return 'ready_toggle';
}
return '';
},
_isProjectHardwareStockHistoryFlow(flow) {
return flow === 'ready_toggle'
|| flow === 'ready_delta'
|| flow === 'ready_adjustment';
},
_isProjectHardwareLegacyHistoryFlow(flow) {
return flow === 'legacy_status'
|| flow === 'legacy_status_repair';
},
_buildProjectHardwareHistoryDeltaMap(history) {
const deltaByKey = new Map();
(history || []).forEach(entry => {
const flow = this._getProjectHardwareHistoryFlow(entry);
if (!this._isProjectHardwareStockHistoryFlow(flow)) return;
const orderId = Number(entry.order_id || 0);
const itemId = Number(entry.item_id || 0);
const qtyChange = parseFloat(entry.qty_change || 0) || 0;
if (!orderId || !itemId || qtyChange === 0) return;
const key = this._projectHardwareKey(orderId, itemId);
deltaByKey.set(key, (deltaByKey.get(key) || 0) + qtyChange);
});
return deltaByKey;
},
_getProjectHardwareHistoryNetDelta(orderId, itemId, historyDeltaMap) {
const key = this._projectHardwareKey(orderId, itemId);
return historyDeltaMap instanceof Map ? (parseFloat(historyDeltaMap.get(key)) || 0) : 0;
},
_getProjectHardwareHistoricalTargetQty(orderId, itemId, history, historyDeltaMap) {
let latestEntry = null;
let latestStockEntry = null;
(history || []).forEach(entry => {
if (Number(entry.order_id || 0) !== Number(orderId || 0)) return;
if (Number(entry.item_id || 0) !== Number(itemId || 0)) return;
const flow = this._getProjectHardwareHistoryFlow(entry);
if (!this._isProjectHardwareStockHistoryFlow(flow)) return;
if (!latestStockEntry) {
latestStockEntry = entry;
} else {
const prevStockTs = new Date(latestStockEntry.created_at || 0).getTime();
const nextStockTs = new Date(entry.created_at || 0).getTime();
if (nextStockTs >= prevStockTs) latestStockEntry = entry;
}
if (!Object.prototype.hasOwnProperty.call(entry || {}, 'project_hardware_target_qty')) return;
if (!latestEntry) {
latestEntry = entry;
return;
}
const prevTs = new Date(latestEntry.created_at || 0).getTime();
const nextTs = new Date(entry.created_at || 0).getTime();
if (nextTs >= prevTs) latestEntry = entry;
});
if (latestEntry) {
const value = round2(Math.max(0, parseFloat(latestEntry.project_hardware_target_qty) || 0));
return Number.isFinite(value) ? value : null;
}
const consumedQty = this._getProjectHardwareConsumedQty(orderId, itemId, historyDeltaMap);
if (latestStockEntry && latestStockEntry.clamped) {
const applied = Math.abs(parseFloat(latestStockEntry.qty_change || 0) || 0);
const requested = Math.abs(parseFloat(latestStockEntry.requested_qty_change || 0) || 0);
if (requested > applied) {
return round2(consumedQty + (requested - applied));
}
}
// Backward compatibility for older collected rows: before explicit target metadata
// existed, the only durable signal was the net consumed stock history itself.
return consumedQty > 0 ? round2(consumedQty) : null;
},
_getProjectHardwareConsumedQty(orderId, itemId, historyDeltaMap) {
return Math.max(0, -this._getProjectHardwareHistoryNetDelta(orderId, itemId, historyDeltaMap));
},
_getProjectHardwareLegacyResidualDelta(orderId, itemId, history) {
return (history || []).reduce((acc, entry) => {
if (Number(entry.order_id || 0) !== Number(orderId || 0)) return acc;
if (Number(entry.item_id || 0) !== Number(itemId || 0)) return acc;
const flow = this._getProjectHardwareHistoryFlow(entry);
if (!this._isProjectHardwareLegacyHistoryFlow(flow)) return acc;
return acc + (parseFloat(entry.qty_change || 0) || 0);
}, 0);
},
_getProjectHardwareLegacyResidualCost(orderId, itemId, history) {
return round2((history || []).reduce((acc, entry) => {
if (Number(entry.order_id || 0) !== Number(orderId || 0)) return acc;
if (Number(entry.item_id || 0) !== Number(itemId || 0)) return acc;
const flow = this._getProjectHardwareHistoryFlow(entry);
if (!this._isProjectHardwareLegacyHistoryFlow(flow)) return acc;
const qtyChange = parseFloat(entry.qty_change || 0) || 0;
if (Math.abs(qtyChange) <= 0.000001) return acc;
const unitPrice = parseFloat(entry.unit_price || 0) || 0;
return acc + round2(-qtyChange * unitPrice);
}, 0));
},
_getProjectHardwareLegacyResidualUnitPrice(orderId, itemId, history) {
const residualDelta = this._getProjectHardwareLegacyResidualDelta(orderId, itemId, history);
const residualCost = this._getProjectHardwareLegacyResidualCost(orderId, itemId, history);
if (Math.abs(residualDelta) <= 0.000001) return 0;
if (Math.abs(residualCost) <= 0.000001) return 0;
return round2(Math.abs(residualCost) / Math.abs(residualDelta));
},
_hasProjectHardwareReadyEvidence(orderId, itemId, history) {
return (history || []).some(entry => {
if (Number(entry.order_id || 0) !== Number(orderId || 0)) return false;
if (Number(entry.item_id || 0) !== Number(itemId || 0)) return false;
const flow = this._getProjectHardwareHistoryFlow(entry);
return this._isProjectHardwareStockHistoryFlow(flow);
});
},
_hasProjectHardwareLegacyOnlyEvidence(orderId, itemId, history) {
let hasLegacy = false;
let hasValid = false;
(history || []).forEach(entry => {
if (Number(entry.order_id || 0) !== Number(orderId || 0)) return;
if (Number(entry.item_id || 0) !== Number(itemId || 0)) return;
const flow = this._getProjectHardwareHistoryFlow(entry);
if (this._isProjectHardwareLegacyHistoryFlow(flow)) hasLegacy = true;
if (this._isProjectHardwareStockHistoryFlow(flow)) hasValid = true;
});
return hasLegacy && !hasValid;
},
_isProjectHardwareHistoricallyReady(orderId, itemId, requiredQty, historyDeltaMap, history) {
const historicalTarget = this._getProjectHardwareHistoricalTargetQty(orderId, itemId, history, historyDeltaMap);
const qty = historicalTarget !== null
? historicalTarget
: (parseFloat(requiredQty || 0) || 0);
if (!qty) return false;
return this._getProjectHardwareConsumedQty(orderId, itemId, historyDeltaMap) >= (qty - 0.000001);
},
_hasProjectHardwareClampedShortfall(orderId, itemId, requiredQty, history, historyDeltaMap) {
const qty = parseFloat(requiredQty || 0) || 0;
if (!qty) return false;
if (this._getProjectHardwareConsumedQty(orderId, itemId, historyDeltaMap) >= (qty - 0.000001)) {
return false;
}
return (history || []).some(entry =>
Number(entry.order_id || 0) === Number(orderId || 0)
&& Number(entry.item_id || 0) === Number(itemId || 0)
&& String(entry.type || '').toLowerCase() === 'deduction'
&& !!entry.clamped
&& /списание собранной/i.test(String(entry.notes || ''))
);
},
_computeProjectHardwareReadyState(orderId, itemId, requiredQty, history, historyDeltaMap) {
const savedReady = this._isProjectHardwareReady(orderId, itemId);
const explicitActual = this._getProjectHardwareActualQty(orderId, itemId);
const historicalTarget = this._getProjectHardwareHistoricalTargetQty(orderId, itemId, history, historyDeltaMap);
const effectiveQty = explicitActual !== null
? explicitActual
: (historicalTarget !== null
? historicalTarget
: (parseFloat(requiredQty || 0) || 0));
const historicalReady = this._isProjectHardwareHistoricallyReady(orderId, itemId, requiredQty, historyDeltaMap, history);
if ((savedReady || historicalReady) && this._hasProjectHardwareClampedShortfall(orderId, itemId, effectiveQty, history, historyDeltaMap)) {
return false;
}
// A bare saved checkbox is not enough to treat the row as collected.
// We require either explicit actual qty or matching stock-history evidence;
// otherwise state/history divergence can falsely show "собрано" without deduction.
if (savedReady && explicitActual === null && !historicalReady) {
return false;
}
return savedReady || historicalReady;
},
async getOrderProjectHardwareCompletion(orderId, detail) {
const normalizedOrderId = Number(orderId || 0);
if (!normalizedOrderId) {
return {
canComplete: true,
totalRows: 0,
readyRows: 0,
pendingRows: 0,
rows: [],
pendingLabels: [],
};
}
await this._ensureProjectHardwareStateLoaded();
let orderDetail = detail && Array.isArray(detail.items) ? detail : null;
if (!orderDetail) {
try {
orderDetail = await loadOrder(normalizedOrderId);
} catch (error) {
console.error('Warehouse.getOrderProjectHardwareCompletion loadOrder failed:', error);
orderDetail = null;
}
}
if (!orderDetail || !Array.isArray(orderDetail.items)) {
return {
canComplete: false,
totalRows: 0,
readyRows: 0,
pendingRows: 0,
rows: [],
pendingLabels: [],
error: 'load_failed',
};
}
const demandRows = this._collectWarehouseDemandFromOrderItems(orderDetail.items || [])
.map(row => ({
warehouse_item_id: Number(row.warehouse_item_id || 0),
qty: parseFloat(row.qty) || 0,
names: Array.isArray(row.names) ? row.names.filter(Boolean) : [],
material_type: row.material_type || 'hardware',
}))
.filter(row => row.warehouse_item_id && row.qty > 0);
if (demandRows.length === 0) {
return {
canComplete: true,
totalRows: 0,
readyRows: 0,
pendingRows: 0,
rows: [],
pendingLabels: [],
};
}
let history = [];
try {
history = await loadWarehouseHistory();
} catch (error) {
console.error('Warehouse.getOrderProjectHardwareCompletion loadWarehouseHistory failed:', error);
return {
canComplete: false,
totalRows: demandRows.length,
readyRows: 0,
pendingRows: demandRows.length,
rows: [],
pendingLabels: [],
error: 'history_failed',
};
}
const historyDeltaMap = this._buildProjectHardwareHistoryDeltaMap(history || []);
const rows = demandRows.map(row => {
const ready = this._computeProjectHardwareReadyState(
normalizedOrderId,
row.warehouse_item_id,
row.qty,
history || [],
historyDeltaMap
);
const label = row.names[0]
|| `${this._projectSupplyKindLabel(row.material_type)} #${row.warehouse_item_id}`;
return { ...row, ready, label };
});
const readyRows = rows.filter(row => row.ready).length;
const pendingRows = rows.filter(row => !row.ready);
return {
canComplete: pendingRows.length === 0,
totalRows: rows.length,
readyRows,
pendingRows: pendingRows.length,
rows,
pendingLabels: pendingRows.map(row => row.label),
};
},
_buildMoldUsageHistoryDeltaMap(history) {
const deltaByKey = new Map();
(history || []).forEach(entry => {
if (String(entry && entry.mold_flow || '') !== 'usage_completed') return;
const orderId = Number(entry.order_id || 0);
const itemId = Number(entry.item_id || 0);
const usageChange = parseFloat(entry.mold_usage_change || 0) || 0;
if (!orderId || !itemId || usageChange === 0) return;
const key = this._projectHardwareKey(orderId, itemId);
deltaByKey.set(key, (deltaByKey.get(key) || 0) + usageChange);
});
return deltaByKey;
},
_getMoldUsageNetDelta(orderId, itemId, historyDeltaMap) {
const key = this._projectHardwareKey(orderId, itemId);
return historyDeltaMap instanceof Map ? (parseFloat(historyDeltaMap.get(key)) || 0) : 0;
},
_getOrderMoldCandidates(orderId, orderItem, warehouseItems) {
const items = Array.isArray(warehouseItems) ? warehouseItems : [];
const templateId = String(orderItem && orderItem.template_id || '').trim();
const isBlank = !!(orderItem && orderItem.is_blank_mold);
const templateName = this._normalizeMoldLookupText(
isBlank && templateId && Array.isArray(App && App.templates)
? ((App.templates.find(t => String(t && t.id || '') === templateId) || {}).name || '')
: ''
);
return items
.filter(item => {
if (!this._isMoldCategory(item && item.category)) return false;
if ((parseFloat(item.qty) || 0) <= 0) return false;
const moldType = this._normalizeMoldType(item.mold_type);
if (!isBlank) {
return moldType === 'customer' && Number(item.linked_order_id || 0) === Number(orderId || 0);
}
if (moldType !== 'blank') return false;
if (templateId && String(item.template_id || '').trim() === templateId) return true;
return !!templateName && this._normalizeMoldLookupText(item && item.name) === templateName;
})
.sort((a, b) => Number(a.id || 0) - Number(b.id || 0));
},
_allocateOrderMoldUsage(orderId, orderItems, warehouseItems) {
const allocations = new Map();
(orderItems || []).forEach(item => {
if (String(item && item.item_type || '').toLowerCase() !== 'product') return;
const qty = parseFloat(item.quantity) || 0;
if (qty <= 0) return;
const candidates = this._getOrderMoldCandidates(orderId, item, warehouseItems);
if (candidates.length === 0) return;
let remainingDemand = qty;
candidates.forEach(candidate => {
if (remainingDemand <= 0) return;
const itemId = Number(candidate.id || 0);
if (!itemId) return;
const total = parseFloat(candidate.mold_capacity_total) || 0;
const used = parseFloat(candidate.mold_capacity_used) || 0;
const alreadyAllocated = allocations.get(itemId) || 0;
const remainingCapacity = total > 0 ? Math.max(0, total - used - alreadyAllocated) : remainingDemand;
const chunk = total > 0 ? Math.min(remainingDemand, remainingCapacity) : remainingDemand;
if (chunk <= 0) return;
allocations.set(itemId, alreadyAllocated + chunk);
remainingDemand -= chunk;
});
if (remainingDemand > 0) {
const fallbackId = Number(candidates[0] && candidates[0].id || 0);
if (fallbackId) {
allocations.set(fallbackId, (allocations.get(fallbackId) || 0) + remainingDemand);
}
}
});
return allocations;
},
async _syncOrderMoldUsageState({ orderId, orderName, managerName, status, currentItems }) {
const normalizedOrderId = Number(orderId || 0);
if (!normalizedOrderId) return;
const [warehouseItems, history] = await Promise.all([
loadWarehouseItems(),
loadWarehouseHistory(),
]);
const targetUsage = status === 'completed'
? this._allocateOrderMoldUsage(normalizedOrderId, currentItems || [], warehouseItems || [])
: new Map();
const historyDeltaMap = this._buildMoldUsageHistoryDeltaMap(history);
const moldIds = new Set([
...Array.from(targetUsage.keys()),
...(history || [])
.filter(entry => String(entry && entry.mold_flow || '') === 'usage_completed' && Number(entry.order_id || 0) === normalizedOrderId)
.map(entry => Number(entry.item_id || 0))
.filter(Boolean),
]);
if (moldIds.size === 0) return;
let changed = false;
const updatedItems = Array.isArray(warehouseItems) ? [...warehouseItems] : [];
const itemIndexById = new Map(updatedItems.map((item, index) => [Number(item.id || 0), index]));
const newHistoryEntries = [];
const usageAlerts = [];
const nowIso = new Date().toISOString();
moldIds.forEach(itemId => {
const idx = itemIndexById.get(Number(itemId || 0));
if (idx == null) return;
const whItem = { ...updatedItems[idx] };
const beforeUsed = Math.max(0, parseFloat(whItem.mold_capacity_used) || 0);
const target = Math.max(0, parseFloat(targetUsage.get(itemId) || 0) || 0);
const current = Math.max(0, this._getMoldUsageNetDelta(normalizedOrderId, itemId, historyDeltaMap));
const delta = target - current;
const afterUsed = Math.max(0, beforeUsed + delta);
let alertsChanged = false;
if (delta > 0) {
const alertedThresholds = this._parseMoldAlertedThresholds(whItem);
const crossedThresholds = this._getCrossedMoldUsageThresholds(beforeUsed, afterUsed, alertedThresholds);
if (crossedThresholds.length > 0) {
whItem.mold_alerted_thresholds = Array.from(new Set([
...alertedThresholds,
...crossedThresholds,
])).sort((a, b) => a - b);
alertsChanged = true;
crossedThresholds.forEach(threshold => {
usageAlerts.push({
item: { ...whItem, mold_capacity_used: afterUsed },
threshold,
orderId: normalizedOrderId,
orderName: orderName || '',
});
});
}
}
if (Math.abs(delta) > 0.000001) {
whItem.mold_capacity_used = afterUsed;
whItem.updated_at = nowIso;
newHistoryEntries.push({
id: Date.now() + Math.floor(Math.random() * 1000) + newHistoryEntries.length,
item_id: Number(itemId || 0),
item_name: whItem.name || '',
item_sku: whItem.sku || '',
item_category: whItem.category || '',
type: 'mold_usage',
qty_change: 0,
requested_qty_change: 0,
qty_before: parseFloat(whItem.qty) || 0,
qty_after: parseFloat(whItem.qty) || 0,
unit_price: parseFloat(whItem.price_per_unit) || 0,
total_cost_change: 0,
order_id: normalizedOrderId,
order_name: orderName || 'Заказ',
notes: delta > 0
? `Списание ресурса молда: +${delta} шт`
: `Возврат ресурса молда: -${Math.abs(delta)} шт`,
clamped: false,
created_at: nowIso,
created_by: managerName || '',
mold_flow: 'usage_completed',
mold_usage_change: delta,
mold_usage_before: beforeUsed,
mold_usage_after: afterUsed,
});
}
if (Math.abs(delta) > 0.000001 || alertsChanged) {
updatedItems[idx] = whItem;
changed = true;
}
});
if (!changed) return;
await saveWarehouseItems(updatedItems);
await saveWarehouseHistory([...(history || []), ...newHistoryEntries]);
if (usageAlerts.length > 0) {
try {
await this._createMoldUsageAlertTasks(usageAlerts);
} catch (error) {
console.error('[Warehouse] mold usage alert task creation failed:', error);
}
}
this.allItems = updatedItems;
},
async _promoteOrdersForReceivedMolds(receivedItems, shipment) {
const moldRows = (receivedItems || []).filter(item => this._isMoldCategory(item && item.category));
if (moldRows.length === 0) return { changedOrders: 0, promotedOrders: 0 };
const purchaseCache = new Map();
const orderCache = new Map();
let changedOrders = 0;
let promotedOrders = 0;
for (const row of moldRows) {
const meta = await this._resolveShipmentMoldMeta(row, {
purchaseCache,
orderCache,
receiptDate: shipment && (shipment.date || shipment.received_at || '') || '',
});
const linkedOrderId = Number(meta && meta.linked_order_id || 0);
if (!linkedOrderId) continue;
const detail = await this._getOrderCached(linkedOrderId, orderCache);
if (!detail || !detail.order) continue;
const updatedItems = (detail.items || []).map(item => {
if (String(item && item.item_type || '').toLowerCase() !== 'product') return { ...item };
if (item.is_blank_mold === true) return { ...item };
if (item.base_mold_in_stock === true) return { ...item, warehouse_mold_item_id: row.warehouse_item_id || item.warehouse_mold_item_id || null };
return {
...item,
base_mold_in_stock: true,
warehouse_mold_item_id: row.warehouse_item_id || null,
};
});
const hadChanges = JSON.stringify(updatedItems) !== JSON.stringify(detail.items || []);
if (hadChanges) {
await saveOrder(detail.order, updatedItems);
detail.items = updatedItems;
orderCache.set(linkedOrderId, detail);
changedOrders += 1;
}
if (detail.order.status === 'sample') {
await updateOrderStatus(linkedOrderId, 'production_casting');
if (typeof Orders !== 'undefined' && Orders && typeof Orders.addChangeRecord === 'function') {
await Orders.addChangeRecord(linkedOrderId, {
field: 'status',
old_value: 'sample',
new_value: 'production_casting',
manager: App.getCurrentEmployeeName() || detail.order.manager_name || '',
description: 'Молд принят на склад, заказ переведён в продакшен',
});
}
if (typeof this.syncProjectHardwareOrderState === 'function') {
await this.syncProjectHardwareOrderState({
orderId: linkedOrderId,
orderName: detail.order.order_name || 'Заказ',
managerName: App.getCurrentEmployeeName() || detail.order.manager_name || '',
status: 'production_casting',
currentItems: updatedItems,
previousItems: updatedItems,
});
}
detail.order.status = 'production_casting';
orderCache.set(linkedOrderId, detail);
promotedOrders += 1;
}
}
return { changedOrders, promotedOrders };
},
async _repairLegacyProjectHardwareMovements(rows, history, fallbackManagerName) {
const uniqueRows = [];
const seenKeys = new Set();
(rows || []).forEach(row => {
const orderId = Number(row && row.order_id || 0);
const itemId = Number(row && row.item_id || 0);
if (!orderId || !itemId) return;
const key = this._projectHardwareKey(orderId, itemId);
if (seenKeys.has(key)) return;
seenKeys.add(key);
uniqueRows.push({
order_id: orderId,
item_id: itemId,
order_name: row.order_name || 'Заказ',
manager_name: row.manager_name || fallbackManagerName || '',
});
});
if (uniqueRows.length === 0) {
return { repaired: false, history, warehouseItems: this.allItems || [] };
}
let repairedCount = 0;
for (const row of uniqueRows) {
const residualDelta = this._getProjectHardwareLegacyResidualDelta(row.order_id, row.item_id, history);
if (Math.abs(residualDelta) <= 0.000001) continue;
const repairDelta = -residualDelta;
const historyUnitPrice = this._getProjectHardwareLegacyResidualUnitPrice(row.order_id, row.item_id, history);
await this.adjustStock(
row.item_id,
repairDelta,
repairDelta > 0 ? 'addition' : 'deduction',
row.order_name || 'Заказ',
repairDelta > 0
? `Автоисправление legacy-списания проектной позиции: +${Math.abs(repairDelta)} шт`
: `Автоисправление legacy-возврата проектной позиции: -${Math.abs(repairDelta)} шт`,
row.manager_name || fallbackManagerName || 'Система',
{
order_id: row.order_id,
project_hardware_flow: 'legacy_status_repair',
history_unit_price: historyUnitPrice,
}
);
repairedCount += 1;
}
if (repairedCount === 0) {
return { repaired: false, history, warehouseItems: this.allItems || [] };
}
return {
repaired: true,
repairedCount,
history: await loadWarehouseHistory(),
warehouseItems: await loadWarehouseItems(),
};
},
_setProjectHardwareReadyFlag(orderId, itemId, isReady) {
const key = this._projectHardwareKey(orderId, itemId);
const current = !!(this.projectHardwareState && this.projectHardwareState.checks && this.projectHardwareState.checks[key]);
if (current === !!isReady) return false;
if (isReady) {
this.projectHardwareState.checks[key] = true;
} else {
delete this.projectHardwareState.checks[key];
}
return true;
},
async reconcileProjectHardwareReservations() {
return await this._runProjectHardwareMutation(async () => {
await this._ensureProjectHardwareStateLoaded();
const [orders, reservations, history] = await Promise.all([
loadOrders(),
loadWarehouseReservations(),
loadWarehouseHistory(),
]);
const activeOrders = (orders || []).filter(o => o.status !== 'deleted');
const reserveOrders = activeOrders.filter(o => this._isProjectHardwareReserveStatus(o.status));
const trackedOrders = activeOrders.filter(o => this._isProjectHardwareTrackedStatus(o.status));
if (activeOrders.length === 0) {
this.allReservations = reservations || [];
return { reservationsChanged: false, stateChanged: false, shortage: false };
}
const details = await Promise.all(trackedOrders.map(o => loadOrder(o.id).catch(() => null)));
const detailByOrderId = new Map();
details.filter(Boolean).forEach(detail => {
const order = detail.order || {};
detailByOrderId.set(Number(order.id), detail);
});
const demandRows = [];
trackedOrders.forEach(order => {
const detail = detailByOrderId.get(Number(order.id));
if (!detail) return;
const demandRowsForOrder = this._collectWarehouseDemandFromOrderItems(detail.items || []);
demandRowsForOrder.forEach(row => {
const itemId = Number(row.warehouse_item_id || 0);
const qty = parseFloat(row.qty) || 0;
if (!itemId || qty <= 0) return;
demandRows.push({
order_id: Number(order.id),
order_name: order.order_name || 'Заказ',
manager_name: order.manager_name || '',
status: order.status || '',
created_at: order.created_at || '',
item_id: Number(itemId),
qty,
ready: false,
material_type: row.material_type || 'hardware',
});
});
});
const repairResult = await this._repairLegacyProjectHardwareMovements(demandRows, history, App.getCurrentEmployeeName() || 'Система');
const effectiveHistory = repairResult.repaired ? repairResult.history : history;
if (repairResult.repaired && Array.isArray(repairResult.warehouseItems) && repairResult.warehouseItems.length > 0) {
this.allItems = repairResult.warehouseItems;
}
const historyDeltaMap = this._buildProjectHardwareHistoryDeltaMap(effectiveHistory);
const trackedKeys = new Set();
let stateChanged = false;
demandRows.forEach(row => {
const key = this._projectHardwareKey(row.order_id, row.item_id);
trackedKeys.add(key);
row.target_qty = this._buildProjectHardwareTargetQty(
row.order_id,
row.item_id,
row.qty,
historyDeltaMap
);
const isReady = this._computeProjectHardwareReadyState(row.order_id, row.item_id, row.qty, effectiveHistory, historyDeltaMap);
row.ready = isReady;
if (this._setProjectHardwareReadyFlag(row.order_id, row.item_id, isReady)) {
stateChanged = true;
}
});
Object.keys(this.projectHardwareState.checks || {}).forEach(key => {
if (trackedKeys.has(key)) return;
delete this.projectHardwareState.checks[key];
stateChanged = true;
});
Object.keys(this.projectHardwareState.actual_qtys || {}).forEach(key => {
if (trackedKeys.has(key)) return;
delete this.projectHardwareState.actual_qtys[key];
stateChanged = true;
});
const nowIso = new Date().toISOString();
let reservationsChanged = false;
let shortage = false;
(reservations || []).forEach(r => {
if (r.status !== 'active') return;
const key = this._projectHardwareKey(r.order_id, r.item_id);
const isAutoHardwareReservation = r.source === 'project_hardware'
|| (r.source === 'order_calc' && trackedKeys.has(key));
if (!isAutoHardwareReservation) return;
r.status = 'released';
r.released_at = nowIso;
reservationsChanged = true;
});
const activeByItem = new Map();
(reservations || []).forEach(r => {
if (r.status !== 'active') return;
const itemId = Number(r.item_id || 0);
if (!itemId) return;
activeByItem.set(itemId, (activeByItem.get(itemId) || 0) + (parseFloat(r.qty) || 0));
});
const reservePriority = {
sample: 0,
production_casting: 1,
production_printing: 2,
production_hardware: 3,
production_packaging: 4,
in_production: 5,
delivery: 6,
completed: 7,
};
demandRows.sort((a, b) => {
const prioDiff = (reservePriority[a.status] ?? 99) - (reservePriority[b.status] ?? 99);
if (prioDiff !== 0) return prioDiff;
const dateDiff = new Date(a.created_at || 0).getTime() - new Date(b.created_at || 0).getTime();
if (dateDiff !== 0) return dateDiff;
if (a.order_id !== b.order_id) return a.order_id - b.order_id;
return a.item_id - b.item_id;
});
const warehouseById = new Map((this.allItems || []).map(item => [Number(item.id), item]));
demandRows.forEach(row => {
const plannedQty = round2(Math.max(0, parseFloat(row.qty) || 0));
const targetQty = round2(Math.max(0, parseFloat(row.target_qty ?? row.qty) || 0));
if (row.ready || plannedQty <= 0 || !this._isProjectHardwareReserveStatus(row.status)) return;
const whItem = warehouseById.get(Number(row.item_id));
if (!whItem) return;
const stockQty = parseFloat(whItem.qty) || 0;
const alreadyReserved = activeByItem.get(Number(row.item_id)) || 0;
const available = Math.max(0, stockQty - alreadyReserved);
const reserveQty = Math.min(targetQty, available);
if (reserveQty > 0) {
reservations.push({
id: Date.now() + Math.floor(Math.random() * 1000),
item_id: Number(row.item_id),
order_id: Number(row.order_id),
order_name: row.order_name || 'Заказ',
qty: reserveQty,
status: 'active',
source: 'project_hardware',
created_at: nowIso,
created_by: row.manager_name || '',
});
activeByItem.set(Number(row.item_id), alreadyReserved + reserveQty);
reservationsChanged = true;
}
if (reserveQty < targetQty) {
shortage = true;
}
});
if (stateChanged) {
this.projectHardwareState.updated_at = nowIso;
this.projectHardwareState.updated_by = App.getCurrentEmployeeName() || 'Система';
await saveProjectHardwareState(this.projectHardwareState);
}
if (reservationsChanged) {
await saveWarehouseReservations(reservations);
}
this.allReservations = reservations || [];
if (typeof Calculator !== 'undefined') {
Calculator._whPickerData = null;
}
return { reservationsChanged, stateChanged, shortage };
});
},
async syncProjectHardwareOrderState({ orderId, orderName, managerName, status, currentItems, previousItems }) {
return await this._runProjectHardwareMutation(async () => {
const normalizedOrderId = Number(orderId || 0);
if (!normalizedOrderId) return { shortage: false, shortageRows: [], reservationsChanged: false, stateChanged: false };
await this._ensureProjectHardwareStateLoaded();
const currentDemandRows = this._collectWarehouseDemandFromOrderItems(currentItems || []);
const currentDemand = this._getProjectHardwareDemandMap(currentItems || []);
const previousDemand = this._getProjectHardwareDemandMap(previousItems || []);
const currentDemandMeta = new Map(currentDemandRows.map(row => [
Number(row.warehouse_item_id || 0),
{
name: (Array.isArray(row.names) ? row.names.find(Boolean) : '') || 'Позиция со склада',
materialType: row.material_type || 'hardware',
plannedQty: parseFloat(row.qty) || 0,
},
]));
const itemIds = Array.from(new Set([
...Array.from(currentDemand.keys()),
...Array.from(previousDemand.keys()),
]));
if (itemIds.length === 0) {
await this._syncOrderMoldUsageState({
orderId: normalizedOrderId,
orderName,
managerName,
status,
currentItems,
});
return { shortage: false, shortageRows: [], reservationsChanged: false, stateChanged: false };
}
const shouldReserve = this._isProjectHardwareReserveStatus(status);
const nowIso = new Date().toISOString();
const [reservations, history] = await Promise.all([
loadWarehouseReservations(),
loadWarehouseHistory(),
]);
const repairRows = itemIds.map(itemId => ({
order_id: normalizedOrderId,
item_id: itemId,
order_name: orderName || 'Заказ',
manager_name: managerName || '',
}));
const repairResult = await this._repairLegacyProjectHardwareMovements(repairRows, history, managerName || '');
const effectiveHistory = repairResult.repaired ? repairResult.history : history;
if (repairResult.repaired && Array.isArray(repairResult.warehouseItems) && repairResult.warehouseItems.length > 0) {
this.allItems = repairResult.warehouseItems;
}
const historyDeltaMap = this._buildProjectHardwareHistoryDeltaMap(effectiveHistory);
let reservationsChanged = false;
let shortage = false;
const shortageRows = [];
let stateChanged = false;
reservations.forEach(r => {
if (r.status !== 'active') return;
if (Number(r.order_id) !== normalizedOrderId) return;
if (!itemIds.includes(Number(r.item_id || 0))) return;
if (!this._isProjectHardwareReservationSource(r.source)) return;
r.status = 'released';
r.released_at = nowIso;
reservationsChanged = true;
});
const activeByItem = new Map();
reservations.forEach(r => {
if (r.status !== 'active') return;
const itemId = Number(r.item_id || 0);
if (!itemId) return;
activeByItem.set(itemId, (activeByItem.get(itemId) || 0) + (parseFloat(r.qty) || 0));
});
const warehouseItems = await loadWarehouseItems();
const warehouseById = new Map((warehouseItems || []).map(item => [Number(item.id), item]));
for (const itemId of itemIds) {
const currentQty = currentDemand.get(itemId) || 0;
const previousQty = previousDemand.get(itemId) || 0;
const targetQty = currentQty > 0
? this._buildProjectHardwareTargetQty(normalizedOrderId, itemId, currentQty, historyDeltaMap)
: 0;
const isReady = currentQty > 0
? this._computeProjectHardwareReadyState(normalizedOrderId, itemId, currentQty, effectiveHistory, historyDeltaMap)
: false;
if (this._setProjectHardwareReadyFlag(normalizedOrderId, itemId, currentQty > 0 && isReady)) {
stateChanged = true;
}
if (currentQty <= 0 && this._setProjectHardwareActualQtyValue(normalizedOrderId, itemId, null)) {
stateChanged = true;
}
if (isReady) {
const explicitActual = this._getProjectHardwareActualQty(normalizedOrderId, itemId);
const consumedQty = this._getProjectHardwareConsumedQty(normalizedOrderId, itemId, historyDeltaMap);
const readyTargetQty = explicitActual !== null
? explicitActual
: (consumedQty > 0 ? consumedQty : currentQty);
const syncResult = await this._syncProjectHardwareConsumedQty({
orderId: normalizedOrderId,
itemId,
targetQty: readyTargetQty,
orderName: orderName || 'Заказ',
managerName: managerName || '',
historyDeltaMap,
flow: 'ready_delta',
});
if (!syncResult.ok) {
shortage = true;
const whItem = warehouseById.get(itemId);
const meta = currentDemandMeta.get(itemId) || {};
const blockers = this._getProjectHardwareBlockingReservations(reservations, normalizedOrderId, itemId);
shortageRows.push({
itemId,
name: meta.name || String(whItem?.name || 'Позиция со склада'),
materialType: meta.materialType || 'hardware',
requestedQty: round2(Math.max(0, readyTargetQty - consumedQty)),
availableQty: round2(Math.max(0, parseFloat(whItem?.qty || 0) || 0)),
mode: 'consume',
blockers,
});
}
continue;
}
if (!shouldReserve || currentQty <= 0) continue;
const whItem = warehouseById.get(itemId);
if (!whItem) continue;
const stockQty = parseFloat(whItem.qty) || 0;
const alreadyReserved = activeByItem.get(itemId) || 0;
const available = Math.max(0, stockQty - alreadyReserved);
const reserveQty = Math.min(targetQty, available);
if (reserveQty > 0) {
reservations.push({
id: Date.now() + Math.floor(Math.random() * 1000),
item_id: itemId,
order_id: normalizedOrderId,
order_name: orderName || 'Заказ',
qty: reserveQty,
status: 'active',
source: 'project_hardware',
created_at: nowIso,
created_by: managerName || '',
});
activeByItem.set(itemId, alreadyReserved + reserveQty);
reservationsChanged = true;
}
if (reserveQty < targetQty) {
shortage = true;
const meta = currentDemandMeta.get(itemId) || {};
const blockers = this._getProjectHardwareBlockingReservations(reservations, normalizedOrderId, itemId);
shortageRows.push({
itemId,
name: meta.name || String(whItem?.name || 'Позиция со склада'),
materialType: meta.materialType || 'hardware',
requestedQty: round2(targetQty),
availableQty: round2(available),
mode: 'reserve',
blockers,
});
}
}
Object.keys(this.projectHardwareState.checks || {}).forEach(existingKey => {
if (!existingKey.startsWith(`${normalizedOrderId}:`)) return;
const [, itemIdStr] = existingKey.split(':');
const itemId = Number(itemIdStr || 0);
if (itemIds.includes(itemId)) return;
delete this.projectHardwareState.checks[existingKey];
stateChanged = true;
});
Object.keys(this.projectHardwareState.actual_qtys || {}).forEach(existingKey => {
if (!existingKey.startsWith(`${normalizedOrderId}:`)) return;
const [, itemIdStr] = existingKey.split(':');
const itemId = Number(itemIdStr || 0);
if (itemIds.includes(itemId) && (currentDemand.get(itemId) || 0) > 0) return;
delete this.projectHardwareState.actual_qtys[existingKey];
stateChanged = true;
});
if (stateChanged) {
this.projectHardwareState.updated_at = nowIso;
this.projectHardwareState.updated_by = managerName || '';
await saveProjectHardwareState(this.projectHardwareState);
}
if (reservationsChanged) {
await saveWarehouseReservations(reservations);
}
this.allReservations = reservations;
this.recalcReservations();
if (typeof Calculator !== 'undefined') {
Calculator._whPickerData = null;
}
if (shortage) {
const labels = shortageRows.slice(0, 3).map(row => {
const requested = row.requestedQty > 0 ? `нужно ${row.requestedQty}` : 'нужно > 0';
return `${row.name} (${requested}, доступно ${row.availableQty}${this._formatProjectHardwareBlockers(row.blockers)})`;
});
const suffix = shortageRows.length > 3 ? ` и ещё ${shortageRows.length - 3}` : '';
App.toast(`Часть позиций со склада не встала полностью: ${labels.join('; ')}${suffix}`);
}
await this._syncOrderMoldUsageState({
orderId: normalizedOrderId,
orderName,
managerName,
status,
currentItems,
});
return { shortage, shortageRows, reservationsChanged, stateChanged };
});
},
async renderProjectHardwareView(viewToken) {
const token = viewToken ?? this._viewToken;
const container = document.getElementById('wh-content');
if (!container) return;
const [orders, reservations, history] = await Promise.all([
loadOrders(),
loadWarehouseReservations(),
loadWarehouseHistory(),
]);
if (token !== this._viewToken || this.currentView !== 'project-hardware') return;
const byOrderId = new Map((orders || []).map(o => [Number(o.id), o]));
const byItemId = new Map((this.allItems || []).map(i => [Number(i.id), i]));
const historyDeltaMap = this._buildProjectHardwareHistoryDeltaMap(history || []);
const sampleOrders = (orders || []).filter(o => this._isSampleStatus(o.status));
const productionOrders = (orders || []).filter(o => this._isProjectHardwareActionStatus(o.status));
const [sampleDetails, productionDetails] = await Promise.all([
Promise.all(sampleOrders.map(o => loadOrder(o.id).catch(() => null))),
Promise.all(productionOrders.map(o => loadOrder(o.id).catch(() => null))),
]);
if (token !== this._viewToken || this.currentView !== 'project-hardware') return;
const sampleHardwareByOrder = new Map();
sampleDetails.filter(Boolean).forEach(detail => {
const order = detail.order || {};
sampleHardwareByOrder.set(
Number(order.id),
new Set(Array.from(this._getProjectHardwareDemandMap(detail.items || []).keys()))
);
});
// 1) Reserve block: active auto-reserves for orders in sample status.
const reserveGrouped = new Map();
(reservations || []).forEach(r => {
if (r.status !== 'active' || !this._isProjectHardwareReservationSource(r.source)) return;
const order = byOrderId.get(Number(r.order_id));
if (!order || !this._isSampleStatus(order.status)) return;
if (r.source === 'order_calc') {
const legacyHw = sampleHardwareByOrder.get(Number(r.order_id));
if (!legacyHw || !legacyHw.has(Number(r.item_id))) return;
}
const item = byItemId.get(Number(r.item_id));
const key = `${Number(r.order_id)}:${Number(r.item_id)}`;
const current = reserveGrouped.get(key) || {
order_id: Number(r.order_id),
order_name: order.order_name || r.order_name || 'Заказ',
manager: order.manager_name || '',
item_id: Number(r.item_id),
item_name: (item && item.name) || r.item_name || 'Фурнитура',
item_sku: (item && item.sku) || '',
item_kind: this._projectSupplyKindLabel((item && item.category) || 'hardware'),
qty: 0,
};
current.qty += parseFloat(r.qty) || 0;
reserveGrouped.set(key, current);
});
const reserveRows = Array.from(reserveGrouped.values());
reserveRows.sort((a, b) => String(a.order_name).localeCompare(String(b.order_name), 'ru'));
// 2) Production block: warehouse hardware demand for production-stage orders.
const productionRows = [];
productionDetails.filter(Boolean).forEach(detail => {
const order = detail.order || {};
const demands = this._collectWarehouseDemandFromOrderItems(detail.items || []);
demands.forEach(d => {
const item = byItemId.get(Number(d.warehouse_item_id));
const plannedQty = parseFloat(d.qty) || 0;
const ready = this._computeProjectHardwareReadyState(
order.id,
d.warehouse_item_id,
plannedQty,
history || [],
historyDeltaMap
);
const actualQty = this._getProjectHardwareDisplayActualQty(
order.id,
d.warehouse_item_id,
plannedQty,
historyDeltaMap,
history || []
);
productionRows.push({
order_id: Number(order.id),
order_name: order.order_name || 'Заказ',
manager: order.manager_name || '',
status: order.status || '',
item_id: Number(d.warehouse_item_id),
item_name: (item && item.name) || d.names.join(', ') || 'Фурнитура',
item_sku: (item && item.sku) || '',
item_kind: this._projectSupplyKindLabel(d.material_type || (item && item.category) || 'hardware'),
qty: plannedQty,
actual_qty: actualQty,
ready,
});
});
});
productionRows.sort((a, b) => String(a.order_name).localeCompare(String(b.order_name), 'ru'));
const orderProgress = new Map();
productionRows.forEach(r => {
const current = orderProgress.get(r.order_id) || { total: 0, ready: 0 };
current.total += 1;
if (r.ready) current.ready += 1;
orderProgress.set(r.order_id, current);
});
const reserveByOrder = new Map();
reserveRows.forEach(r => {
const key = Number(r.order_id);
const current = reserveByOrder.get(key) || {
order_id: key,
order_name: r.order_name || 'Заказ',
manager: r.manager || '',
items: [],
total_qty: 0,
};
current.items.push(r);
current.total_qty += parseFloat(r.qty) || 0;
reserveByOrder.set(key, current);
});
const reserveOrders = Array.from(reserveByOrder.values()).sort((a, b) =>
String(a.order_name).localeCompare(String(b.order_name), 'ru')
);
reserveOrders.forEach(o => {
o.items.sort((a, b) => String(a.item_name).localeCompare(String(b.item_name), 'ru'));
});
const reserveHtml = reserveOrders.length
? `${reserveOrders.map(o => `
${this.esc(o.order_name)}
Менеджер: ${this.esc(o.manager || '—')} · Резерв: ${o.total_qty}
Открыть
Комплектующая Резерв
${o.items.map(r => `
${this.esc(r.item_name)}
${this.esc(r.item_kind || 'Фурнитура')}
${r.item_sku ? `${this.esc(r.item_sku)}
` : ''}
${r.qty}
`).join('')}
`).join('')}
`
: 'Нет активных резервов для заказов в статусе «Образец».
';
const productionByOrder = new Map();
productionRows.forEach(r => {
const key = Number(r.order_id);
const current = productionByOrder.get(key) || {
order_id: key,
order_name: r.order_name || 'Заказ',
manager: r.manager || '',
status: r.status || '',
items: [],
total_qty: 0,
};
current.items.push(r);
current.total_qty += parseFloat(r.qty) || 0;
productionByOrder.set(key, current);
});
const productionOrdersGrouped = Array.from(productionByOrder.values()).sort((a, b) =>
String(a.order_name).localeCompare(String(b.order_name), 'ru')
);
productionOrdersGrouped.forEach(o => {
o.items.sort((a, b) => String(a.item_name).localeCompare(String(b.item_name), 'ru'));
});
const activeProductionOrders = [];
const collectedProductionOrders = [];
const archivedCollectedOrders = [];
productionOrdersGrouped.forEach(order => {
const progress = orderProgress.get(order.order_id) || { total: 0, ready: 0 };
const done = progress.total > 0 && progress.ready === progress.total;
if (done) {
if (order.status === 'completed') {
archivedCollectedOrders.push(order);
} else {
collectedProductionOrders.push(order);
}
} else {
activeProductionOrders.push(order);
}
});
const renderProjectHardwareOrders = (ordersList, mode = 'active') => ordersList.length
? `${ordersList.map(o => {
const p = orderProgress.get(o.order_id) || { total: 0, ready: 0 };
const done = p.total > 0 && p.ready === p.total;
const badge = done
? '
готово '
: '
не готово ';
const progressText = done
? `Собрано ${p.ready} из ${p.total}`
: `Собрано ${p.ready} из ${p.total}`;
return `
${this.esc(o.order_name)}
${this.esc(App.statusLabel(o.status))} · ${badge} · ${this.esc(progressText)} · Менеджер: ${this.esc(o.manager || '—')}
Открыть
`;
}).join('')}
`
: (mode === 'collected'
? 'Пока нет полностью собранных заказов.
'
: 'Нет позиций со склада для заказов, которые нужно собрать.
');
const activeProductionHtml = renderProjectHardwareOrders(activeProductionOrders, 'active');
const collectedVisibleOrders = [...collectedProductionOrders, ...archivedCollectedOrders];
const collectedProductionHtml = renderProjectHardwareOrders(collectedVisibleOrders, 'collected');
const archivedCollectedNote = archivedCollectedOrders.length > 0
? `Включая завершённые заказы: ${archivedCollectedOrders.length}
`
: '';
const collectedCount = collectedVisibleOrders.length;
const collectedSummary = collectedCount === 1
? '1 заказ'
: `${collectedCount} заказ${collectedCount >= 2 && collectedCount <= 4 ? 'а' : 'ов'}`;
const collectedSectionHtml = collectedVisibleOrders.length > 0
? `
Уже собрано
${this.esc(collectedSummary)} · активные заказы выше, собранные и завершённые — здесь
${archivedCollectedNote}
Показать
${collectedProductionHtml}
`
: '';
if (token !== this._viewToken || this.currentView !== 'project-hardware') return;
container.innerHTML = `
${reserveHtml}
${activeProductionHtml}
${collectedSectionHtml}
`;
},
// ==========================================
// VIEW SWITCHING
// ==========================================
applyTabStyles(view) {
const tabs = document.querySelectorAll('#wh-tabs .tab');
tabs.forEach(t => {
const active = t.dataset.tab === view;
t.classList.toggle('active', active);
// Keep tab visuals deterministic (index has inline styles).
t.style.fontWeight = active ? '600' : '500';
t.style.color = active ? 'var(--text)' : 'var(--text-muted)';
t.style.borderBottom = active ? '2px solid var(--accent)' : '2px solid transparent';
});
},
setView(view, options) {
const force = !!(options && options.force);
if (!view) view = 'table';
if (!force && this._viewInitialized && this.currentView === view && view !== 'shipments') return;
this._viewInitialized = true;
this.currentView = view;
this._viewToken += 1;
const token = this._viewToken;
this.applyTabStyles(view);
const mainContent = document.getElementById('wh-content');
const shipmentsContent = document.getElementById('wh-shipments-content');
const filtersCard = document.getElementById('wh-filters-card');
if (filtersCard) {
filtersCard.style.display = view === 'table' ? '' : 'none';
}
if (view === 'shipments') {
if (mainContent) mainContent.style.display = 'none';
if (shipmentsContent) shipmentsContent.style.display = '';
const now = Date.now();
if (now - (this._shipmentsLoadedAt || 0) > 1500) {
this.loadShipmentsList();
this._shipmentsLoadedAt = now;
}
} else {
if (mainContent) mainContent.style.display = '';
if (shipmentsContent) shipmentsContent.style.display = 'none';
if (view === 'history') {
this.renderHistory();
} else if (view === 'inventory') {
this.renderInventoryView();
} else if (view === 'project-hardware') {
this.renderProjectHardwareView(token);
} else if (view === 'ready-goods') {
this.renderReadyGoodsView();
} else {
this.filterAndRender();
}
}
},
// ==========================================
// PHOTO UPLOAD
// ==========================================
_drawThumbnailOnWhiteBackground(ctx, img, width, height) {
if (!ctx || !img) return;
const w = Math.max(1, Math.round(parseFloat(width) || 0));
const h = Math.max(1, Math.round(parseFloat(height) || 0));
if (typeof ctx.save === 'function') ctx.save();
if ('fillStyle' in ctx) ctx.fillStyle = '#ffffff';
if (typeof ctx.fillRect === 'function') ctx.fillRect(0, 0, w, h);
if (typeof ctx.drawImage === 'function') ctx.drawImage(img, 0, 0, w, h);
if (typeof ctx.restore === 'function') ctx.restore();
},
async compressImageToThumbnail(file, maxW, maxH, quality) {
maxW = maxW || 200; maxH = maxH || 200; quality = quality || 0.7;
return new Promise((resolve) => {
const reader = new FileReader();
reader.onload = (e) => {
const img = new Image();
img.onload = () => {
const canvas = document.createElement('canvas');
let w = img.width, h = img.height;
if (w > maxW || h > maxH) {
const ratio = Math.min(maxW / w, maxH / h);
w = Math.round(w * ratio);
h = Math.round(h * ratio);
}
canvas.width = w; canvas.height = h;
const ctx = canvas.getContext('2d');
this._drawThumbnailOnWhiteBackground(ctx, img, w, h);
resolve(canvas.toDataURL('image/jpeg', quality));
};
img.onerror = () => resolve(null);
img.src = e.target.result;
};
reader.onerror = () => resolve(null);
reader.readAsDataURL(file);
});
},
async onPhotoFileSelected(input) {
if (!input.files || !input.files[0]) return;
const file = input.files[0];
if (file.size > 10 * 1024 * 1024) { App.toast('Файл слишком большой (макс 10 МБ)'); return; }
const thumbnail = await this.compressImageToThumbnail(file);
if (!thumbnail) { App.toast('Не удалось обработать фото'); return; }
this._pendingThumbnail = thumbnail;
this.updatePhotoPreview(thumbnail);
},
onPhotoUrlChanged(url) {
if (url && url.startsWith('http')) {
this._pendingThumbnail = null;
this.updatePhotoPreview(url);
}
},
clearPhoto() {
this._pendingThumbnail = null;
document.getElementById('wh-f-photo-url').value = '';
const fileInput = document.getElementById('wh-f-photo-file');
if (fileInput) fileInput.value = '';
const preview = document.getElementById('wh-f-photo-preview');
if (preview) preview.innerHTML = '📷 ';
},
updatePhotoPreview(src) {
const preview = document.getElementById('wh-f-photo-preview');
if (!preview) return;
if (src) {
const safeSrc = src.startsWith('data:') ? src : this.esc(src);
preview.innerHTML = ` `;
} else {
preview.innerHTML = '📷 ';
}
},
// ==========================================
// SHIPMENTS (Приёмки из Китая)
// ==========================================
async loadShipmentsList() {
this.allShipments = await loadShipments();
this.renderShipmentsList();
},
renderShipmentsList() {
const container = document.getElementById('wh-shipments-list');
if (!container) return;
const getShipmentDateValue = (sh) => {
const raw = sh?.date || sh?.received_at || sh?.created_at;
if (!raw) return 0;
if (typeof raw === 'string') {
const match = raw.match(/^(\d{2})\.(\d{2})\.(\d{4})/);
if (match) {
const iso = `${match[3]}-${match[2]}-${match[1]}`;
const ts = Date.parse(iso);
if (!Number.isNaN(ts)) return ts;
}
}
const ts = Date.parse(raw);
return Number.isNaN(ts) ? 0 : ts;
};
const sorted = [...this.allShipments].sort((a, b) =>
getShipmentDateValue(b) - getShipmentDateValue(a)
);
if (sorted.length === 0) {
container.innerHTML = `
📦
Нет приёмок
+ Новая приёмка
`;
return;
}
const statusBadge = (s) => s === 'received'
? 'Принята '
: 'Черновик ';
container.innerHTML = `
+ Новая приёмка
Дата Название Поставщик
Позиций Закупка
Доставка Статус
${sorted.map(sh => `
${App.formatDate(sh.date || sh.received_at || sh.created_at)}
${this.esc(sh.shipment_name || '')}
${this.esc(sh.supplier || '—')}
${(sh.items || []).length}
${Math.round(sh.total_purchase_rub || 0).toLocaleString('ru-RU')} ₽
${Math.round(sh.total_delivery || 0).toLocaleString('ru-RU')} ₽
${statusBadge(sh.status)}
✎
`).join('')}
`;
},
showNewShipmentForm() {
this.editingShipmentId = null;
this.shipmentItems = [];
this.clearShipmentForm();
document.getElementById('wh-shipment-form-title').textContent = 'Новая приёмка';
document.getElementById('wh-sh-delete-btn').style.display = 'none';
document.getElementById('wh-sh-confirm-btn').style.display = '';
document.getElementById('wh-sh-confirm-btn').textContent = 'Принять на склад';
document.getElementById('wh-sh-update-btn').style.display = 'none';
document.getElementById('wh-shipment-form').style.display = '';
this.addShipmentItem();
document.getElementById('wh-shipment-form').scrollIntoView({ behavior: 'smooth' });
},
async editShipment(id) {
const sh = this.allShipments.find(s => s.id === id);
if (!sh) return;
this.editingShipmentId = id;
this.shipmentItems = JSON.parse(JSON.stringify(sh.items || [])).map(it => ({
...it,
source: it.source || (it.warehouse_item_id ? 'existing' : 'new'),
unit: it.unit || 'шт',
category: it.category || 'other',
}));
document.getElementById('wh-sh-name').value = sh.shipment_name || '';
document.getElementById('wh-sh-date').value = sh.date || '';
document.getElementById('wh-sh-supplier').value = sh.supplier || '';
document.getElementById('wh-sh-purchase-cny').value = sh.total_purchase_cny || 0;
document.getElementById('wh-sh-cny-rate').value = sh.cny_rate || 12.5;
document.getElementById('wh-sh-fee-cashout').value = sh.fee_cashout_percent ?? 1.5;
document.getElementById('wh-sh-fee-crypto').value = sh.fee_crypto_percent ?? 2;
document.getElementById('wh-sh-fee-1688').value = sh.fee_1688_percent ?? 3;
document.getElementById('wh-sh-delivery-china').value = sh.delivery_china_to_russia || 0;
document.getElementById('wh-sh-delivery-moscow').value = (sh.delivery_moscow || 0) + (sh.customs_fees || 0);
document.getElementById('wh-sh-pricing-mode').value = sh.pricing_mode || 'weighted_avg';
document.getElementById('wh-sh-notes').value = sh.notes || '';
document.getElementById('wh-shipment-form-title').textContent = 'Редактирование приёмки';
document.getElementById('wh-sh-delete-btn').style.display = '';
const isReceived = sh.status === 'received';
document.getElementById('wh-sh-confirm-btn').style.display = isReceived ? 'none' : '';
document.getElementById('wh-sh-confirm-btn').textContent = 'Принять на склад';
document.getElementById('wh-sh-update-btn').style.display = isReceived ? '' : 'none';
document.getElementById('wh-shipment-form').style.display = '';
this.recalcShipment();
document.getElementById('wh-shipment-form').scrollIntoView({ behavior: 'smooth' });
},
hideShipmentForm() {
document.getElementById('wh-shipment-form').style.display = 'none';
},
clearShipmentForm() {
['wh-sh-name', 'wh-sh-supplier', 'wh-sh-notes'].forEach(id => {
const el = document.getElementById(id); if (el) el.value = '';
});
document.getElementById('wh-sh-date').value = App.todayLocalYMD();
document.getElementById('wh-sh-purchase-cny').value = 0;
document.getElementById('wh-sh-cny-rate').value = 12.5;
document.getElementById('wh-sh-fee-cashout').value = 1.5;
document.getElementById('wh-sh-fee-crypto').value = 2;
document.getElementById('wh-sh-fee-1688').value = 3;
document.getElementById('wh-sh-fee-total').value = 0;
document.getElementById('wh-sh-delivery-china').value = 0;
document.getElementById('wh-sh-delivery-moscow').value = 0;
document.getElementById('wh-sh-pricing-mode').value = 'weighted_avg';
document.getElementById('wh-sh-purchase-rub').value = 0;
document.getElementById('wh-sh-total-delivery').value = 0;
document.getElementById('wh-sh-items-table').innerHTML = '';
document.getElementById('wh-sh-summary').innerHTML = '';
},
addShipmentItem() {
this.shipmentItems.push({
source: 'existing',
warehouse_item_id: null, name: '', sku: '', category: '',
size: '', color: '', unit: 'шт',
photo_url: '', photo_thumbnail: '',
qty_received: 0, weight_grams: 0,
purchase_price_cny: 0, purchase_price_rub: 0,
delivery_allocated: 0, total_cost_per_unit: 0,
});
this.renderShipmentItemsTable();
},
removeShipmentItem(idx) {
this.shipmentItems.splice(idx, 1);
this.renderShipmentItemsTable();
this.recalcShipmentValues();
},
async renderShipmentItemsTable() {
const container = document.getElementById('wh-sh-items-table');
if (!container) return;
const grouped = await this.getItemsForPicker();
await this._loadMoldOrders();
const categoryOptions = WAREHOUSE_CATEGORIES.map(c =>
`${c.icon} ${c.label} `
).join('');
const rows = this.shipmentItems.map((item, idx) => {
this._syncShipmentMoldDerivedFields(item);
const selectOptions = this.buildPickerOptions(grouped, item.warehouse_item_id, true);
const simpleSelectHtml = `
${selectOptions}
`;
const photoSrc = item.photo_thumbnail || item.photo_url || '';
const photoPreview = photoSrc
? `📷 `
: `📷 `;
const moldFieldsHtml = item.source === 'new' && this._isMoldCategory(item.category)
? `
Бланк / stock
Клиентский
${this._normalizeMoldType(item.mold_type) === 'customer'
? `
${this._buildMoldOrderOptionsHtml(item.linked_order_id || '')}
`
: '
SKU назначится автоматически
'
}
`
: '';
const skuCellHtml = item.source === 'new' && this._isMoldCategory(item.category)
? `${this.esc(item.sku || this._buildAutoMoldSku(item.name || '', item.mold_type, item.linked_order_id))}
`
: ` `;
const itemSourceCell = item.source === 'new'
? ``
: `
Со склада
Новая
${simpleSelectHtml}
`;
const weakIdentityHintHtml = item.source === 'new' && this._isWeakShipmentIdentity(item)
? `
${this.esc(this._shipmentIdentityHint(item))}
`
: '';
return `
${itemSourceCell}${weakIdentityHintHtml}
${(item.purchase_price_rub || 0).toFixed(2)}
${Math.round(item.delivery_allocated || 0).toLocaleString('ru-RU')}
${(item.total_cost_per_unit || 0).toFixed(2)}
✕
`;
}).join('');
container.innerHTML = `
Позиция со склада
Кол-во
Вес (г)
Цена CNY (за позицию)
Цена RUB/ед
Доставка ₽
С/с ед. ₽
${rows}
`;
},
onShipmentPickerSelect(idx, itemId) {
this.onShipmentItemSelect(idx, itemId);
document.querySelectorAll('.wh-picker-dropdown').forEach(d => d.style.display = 'none');
this.renderShipmentItemsTable();
},
setShipmentItemSource(idx, source) {
const item = this.shipmentItems[idx];
if (!item) return;
item.source = source === 'new' ? 'new' : 'existing';
if (item.source === 'new') {
item.warehouse_item_id = null;
if (!item.category) item.category = 'other';
if (!item.unit) item.unit = 'шт';
this._syncShipmentMoldDerivedFields(item);
}
this.renderShipmentItemsTable();
this.recalcShipmentValues();
},
onShipmentItemSelect(idx, itemIdStr) {
const itemId = parseInt(itemIdStr) || null;
const shItem = this.shipmentItems[idx];
if (!itemId) {
shItem.warehouse_item_id = null;
shItem.name = ''; shItem.sku = ''; shItem.category = '';
shItem.mold_type = '';
shItem.linked_order_id = '';
shItem.linked_order_name = '';
shItem.template_id = '';
shItem.mold_capacity_total = 0;
shItem.mold_capacity_used = 0;
shItem.mold_arrived_at = '';
shItem.mold_storage_until = '';
} else {
const whItem = this.allItems.find(i => Number(i && i.id || 0) === itemId);
if (whItem) {
shItem.source = 'existing';
shItem.warehouse_item_id = whItem.id;
shItem.name = whItem.name || '';
shItem.sku = whItem.sku || '';
shItem.category = whItem.category || '';
shItem.color = whItem.color || '';
shItem.size = whItem.size || '';
shItem.unit = whItem.unit || 'шт';
shItem.photo_url = whItem.photo_url || '';
shItem.photo_thumbnail = whItem.photo_thumbnail || '';
shItem.mold_type = whItem.mold_type || '';
shItem.linked_order_id = whItem.linked_order_id || '';
shItem.linked_order_name = whItem.linked_order_name || '';
shItem.template_id = whItem.template_id || '';
shItem.mold_capacity_total = whItem.mold_capacity_total || 0;
shItem.mold_capacity_used = whItem.mold_capacity_used || 0;
shItem.mold_arrived_at = whItem.mold_arrived_at || '';
shItem.mold_storage_until = whItem.mold_storage_until || '';
}
}
this._syncShipmentMoldDerivedFields(shItem);
this.recalcShipmentValues();
},
onShipmentItemField(idx, field, value) {
const numericFields = new Set(['qty_received', 'weight_grams', 'purchase_price_cny', 'purchase_price_rub', 'delivery_allocated', 'total_cost_per_unit']);
this.shipmentItems[idx][field] = numericFields.has(field) ? (parseFloat(value) || 0) : String(value || '');
const row = this.shipmentItems[idx];
if (field === 'linked_order_id') {
row.linked_order_name = this._getOrderNameById(value) || '';
}
this._syncShipmentMoldDerivedFields(row);
this.recalcShipmentValues();
if (field === 'category' || field === 'mold_type' || field === 'linked_order_id' || field === 'name') {
this.renderShipmentItemsTable();
}
},
onShipmentItemPhotoUrl(idx, value) {
const item = this.shipmentItems[idx];
if (!item) return;
item.photo_url = String(value || '').trim();
if (item.photo_url) item.photo_thumbnail = '';
this.renderShipmentItemsTable();
},
async onShipmentItemPhotoFile(idx, input) {
if (!input || !input.files || !input.files[0]) return;
const file = input.files[0];
if (file.size > 10 * 1024 * 1024) { App.toast('Файл слишком большой (макс 10 МБ)'); return; }
const thumbnail = await this.compressImageToThumbnail(file, 200, 200, 0.72);
if (!thumbnail) { App.toast('Не удалось обработать фото'); return; }
const item = this.shipmentItems[idx];
if (!item) return;
item.photo_thumbnail = thumbnail;
item.photo_url = '';
this.renderShipmentItemsTable();
},
recalcShipment() {
// Called from form-level inputs (oninput), recalculates and re-renders items
this.recalcShipmentValues();
this.renderShipmentItemsTable();
},
recalcShipmentValues() {
// purchase_price_cny хранится как цена за всю позицию (тираж), не за 1 шт.
const cny = this.shipmentItems.reduce((sum, i) => {
const priceCnyTotal = parseFloat(i.purchase_price_cny) || 0;
return sum + priceCnyTotal;
}, 0);
document.getElementById('wh-sh-purchase-cny').value = (Math.round(cny * 100) / 100).toString();
const rate = parseFloat(document.getElementById('wh-sh-cny-rate').value) || 0;
const feeCashout = parseFloat(document.getElementById('wh-sh-fee-cashout').value) || 0;
const feeCrypto = parseFloat(document.getElementById('wh-sh-fee-crypto').value) || 0;
const fee1688 = parseFloat(document.getElementById('wh-sh-fee-1688').value) || 0;
const feeTotalPct = feeCashout + feeCrypto + fee1688;
const feeMultiplier = 1 + (feeTotalPct / 100);
document.getElementById('wh-sh-fee-total').value = (Math.round(feeTotalPct * 100) / 100).toString();
const purchaseRub = cny * rate * feeMultiplier;
document.getElementById('wh-sh-purchase-rub').value = Math.round(purchaseRub).toString();
const deliveryChina = parseFloat(document.getElementById('wh-sh-delivery-china').value) || 0;
const deliveryMoscow = parseFloat(document.getElementById('wh-sh-delivery-moscow').value) || 0;
const totalDelivery = deliveryChina + deliveryMoscow;
document.getElementById('wh-sh-total-delivery').value = Math.round(totalDelivery).toString();
const totalWeight = this.shipmentItems.reduce((s, i) => s + (i.weight_grams || 0), 0);
this.shipmentItems.forEach(item => {
const qty = parseFloat(item.qty_received) || 0;
const lineCnyTotal = parseFloat(item.purchase_price_cny) || 0;
const lineRubTotal = lineCnyTotal * rate * feeMultiplier;
item.purchase_price_rub = qty > 0 ? (lineRubTotal / qty) : 0;
item.delivery_allocated = totalWeight > 0
? totalDelivery * ((item.weight_grams || 0) / totalWeight) : 0;
item.total_cost_per_unit = qty > 0
? item.purchase_price_rub + (item.delivery_allocated / qty) : 0;
});
// Update summary
const totalItems = this.shipmentItems.length;
const totalQty = this.shipmentItems.reduce((s, i) => s + (i.qty_received || 0), 0);
const avgCost = totalQty > 0
? this.shipmentItems.reduce((s, i) => s + (i.total_cost_per_unit || 0) * (i.qty_received || 0), 0) / totalQty : 0;
const summaryEl = document.getElementById('wh-sh-summary');
if (summaryEl) {
summaryEl.innerHTML = `
Позиций: ${totalItems}
Общее кол-во: ${totalQty.toLocaleString('ru-RU')}
Общий вес: ${totalWeight.toLocaleString('ru-RU')} г
Товары (CNY): ${(Math.round(cny * 100) / 100).toLocaleString('ru-RU')} ¥
Закупка с комиссиями: ${Math.round(purchaseRub).toLocaleString('ru-RU')} ₽
Доставка: ${Math.round(totalDelivery).toLocaleString('ru-RU')} ₽
Ср. с/с ед.: ${avgCost.toFixed(2)} ₽
`;
}
},
_buildShipmentData() {
const name = document.getElementById('wh-sh-name').value.trim();
if (!name) { App.toast('Укажите название поставки'); return null; }
const existingShipment = this.editingShipmentId
? this.allShipments.find(s => s.id === this.editingShipmentId)
: null;
const derivedChinaPurchaseIds = [...new Set(
this.shipmentItems
.map(item => parseInt(item.china_purchase_id, 10))
.filter(Boolean)
)];
const chinaPurchaseIds = derivedChinaPurchaseIds.length
? derivedChinaPurchaseIds
: (Array.isArray(existingShipment?.china_purchase_ids) ? existingShipment.china_purchase_ids : []);
const shipmentSource = existingShipment?.source || (chinaPurchaseIds.length ? 'china_consolidation' : '');
const cny = this.shipmentItems.reduce((sum, i) => {
const priceCnyTotal = parseFloat(i.purchase_price_cny) || 0;
return sum + priceCnyTotal;
}, 0);
const rate = parseFloat(document.getElementById('wh-sh-cny-rate').value) || 0;
const feeCashout = parseFloat(document.getElementById('wh-sh-fee-cashout').value) || 0;
const feeCrypto = parseFloat(document.getElementById('wh-sh-fee-crypto').value) || 0;
const fee1688 = parseFloat(document.getElementById('wh-sh-fee-1688').value) || 0;
const feeTotalPct = feeCashout + feeCrypto + fee1688;
const feeMultiplier = 1 + feeTotalPct / 100;
return {
id: this.editingShipmentId || undefined,
date: document.getElementById('wh-sh-date').value,
shipment_name: name,
supplier: document.getElementById('wh-sh-supplier').value.trim(),
total_purchase_cny: cny,
cny_rate: rate,
fee_cashout_percent: feeCashout,
fee_crypto_percent: feeCrypto,
fee_1688_percent: fee1688,
fee_total_percent: feeTotalPct,
total_purchase_rub: cny * rate * feeMultiplier,
delivery_china_to_russia: parseFloat(document.getElementById('wh-sh-delivery-china').value) || 0,
delivery_moscow: parseFloat(document.getElementById('wh-sh-delivery-moscow').value) || 0,
customs_fees: existingShipment?.customs_fees || 0,
total_delivery: parseFloat(document.getElementById('wh-sh-total-delivery').value) || 0,
pricing_mode: document.getElementById('wh-sh-pricing-mode').value || 'weighted_avg',
items: JSON.parse(JSON.stringify(this.shipmentItems)),
total_weight_grams: this.shipmentItems.reduce((s, i) => s + (i.weight_grams || 0), 0),
notes: document.getElementById('wh-sh-notes').value.trim(),
source: shipmentSource || undefined,
china_purchase_ids: chinaPurchaseIds,
china_box_status: existingShipment?.china_box_status || (shipmentSource === 'china_consolidation' ? (existingShipment?.status || 'draft') : undefined),
china_delivery_type: existingShipment?.china_delivery_type || '',
china_estimated_days: existingShipment?.china_estimated_days || 0,
china_tracking_number: existingShipment?.china_tracking_number || '',
china_delivery_estimated_usd: existingShipment?.china_delivery_estimated_usd || 0,
waybill_pdf_name: existingShipment?.waybill_pdf_name || '',
waybill_pdf_data: existingShipment?.waybill_pdf_data || '',
};
},
async saveShipmentDraft() {
const data = this._buildShipmentData();
if (!data) return;
data.status = this.editingShipmentId
? (this.allShipments.find(s => s.id === this.editingShipmentId) || {}).status || 'draft'
: 'draft';
await saveShipment(data);
App.toast('Черновик сохранён');
this.hideShipmentForm();
await this.loadShipmentsList();
},
async confirmShipment() {
const data = this._buildShipmentData();
if (!data) return;
const validItemsRaw = data.items.filter(i => {
const qty = parseFloat(i.qty_received) || 0;
if (qty <= 0) return false;
return !!i.warehouse_item_id || (i.source === 'new' && (i.name || '').trim());
});
if (validItemsRaw.length === 0) {
App.toast('Добавьте хотя бы одну позицию с кол-вом > 0');
return;
}
const unsafeItems = this._collectUnsafeShipmentItems(validItemsRaw);
if (unsafeItems.length > 0) {
const details = unsafeItems.map(entry => {
const prefix = entry.index === null ? 'Позиция' : `Строка ${entry.index + 1}`;
return `${prefix}: ${this._shipmentIdentityHint(entry.item)}`;
}).join('\n');
const message = `Нельзя автоматически принять слишком общие новые позиции.\n\n${details}\n\nВыберите существующую позицию со склада или сначала заполните SKU / точную категорию и цвет/размер.`;
if (typeof alert === 'function') alert(message);
App.toast('Проверьте новые позиции приёмки: не хватает данных для безопасного автосоздания');
return;
}
const existingShipment = this.editingShipmentId
? this.allShipments.find(s => s.id === this.editingShipmentId)
: null;
const isRepost = !!(existingShipment && existingShipment.status === 'received');
const confirmText = isRepost
? `Перепровести приёмку (${validItemsRaw.length} позиций)?\nОстатки на складе будут пересчитаны по разнице.`
: `Принять ${validItemsRaw.length} позиций на склад?\nОстатки и себестоимость будут обновлены.`;
if (!confirm(confirmText)) return;
const itemsBefore = await loadWarehouseItems();
const beforeById = new Map(itemsBefore.map(i => [i.id, {
qty: i.qty || 0,
price: i.price_per_unit || 0,
}]));
const purchaseCache = new Map();
const orderCache = new Map();
// Ensure all "new" items are matched with existing stock or created once.
for (const shItem of validItemsRaw) {
if (shItem.warehouse_item_id) continue;
if (this._isMoldCategory(shItem.category)) {
const moldMeta = await this._resolveShipmentMoldMeta(shItem, {
purchaseCache,
orderCache,
receiptDate: data.date || data.received_at || '',
});
if (moldMeta) {
Object.assign(shItem, moldMeta);
this._applyAutoMoldSku(shItem);
}
}
const matched = this._findExistingItemForShipment(shItem, itemsBefore);
if (matched) {
shItem.warehouse_item_id = matched.id;
continue;
}
const newItem = {
category: shItem.category || 'other',
name: (shItem.name || '').trim(),
sku: (shItem.sku || '').trim(),
size: (shItem.size || '').trim(),
color: (shItem.color || '').trim(),
unit: (shItem.unit || 'шт').trim() || 'шт',
photo_url: (shItem.photo_url || '').trim(),
photo_thumbnail: shItem.photo_thumbnail || '',
qty: 0,
min_qty: 0,
price_per_unit: Math.round((shItem.total_cost_per_unit || 0) * 100) / 100,
notes: this._isMoldCategory(shItem.category)
? 'Молд создан автоматически из приёмки Китая'
: 'Создано автоматически из приёмки Китая',
};
if (this._isMoldCategory(shItem.category)) {
Object.assign(newItem, this._mergeMoldMetaIntoItem(newItem, shItem));
}
const newId = await saveWarehouseItem(newItem);
shItem.warehouse_item_id = newId;
beforeById.set(newId, { qty: 0, price: newItem.price_per_unit || 0 });
itemsBefore.push({ ...newItem, id: newId, qty: 0 });
}
const validItems = this._mergeShipmentItemsByWarehouseId(validItemsRaw);
const previousItems = isRepost
? this._mergeShipmentItemsByWarehouseId((existingShipment.items || []).filter(i => {
const qty = parseFloat(i.qty_received) || 0;
return qty > 0 && !!i.warehouse_item_id;
}))
: [];
const prevQtyById = new Map();
previousItems.forEach(i => {
prevQtyById.set(i.warehouse_item_id, (prevQtyById.get(i.warehouse_item_id) || 0) + (parseFloat(i.qty_received) || 0));
});
const newQtyById = new Map();
validItems.forEach(i => {
newQtyById.set(i.warehouse_item_id, (newQtyById.get(i.warehouse_item_id) || 0) + (parseFloat(i.qty_received) || 0));
});
data.items = validItems;
data.status = 'received';
if (data.source === 'china_consolidation') data.china_box_status = 'received';
data.received_at = new Date().toISOString();
await saveShipment(data);
// If this receipt came from China consolidation, mark linked purchases as received too.
if (Array.isArray(data.china_purchase_ids) && data.china_purchase_ids.length) {
for (const purchaseId of data.china_purchase_ids) {
const purchase = await loadChinaPurchase(purchaseId);
if (!purchase) continue;
purchase.shipment_id = data.id;
purchase.delivery_type = data.china_delivery_type || purchase.delivery_type || '';
purchase.tracking_number = data.china_tracking_number || purchase.tracking_number || '';
purchase.estimated_days = data.china_estimated_days || purchase.estimated_days || 0;
purchase.status = 'received';
purchase.status_history = Array.isArray(purchase.status_history) ? purchase.status_history : [];
purchase.status_history.push({
status: 'received',
date: data.received_at,
note: `Принято на склад по коробке «${data.shipment_name || ''}»`,
});
await saveChinaPurchase(purchase);
}
}
// Apply stock delta for each item (supports repost/edit of already received shipment)
const allIds = new Set([...prevQtyById.keys(), ...newQtyById.keys()]);
for (const itemId of allIds) {
const prevQty = prevQtyById.get(itemId) || 0;
const nextQty = newQtyById.get(itemId) || 0;
const delta = nextQty - prevQty;
if (!delta) continue;
const note = isRepost
? `Перепроведение приёмки: было ${prevQty}, стало ${nextQty}`
: `Приёмка: ${nextQty} шт`;
await this.adjustStock(
itemId,
delta,
delta > 0 ? 'addition' : 'deduction',
data.shipment_name,
note,
''
);
}
// Update weighted price only for net positive additions
const pricingMode = data.pricing_mode || 'weighted_avg';
const itemsAfter = await loadWarehouseItems();
validItems.forEach(shItem => {
const idx = itemsAfter.findIndex(i => i.id === shItem.warehouse_item_id);
if (idx < 0) return;
let after = itemsAfter[idx];
if (this._isMoldCategory(after.category) || this._isMoldCategory(shItem.category)) {
after = this._mergeMoldMetaIntoItem(after, shItem);
itemsAfter[idx] = after;
}
const before = beforeById.get(shItem.warehouse_item_id) || { qty: 0, price: after.price_per_unit || 0 };
const prevQty = prevQtyById.get(shItem.warehouse_item_id) || 0;
const nextQty = newQtyById.get(shItem.warehouse_item_id) || 0;
const addedQty = Math.max(0, nextQty - prevQty);
if (addedQty <= 0) return;
const newCost = Math.round((parseFloat(shItem.total_cost_per_unit) || 0) * 100) / 100;
const totalQty = (before.qty || 0) + addedQty;
const weighted = totalQty > 0
? (((before.qty || 0) * (before.price || 0) + addedQty * newCost) / totalQty)
: newCost;
after.price_per_unit = Math.round(weighted * 100) / 100;
after.updated_at = new Date().toISOString();
if (pricingMode === 'weighted_with_layers') {
if (!Array.isArray(after.cost_layers)) after.cost_layers = [];
after.cost_layers.push({
shipment_id: data.id,
shipment_name: data.shipment_name || '',
received_at: data.received_at,
qty_added: addedQty,
unit_cost: newCost,
});
}
itemsAfter[idx] = after;
});
await saveWarehouseItems(itemsAfter);
const moldResult = await this._promoteOrdersForReceivedMolds(validItems, data);
App.toast(isRepost
? `Приёмка перепроведена: ${validItems.length} позиций обновлено`
: `Приёмка завершена: ${validItems.length} позиций на складе`);
if (moldResult && moldResult.promotedOrders > 0) {
App.toast(`Молды приняты: ${moldResult.promotedOrders} заказ(а) переведены в продакшен`);
}
this.hideShipmentForm();
await this.load();
this.setView('shipments');
},
async updateShipmentValues() {
if (!this.editingShipmentId) {
App.toast('Сначала откройте приёмку для редактирования');
return;
}
const sh = this.allShipments.find(s => s.id === this.editingShipmentId);
if (!sh || sh.status !== 'received') {
App.toast('Эта кнопка доступна только для уже принятой приёмки');
return;
}
await this.confirmShipment();
},
async deleteShipmentFromForm() {
if (!this.editingShipmentId) return;
if (!confirm('Удалить эту приёмку?')) return;
await deleteShipment(this.editingShipmentId);
App.toast('Приёмка удалена');
this.hideShipmentForm();
await this.loadShipmentsList();
},
// ==========================================
// UTILITIES
// ==========================================
esc(str) {
if (!str) return '';
return String(str).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"');
},
_normStr(v) {
return String(v || '').trim().toLowerCase();
},
_formatMoney(value) {
const rounded = Math.round((parseFloat(value) || 0) * 100) / 100;
return `${rounded.toLocaleString('ru-RU')} ₽`;
},
_normalizeReadyGoodsLocation(value) {
return String(value || '').trim().toLowerCase() === 'partner' ? 'partner' : 'our';
},
_readyGoodsLocationLabel(value) {
return this._normalizeReadyGoodsLocation(value) === 'partner' ? 'Склад партнёра' : 'Наш склад';
},
_readyGoodsLocationEmoji(value) {
return this._normalizeReadyGoodsLocation(value) === 'partner' ? '🤝' : '🏠';
},
_normalizeReadyGoodsItem(item) {
if (!item || typeof item !== 'object') return item;
return {
...item,
location_type: this._normalizeReadyGoodsLocation(item.location_type),
};
},
_normalizeSalesRecord(record) {
if (!record || typeof record !== 'object') return record;
return {
...record,
location_type: this._normalizeReadyGoodsLocation(record.location_type),
};
},
_resolveReadyGoodsLocation(orderData = null) {
const candidates = [
orderData?.order?.ready_goods_location,
orderData?.order?.warehouse_location,
orderData?.order?.stock_location,
orderData?.order?.delivery_stock_location,
orderData?.order?.partner_stock ? 'partner' : '',
];
const found = candidates.find(value => String(value || '').trim());
return this._normalizeReadyGoodsLocation(found);
},
async _getReadyGoodsFrozenAmount() {
const rg = await loadReadyGoods();
return rg.reduce((sum, row) => {
const qty = Math.max(0, parseFloat(row.qty) || 0);
const unitCost = Math.max(0, parseFloat(row.cost_per_unit) || 0);
return sum + qty * unitCost;
}, 0);
},
// ==========================================
// READY GOODS (Готовая продукция)
// ==========================================
async renderReadyGoodsView() {
const container = document.getElementById('wh-content');
if (!container) return;
const filtersCard = document.getElementById('wh-filters-card');
if (filtersCard) filtersCard.style.display = 'none';
const rg = (await loadReadyGoods()).map(item => this._normalizeReadyGoodsItem(item));
const salesRecords = (await loadSalesRecords()).map(item => this._normalizeSalesRecord(item));
const sourceStatus = typeof getReadyGoodsSourceStatus === 'function' ? getReadyGoodsSourceStatus() : null;
const sourceItems = sourceStatus ? [sourceStatus.ready_goods, sourceStatus.ready_goods_history, sourceStatus.sales_records].filter(Boolean) : [];
const usesSharedSource = sourceItems.length > 0 && sourceItems.every(item => item.source === 'shared-settings');
const readyGoodsAvailable = usesSharedSource;
const sourceHtml = sourceItems.length > 0
? `
${usesSharedSource ? 'Источник готовой продукции: общая база' : 'Готовая продукция временно недоступна'}
${usesSharedSource
? 'Остатки, история готовой продукции и продажи читаются из канонического shared-хранилища Supabase settings.'
: 'Локальный кэш для готовой продукции больше не используется. Раздел доступен только при live-синхронизации через shared Supabase settings, чтобы у всех сотрудников были одни и те же остатки.'}
`
: '';
// Stats
const positiveRg = rg.filter(i => (parseFloat(i.qty) || 0) > 0);
const ourItems = positiveRg.filter(item => this._normalizeReadyGoodsLocation(item.location_type) === 'our');
const partnerItems = positiveRg.filter(item => this._normalizeReadyGoodsLocation(item.location_type) === 'partner');
const totalQty = positiveRg.reduce((s, i) => s + (parseFloat(i.qty) || 0), 0);
const totalValue = positiveRg.reduce((s, i) => s + (parseFloat(i.qty) || 0) * (parseFloat(i.cost_per_unit) || 0), 0);
const ourQty = ourItems.reduce((s, i) => s + (parseFloat(i.qty) || 0), 0);
const ourValue = ourItems.reduce((s, i) => s + (parseFloat(i.qty) || 0) * (parseFloat(i.cost_per_unit) || 0), 0);
const partnerQty = partnerItems.reduce((s, i) => s + (parseFloat(i.qty) || 0), 0);
const partnerValue = partnerItems.reduce((s, i) => s + (parseFloat(i.qty) || 0) * (parseFloat(i.cost_per_unit) || 0), 0);
const totalSalesRevenue = salesRecords.reduce((s, r) => s + (parseFloat(r.revenue) || 0), 0);
const totalSalesCost = salesRecords.reduce((s, r) => s + (parseFloat(r.qty) || 0) * (parseFloat(r.cost_per_unit) || 0), 0);
const totalProfit = totalSalesRevenue - totalSalesCost;
let html = `
${sourceHtml}
Стоимость на нашем складе
${this._formatMoney(ourValue)}
Склад партнёра (шт)
${partnerQty}
Стоимость у партнёра
${this._formatMoney(partnerValue)}
Выручка продаж
${this._formatMoney(totalSalesRevenue)}
Прибыль продаж
${this._formatMoney(totalProfit)}
Всего готовой продукции: ${totalQty} шт · общая стоимость ${this._formatMoney(totalValue)}
📤 Списать продажу
+ Добавить вручную
`;
// Ready goods table
if (positiveRg.length === 0) {
html += `
📦
Нет готовой продукции на складе
${readyGoodsAvailable ? 'Товары появятся здесь после ручной приёмки B2C / партнёрского стока.' : 'Как только восстановится shared-база, здесь снова появится live-остаток готовой продукции.'}
`;
} else {
const renderLocationSection = (locationType, title) => {
const items = positiveRg.filter(item => this._normalizeReadyGoodsLocation(item.location_type) === locationType);
const sectionQty = items.reduce((sum, item) => sum + (parseFloat(item.qty) || 0), 0);
const sectionValue = items.reduce((sum, item) => sum + (parseFloat(item.qty) || 0) * (parseFloat(item.cost_per_unit) || 0), 0);
if (items.length === 0) {
return `
${title}
0 шт · ${this._formatMoney(0)}
Здесь пока нет остатков.
`;
}
const rows = items.map(item => {
const cost = parseFloat(item.cost_per_unit) || 0;
const qty = parseFloat(item.qty) || 0;
return `
${this.esc(item.product_name || '—')}
${this.esc(item.order_name || '—')}
${this.esc(item.marketplace_set || '—')}
${qty}
${this._formatMoney(cost)}
${this._formatMoney(qty * cost)}
${item.added_at ? new Date(item.added_at).toLocaleDateString('ru-RU') : '—'}
`;
}).join('');
return `
${title}
${sectionQty} шт · ${this._formatMoney(sectionValue)}
Товар
Из заказа
Набор
Кол-во
Себестоимость/шт
Сумма
Дата
${rows}
`;
};
html += renderLocationSection('our', `${this._readyGoodsLocationEmoji('our')} Наш склад`);
html += renderLocationSection('partner', `${this._readyGoodsLocationEmoji('partner')} Склад партнёра`);
}
// Sales history
if (salesRecords.length > 0) {
const salesRows = [...salesRecords].sort((a, b) => new Date(b.date) - new Date(a.date)).map(r => {
const locationLabel = this._readyGoodsLocationLabel(r.location_type);
const channel = r.channel === 'marketplace' ? '🏪 Маркетплейс' : (r.channel === 'website' ? '🌐 Сайт' : '📋 Другое');
const profit = (parseFloat(r.revenue) || 0) - (parseFloat(r.qty) || 0) * (parseFloat(r.cost_per_unit) || 0);
return `
${r.date ? new Date(r.date).toLocaleDateString('ru-RU') : '—'}
${this.esc(r.product_name || '—')}
${this.esc(locationLabel)}
${channel}
${r.qty || 0}
${this._formatMoney(r.revenue || 0)}
${this._formatMoney(r.payout || 0)}
${this._formatMoney(profit)}
${this.esc(r.notes || '')}
`;
}).join('');
html += `История продаж
Дата
Товар
Со склада
Канал
Кол-во
Выручка
Поступление
Прибыль
Заметки
${salesRows}
`;
}
container.innerHTML = html;
},
async showWriteOffDialog() {
if (!this._isReadyGoodsSharedAvailable()) {
App.toast('Готовая продукция недоступна без общей базы');
return;
}
const rg = (await loadReadyGoods())
.map(item => this._normalizeReadyGoodsItem(item))
.filter(i => (parseFloat(i.qty) || 0) > 0);
if (rg.length === 0) {
App.toast('Нет товаров для списания');
return;
}
const existing = document.getElementById('rg-writeoff-dialog');
if (existing) existing.remove();
const opts = rg.map((item, i) => {
const label = `${item.product_name} · ${this._readyGoodsLocationLabel(item.location_type)} (${item.qty} шт, себест. ${this._formatMoney(item.cost_per_unit || 0)})`;
return `${this.esc(label)} `;
}).join('');
const overlay = document.createElement('div');
overlay.id = 'rg-writeoff-dialog';
overlay.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,.35);z-index:1000;display:flex;align-items:center;justify-content:center;';
overlay.onclick = (e) => { if (e.target === overlay) overlay.remove(); };
overlay.innerHTML = `
📤 Списать продажу
✕
Товар
${opts}
Заметки
Отмена
Списать
`;
document.body.appendChild(overlay);
},
async doWriteOff() {
if (!this._isReadyGoodsSharedAvailable()) {
App.toast('Готовая продукция недоступна без общей базы');
return;
}
const rg = (await loadReadyGoods())
.map(item => this._normalizeReadyGoodsItem(item))
.filter(i => (parseFloat(i.qty) || 0) > 0);
const idx = parseInt(document.getElementById('rg-wo-product').value);
const item = rg[idx];
if (!item) { App.toast('Товар не найден'); return; }
const qty = parseInt(document.getElementById('rg-wo-qty').value) || 0;
if (qty <= 0) { App.toast('Укажите количество'); return; }
if (qty > item.qty) { App.toast(`На складе только ${item.qty} шт`); return; }
const channel = document.getElementById('rg-wo-channel').value;
const revenue = parseFloat(document.getElementById('rg-wo-revenue').value) || 0;
const payout = parseFloat(document.getElementById('rg-wo-payout').value) || 0;
const notes = (document.getElementById('rg-wo-notes').value || '').trim();
// Deduct from ready goods
const allRg = (await loadReadyGoods()).map(entry => this._normalizeReadyGoodsItem(entry));
const rgItem = allRg.find(i => Number(i.id) === Number(item.id));
if (rgItem) {
rgItem.qty = Math.max(0, (rgItem.qty || 0) - qty);
}
await saveReadyGoods(allRg);
// Record sale
const records = await loadSalesRecords();
records.push({
id: Date.now(),
ready_goods_id: item.id,
product_name: item.product_name,
order_name: item.order_name || '',
marketplace_set: item.marketplace_set || '',
channel,
location_type: this._normalizeReadyGoodsLocation(item.location_type),
qty,
cost_per_unit: item.cost_per_unit || 0,
revenue,
payout,
notes,
date: new Date().toISOString(),
created_by: App.getCurrentEmployeeName() || '',
});
await saveSalesRecords(records);
// History
const history = await loadReadyGoodsHistory();
history.push({
id: Date.now(),
type: 'writeoff',
product_name: item.product_name,
location_type: this._normalizeReadyGoodsLocation(item.location_type),
qty: -qty,
channel,
revenue,
payout,
notes: `Продажа: ${channel === 'marketplace' ? 'маркетплейс' : channel === 'website' ? 'сайт' : 'другое'}. ${notes}`,
date: new Date().toISOString(),
created_by: App.getCurrentEmployeeName() || '',
});
await saveReadyGoodsHistory(history);
const dialog = document.getElementById('rg-writeoff-dialog');
if (dialog) dialog.remove();
App.toast(`Списано ${qty} шт «${item.product_name}»`);
this.renderStats();
this.renderReadyGoodsView();
},
showAddReadyGoodsDialog() {
if (!this._isReadyGoodsSharedAvailable()) {
App.toast('Готовая продукция недоступна без общей базы');
return;
}
const existing = document.getElementById('rg-add-dialog');
if (existing) existing.remove();
const overlay = document.createElement('div');
overlay.id = 'rg-add-dialog';
overlay.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,.35);z-index:1000;display:flex;align-items:center;justify-content:center;';
overlay.onclick = (e) => { if (e.target === overlay) overlay.remove(); };
overlay.innerHTML = `
`;
document.body.appendChild(overlay);
},
async doAddReadyGoods() {
if (!this._isReadyGoodsSharedAvailable()) {
App.toast('Готовая продукция недоступна без общей базы');
return;
}
const name = (document.getElementById('rg-add-name').value || '').trim();
if (!name) { App.toast('Укажите название'); return; }
const qty = parseInt(document.getElementById('rg-add-qty').value) || 0;
if (qty <= 0) { App.toast('Укажите количество'); return; }
const cost = parseFloat(document.getElementById('rg-add-cost').value) || 0;
const locationType = this._normalizeReadyGoodsLocation(document.getElementById('rg-add-location').value);
const setName = (document.getElementById('rg-add-set').value || '').trim();
const rg = (await loadReadyGoods()).map(item => this._normalizeReadyGoodsItem(item));
rg.push({
id: Date.now(),
product_name: name,
order_name: 'Ручное добавление',
order_id: null,
marketplace_set: setName,
location_type: locationType,
qty,
cost_per_unit: cost,
added_at: new Date().toISOString(),
added_by: App.getCurrentEmployeeName() || '',
});
await saveReadyGoods(rg);
const history = await loadReadyGoodsHistory();
history.push({
id: Date.now(),
type: 'manual_add',
product_name: name,
location_type: locationType,
qty,
notes: `Ручное добавление (${this._readyGoodsLocationLabel(locationType)}): ${name} × ${qty}`,
date: new Date().toISOString(),
created_by: App.getCurrentEmployeeName() || '',
});
await saveReadyGoodsHistory(history);
const dialog = document.getElementById('rg-add-dialog');
if (dialog) dialog.remove();
App.toast(`Добавлено ${qty} шт «${name}»`);
this.renderStats();
this.renderReadyGoodsView();
},
// Move products from a completed order to ready goods
async moveOrderToReadyGoods(orderId, orderName) {
const data = await loadOrder(orderId);
if (!data || !data.items) return;
if ((data.order?.client_name || '').toUpperCase() !== 'B2C') return 0;
const locationType = this._resolveReadyGoodsLocation(data);
await Promise.all([
loadReadyGoods(),
loadReadyGoodsHistory(),
loadSalesRecords(),
]);
if (!this._isReadyGoodsSharedAvailable()) return 0;
const rg = (await loadReadyGoods()).map(item => this._normalizeReadyGoodsItem(item));
const history = await loadReadyGoodsHistory();
const nowIso = new Date().toISOString();
const employee = App.getCurrentEmployeeName() || '';
let addedCount = 0;
// Only move product-type items (not hardware/packaging raw materials)
data.items.filter(it => it.item_type === 'product').forEach(item => {
const qty = parseFloat(item.quantity) || 0;
if (qty <= 0) return;
// Calculate unit cost: total cost / quantity
const costTotal = parseFloat(item.cost_total) || 0;
const costPerUnit = qty > 0 ? Math.round(costTotal * 100) / 100 : 0;
rg.push({
id: Date.now() + addedCount,
product_name: item.product_name || 'Товар',
order_name: orderName || 'Заказ',
order_id: orderId,
marketplace_set: item.marketplace_set_name || '',
location_type: locationType,
qty,
cost_per_unit: costPerUnit,
added_at: nowIso,
added_by: employee,
});
history.push({
id: Date.now() + addedCount + 50000,
type: 'from_order',
product_name: item.product_name || 'Товар',
order_name: orderName,
location_type: locationType,
qty,
cost_per_unit: costPerUnit,
notes: `Из заказа «${orderName}» → ${this._readyGoodsLocationLabel(locationType)}: ${item.product_name} × ${qty}`,
date: nowIso,
created_by: employee,
});
addedCount++;
});
if (addedCount > 0) {
await saveReadyGoods(rg);
await saveReadyGoodsHistory(history);
}
return addedCount;
},
async removeOrderFromReadyGoods(orderId, orderName, nextStatus) {
const readyGoods = (await loadReadyGoods()).map(item => this._normalizeReadyGoodsItem(item));
const history = await loadReadyGoodsHistory();
const nowIso = new Date().toISOString();
const employee = App.getCurrentEmployeeName() || '';
const remaining = [];
let removedCount = 0;
readyGoods.forEach(item => {
if (Number(item.order_id) === Number(orderId)) {
history.push({
id: Date.now() + removedCount + 80000,
type: 'return_to_order',
product_name: item.product_name || 'Товар',
order_name: item.order_name || orderName || 'Заказ',
location_type: this._normalizeReadyGoodsLocation(item.location_type),
qty: -(parseFloat(item.qty) || 0),
cost_per_unit: parseFloat(item.cost_per_unit) || 0,
notes: `Возврат из ${this._readyGoodsLocationLabel(item.location_type)}: ${App.statusLabel('completed')} → ${App.statusLabel(nextStatus)}`,
date: nowIso,
created_by: employee,
});
removedCount++;
return;
}
remaining.push(item);
});
if (removedCount > 0) {
await saveReadyGoods(remaining);
await saveReadyGoodsHistory(history);
}
return removedCount;
},
_isReadyGoodsSharedAvailable() {
const sourceStatus = typeof getReadyGoodsSourceStatus === 'function' ? getReadyGoodsSourceStatus() : null;
const sourceItems = sourceStatus ? [sourceStatus.ready_goods, sourceStatus.ready_goods_history, sourceStatus.sales_records].filter(Boolean) : [];
return sourceItems.length > 0 && sourceItems.every(item => item.source === 'shared-settings');
},
_getSeedPhotoMapBySku() {
const map = {};
// Preferred source: explicit sku->photo mapping (if present).
if (typeof WAREHOUSE_SEED_PHOTOS_BY_SKU !== 'undefined' && WAREHOUSE_SEED_PHOTOS_BY_SKU) {
Object.entries(WAREHOUSE_SEED_PHOTOS_BY_SKU).forEach(([sku, photo]) => {
const key = this._normStr(sku);
if (key && photo) map[key] = photo;
});
if (Object.keys(map).length > 0) return map;
}
// Fallback: build mapping from seed rows by their sku and indexed photo.
if (typeof WAREHOUSE_SEED_DATA === 'undefined' || typeof WAREHOUSE_SEED_PHOTOS === 'undefined') {
return map;
}
WAREHOUSE_SEED_DATA.forEach((seed, i) => {
const key = this._normStr(seed && seed.sku);
const photo = WAREHOUSE_SEED_PHOTOS[i];
if (!key || !photo) return;
if (!map[key]) map[key] = photo;
});
return map;
},
_itemIdentityKey(item) {
return [
this._normStr(item.category || 'other'),
this._normStr(item.sku),
this._normStr(item.name),
this._normStr(item.size),
this._normStr(item.color),
this._normStr(item.unit || 'шт'),
].join('|');
},
_isWeakShipmentIdentity(item) {
if (!item || this._isMoldCategory(item.category)) return false;
const sku = this._normStr(item.sku);
if (sku) return false;
const category = this._normStr(item.category || 'other');
const color = this._normStr(item.color);
const size = this._normStr(item.size);
return (!category || category === 'other') || (!color && !size);
},
_shipmentIdentityHint(item) {
const name = String(item && item.name || '').trim() || 'Без названия';
return `«${name}» — для новой позиции заполните SKU или хотя бы укажите точную категорию и цвет/размер.`;
},
_collectUnsafeShipmentItems(items) {
return (items || []).map(item => {
const index = this.shipmentItems.indexOf(item);
return {
item,
index: index >= 0 ? index : null,
};
}).filter(entry => {
if (!entry.item) return false;
if (entry.item.warehouse_item_id) return false;
if (entry.item.source !== 'new') return false;
return this._isWeakShipmentIdentity(entry.item);
});
},
_findExistingItemForShipment(shItem, warehouseItems) {
if (this._isMoldCategory(shItem.category)) {
const moldType = this._normalizeMoldType(shItem.mold_type);
const linkedOrderId = Number(shItem.linked_order_id || 0) || 0;
const templateId = this._normStr(shItem.template_id || '');
const moldName = this._normStr(shItem.name || '');
const byMoldKey = warehouseItems.find(i =>
this._isMoldCategory(i.category)
&& this._normalizeMoldType(i.mold_type) === moldType
&& (
(linkedOrderId && Number(i.linked_order_id || 0) === linkedOrderId)
|| (templateId && this._normStr(i.template_id || '') === templateId)
|| (moldType === 'blank' && moldName && this._normStr(i.name || '') === moldName)
)
);
if (byMoldKey) return byMoldKey;
}
if (this._isWeakShipmentIdentity(shItem)) return null;
const sku = this._normStr(shItem.sku);
const category = this._normStr(shItem.category);
if (sku) {
const bySku = warehouseItems.find(i =>
this._normStr(i.sku) === sku &&
(!category || this._normStr(i.category) === category)
);
if (bySku) return bySku;
}
const key = this._itemIdentityKey(shItem);
return warehouseItems.find(i => this._itemIdentityKey(i) === key) || null;
},
async _getChinaPurchaseCached(purchaseId, cache) {
const normalizedId = Number(purchaseId || 0);
if (!normalizedId) return null;
if (cache.has(normalizedId)) return cache.get(normalizedId);
const purchase = typeof loadChinaPurchase === 'function'
? await loadChinaPurchase(normalizedId).catch(() => null)
: null;
cache.set(normalizedId, purchase || null);
return purchase || null;
},
async _getOrderCached(orderId, cache) {
const normalizedId = Number(orderId || 0);
if (!normalizedId) return null;
if (cache.has(normalizedId)) return cache.get(normalizedId);
const detail = typeof loadOrder === 'function'
? await loadOrder(normalizedId).catch(() => null)
: null;
cache.set(normalizedId, detail || null);
return detail || null;
},
async _resolveShipmentMoldMeta(shItem, context) {
if (!this._isMoldCategory(shItem && shItem.category)) return null;
const ctx = context && typeof context === 'object' ? context : {};
const purchaseCache = ctx.purchaseCache instanceof Map ? ctx.purchaseCache : new Map();
const orderCache = ctx.orderCache instanceof Map ? ctx.orderCache : new Map();
let linkedOrderId = Number(shItem.linked_order_id || 0) || 0;
let linkedOrderName = String(shItem.linked_order_name || '').trim();
let purchase = null;
if (!linkedOrderId && shItem.china_purchase_id) {
purchase = await this._getChinaPurchaseCached(shItem.china_purchase_id, purchaseCache);
linkedOrderId = Number(purchase && purchase.order_id || 0) || 0;
if (!linkedOrderName) {
linkedOrderName = String(purchase && (purchase.order_name || purchase.order_label) || '').trim();
}
}
if (linkedOrderId && !linkedOrderName) {
const detail = await this._getOrderCached(linkedOrderId, orderCache);
linkedOrderName = String(detail && detail.order && detail.order.order_name || '').trim();
}
const moldMeta = this._buildMoldMeta(shItem, {
mold_type: shItem.mold_type || (linkedOrderId ? 'customer' : 'blank'),
linked_order_id: linkedOrderId,
linked_order_name: linkedOrderName,
mold_capacity_total: shItem.mold_capacity_total,
mold_capacity_used: shItem.mold_capacity_used,
mold_arrived_at: shItem.mold_arrived_at || ctx.receiptDate || '',
mold_storage_until: shItem.mold_storage_until || '',
receiptDate: ctx.receiptDate || '',
});
if (!moldMeta) return null;
return {
...moldMeta,
china_purchase_id: Number(shItem.china_purchase_id || 0) || null,
};
},
_mergeMoldMetaIntoItem(item, moldMeta) {
if (!item || !moldMeta) return item;
const merged = { ...item, ...moldMeta };
if (!merged.mold_capacity_total) {
merged.mold_capacity_total = this._defaultMoldCapacityTotal(merged.mold_type);
}
if (!merged.mold_arrived_at) merged.mold_arrived_at = this._todayYMD();
if (this._normalizeMoldType(merged.mold_type) === 'customer' && !merged.mold_storage_until) {
merged.mold_storage_until = this._plusDaysYMD(merged.mold_arrived_at, 365);
}
if (this._normalizeMoldType(merged.mold_type) !== 'customer') {
merged.mold_storage_until = '';
}
return merged;
},
_mergeShipmentItemsByWarehouseId(items) {
const map = new Map();
items.forEach(src => {
const itemId = src.warehouse_item_id;
const qty = parseFloat(src.qty_received) || 0;
if (!itemId || qty <= 0) return;
const cost = parseFloat(src.total_cost_per_unit) || 0;
const current = map.get(itemId);
if (!current) {
map.set(itemId, {
...src,
qty_received: qty,
weight_grams: parseFloat(src.weight_grams) || 0,
purchase_price_cny: parseFloat(src.purchase_price_cny) || 0,
purchase_price_rub: parseFloat(src.purchase_price_rub) || 0,
delivery_allocated: parseFloat(src.delivery_allocated) || 0,
total_cost_per_unit: cost,
});
return;
}
const oldQty = parseFloat(current.qty_received) || 0;
const newQty = oldQty + qty;
current.total_cost_per_unit = newQty > 0
? (((parseFloat(current.total_cost_per_unit) || 0) * oldQty + cost * qty) / newQty)
: 0;
current.qty_received = newQty;
current.weight_grams = (parseFloat(current.weight_grams) || 0) + (parseFloat(src.weight_grams) || 0);
current.purchase_price_cny = (parseFloat(current.purchase_price_cny) || 0) + (parseFloat(src.purchase_price_cny) || 0);
current.purchase_price_rub = (parseFloat(current.purchase_price_rub) || 0) + (parseFloat(src.purchase_price_rub) || 0);
current.delivery_allocated = (parseFloat(current.delivery_allocated) || 0) + (parseFloat(src.delivery_allocated) || 0);
map.set(itemId, current);
});
return Array.from(map.values());
},
_cleanupZeroDuplicateItems() {
const grouped = new Map();
(this.allItems || []).forEach(item => {
const key = this._itemIdentityKey(item);
if (!grouped.has(key)) grouped.set(key, []);
grouped.get(key).push(item);
});
const toRemove = new Set();
grouped.forEach(group => {
if (group.length < 2) return;
const nonZero = group.filter(i => (parseFloat(i.qty) || 0) > 0);
if (nonZero.length > 0) {
group.forEach(i => {
if ((parseFloat(i.qty) || 0) <= 0) toRemove.add(i.id);
});
return;
}
const sorted = [...group].sort((a, b) => (a.id || 0) - (b.id || 0));
sorted.slice(1).forEach(i => toRemove.add(i.id));
});
if (toRemove.size === 0) return false;
this.allItems = this.allItems.filter(i => !toRemove.has(i.id));
return true;
},
// ==========================================
// PICKER FOR CALCULATOR INTEGRATION
// ==========================================
async getItemsForPicker() {
const items = await this._ensureRequiredSeedItems(await loadWarehouseItems());
const needsReservationSnapshot = (items || []).some(item =>
item && (item.available_qty === undefined || item.available_qty === null || item.reserved_qty === undefined || item.reserved_qty === null)
);
const activeReservedByItem = new Map();
if (needsReservationSnapshot) {
const reservations = await loadWarehouseReservations();
(reservations || []).forEach(reservation => {
if (!reservation || reservation.status !== 'active') return;
const itemId = Number(reservation.item_id || 0);
if (!itemId) return;
activeReservedByItem.set(
itemId,
(activeReservedByItem.get(itemId) || 0) + this._parseWarehouseQty(reservation.qty)
);
});
}
items.forEach(item => {
const qty = this._parseWarehouseQty(item && item.qty);
const reservedQty = needsReservationSnapshot
? (activeReservedByItem.get(Number(item && item.id || 0)) || 0)
: this._parseWarehouseQty(item && item.reserved_qty);
item.reserved_qty = reservedQty;
item.available_qty = Math.max(0, qty - reservedQty);
});
const grouped = {};
WAREHOUSE_CATEGORIES.forEach(cat => {
const catItems = items
.filter(i => i.category === cat.key)
.sort((a, b) => (a.name || '').localeCompare(b.name || '', 'ru'));
if (catItems.length > 0) {
grouped[cat.key] = {
label: cat.label,
icon: cat.icon,
items: catItems.map(i => ({
id: i.id,
category: i.category,
name: i.name || '',
sku: i.sku || '',
size: i.size || '',
color: i.color || '',
qty: i.qty || 0,
available_qty: i.available_qty || 0,
reserved_qty: i.reserved_qty || 0,
price_per_unit: this.getPickerEffectivePrice(i),
unit: this.getPickerUnitLabel(i),
photo_thumbnail: i.photo_thumbnail || '',
})),
};
}
});
return grouped;
},
buildPickerOptions(grouped, selectedId, showSku = false) {
let html = '— Выберите позицию — ';
for (const catKey of Object.keys(grouped)) {
const g = grouped[catKey];
html += ``;
g.items.forEach(item => {
const parts = [item.name];
if (showSku && item.sku) parts.push(item.sku);
if (item.size) parts.push(item.size);
if (item.color) parts.push(item.color);
const label = parts.join(' · ');
const stock = item.available_qty > 0 ? `(${item.available_qty} ${this.getPickerUnitLabel(item)})` : '(нет)';
const sel = String(item.id) === String(selectedId) ? ' selected' : '';
html += `${label} ${stock} `;
});
html += ' ';
}
return html;
},
_pickerIdsEqual(left, right) {
return String(left ?? '') === String(right ?? '');
},
getPickerUnitLabel(item) {
const unit = String(item?.unit || '').trim();
return unit || 'шт';
},
getPickerEffectivePrice(item) {
const rawPrice = parseFloat(item?.price_per_unit) || 0;
const unit = this._normStr(item?.unit || '');
const category = this._normStr(item?.category || '');
const sku = this._normStr(item?.sku || '');
// Some legacy warehouse cord rows were saved as RUB/m while qty is tracked in cm.
// In pickers and downstream calculations we want the real per-cm price.
if (unit === 'см' && rawPrice >= 10 && (category === 'cords' || sku.startsWith('msn-'))) {
return Math.round((rawPrice / 100) * 100) / 100;
}
return Math.round(rawPrice * 100) / 100;
},
getPickerPriceLabel(item) {
const effectivePrice = this.getPickerEffectivePrice(item);
if (!(effectivePrice > 0)) return '';
const formatted = new Intl.NumberFormat('ru-RU').format(effectivePrice);
return `${formatted} ₽/${this.getPickerUnitLabel(item)}`;
},
_pickerMetaText(item) {
if (!item) return '';
if (item.meta_line) return String(item.meta_line);
const unitLabel = this.getPickerUnitLabel(item);
const availableQty = item.available_qty == null ? null : this._parseWarehouseQty(item.available_qty);
const reservedQty = this._parseWarehouseQty(item.reserved_qty);
const stock = availableQty == null
? ''
: (availableQty > 0 ? `${availableQty} ${unitLabel}` : 'нет');
const reserve = reservedQty > 0 ? `резерв ${reservedQty} ${unitLabel}` : '';
const priceStr = this.getPickerPriceLabel(item);
return [item.sku || '', stock, reserve, priceStr].filter(Boolean).join(' · ');
},
// Custom image-based picker for calculator and other warehouse-linked pickers.
// onSelectFn: string like "Calculator.onHwWarehouseSelect" or "Calculator.onPkgWarehouseSelect"
// categoryFilter: null = all, 'hardware' = exclude packaging, 'packaging' = only packaging
buildImagePicker(containerId, grouped, selectedId, onSelectFn, categoryFilter, options = {}) {
const cat = WAREHOUSE_CATEGORIES;
if (!onSelectFn) onSelectFn = 'Calculator.onHwWarehouseSelect';
const idxStr = containerId.replace(/^[a-z]+-picker-/, '');
const searchPlaceholder = options.searchPlaceholder || 'Поиск по названию или артикулу...';
// Filter categories
const packagingKeys = ['packaging'];
const hardwareKeys = Object.keys(grouped).filter(k => !packagingKeys.includes(k));
let visibleKeys;
if (categoryFilter === 'packaging') {
visibleKeys = Object.keys(grouped).filter(k => packagingKeys.includes(k));
} else if (categoryFilter === 'hardware') {
visibleKeys = hardwareKeys;
} else {
visibleKeys = Object.keys(grouped);
}
const selectedItem = selectedId ? this._findInGrouped(grouped, selectedId) : null;
// Selected display
let selectedHtml = '';
if (selectedItem) {
const parts = [selectedItem.name];
if (selectedItem.size) parts.push(selectedItem.size);
if (selectedItem.color) parts.push(selectedItem.color);
const selectedGroup = grouped[selectedItem.__groupKey] || {};
const catObj = cat.find(c => c.key === selectedItem.category) || {
icon: selectedGroup.icon || '📦',
color: selectedGroup.color || 'var(--accent-light)',
textColor: selectedGroup.textColor || 'var(--text)',
};
const photoSrc = selectedItem.photo_thumbnail || selectedItem.photo_url || '';
const photoHtml = photoSrc
? ` `
: `${catObj.icon} `;
selectedHtml = `${photoHtml}${parts.join(' · ')} ${this._pickerMetaText(selectedItem)} `;
} else {
selectedHtml = '— Выберите позицию — ';
}
// Build dropdown items
let itemsHtml = '';
for (const catKey of visibleKeys) {
const g = grouped[catKey];
if (!g) continue;
const catObj = cat.find(c => c.key === catKey) || {
icon: g.icon || '📦',
color: g.color || 'var(--accent-light)',
textColor: g.textColor || 'var(--text)',
};
itemsHtml += ``;
g.items.forEach(item => {
const parts = [item.name];
if (item.size) parts.push(item.size);
if (item.color) parts.push(item.color);
const label = parts.join(' · ');
const metaText = this._pickerMetaText(item);
const photoSrc = item.photo_thumbnail || item.photo_url || '';
const photoHtml = photoSrc
? ` `
: `${catObj.icon} `;
const background = this._pickerIdsEqual(item.id, selectedId) ? 'rgba(59,130,246,0.1)' : 'transparent';
itemsHtml += `
${photoHtml}
`;
});
}
return ``;
},
_findInGrouped(grouped, id) {
for (const catKey of Object.keys(grouped)) {
const found = grouped[catKey].items.find(i => this._pickerIdsEqual(i.id, id));
if (found) return { ...found, __groupKey: catKey };
}
return null;
},
togglePicker(containerId) {
const el = document.getElementById(containerId);
if (!el) return;
const dd = el.querySelector('.wh-picker-dropdown');
const isOpen = dd.style.display !== 'none';
// Close all pickers first
document.querySelectorAll('.wh-picker-dropdown').forEach(d => d.style.display = 'none');
if (!isOpen) {
dd.style.display = 'block';
const searchInput = dd.querySelector('.wh-picker-search');
if (searchInput) { searchInput.value = ''; searchInput.focus(); }
// Show all items
dd.querySelectorAll('.wh-picker-item').forEach(i => i.style.display = '');
this._syncPickerHeaders(el);
}
},
filterPicker(containerId, query) {
const el = document.getElementById(containerId);
if (!el) return;
const q = (query || '').toLowerCase().trim();
const items = el.querySelectorAll('.wh-picker-item');
items.forEach(item => {
const text = item.textContent.toLowerCase();
item.style.display = q === '' || text.includes(q) ? '' : 'none';
});
this._syncPickerHeaders(el);
},
_syncPickerHeaders(el) {
if (!el) return;
const items = Array.from(el.querySelectorAll('.wh-picker-item'));
const headers = Array.from(el.querySelectorAll('.wh-picker-cat-header'));
headers.forEach(header => {
const groupKey = header.dataset?.groupKey || '';
const hasVisibleItems = items.some(item => (item.dataset?.groupKey || '') === groupKey && item.style.display !== 'none');
header.style.display = hasVisibleItems ? '' : 'none';
});
},
_resolvePickerCallback(fnName) {
const parts = String(fnName || '')
.split('.')
.map(part => String(part || '').trim())
.filter(Boolean);
if (!parts.length) return null;
const isSafePath = parts.every(part => /^[A-Za-z_$][\w$]*$/.test(part));
if (!isSafePath) return null;
let target = globalThis[parts[0]] || null;
let owner = null;
if (!target) {
try {
target = Function(`return (typeof ${parts[0]} !== 'undefined' ? ${parts[0]} : null);`)();
} catch (_) {
target = null;
}
}
owner = target;
for (let i = 1; target && i < parts.length; i += 1) {
owner = target;
target = target[parts[i]];
}
return typeof target === 'function'
? { fn: target, owner }
: null;
},
handlePickerSelect(buttonEl) {
const dataset = buttonEl?.dataset || {};
const fnName = dataset.selectFn || '';
const pickValue = dataset.pickValue || '';
const idxRaw = dataset.selectIdx || '';
document.querySelectorAll('.wh-picker-dropdown').forEach(d => d.style.display = 'none');
const resolved = this._resolvePickerCallback(fnName);
if (!resolved || typeof resolved.fn !== 'function') {
console.warn('[Warehouse.handlePickerSelect] callback not found:', fnName);
return;
}
const numericIdx = Number(idxRaw);
resolved.fn.call(
resolved.owner || null,
Number.isNaN(numericIdx) ? idxRaw : numericIdx,
pickValue
);
},
};
// Close image picker dropdowns when clicking outside
document.addEventListener('click', function(e) {
if (!e.target.closest('.wh-img-picker')) {
document.querySelectorAll('.wh-picker-dropdown').forEach(d => d.style.display = 'none');
}
});