From 42e4f630b1890fe9f2f47b2f44c94fda43f44e1d Mon Sep 17 00:00:00 2001 From: nns Date: Mon, 12 Oct 2026 17:08:41 +0500 Subject: [PATCH] =?UTF-8?q?feat(superadmin):=20=D0=BF=D0=B5=D1=80=D0=B5?= =?UTF-8?q?=D0=BA=D0=BB=D1=8E=D1=87=D0=B0=D1=82=D0=B5=D0=BB=D1=8C=20=D0=BF?= =?UTF-8?q?=D0=BE=D0=B4=D0=B3=D1=80=D1=83=D0=B7=D0=BA=D0=B8=20=D0=B8=D0=B7?= =?UTF-8?q?=20=D0=9C=D0=BE=D0=B9=D0=A1=D0=BA=D0=BB=D0=B0=D0=B4=20+=20?= =?UTF-8?q?=D1=81=D0=BE=D1=81=D1=82=D0=BE=D1=8F=D0=BD=D0=B8=D0=B5=20=D0=BF?= =?UTF-8?q?=D1=80=D0=BE=D0=B3=D0=BE=D0=BD=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Суперадмин управляет дельта-подгрузкой МойСклад на стенд флагом в «Настройках платформы» (по умолчанию ВЫКЛ), не трогая systemd-таймер руками. - PlatformSettings: MoySkladDeltaSyncEnabled (bool, default false) + состояние последнего прогона (startedAt/finishedAt/ok/migrated/error); миграция Phase46 (ADD COLUMN IF NOT EXISTS, идемпотентно). - Super-admin API: GET/PUT /platform-settings/moysklad-delta — тоггл с reason, пишется в SuperAdminAuditLog; GET отдаёт состояние прогона. Только SuperAdmin. - Admin API (для скрипта, роль Admin): GET /admin/moysklad/delta-sync (флаг), POST /admin/moysklad/delta-sync/report (итог прогона → PlatformSettings). - Серверная защита: import-supplies при delta=true и выключенном флаге → 409 с понятной причиной. Ручной импорт из мастера (без delta=true) не затронут. - delta_sync.py: сначала спрашивает флаг — при «Выкл» одна строка в лог и выход без изменения lastSync (повторное включение догонит пропущенное); помечает запросы delta=true; в конце репортит итог прогона суперадмину. - SuperAdminPlatformSettingsPage: блок «Подгрузка из МойСклад» — тоггл Вкл/Выкл с причиной, подпись, состояние последнего прогона. Co-Authored-By: Claude Opus 4.8 --- scripts/migration/delta_sync.py | 135 +++++++++++++----- .../Admin/MoySkladImportController.cs | 56 ++++++++ .../SuperAdmin/PlatformSettingsController.cs | 42 ++++++ .../Platform/PlatformSettings.cs | 21 +++ .../Persistence/AppDbContext.cs | 1 + ...61012010000_Phase46_MoySkladDeltaToggle.cs | 42 ++++++ .../pages/SuperAdminPlatformSettingsPage.tsx | 110 +++++++++++++- 7 files changed, 368 insertions(+), 39 deletions(-) create mode 100644 src/food-market.infrastructure/Persistence/Migrations/20261012010000_Phase46_MoySkladDeltaToggle.cs diff --git a/scripts/migration/delta_sync.py b/scripts/migration/delta_sync.py index 0ff52b0..e9b7505 100644 --- a/scripts/migration/delta_sync.py +++ b/scripts/migration/delta_sync.py @@ -69,12 +69,50 @@ def am_token(): return json.loads(r.read())["access_token"] +class DeltaDisabled(Exception): + """Флаг подгрузки выключен суперадмином (эндпоинт ответил 409).""" + + +def get_delta_status(tok): + """Спрашивает у API, включена ли подгрузка. Источник правды — PlatformSettings, + которым управляет суперадмин. При «Выкл» скрипт выходит, не трогая lastSync.""" + req = urllib.request.Request(AM + "/api/admin/moysklad/delta-sync", + headers={"Authorization": f"Bearer {tok}"}) + with urllib.request.urlopen(req, timeout=60, context=_ctx) as r: + return json.loads(r.read()) + + +def report_run(tok, started_utc, ok, migrated, error): + """Пишет итог прогона в PlatformSettings, чтобы суперадмин видел его на + странице «Настройки платформы». Ошибку самого отчёта глотаем — она не должна + ронять прогон.""" + try: + body = json.dumps({ + "startedAt": started_utc.strftime("%Y-%m-%dT%H:%M:%SZ"), + "finishedAt": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), + "ok": bool(ok), "migrated": int(migrated), + "error": (error[:2000] if error else None), + }).encode("utf-8") + req = urllib.request.Request(AM + "/api/admin/moysklad/delta-sync/report", data=body, + headers={"Authorization": f"Bearer {tok}", "Content-Type": "application/json"}, method="POST") + urllib.request.urlopen(req, timeout=60, context=_ctx).read() + except Exception as e: + print(f" [report] не удалось записать итог прогона: {e}") + + def trigger(tok, doc_type, updated_from): - q = urllib.parse.urlencode({"type": doc_type, "updatedFrom": updated_from}) + # delta=true помечает запрос как дельта-подгрузку: при выключенном флаге + # сервер ответит 409 (защита даже при ручном запуске скрипта). + q = urllib.parse.urlencode({"type": doc_type, "updatedFrom": updated_from, "delta": "true"}) req = urllib.request.Request(AM + "/api/admin/moysklad/import-supplies?" + q, data=b"{}", headers={"Authorization": f"Bearer {tok}", "Content-Type": "application/json"}, method="POST") - with urllib.request.urlopen(req, timeout=60, context=_ctx) as r: - return json.loads(r.read())["jobId"] + try: + with urllib.request.urlopen(req, timeout=60, context=_ctx) as r: + return json.loads(r.read())["jobId"] + except urllib.error.HTTPError as e: + if e.code == 409: + raise DeltaDisabled() + raise def poll(tok, job_id, timeout_s=1800): @@ -108,6 +146,20 @@ def main(): if "--since" in sys.argv: since = sys.argv[sys.argv.index("--since") + 1] st = load_state() + tok = am_token() + + # Главный флаг: источник правды — PlatformSettings (суперадмин). При «Выкл» — + # одна строка в логе и выход; lastSync НЕ трогаем, чтобы при повторном включении + # первый прогон догнал пропущенное. + try: + status = get_delta_status(tok) + except Exception as e: + print(f"Не удалось получить флаг подгрузки у API: {e}") + sys.exit(1) + if not status.get("enabled", False): + print("Подгрузка из МойСклад выключена (флаг Выкл) — выходим, lastSync не трогаем.") + return + last = since or st.get("lastSync") if not last: print("Нет lastSync. Укажи --since 'YYYY-MM-DD HH:MM:SS' (МСК) при первом запуске.") @@ -118,43 +170,58 @@ def main(): last = last_dt.strftime("%Y-%m-%d %H:%M:%S") except Exception: pass + started_utc = datetime.now(timezone.utc) run_start = datetime.now(MSK).strftime("%Y-%m-%d %H:%M:%S") print(f"=== Дельта-синхронизация: изменения с {last} (МСК), старт {run_start} ===") - tok = am_token() total_created = 0 - for dt in DOC_TYPES: - jid = trigger(tok, dt, last) - res = poll(tok, jid) - # эндпоинт сериализует ключи в camelCase (created/skipped/total/message) - created = res.get("created", 0) or 0 - skipped = res.get("skipped", 0) or 0 - total_created += created - print(f" [{dt}] {res.get('status')}: создано {created}, пропущено {skipped}. {res.get('message','')[:140]}") - - # удаления (audit). filter целиком экранируем; при 4xx/недоступности — мягко пропускаем. - deleted = [] + ok = False + error_text = None try: - flt = urllib.parse.quote(f"eventType=delete;moment>={last}") - audit = ms(f"/audit?limit=100&filter={flt}") - except Exception: - audit = None - if audit is None: - print(" [deletions] /audit недоступен (нет прав токена) — проверить удаления вручную на дне перехода.") - else: - for row in audit.get("rows", []): - if row.get("entityType") in AUDIT_ENTITIES: - deleted.append({"entityType": row.get("entityType"), "moment": row.get("moment"), "info": row.get("info")}) - print(f" [deletions] событий удаления документов с {last}: {len(deleted)}") - for d in deleted[:20]: - print(f" - {d['entityType']} @ {d['moment']}: {d['info']}") + for dt in DOC_TYPES: + jid = trigger(tok, dt, last) + res = poll(tok, jid) + # эндпоинт сериализует ключи в camelCase (created/skipped/total/message) + created = res.get("created", 0) or 0 + skipped = res.get("skipped", 0) or 0 + total_created += created + print(f" [{dt}] {res.get('status')}: создано {created}, пропущено {skipped}. {res.get('message','')[:140]}") - st["lastSync"] = run_start - st["lastRun"] = datetime.now(MSK).strftime("%Y-%m-%d %H:%M:%S") - st["lastCreated"] = total_created - st["lastDeletions"] = deleted - save_state(st) - print(f"=== Готово. Перенесено изменений: {total_created}. lastSync → {run_start}. Состояние: {STATE} ===") + # удаления (audit). filter целиком экранируем; при 4xx/недоступности — мягко пропускаем. + deleted = [] + try: + flt = urllib.parse.quote(f"eventType=delete;moment>={last}") + audit = ms(f"/audit?limit=100&filter={flt}") + except Exception: + audit = None + if audit is None: + print(" [deletions] /audit недоступен (нет прав токена) — проверить удаления вручную на дне перехода.") + else: + for row in audit.get("rows", []): + if row.get("entityType") in AUDIT_ENTITIES: + deleted.append({"entityType": row.get("entityType"), "moment": row.get("moment"), "info": row.get("info")}) + print(f" [deletions] событий удаления документов с {last}: {len(deleted)}") + for d in deleted[:20]: + print(f" - {d['entityType']} @ {d['moment']}: {d['info']}") + + st["lastSync"] = run_start + st["lastRun"] = datetime.now(MSK).strftime("%Y-%m-%d %H:%M:%S") + st["lastCreated"] = total_created + st["lastDeletions"] = deleted + save_state(st) + ok = True + print(f"=== Готово. Перенесено изменений: {total_created}. lastSync → {run_start}. Состояние: {STATE} ===") + except DeltaDisabled: + # Флаг выключили посреди прогона — выходим чисто, lastSync не трогаем. + print("Подгрузка выключена во время прогона (409) — выходим, lastSync не трогаем.") + return + except Exception as e: + error_text = str(e) + print(f"ОШИБКА прогона: {e}") + finally: + report_run(tok, started_utc, ok, total_created, error_text) + if not ok: + sys.exit(1) if __name__ == "__main__": diff --git a/src/food-market.api/Controllers/Admin/MoySkladImportController.cs b/src/food-market.api/Controllers/Admin/MoySkladImportController.cs index c11074b..320f060 100644 --- a/src/food-market.api/Controllers/Admin/MoySkladImportController.cs +++ b/src/food-market.api/Controllers/Admin/MoySkladImportController.cs @@ -1,5 +1,6 @@ using foodmarket.Api.Infrastructure.Tenancy; using foodmarket.Application.Common.Tenancy; +using foodmarket.Domain.Platform; using foodmarket.Infrastructure.Integrations.MoySklad; using foodmarket.Infrastructure.Persistence; using Microsoft.AspNetCore.Authorization; @@ -143,9 +144,64 @@ public async Task> JobStatus(Guid id, CancellationToken ct) return Ok(new { j.Kind, status = j.Status.ToString(), j.Stage, j.Total, j.Created, j.Updated, j.Skipped, j.Message, j.StartedAt, j.FinishedAt }); } + // ── Дельта-подгрузка из МойСклад: флаг и отчёт о прогоне (для delta_sync.py) ── + // Читается/пишется миграционным аккаунтом (роль Admin). SuperAdmin управляет + // флагом на /super-admin/platform-settings/moysklad-delta; здесь — только чтение + // флага скриптом и запись итога последнего прогона, чтобы SuperAdmin его видел. + + public record DeltaStatusDto( + bool Enabled, + DateTime? LastRunStartedAt, DateTime? LastRunFinishedAt, + bool? LastRunOk, int? LastRunMigrated, string? LastRunError); + + public record DeltaReportInput( + DateTime? StartedAt, DateTime? FinishedAt, bool Ok, int Migrated, string? Error); + + [HttpGet("delta-sync")] + public async Task> GetDeltaSync(CancellationToken ct) + { + var s = await _db.PlatformSettings.AsNoTracking().FirstOrDefaultAsync(ct); + return new DeltaStatusDto( + s?.MoySkladDeltaSyncEnabled ?? false, + s?.MoySkladDeltaLastRunStartedAt, s?.MoySkladDeltaLastRunFinishedAt, + s?.MoySkladDeltaLastRunOk, s?.MoySkladDeltaLastRunMigrated, s?.MoySkladDeltaLastRunError); + } + + [HttpPost("delta-sync/report")] + public async Task ReportDeltaSync([FromBody] DeltaReportInput input, CancellationToken ct) + { + var s = await _db.PlatformSettings.FirstOrDefaultAsync(ct); + if (s is null) { s = new PlatformSettings(); _db.PlatformSettings.Add(s); } + s.MoySkladDeltaLastRunStartedAt = input.StartedAt?.ToUniversalTime(); + s.MoySkladDeltaLastRunFinishedAt = (input.FinishedAt ?? DateTime.UtcNow).ToUniversalTime(); + s.MoySkladDeltaLastRunOk = input.Ok; + s.MoySkladDeltaLastRunMigrated = input.Migrated; + s.MoySkladDeltaLastRunError = string.IsNullOrWhiteSpace(input.Error) + ? null + : (input.Error.Length > 2000 ? input.Error[..2000] : input.Error); + await _db.SaveChangesAsync(ct); + return NoContent(); + } + [HttpPost("import-supplies")] public async Task> ImportSupplies([FromBody] ImportRequest req, CancellationToken ct) { + // Серверная защита дельта-подгрузки: запросы от delta_sync.py помечены + // delta=true. При выключенном флаге — отказ с понятной причиной, чтобы + // подгрузка не шла даже при ручном запуске скрипта. Ручной импорт из + // мастера подключения delta=true не передаёт — он не затрагивается. + if (HttpContext.Request.Query["delta"].ToString() == "true") + { + var enabled = await _db.PlatformSettings.AsNoTracking() + .Select(p => (bool?)p.MoySkladDeltaSyncEnabled).FirstOrDefaultAsync(ct) ?? false; + if (!enabled) + return StatusCode(StatusCodes.Status409Conflict, new + { + error = "Подгрузка из МойСклад выключена суперадмином. Включите её в «Настройки платформы».", + deltaDisabled = true, + }); + } + var token = string.IsNullOrWhiteSpace(req.Token) ? await ReadTokenFromOrgAsync(ct) : req.Token; if (string.IsNullOrWhiteSpace(token)) return BadRequest(new { error = "Токен не настроен." }); diff --git a/src/food-market.api/Controllers/SuperAdmin/PlatformSettingsController.cs b/src/food-market.api/Controllers/SuperAdmin/PlatformSettingsController.cs index 1b74857..e857424 100644 --- a/src/food-market.api/Controllers/SuperAdmin/PlatformSettingsController.cs +++ b/src/food-market.api/Controllers/SuperAdmin/PlatformSettingsController.cs @@ -48,6 +48,13 @@ public record PlatformSettingsInput( public record TestSendInput(string ToEmail, string Subject, string Body); + public record MoySkladDeltaDto( + bool Enabled, + DateTime? LastRunStartedAt, DateTime? LastRunFinishedAt, + bool? LastRunOk, int? LastRunMigrated, string? LastRunError); + + public record MoySkladDeltaInput(bool Enabled, string Reason); + [HttpGet] public async Task> Get(CancellationToken ct) { @@ -132,6 +139,41 @@ public async Task TestSend([FromBody] TestSendInput input, Cancel } } + // ── Подгрузка из МойСклад (дельта-синхронизация) ───────────────────────── + + [HttpGet("moysklad-delta")] + public async Task> GetMoySkladDelta(CancellationToken ct) + { + var s = await GetOrCreateAsync(ct); + return new MoySkladDeltaDto( + s.MoySkladDeltaSyncEnabled, + s.MoySkladDeltaLastRunStartedAt, s.MoySkladDeltaLastRunFinishedAt, + s.MoySkladDeltaLastRunOk, s.MoySkladDeltaLastRunMigrated, s.MoySkladDeltaLastRunError); + } + + [HttpPut("moysklad-delta")] + public async Task SetMoySkladDelta([FromBody] MoySkladDeltaInput input, CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(input.Reason) || input.Reason.Trim().Length < 10) + return BadRequest(new { error = "Причина изменения обязательна (≥ 10 символов) — она пишется в журнал." }); + + var s = await GetOrCreateAsync(ct); + var prev = s.MoySkladDeltaSyncEnabled; + if (prev == input.Enabled) + return NoContent(); // идемпотентно, не плодим аудит-записи на no-op + + s.MoySkladDeltaSyncEnabled = input.Enabled; + s.UpdatedAt = DateTime.UtcNow; + await _db.SaveChangesAsync(ct); + + await LogAsync("MoySkladDeltaSyncToggle", + $"Подгрузка из МойСклад: {(input.Enabled ? "ВКЛ" : "ВЫКЛ")}", + input.Reason, + System.Text.Json.JsonSerializer.Serialize(new { prev, next = input.Enabled }), ct); + + return NoContent(); + } + private async Task GetOrCreateAsync(CancellationToken ct) { var s = await _db.PlatformSettings.FirstOrDefaultAsync(ct); diff --git a/src/food-market.domain/Platform/PlatformSettings.cs b/src/food-market.domain/Platform/PlatformSettings.cs index 2947854..e42de2c 100644 --- a/src/food-market.domain/Platform/PlatformSettings.cs +++ b/src/food-market.domain/Platform/PlatformSettings.cs @@ -42,4 +42,25 @@ public class PlatformSettings : Entity public string? WebPushVapidPrivateKey { get; set; } /// VAPID subject — mailto:/URL контакта оператора push (по умолчанию mailto:). public string? WebPushVapidSubject { get; set; } + + // ── Подгрузка из МойСклад (дельта-синхронизация на тестовый стенд) ───────── + // Переключатель, которым SuperAdmin включает/выключает автоматический перенос + // новых и изменённых документов из МойСклад на стенд (systemd-таймер раз в + // 10 мин, скрипт scripts/migration/delta_sync.py). МойСклад только читается. + // Скрипт спрашивает этот флаг у API и при «Выкл» сразу выходит, не трогая + // своё состояние (lastSync); эндпоинт подгрузки тоже отказывает при «Выкл». + /// Главный флаг дельта-подгрузки из МойСклад. По умолчанию ВЫКЛ. + public bool MoySkladDeltaSyncEnabled { get; set; } + + // Состояние последнего прогона дельта-синхронизации — пишется самим скриптом + // (delta_sync.py) через /api/admin/moysklad/delta-sync/report, чтобы SuperAdmin + // видел результат на странице «Настройки платформы». + public DateTime? MoySkladDeltaLastRunStartedAt { get; set; } + public DateTime? MoySkladDeltaLastRunFinishedAt { get; set; } + /// Итог последнего прогона: true=OK, false=ошибка, null=ещё не было. + public bool? MoySkladDeltaLastRunOk { get; set; } + /// Сколько изменений перенесено в последнем прогоне. + public int? MoySkladDeltaLastRunMigrated { get; set; } + /// Текст ошибки последнего прогона (если был сбой). + public string? MoySkladDeltaLastRunError { get; set; } } diff --git a/src/food-market.infrastructure/Persistence/AppDbContext.cs b/src/food-market.infrastructure/Persistence/AppDbContext.cs index 494b58e..eab52c2 100644 --- a/src/food-market.infrastructure/Persistence/AppDbContext.cs +++ b/src/food-market.infrastructure/Persistence/AppDbContext.cs @@ -150,6 +150,7 @@ protected override void OnModelCreating(ModelBuilder builder) b.Property(x => x.SmtpUsername).HasMaxLength(200); b.Property(x => x.FromEmail).HasMaxLength(200); b.Property(x => x.FromName).HasMaxLength(200); + b.Property(x => x.MoySkladDeltaLastRunError).HasMaxLength(2000); }); builder.Entity(b => diff --git a/src/food-market.infrastructure/Persistence/Migrations/20261012010000_Phase46_MoySkladDeltaToggle.cs b/src/food-market.infrastructure/Persistence/Migrations/20261012010000_Phase46_MoySkladDeltaToggle.cs new file mode 100644 index 0000000..ea649d5 --- /dev/null +++ b/src/food-market.infrastructure/Persistence/Migrations/20261012010000_Phase46_MoySkladDeltaToggle.cs @@ -0,0 +1,42 @@ +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using foodmarket.Infrastructure.Persistence; + +#nullable disable + +namespace foodmarket.Infrastructure.Persistence.Migrations +{ + /// Переключатель дельта-подгрузки из МойСклад + состояние последнего + /// прогона в platform_settings. Управляется SuperAdmin'ом через + /// /super-admin/platform-settings/moysklad-delta. По умолчанию ВЫКЛ. + [DbContext(typeof(AppDbContext))] + [Migration("20261012010000_Phase46_MoySkladDeltaToggle")] + public partial class Phase46_MoySkladDeltaToggle : Migration + { + protected override void Up(MigrationBuilder b) + { + // Идемпотентно — ADD COLUMN IF NOT EXISTS, чтобы повтор применения + // (ручной SQL / соседняя миграция) не падал. + b.Sql(@" + ALTER TABLE public.platform_settings + ADD COLUMN IF NOT EXISTS ""MoySkladDeltaSyncEnabled"" boolean NOT NULL DEFAULT false, + ADD COLUMN IF NOT EXISTS ""MoySkladDeltaLastRunStartedAt"" timestamp with time zone NULL, + ADD COLUMN IF NOT EXISTS ""MoySkladDeltaLastRunFinishedAt"" timestamp with time zone NULL, + ADD COLUMN IF NOT EXISTS ""MoySkladDeltaLastRunOk"" boolean NULL, + ADD COLUMN IF NOT EXISTS ""MoySkladDeltaLastRunMigrated"" integer NULL, + ADD COLUMN IF NOT EXISTS ""MoySkladDeltaLastRunError"" varchar(2000) NULL;"); + } + + protected override void Down(MigrationBuilder b) + { + b.Sql(@" + ALTER TABLE public.platform_settings + DROP COLUMN IF EXISTS ""MoySkladDeltaSyncEnabled"", + DROP COLUMN IF EXISTS ""MoySkladDeltaLastRunStartedAt"", + DROP COLUMN IF EXISTS ""MoySkladDeltaLastRunFinishedAt"", + DROP COLUMN IF EXISTS ""MoySkladDeltaLastRunOk"", + DROP COLUMN IF EXISTS ""MoySkladDeltaLastRunMigrated"", + DROP COLUMN IF EXISTS ""MoySkladDeltaLastRunError"";"); + } + } +} diff --git a/src/food-market.web/src/pages/SuperAdminPlatformSettingsPage.tsx b/src/food-market.web/src/pages/SuperAdminPlatformSettingsPage.tsx index a56a586..29e41fe 100644 --- a/src/food-market.web/src/pages/SuperAdminPlatformSettingsPage.tsx +++ b/src/food-market.web/src/pages/SuperAdminPlatformSettingsPage.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react' import { useQuery, useQueryClient } from '@tanstack/react-query' -import { Save, Send, CheckCircle2, AlertTriangle, Mail } from 'lucide-react' +import { Save, Send, CheckCircle2, AlertTriangle, Mail, Database } from 'lucide-react' import { api } from '@/lib/api' import { PageHeader } from '@/components/PageHeader' import { Field, TextInput, TextArea, Checkbox } from '@/components/Field' @@ -18,6 +18,15 @@ interface PlatformSettingsDto { updatedAt: string | null } +interface MoySkladDeltaDto { + enabled: boolean + lastRunStartedAt: string | null + lastRunFinishedAt: string | null + lastRunOk: boolean | null + lastRunMigrated: number | null + lastRunError: string | null +} + interface Form { smtpHost: string smtpPort: string @@ -56,6 +65,35 @@ export function SuperAdminPlatformSettingsPage() { const [testBusy, setTestBusy] = useState(false) const [testResult, setTestResult] = useState<{ ok: boolean; message: string } | null>(null) + // Подгрузка из МойСклад (дельта-синхронизация). + const { data: delta } = useQuery({ + queryKey: ['/api/super-admin/platform-settings/moysklad-delta'], + queryFn: async () => (await api.get('/api/super-admin/platform-settings/moysklad-delta')).data, + }) + const [deltaPending, setDeltaPending] = useState(false) + const [deltaLoaded, setDeltaLoaded] = useState(false) + const [deltaReason, setDeltaReason] = useState('') + const [deltaSaving, setDeltaSaving] = useState(false) + const [deltaSaved, setDeltaSaved] = useState(false) + const [deltaError, setDeltaError] = useState(null) + + useEffect(() => { + if (delta && !deltaLoaded) { setDeltaPending(delta.enabled); setDeltaLoaded(true) } + }, [delta, deltaLoaded]) + + const saveDelta = async () => { + setDeltaError(null); setDeltaSaving(true) + try { + await api.put('/api/super-admin/platform-settings/moysklad-delta', { enabled: deltaPending, reason: deltaReason }) + await qc.invalidateQueries({ queryKey: ['/api/super-admin/platform-settings/moysklad-delta'] }) + setDeltaReason('') + setDeltaSaved(true); setTimeout(() => setDeltaSaved(false), 2500) + } catch (e) { + const err = e as { response?: { data?: { error?: string } }, message?: string } + setDeltaError(err.response?.data?.error ?? err.message ?? 'Не удалось сохранить') + } finally { setDeltaSaving(false) } + } + useEffect(() => { if (data && !loaded) { setForm({ @@ -248,10 +286,72 @@ export function SuperAdminPlatformSettingsPage() { )} - {/* Hidden Checkbox import используется чтобы линтер не ругался — оставляем - чтобы при будущем расширении (например «Использовать SMTP-настройки - организации вместо платформенных») было где включить тоггл. */} -
{}} />
+
+

Подгрузка из МойСклад

+

+ Раз в 10 минут переносит новые и изменённые документы из МойСклад в магазин FOOD MARKET + на тестовом стенде. МойСклад только читается. +

+ +
+ +
+ + {/* Состояние последнего прогона */} +
+
Последний прогон
+ {delta && (delta.lastRunStartedAt || delta.lastRunFinishedAt) ? ( +
+
Начало: {delta.lastRunStartedAt ? new Date(delta.lastRunStartedAt).toLocaleString('ru-RU') : '—'}
+
Окончание: {delta.lastRunFinishedAt ? new Date(delta.lastRunFinishedAt).toLocaleString('ru-RU') : '—'}
+
+ Итог:{' '} + {delta.lastRunOk == null ? '—' : delta.lastRunOk ? ( + OK + ) : ( + ошибка + )} +
+
Перенесено изменений: {delta.lastRunMigrated ?? 0}
+ {delta.lastRunError &&
Ошибка: {delta.lastRunError}
} +
+ ) : ( +
Прогонов ещё не было.
+ )} +
+ + {deltaError && ( +
+ {deltaError} +
+ )} + + +