feat(superadmin): переключатель подгрузки из МойСклад + состояние прогона
Some checks are pending
Auto-tag / Create date-tag (push) Waiting to run
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run
Docker API / Build + push API (push) Waiting to run
Docker API / Deploy API on stage (push) Blocked by required conditions
Docker Web / Build + push Web (push) Waiting to run
Docker Web / Deploy Web on stage (push) Blocked by required conditions

Суперадмин управляет дельта-подгрузкой МойСклад на стенд флагом в «Настройках
платформы» (по умолчанию ВЫКЛ), не трогая 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 <noreply@anthropic.com>
This commit is contained in:
nns 2026-10-12 17:08:41 +05:00
parent e4d756e004
commit 42e4f630b1
7 changed files with 368 additions and 39 deletions

View file

@ -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")
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,11 +170,14 @@ 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
ok = False
error_text = None
try:
for dt in DOC_TYPES:
jid = trigger(tok, dt, last)
res = poll(tok, jid)
@ -154,7 +209,19 @@ def main():
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__":

View file

@ -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<ActionResult<object>> 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<ActionResult<DeltaStatusDto>> 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<IActionResult> 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<ActionResult<object>> 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 = "Токен не настроен." });

View file

@ -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<ActionResult<PlatformSettingsDto>> Get(CancellationToken ct)
{
@ -132,6 +139,41 @@ public async Task<IActionResult> TestSend([FromBody] TestSendInput input, Cancel
}
}
// ── Подгрузка из МойСклад (дельта-синхронизация) ─────────────────────────
[HttpGet("moysklad-delta")]
public async Task<ActionResult<MoySkladDeltaDto>> 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<IActionResult> 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<PlatformSettings> GetOrCreateAsync(CancellationToken ct)
{
var s = await _db.PlatformSettings.FirstOrDefaultAsync(ct);

View file

@ -42,4 +42,25 @@ public class PlatformSettings : Entity
public string? WebPushVapidPrivateKey { get; set; }
/// <summary>VAPID subject — mailto:/URL контакта оператора push (по умолчанию mailto:).</summary>
public string? WebPushVapidSubject { get; set; }
// ── Подгрузка из МойСклад (дельта-синхронизация на тестовый стенд) ─────────
// Переключатель, которым SuperAdmin включает/выключает автоматический перенос
// новых и изменённых документов из МойСклад на стенд (systemd-таймер раз в
// 10 мин, скрипт scripts/migration/delta_sync.py). МойСклад только читается.
// Скрипт спрашивает этот флаг у API и при «Выкл» сразу выходит, не трогая
// своё состояние (lastSync); эндпоинт подгрузки тоже отказывает при «Выкл».
/// <summary>Главный флаг дельта-подгрузки из МойСклад. По умолчанию ВЫКЛ.</summary>
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; }
/// <summary>Итог последнего прогона: true=OK, false=ошибка, null=ещё не было.</summary>
public bool? MoySkladDeltaLastRunOk { get; set; }
/// <summary>Сколько изменений перенесено в последнем прогоне.</summary>
public int? MoySkladDeltaLastRunMigrated { get; set; }
/// <summary>Текст ошибки последнего прогона (если был сбой).</summary>
public string? MoySkladDeltaLastRunError { get; set; }
}

View file

@ -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<SuperAdminAuditLog>(b =>

View file

@ -0,0 +1,42 @@
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using foodmarket.Infrastructure.Persistence;
#nullable disable
namespace foodmarket.Infrastructure.Persistence.Migrations
{
/// <summary>Переключатель дельта-подгрузки из МойСклад + состояние последнего
/// прогона в platform_settings. Управляется SuperAdmin'ом через
/// /super-admin/platform-settings/moysklad-delta. По умолчанию ВЫКЛ.</summary>
[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"";");
}
}
}

View file

@ -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<MoySkladDeltaDto>('/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<string | null>(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() {
)}
</section>
{/* Hidden Checkbox import используется чтобы линтер не ругался — оставляем
чтобы при будущем расширении (например «Использовать SMTP-настройки
организации вместо платформенных») было где включить тоггл. */}
<div className="hidden"><Checkbox label="" checked={false} onChange={() => {}} /></div>
<section className="rounded-xl border border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 p-5 space-y-3">
<h3 className="font-semibold flex items-center gap-2"><Database className="w-4 h-4" /> Подгрузка из МойСклад</h3>
<p className="text-xs text-slate-500">
Раз в 10 минут переносит новые и изменённые документы из МойСклад в магазин FOOD MARKET
на тестовом стенде. МойСклад только читается.
</p>
<div className="rounded-md border border-slate-200 dark:border-slate-700 px-3 py-2">
<Checkbox
label={deltaPending ? 'Включена' : 'Выключена'}
checked={deltaPending}
onChange={setDeltaPending}
/>
</div>
{/* Состояние последнего прогона */}
<div className="text-sm">
<div className="font-medium text-slate-600 dark:text-slate-400 mb-1">Последний прогон</div>
{delta && (delta.lastRunStartedAt || delta.lastRunFinishedAt) ? (
<div className="space-y-0.5 text-slate-600 dark:text-slate-400">
<div>Начало: {delta.lastRunStartedAt ? new Date(delta.lastRunStartedAt).toLocaleString('ru-RU') : '—'}</div>
<div>Окончание: {delta.lastRunFinishedAt ? new Date(delta.lastRunFinishedAt).toLocaleString('ru-RU') : '—'}</div>
<div className="inline-flex items-center gap-1">
Итог:{' '}
{delta.lastRunOk == null ? '—' : delta.lastRunOk ? (
<span className="text-emerald-600 inline-flex items-center gap-1"><CheckCircle2 className="w-4 h-4" /> OK</span>
) : (
<span className="text-red-600 inline-flex items-center gap-1"><AlertTriangle className="w-4 h-4" /> ошибка</span>
)}
</div>
<div>Перенесено изменений: {delta.lastRunMigrated ?? 0}</div>
{delta.lastRunError && <div className="text-red-600">Ошибка: {delta.lastRunError}</div>}
</div>
) : (
<div className="text-slate-500">Прогонов ещё не было.</div>
)}
</div>
{deltaError && (
<div className="rounded-md bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 px-3 py-2 text-sm text-red-700 dark:text-red-300">
{deltaError}
</div>
)}
<Field label="Причина изменения (≥ 10 символов, в журнал)">
<TextArea rows={2} value={deltaReason}
onChange={(e) => setDeltaReason(e.target.value)}
placeholder="Например: включаю подгрузку из МойСклад на стенде для догона изменений перед переходом" />
</Field>
<div className="flex items-center gap-3">
<Button
onClick={saveDelta}
disabled={deltaSaving || deltaReason.trim().length < 10 || !delta || deltaPending === delta.enabled}
>
<Save className="w-4 h-4" /> {deltaSaving ? 'Сохраняю…' : (deltaPending ? 'Включить подгрузку' : 'Выключить подгрузку')}
</Button>
{deltaSaved && (
<span className="text-sm text-emerald-600 inline-flex items-center gap-1">
<CheckCircle2 className="w-4 h-4" /> Сохранено
</span>
)}
{delta && deltaPending === delta.enabled && (
<span className="text-xs text-slate-500">Текущее состояние: {delta.enabled ? 'Вкл' : 'Выкл'}</span>
)}
</div>
</section>
</div>
</div>
)