| | | 1 | | @* |
| | | 2 | | Проверка остатков 1С на странице транзакции: панель над таблицей, бейджи у строк и модалка |
| | | 3 | | подтверждения при отправке. Тот же расчёт, что и серверный гейт — сервер агрегирует строки по |
| | | 4 | | (карточка номенклатуры, склад) и возвращает нехватку. |
| | | 5 | | |
| | | 6 | | Склад-отправитель: data-warehouse в разметке — «эффективный» склад заказа. Для транзакций, где |
| | | 7 | | он резолвится через маппинг складов (ShippedToCommissioner/ShippedToSeller/Lost), контроллер |
| | | 8 | | кладёт ViewData["StockCheckSenderByOrderId"], и он имеет приоритет. |
| | | 9 | | *@ |
| | | 10 | | |
| | | 11 | | @* Модалка подтверждения. Своя, а не переиспользование #staticBackdrop: тот показывает прогресс |
| | | 12 | | отправки и объявлен с data-bs-backdrop="static". *@ |
| | | 13 | | <div class="modal fade tx-stock-modal" id="oneCStockWarningModal" tabindex="-1" |
| | | 14 | | aria-labelledby="oneCStockWarningLabel" aria-hidden="true"> |
| | | 15 | | <div class="modal-dialog modal-lg modal-dialog-centered modal-dialog-scrollable"> |
| | | 16 | | <div class="modal-content"> |
| | | 17 | | <div class="modal-header bg-light"> |
| | | 18 | | <h5 class="modal-title" id="oneCStockWarningLabel"> |
| | | 19 | | <i class="fas fa-triangle-exclamation me-2 text-warning"></i>Не хватает остатка в 1С |
| | | 20 | | </h5> |
| | | 21 | | <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Закрыть"></button> |
| | | 22 | | </div> |
| | | 23 | | <div class="modal-body" id="oneCStockWarningBody"></div> |
| | | 24 | | <div class="modal-footer d-flex justify-content-between align-items-center"> |
| | | 25 | | <button type="button" class="btn btn-secondary" data-bs-dismiss="modal"> |
| | | 26 | | Вернуться и исправить |
| | | 27 | | </button> |
| | | 28 | | <button type="button" class="btn tx-stock-modal-continue" id="oneCStockWarningContinue"> |
| | | 29 | | <i class="fas fa-forward me-1"></i>Провести и создать черновики |
| | | 30 | | </button> |
| | | 31 | | </div> |
| | | 32 | | </div> |
| | | 33 | | </div> |
| | | 34 | | </div> |
| | | 35 | | @{ |
| | 0 | 36 | | var stockCheckSenderMap = ViewData["StockCheckSenderByOrderId"] as IDictionary<int, string>; |
| | 0 | 37 | | var stockCheckSenderJson = stockCheckSenderMap != null && stockCheckSenderMap.Count > 0 |
| | 0 | 38 | | ? Newtonsoft.Json.JsonConvert.SerializeObject( |
| | 0 | 39 | | stockCheckSenderMap.ToDictionary(kv => kv.Key.ToString(), kv => kv.Value)) |
| | 0 | 40 | | : "{}"; |
| | | 41 | | |
| | | 42 | | // Страницы, где засыл выбирают прямо в форме, кладут варианты складов по каждому заказу — |
| | | 43 | | // тогда склад пересчитывается при выборе, а не остаётся тем, что посчитан на загрузке. |
| | 0 | 44 | | var zasylOptions = ViewData["ZasylSenderOptionsByOrderId"]; |
| | 0 | 45 | | var zasylOptionsJson = zasylOptions != null |
| | 0 | 46 | | ? Newtonsoft.Json.JsonConvert.SerializeObject(zasylOptions, new Newtonsoft.Json.JsonSerializerSettings |
| | 0 | 47 | | { |
| | 0 | 48 | | ContractResolver = new Newtonsoft.Json.Serialization.CamelCasePropertyNamesContractResolver() |
| | 0 | 49 | | }) |
| | 0 | 50 | | : "{}"; |
| | | 51 | | } |
| | | 52 | | |
| | | 53 | | <script> |
| | | 54 | | (function () { |
| | | 55 | | const ENDPOINT = '/Studio2/WarehouseStock/onec-stock-check'; |
| | 0 | 56 | | const SENDER_BY_ORDER = @Html.Raw(stockCheckSenderJson); |
| | 0 | 57 | | const ZASYL_OPTIONS = @Html.Raw(zasylOptionsJson); |
| | | 58 | | const ZASYL_DYNAMIC = Object.keys(ZASYL_OPTIONS).length > 0; |
| | | 59 | | |
| | | 60 | | // Тот же ключ, что строит сервер: короткие коды приводятся к латинице («М2» → «m2»). |
| | | 61 | | function zasylOptionKey(value) { |
| | | 62 | | const name = (value || '').trim(); |
| | | 63 | | if (name.length !== 2 || !/\d/.test(name[1])) return name.toLowerCase(); |
| | | 64 | | const prefix = { 'М': 'M', 'В': 'B' }[name[0].toUpperCase()] || name[0].toUpperCase(); |
| | | 65 | | return (prefix + name[1]).toLowerCase(); |
| | | 66 | | } |
| | | 67 | | |
| | | 68 | | /// Склад-отправитель строки: выбранный засыл резолвится через связи складов, и только если |
| | | 69 | | /// поле пустое — берётся склад, посчитанный сервером при загрузке страницы. |
| | | 70 | | function warehouseForOrder(orderId, fallbackWarehouse) { |
| | | 71 | | // Для «Возврата поставщику» склад отправителя задаётся прямо в маршруте строки. |
| | | 72 | | // Он важнее настроек/засыла: предпросмотр и серверный гейт должны смотреть один склад. |
| | | 73 | | const selectedSender = document.getElementById('senderWarehouse_' + orderId)?.value?.trim(); |
| | | 74 | | if (selectedSender) return selectedSender; |
| | | 75 | | if (ZASYL_DYNAMIC) { |
| | | 76 | | const zasyl = document.getElementById('orderSupplierMap_' + orderId)?.value?.trim(); |
| | | 77 | | if (zasyl) { |
| | | 78 | | const option = ZASYL_OPTIONS[String(orderId)]?.options?.[zasylOptionKey(zasyl)]; |
| | | 79 | | return option ? option.warehouse : zasyl; |
| | | 80 | | } |
| | | 81 | | } |
| | | 82 | | return SENDER_BY_ORDER[String(orderId)] || fallbackWarehouse || ''; |
| | | 83 | | } |
| | | 84 | | |
| | | 85 | | const form = document.getElementById('createOrderForm'); |
| | | 86 | | if (!form) return; |
| | | 87 | | |
| | | 88 | | const esc = s => (s || '').replace(/[&<>"']/g, c => |
| | | 89 | | ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); |
| | | 90 | | |
| | | 91 | | const num = v => { |
| | | 92 | | const n = Number(v); |
| | | 93 | | return Number.isFinite(n) ? n : 0; |
| | | 94 | | }; |
| | | 95 | | |
| | | 96 | | function processIn1CEnabled() { |
| | | 97 | | const cb = document.getElementById('processIn1C'); |
| | | 98 | | return !cb || cb.checked; |
| | | 99 | | } |
| | | 100 | | |
| | | 101 | | // Кратность упаковки: артикул вида 9046708011=TYT[10] значит, что заказ на 2 упаковки |
| | | 102 | | // списывает со склада 20 штук. Без множителя проверка занижала бы потребность в 10 раз. |
| | | 103 | | function quantityFor(row, orderId) { |
| | | 104 | | const multiplier = num(row.dataset.packMultiplier) || 1; |
| | | 105 | | const input = document.querySelector(`input.quantity-input-field[data-order-id="${orderId}"]`); |
| | | 106 | | |
| | | 107 | | if (input && input.value !== '') |
| | | 108 | | return num(input.value) * multiplier; |
| | | 109 | | |
| | | 110 | | return num(row.dataset.trueQuantity) || num(row.dataset.orderQuantity) * multiplier; |
| | | 111 | | } |
| | | 112 | | |
| | | 113 | | function collectLines() { |
| | | 114 | | const lines = []; |
| | | 115 | | document.querySelectorAll('tr.transaction-order-row[data-order-id]').forEach(row => { |
| | | 116 | | const orderId = Number(row.dataset.orderId); |
| | | 117 | | if (!orderId) return; |
| | | 118 | | |
| | | 119 | | const meta = row.querySelector('[data-warehouse-stock-load]'); |
| | | 120 | | if (!meta) return; |
| | | 121 | | |
| | | 122 | | const quantity = quantityFor(row, orderId); |
| | | 123 | | if (!(quantity > 0)) return; |
| | | 124 | | |
| | | 125 | | const warehouse = warehouseForOrder(orderId, meta.dataset.warehouse); |
| | | 126 | | if (!meta.dataset.article || !warehouse) return; |
| | | 127 | | |
| | | 128 | | lines.push({ |
| | | 129 | | orderId: orderId, |
| | | 130 | | article: meta.dataset.article, |
| | | 131 | | // Наименование берём из строки таблицы: сервер его в ответе не восстанавливает, |
| | | 132 | | // и без этого колонка «Наименование» в предпросмотре оставалась пустой. |
| | | 133 | | productName: row.dataset.productName || '', |
| | | 134 | | producer: meta.dataset.manufacturer || '', |
| | | 135 | | warehouse: warehouse, |
| | | 136 | | quantity: quantity |
| | | 137 | | }); |
| | | 138 | | }); |
| | | 139 | | return lines; |
| | | 140 | | } |
| | | 141 | | |
| | | 142 | | async function check() { |
| | | 143 | | const lines = collectLines(); |
| | | 144 | | if (lines.length === 0) return { ok: true, unavailable: false, lines: [] }; |
| | | 145 | | |
| | | 146 | | const response = await fetch(ENDPOINT, { |
| | | 147 | | method: 'POST', |
| | | 148 | | headers: { 'Content-Type': 'application/json' }, |
| | | 149 | | body: JSON.stringify({ lines }) |
| | | 150 | | }); |
| | | 151 | | if (!response.ok) throw new Error(response.statusText); |
| | | 152 | | return await response.json(); |
| | | 153 | | } |
| | | 154 | | |
| | | 155 | | function blockingLines(result) { |
| | | 156 | | return (result.lines || []).filter(l => l.status === 'InsufficientStock'); |
| | | 157 | | } |
| | | 158 | | |
| | | 159 | | /// Бейдж живёт в СВОЁМ контейнере, рядом с #bitrixStores_{id}, а не внутри него: загрузка |
| | | 160 | | /// остатков по строкам асинхронная и перезаписывает #bitrixStores_{id} целиком через innerHTML. |
| | | 161 | | /// Строки с бо́льшим числом складов отвечают позже — их бейджи затирались сразу после отрисовки. |
| | | 162 | | function badgeHost(orderId) { |
| | | 163 | | const existing = document.getElementById('stockCheckBadge_' + orderId); |
| | | 164 | | if (existing) return existing; |
| | | 165 | | |
| | | 166 | | const stocks = document.getElementById('bitrixStores_' + orderId); |
| | | 167 | | if (!stocks || !stocks.parentElement) return null; |
| | | 168 | | |
| | | 169 | | const host = document.createElement('div'); |
| | | 170 | | host.id = 'stockCheckBadge_' + orderId; |
| | | 171 | | stocks.parentElement.insertBefore(host, stocks.nextSibling); |
| | | 172 | | return host; |
| | | 173 | | } |
| | | 174 | | |
| | | 175 | | function renderBadges(result) { |
| | | 176 | | document.querySelectorAll('[id^="stockCheckBadge_"]').forEach(el => { el.innerHTML = ''; }); |
| | | 177 | | |
| | | 178 | | blockingLines(result).forEach(line => { |
| | | 179 | | (line.orderIds || []).forEach(orderId => { |
| | | 180 | | const host = badgeHost(orderId); |
| | | 181 | | if (!host) return; |
| | | 182 | | const badge = document.createElement('div'); |
| | | 183 | | badge.className = 'tx-bitrix-stores-line tx-stock-check-badge'; |
| | | 184 | | badge.textContent = `${line.warehouse}: нужно ${line.required}, остаток ${line.available}`; |
| | | 185 | | badge.title = 'Не хватает остатка в 1С — документ не будет проведён'; |
| | | 186 | | host.appendChild(badge); |
| | | 187 | | }); |
| | | 188 | | }); |
| | | 189 | | } |
| | | 190 | | |
| | | 191 | | /// Одна и та же вёрстка для панели над формой и для тела модалки — классы .tx-stock-gap*. |
| | | 192 | | function buildSummaryHtml(blocking, withHint = true) { |
| | | 193 | | const rows = blocking.map(l => ` |
| | | 194 | | <tr> |
| | | 195 | | <td class="tx-stock-gap-article">${esc(l.article)}</td> |
| | | 196 | | <td class="tx-stock-gap-name">${esc(l.productName || '')}</td> |
| | | 197 | | <td><span class="tx-stock-gap-wh">${esc(l.warehouse)}</span></td> |
| | | 198 | | <td class="tx-stock-gap-num">${l.required}</td> |
| | | 199 | | <td class="tx-stock-gap-num tx-stock-gap-zero">${l.available}</td> |
| | | 200 | | </tr>`).join(''); |
| | | 201 | | |
| | | 202 | | const word = blocking.length === 1 ? 'позиции' : 'позициям'; |
| | | 203 | | const hint = withHint |
| | | 204 | | ? `<div class="tx-stock-gap-hint"> |
| | | 205 | | Уменьшите количество, смените склад отгрузки или проведите приход в 1С. |
| | | 206 | | Если продолжить, эти позиции не войдут в документ 1С, а позиции с достаточным остатком будут проведен |
| | | 207 | | </div>` |
| | | 208 | | : ''; |
| | | 209 | | |
| | | 210 | | return ` |
| | | 211 | | <div class="tx-stock-gap"> |
| | | 212 | | <div class="tx-stock-gap-head"> |
| | | 213 | | <i class="fas fa-triangle-exclamation"></i> |
| | | 214 | | <span>Не хватает остатка в 1С по ${blocking.length} ${word} — для них будет создан отдельный чернови |
| | | 215 | | </div> |
| | | 216 | | <div class="tx-stock-gap-table-wrap"> |
| | | 217 | | <table class="tx-stock-gap-table"> |
| | | 218 | | <thead> |
| | | 219 | | <tr> |
| | | 220 | | <th>Артикул</th> |
| | | 221 | | <th>Наименование</th> |
| | | 222 | | <th>Склад</th> |
| | | 223 | | <th class="tx-stock-gap-num">Нужно</th> |
| | | 224 | | <th class="tx-stock-gap-num">Остаток</th> |
| | | 225 | | </tr> |
| | | 226 | | </thead> |
| | | 227 | | <tbody>${rows}</tbody> |
| | | 228 | | </table> |
| | | 229 | | </div> |
| | | 230 | | ${hint} |
| | | 231 | | </div>`; |
| | | 232 | | } |
| | | 233 | | |
| | | 234 | | function renderSummary(result) { |
| | | 235 | | let panel = document.getElementById('oneCStockCheckSummary'); |
| | | 236 | | if (!panel) { |
| | | 237 | | panel = document.createElement('div'); |
| | | 238 | | panel.id = 'oneCStockCheckSummary'; |
| | | 239 | | form.prepend(panel); |
| | | 240 | | } |
| | | 241 | | |
| | | 242 | | const blocking = blockingLines(result); |
| | | 243 | | panel.innerHTML = blocking.length === 0 ? '' : buildSummaryHtml(blocking); |
| | | 244 | | } |
| | | 245 | | |
| | | 246 | | function renderSummaryLoading() { |
| | | 247 | | let panel = document.getElementById('oneCStockCheckSummary'); |
| | | 248 | | if (!panel) { |
| | | 249 | | panel = document.createElement('div'); |
| | | 250 | | panel.id = 'oneCStockCheckSummary'; |
| | | 251 | | form.prepend(panel); |
| | | 252 | | } |
| | | 253 | | |
| | | 254 | | panel.innerHTML = `<div class="tx-stock-check-loading" role="status"> |
| | | 255 | | <span class="tx-stock-check-spinner" aria-hidden="true"></span> |
| | | 256 | | <span>Проверяем остатки в 1С…</span> |
| | | 257 | | </div>`; |
| | | 258 | | } |
| | | 259 | | |
| | | 260 | | function clearAll() { |
| | | 261 | | document.querySelectorAll('[id^="stockCheckBadge_"]').forEach(el => { el.innerHTML = ''; }); |
| | | 262 | | const panel = document.getElementById('oneCStockCheckSummary'); |
| | | 263 | | if (panel) panel.innerHTML = ''; |
| | | 264 | | } |
| | | 265 | | |
| | | 266 | | // Ответ 1С приходит асинхронно. Если пользователь уже сменил засыл/склад, старый ответ не |
| | | 267 | | // должен вернуть в панель нехватку по прежнему складу. |
| | | 268 | | let stockRefreshVersion = 0; |
| | | 269 | | |
| | | 270 | | async function refresh() { |
| | | 271 | | const refreshVersion = ++stockRefreshVersion; |
| | | 272 | | if (!processIn1CEnabled()) { |
| | | 273 | | clearAll(); |
| | | 274 | | return; |
| | | 275 | | } |
| | | 276 | | try { |
| | | 277 | | renderSummaryLoading(); |
| | | 278 | | const result = await check(); |
| | | 279 | | if (refreshVersion !== stockRefreshVersion) return; |
| | | 280 | | renderBadges(result); |
| | | 281 | | renderSummary(result); |
| | | 282 | | } catch (e) { |
| | | 283 | | if (refreshVersion !== stockRefreshVersion) return; |
| | | 284 | | // Предпросмотр — подсказка, а не блокировка: серверный гейт всё равно отработает. |
| | | 285 | | console.warn('[stock-check] preview failed', e); |
| | | 286 | | clearAll(); |
| | | 287 | | } |
| | | 288 | | } |
| | | 289 | | |
| | | 290 | | // Первый прогон с задержкой, чтобы не конкурировать с загрузкой остатков в строках таблицы. |
| | | 291 | | setTimeout(refresh, 600); |
| | | 292 | | document.getElementById('processIn1C')?.addEventListener('change', refresh); |
| | | 293 | | |
| | | 294 | | // Правка количества меняет потребность — пересчитываем, но не на каждое нажатие клавиши. |
| | | 295 | | let debounce = null; |
| | | 296 | | document.addEventListener('change', e => { |
| | | 297 | | if (!e.target.classList?.contains('quantity-input-field') && |
| | | 298 | | !e.target.classList?.contains('sender-by-order-select')) return; |
| | | 299 | | clearTimeout(debounce); |
| | | 300 | | debounce = setTimeout(refresh, 400); |
| | | 301 | | }); |
| | | 302 | | |
| | | 303 | | // Смена засыла меняет склад списания. Комбобокс шлёт и input, и change — хватает одного |
| | | 304 | | // обработчика с тем же debounce. |
| | | 305 | | if (ZASYL_DYNAMIC) { |
| | | 306 | | let zasylDebounce = null; |
| | | 307 | | ['input', 'change'].forEach(eventName => { |
| | | 308 | | document.addEventListener(eventName, e => { |
| | | 309 | | if (!e.target.classList?.contains('zasyl-input')) return; |
| | | 310 | | clearTimeout(zasylDebounce); |
| | | 311 | | zasylDebounce = setTimeout(refresh, 400); |
| | | 312 | | }); |
| | | 313 | | }); |
| | | 314 | | } |
| | | 315 | | |
| | | 316 | | // ── Подтверждение при отправке ───────────────────────────────────────── |
| | | 317 | | // Нехватка остатка не отменяет транзакцию — она отключает создание документа в 1С. Решение |
| | | 318 | | // за пользователем, поэтому останавливаем отправку и показываем модалку с вариантами. |
| | | 319 | | |
| | | 320 | | // Пропуск проверки на время повторной отправки. Сбрасывается в следующем такте — за это время |
| | | 321 | | // синхронная цепочка click → submit → $.ajax успевает пройти целиком. |
| | | 322 | | let bypass = false; |
| | | 323 | | |
| | | 324 | | function withBypass(action) { |
| | | 325 | | bypass = true; |
| | | 326 | | try { |
| | | 327 | | action(); |
| | | 328 | | } finally { |
| | | 329 | | setTimeout(() => { bypass = false; }, 0); |
| | | 330 | | } |
| | | 331 | | } |
| | | 332 | | |
| | | 333 | | function hideCompetingUi() { |
| | | 334 | | // Кнопка «Сохранить» несёт data-bs-target="#staticBackdrop", а обработчики страницы |
| | | 335 | | // показывают оверлей отправки. Если что-то успело появиться — убираем, иначе наша модалка |
| | | 336 | | // окажется под чужим backdrop. |
| | | 337 | | window.txHideSubmitOverlay?.(); |
| | | 338 | | if (window.bootstrap?.Modal) { |
| | | 339 | | ['staticBackdrop', 'cartModal'].forEach(id => { |
| | | 340 | | const el = document.getElementById(id); |
| | | 341 | | if (el?.classList.contains('show')) bootstrap.Modal.getInstance(el)?.hide(); |
| | | 342 | | }); |
| | | 343 | | } |
| | | 344 | | } |
| | | 345 | | |
| | | 346 | | function resubmit() { |
| | | 347 | | withBypass(() => { |
| | | 348 | | if (typeof form.requestSubmit === 'function') { |
| | | 349 | | form.requestSubmit(); |
| | | 350 | | } else { |
| | | 351 | | form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); |
| | | 352 | | } |
| | | 353 | | }); |
| | | 354 | | } |
| | | 355 | | |
| | | 356 | | // Обработчик кнопки «Провести без документа 1С» вешаем один раз при инициализации. |
| | | 357 | | // Подписка на каждый показ с последующим снятием в hidden.bs.modal легко рассинхронизируется: |
| | | 358 | | // после отмены и повторного открытия кнопка оставалась без обработчика. |
| | | 359 | | const warningModalElement = document.getElementById('oneCStockWarningModal'); |
| | | 360 | | let pendingProceed = null; |
| | | 361 | | |
| | | 362 | | document.getElementById('oneCStockWarningContinue')?.addEventListener('click', () => { |
| | | 363 | | const proceed = pendingProceed; |
| | | 364 | | pendingProceed = null; |
| | | 365 | | // Продолжаем только после закрытия, иначе backdrop останется поверх страницы. |
| | | 366 | | warningModalElement.addEventListener('hidden.bs.modal', () => proceed?.(), { once: true }); |
| | | 367 | | bootstrap.Modal.getOrCreateInstance(warningModalElement).hide(); |
| | | 368 | | }); |
| | | 369 | | |
| | | 370 | | function showWarningModal(result, proceed) { |
| | | 371 | | if (!warningModalElement || !window.bootstrap?.Modal) { |
| | | 372 | | // Fail-open: без UI подтверждения не блокируем работу — серверный гейт всё равно |
| | | 373 | | // не создаст документ и вернёт предупреждение. |
| | | 374 | | proceed(); |
| | | 375 | | return; |
| | | 376 | | } |
| | | 377 | | |
| | | 378 | | pendingProceed = proceed; |
| | | 379 | | |
| | | 380 | | // Повторный show() во время анимации предыдущего оставляет модалку в подвешенном |
| | | 381 | | // состоянии: Bootstrap игнорирует вызов, но backdrop и modal-open остаются. |
| | | 382 | | if (warningModalElement.classList.contains('show')) return; |
| | | 383 | | |
| | | 384 | | hideCompetingUi(); |
| | | 385 | | document.getElementById('oneCStockWarningBody').innerHTML = |
| | | 386 | | buildSummaryHtml(blockingLines(result), false) + |
| | | 387 | | "<p class='mb-0 mt-2 small text-muted'>Если продолжить, позиции с достаточным остатком " + |
| | | 388 | | "будут проведены отдельными документами 1С. Для позиций из списка будет создан отдельный черновик 1С с поясн |
| | | 389 | | "После оприходования проведите его вручную.</p>"; |
| | | 390 | | |
| | | 391 | | bootstrap.Modal.getOrCreateInstance(warningModalElement).show(); |
| | | 392 | | } |
| | | 393 | | |
| | | 394 | | /// Проверяет остатки и вызывает proceed(), если можно продолжать. При нехватке показывает |
| | | 395 | | /// модалку и вызывает proceed() только после подтверждения пользователем. |
| | | 396 | | function guard(proceed) { |
| | | 397 | | renderSummaryLoading(); |
| | | 398 | | check().then(result => { |
| | | 399 | | renderBadges(result); |
| | | 400 | | renderSummary(result); |
| | | 401 | | |
| | | 402 | | if (result.ok === false) { |
| | | 403 | | showWarningModal(result, proceed); |
| | | 404 | | } else { |
| | | 405 | | proceed(); |
| | | 406 | | } |
| | | 407 | | }).catch(err => { |
| | | 408 | | // 1С недоступна или эндпоинт не ответил — не мешаем работать, серверный гейт тоже |
| | | 409 | | // fail-open. Ошибку логируем: молчаливый пропуск проверки иначе не отличить от «всё ок». |
| | | 410 | | console.warn('[stock-check] проверка остатков не выполнена, отправляем как есть', err); |
| | | 411 | | clearAll(); |
| | | 412 | | proceed(); |
| | | 413 | | }); |
| | | 414 | | } |
| | | 415 | | |
| | | 416 | | // Для страниц, где отправка идёт не через submit-событие формы (например «Отгружен клиенту»: |
| | | 417 | | // кнопка type="button" и прямой $.ajax). Такую отправку перехватить нельзя — вьюха сама |
| | | 418 | | // оборачивает свой вызов в этот гард. |
| | | 419 | | window.txOneCStockGuard = function (proceed) { |
| | | 420 | | if (bypass || !processIn1CEnabled()) { proceed(); return; } |
| | | 421 | | guard(() => withBypass(proceed)); |
| | | 422 | | }; |
| | | 423 | | |
| | | 424 | | // Перехватываем КЛИК по кнопке отправки, а не событие submit формы. На части страниц |
| | | 425 | | // (например «Отгружен реализатором») на самой кнопке висит click-обработчик с preventDefault |
| | | 426 | | // и прямым $.ajax — событие submit там не возникает вообще, перехватывать нечего. |
| | | 427 | | // Capture на document срабатывает до обработчиков кнопки, поэтому проверка успевает первой. |
| | | 428 | | document.addEventListener('click', function (e) { |
| | | 429 | | // Только явные submit-кнопки: в таблице транзакций есть <button> без type, и трактовка |
| | | 430 | | // «кнопка без type = submit» ломала бы их собственные обработчики. |
| | | 431 | | const trigger = e.target?.closest?.('input[type="submit"], button[type="submit"]'); |
| | | 432 | | if (!trigger) return; |
| | | 433 | | |
| | | 434 | | // Кнопка может быть вне формы и указывать на неё через form="...". |
| | | 435 | | const targetForm = trigger.form || trigger.closest('form'); |
| | | 436 | | if (targetForm !== form) return; |
| | | 437 | | |
| | | 438 | | if (bypass || !processIn1CEnabled()) return; |
| | | 439 | | |
| | | 440 | | e.preventDefault(); |
| | | 441 | | e.stopImmediatePropagation(); |
| | | 442 | | |
| | | 443 | | // Продолжаем повторным кликом по той же кнопке: так отработает родной сценарий страницы — |
| | | 444 | | // и собственный click-обработчик, и обычная отправка формы, если обработчика нет. |
| | | 445 | | guard(() => withBypass(() => trigger.click())); |
| | | 446 | | }, true); |
| | | 447 | | |
| | | 448 | | // Страховка: отправка с клавиатуры (Enter в поле) и программный requestSubmit. |
| | | 449 | | // На самой форме обработчики вызываются в порядке регистрации независимо от capture-флага, |
| | | 450 | | // а наш partial подключается последним — поэтому слушаем на document в фазе capture. |
| | | 451 | | document.addEventListener('submit', function (e) { |
| | | 452 | | if (e.target !== form) return; |
| | | 453 | | if (bypass || !processIn1CEnabled()) return; |
| | | 454 | | |
| | | 455 | | e.preventDefault(); |
| | | 456 | | e.stopImmediatePropagation(); |
| | | 457 | | guard(resubmit); |
| | | 458 | | }, true); |
| | | 459 | | })(); |
| | | 460 | | </script> |