// ============================================= // 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 => `` ).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(``); }); options.push(``); 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 ? `
` : ''; 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(``); }); if (normalizedSelected && !selectedPresent) { options.push(``); } 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}`; // 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 => `` ).join(''); return ` ${photo}
${this.esc(item.name)}
${this.esc(item.sku || '')}
${moldMetaHtml} ${catBadge} ${this.esc(item.size || '—')} ${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 => ``).join('')}
НазваниеАртикулРазмерЦветКол-во
${this.esc(it.name)} ${this.esc(it.sku)} ${this.esc(it.size)} ${this.esc(it.color)} ${it.qty}
`; }, 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 = `
${items.map(item => { const cat = WAREHOUSE_CATEGORIES.find(c => c.key === item.category); const actualValue = this._getAuditStoredValue(item.id); const rendered = this._renderAuditDiffMarkup(item, actualValue); const systemQty = this._getAuditBaselineQty(item.id, item.qty); const systemQtyLabel = this._formatInventoryQty(systemQty, item.unit); const photoSrc = item.photo_thumbnail || item.photo_url || ''; const safePhotoSrc = photoSrc ? (photoSrc.startsWith('data:') ? photoSrc : this.esc(photoSrc)) : ''; return ``; }).join('')}
Фото Категория Название Артикул В системе Факт Разница Расхождение ₽
${safePhotoSrc ? `${this.esc(item.name || '')}` : `
${cat?.icon || '📦'}
`}
${cat?.label || '?'} ${this.esc(item.name)} ${this.esc(item.sku || '')} ${systemQtyLabel} ${rendered.qty} ${rendered.money}
`; 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 ``; }).join('')}
Дата Позиция Изменение Остаток Причина
${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 || '')}
`; }, _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 ``; } 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 => ``).join('')}
КомплектующаяРезерв
${this.esc(r.item_name)}
${this.esc(r.item_kind || 'Фурнитура')}
${r.item_sku ? `
${this.esc(r.item_sku)}
` : ''}
${r.qty}
`).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 || '—')}
${o.items.map(r => ``).join('')}
КомплектующаяНужноФактСобрано
${this.esc(r.item_name)}
${this.esc(r.item_kind || 'Фурнитура')}
${r.item_sku ? `
${this.esc(r.item_sku)}
` : ''}
${r.qty}
план ${r.qty}
`; }).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 => ``).join('')}
ДатаНазваниеПоставщик ПозицийЗакупка ДоставкаСтатус
${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)}
`; }, 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 => `` ).join(''); const rows = this.shipmentItems.map((item, idx) => { this._syncShipmentMoldDerivedFields(item); const selectOptions = this.buildPickerOptions(grouped, item.warehouse_item_id, true); const simpleSelectHtml = ``; const photoSrc = item.photo_thumbnail || item.photo_url || ''; const photoPreview = photoSrc ? `📷` : `📷`; const moldFieldsHtml = item.source === 'new' && this._isMoldCategory(item.category) ? `
${this._normalizeMoldType(item.mold_type) === 'customer' ? `` : '
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' ? `
${skuCellHtml}
${photoPreview}
${moldFieldsHtml}
` : `
${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 = `
${rows}
Позиция со склада Кол-во Вес (г) Цена CNY (за позицию) Цена RUB/ед Доставка ₽ С/с ед. ₽
`; }, 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}
Наш склад (шт)
${ourQty}
Стоимость на нашем складе
${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 ``; }).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 = `

📤 Списать продажу

`; 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 += ``; }); 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.icon || catObj.icon} ${g.label || catKey}
`; 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 += ``; }); } return `
${selectedHtml}
`; }, _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'); } });