< Summary

Information
Class: AspNetCoreGeneratedDocument.Areas_Studio2_Views_Shared__OneCStockCheckScript
Assembly: OzonOrdersWeb
File(s): /home/runner/work/Studio2Prod/Studio2Prod/OzonOrdersWeb/Areas/Studio2/Views/Shared/_OneCStockCheckScript.cshtml
Line coverage
0%
Covered lines: 0
Uncovered lines: 14
Coverable lines: 14
Total lines: 460
Line coverage: 0%
Branch coverage
0%
Covered branches: 0
Total branches: 6
Branch coverage: 0%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
ExecuteAsync()0%4260%

File(s)

/home/runner/work/Studio2Prod/Studio2Prod/OzonOrdersWeb/Areas/Studio2/Views/Shared/_OneCStockCheckScript.cshtml

#LineLine coverage
 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@{
 036    var stockCheckSenderMap = ViewData["StockCheckSenderByOrderId"] as IDictionary<int, string>;
 037    var stockCheckSenderJson = stockCheckSenderMap != null && stockCheckSenderMap.Count > 0
 038        ? Newtonsoft.Json.JsonConvert.SerializeObject(
 039            stockCheckSenderMap.ToDictionary(kv => kv.Key.ToString(), kv => kv.Value))
 040        : "{}";
 41
 42    // Страницы, где засыл выбирают прямо в форме, кладут варианты складов по каждому заказу —
 43    // тогда склад пересчитывается при выборе, а не остаётся тем, что посчитан на загрузке.
 044    var zasylOptions = ViewData["ZasylSenderOptionsByOrderId"];
 045    var zasylOptionsJson = zasylOptions != null
 046        ? Newtonsoft.Json.JsonConvert.SerializeObject(zasylOptions, new Newtonsoft.Json.JsonSerializerSettings
 047        {
 048            ContractResolver = new Newtonsoft.Json.Serialization.CamelCasePropertyNamesContractResolver()
 049        })
 050        : "{}";
 51}
 52
 53<script>
 54(function () {
 55    const ENDPOINT = '/Studio2/WarehouseStock/onec-stock-check';
 056    const SENDER_BY_ORDER = @Html.Raw(stockCheckSenderJson);
 057    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        ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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>

Methods/Properties

ExecuteAsync()