| | | 1 | | using Microsoft.AspNetCore.Authorization; |
| | | 2 | | using Microsoft.AspNetCore.Mvc; |
| | | 3 | | using Microsoft.EntityFrameworkCore; |
| | | 4 | | using Microsoft.Extensions.Configuration; |
| | | 5 | | using Microsoft.Extensions.Options; |
| | | 6 | | using OzonOrdersWeb.Areas.Studio2.ViewModels.WarehouseStock; |
| | | 7 | | using OzonDomains.Models.Authorization.Enums; |
| | | 8 | | using OzonOrdersWeb.Authorization; |
| | | 9 | | using OzonOrdersWeb.WebServices.Grid; |
| | | 10 | | using OzonOrdersWeb.WebServices.Journals; |
| | | 11 | | using OzonRepositories.Context; |
| | | 12 | | using Hangfire; |
| | | 13 | | using Services.ApiServices; |
| | | 14 | | using Services.ApiServices._1CApi.Stock; |
| | | 15 | | using Services.Authorization.Services; |
| | | 16 | | using Services.StockSyncServices; |
| | | 17 | | |
| | | 18 | | namespace OzonOrdersWeb.Areas.Studio2.Controllers; |
| | | 19 | | |
| | | 20 | | [Authorize(Roles = "User,Admin")] |
| | | 21 | | [Area("Studio2")] |
| | | 22 | | [Route("Studio2/WarehouseStock")] |
| | | 23 | | [Route("WarehouseStock")] |
| | | 24 | | public class WarehouseStockController : Controller |
| | | 25 | | { |
| | | 26 | | private readonly ApplicationDbContext _context; |
| | | 27 | | private readonly IWarehouseStockSyncService _syncService; |
| | | 28 | | private readonly IWarehouseStockLookupService _warehouseStockLookup; |
| | | 29 | | private readonly IBackgroundJobClient _backgroundJobs; |
| | | 30 | | private readonly GoogleSheetsStockSyncOptions _stockSyncOptions; |
| | | 31 | | private readonly IConfiguration _configuration; |
| | | 32 | | private readonly OneCDataManager _oneCDataManager; |
| | | 33 | | private readonly IOneCStockAvailabilityService _stockAvailability; |
| | | 34 | | private readonly IAuditService _audit; |
| | | 35 | | |
| | 9 | 36 | | public WarehouseStockController( |
| | 9 | 37 | | ApplicationDbContext context, |
| | 9 | 38 | | IWarehouseStockSyncService syncService, |
| | 9 | 39 | | IWarehouseStockLookupService warehouseStockLookup, |
| | 9 | 40 | | IBackgroundJobClient backgroundJobs, |
| | 9 | 41 | | IOptions<GoogleSheetsStockSyncOptions> stockSyncOptions, |
| | 9 | 42 | | IConfiguration configuration, |
| | 9 | 43 | | OneCDataManager oneCDataManager, |
| | 9 | 44 | | IOneCStockAvailabilityService stockAvailability, |
| | 9 | 45 | | IAuditService audit) |
| | | 46 | | { |
| | 9 | 47 | | _stockAvailability = stockAvailability; |
| | 9 | 48 | | _context = context; |
| | 9 | 49 | | _syncService = syncService; |
| | 9 | 50 | | _warehouseStockLookup = warehouseStockLookup; |
| | 9 | 51 | | _backgroundJobs = backgroundJobs; |
| | 9 | 52 | | _stockSyncOptions = stockSyncOptions.Value; |
| | 9 | 53 | | _configuration = configuration; |
| | 9 | 54 | | _oneCDataManager = oneCDataManager; |
| | 9 | 55 | | _audit = audit; |
| | 9 | 56 | | } |
| | | 57 | | |
| | | 58 | | private const string PageCode = "Studio2.WarehouseStock.Index"; |
| | | 59 | | |
| | | 60 | | /// <summary> |
| | | 61 | | /// Каркас страницы: строки грида приходят блоками из <see cref="GridRows"/>. Параметры прежних ссылок |
| | | 62 | | /// (?article=, ?manufacturer=, ?warehouse=, ?minQty=) страница превращает в стартовые фильтры грида. |
| | | 63 | | /// </summary> |
| | | 64 | | [HttpGet("")] |
| | | 65 | | public async Task<IActionResult> Index( |
| | | 66 | | string? article = null, |
| | | 67 | | string? manufacturer = null, |
| | | 68 | | string? warehouse = null, |
| | | 69 | | decimal? minQty = null, |
| | | 70 | | CancellationToken cancellationToken = default) |
| | | 71 | | { |
| | 0 | 72 | | var lastSync = await _context.WarehouseStockSnapshots.AsNoTracking() |
| | 0 | 73 | | .GroupBy(_ => 1) |
| | 0 | 74 | | .Select(g => new { Total = g.Count(), SyncedAtUtc = g.Max(x => x.SyncedAtUtc) }) |
| | 0 | 75 | | .SingleOrDefaultAsync(cancellationToken); |
| | | 76 | | |
| | 0 | 77 | | return View(new WarehouseStockIndexViewModel |
| | 0 | 78 | | { |
| | 0 | 79 | | TotalCount = lastSync?.Total ?? 0, |
| | 0 | 80 | | LastSyncedAtUtc = lastSync?.SyncedAtUtc, |
| | 0 | 81 | | Article = article, |
| | 0 | 82 | | Manufacturer = manufacturer, |
| | 0 | 83 | | Warehouse = warehouse, |
| | 0 | 84 | | MinQty = minQty |
| | 0 | 85 | | }); |
| | 0 | 86 | | } |
| | | 87 | | |
| | | 88 | | [HttpPost("grid")] |
| | | 89 | | [ValidateAntiForgeryToken] |
| | | 90 | | [PageAuthorize(PageCode, AccessType.View)] |
| | | 91 | | public async Task<IActionResult> GridRows([FromBody] ServerGridRequest? request, CancellationToken cancellationToken |
| | | 92 | | { |
| | 0 | 93 | | var error = ServerGridQuery.Validate(request); |
| | 0 | 94 | | if (error != null) return BadRequest(new { success = false, error }); |
| | | 95 | | |
| | 0 | 96 | | var def = WarehouseStockGridDefinition; |
| | 0 | 97 | | var filtered = ServerGridQuery.ApplyFilters(_context.WarehouseStockSnapshots.AsNoTracking(), def, request!); |
| | 0 | 98 | | var total = await filtered.CountAsync(cancellationToken); |
| | 0 | 99 | | var start = Math.Max(0, request!.StartRow); |
| | 0 | 100 | | var take = Math.Clamp(request.EndRow - start, 1, ServerGridQuery.MaxBlockSize); |
| | 0 | 101 | | var rows = start >= total |
| | 0 | 102 | | ? [] |
| | 0 | 103 | | : await ServerGridQuery.ApplySort(filtered, def, request.SortModel).Skip(start).Take(take) |
| | 0 | 104 | | .Select(x => new |
| | 0 | 105 | | { |
| | 0 | 106 | | x.Id, x.SourceRowNumber, x.Manufacturer, x.Article, x.ProductName, x.SaleFlag, |
| | 0 | 107 | | x.M2Qty, x.M1Qty, x.M3Qty, x.B1Qty, x.B2Qty, |
| | 0 | 108 | | x.UnitCost, x.TotalCost, x.SalePriceUnit, x.AccountingPriceUnit, x.PriceDiffAccountingMinusSale, |
| | 0 | 109 | | x.C0, x.StockAmountByC0, x.ExistPrice, x.C1ExistMinus5Percent, x.C1PercentToC0, |
| | 0 | 110 | | x.Warehouse, x.SyncedAtUtc |
| | 0 | 111 | | }) |
| | 0 | 112 | | .ToListAsync(cancellationToken); |
| | 0 | 113 | | var lastRow = start + rows.Count >= total ? total : -1; |
| | 0 | 114 | | return Ok(new { success = true, rows, totalCount = total, lastRow }); |
| | 0 | 115 | | } |
| | | 116 | | |
| | | 117 | | [HttpPost("grid/facets")] |
| | | 118 | | [ValidateAntiForgeryToken] |
| | | 119 | | [PageAuthorize(PageCode, AccessType.View)] |
| | | 120 | | public async Task<IActionResult> GridFacets([FromBody] ServerGridRequest? request, CancellationToken cancellationTok |
| | | 121 | | { |
| | 0 | 122 | | if (request is null || string.IsNullOrWhiteSpace(request.ColId)) |
| | 0 | 123 | | return BadRequest(new { success = false, error = "colId обязателен" }); |
| | 0 | 124 | | request.StartRow = 0; |
| | 0 | 125 | | request.EndRow = 1; |
| | 0 | 126 | | var error = ServerGridQuery.Validate(request); |
| | 0 | 127 | | if (error != null) return BadRequest(new { success = false, error }); |
| | | 128 | | |
| | 0 | 129 | | var values = await ServerGridQuery.FacetsAsync(_context.WarehouseStockSnapshots.AsNoTracking(), |
| | 0 | 130 | | WarehouseStockGridDefinition, request, cancellationToken); |
| | 0 | 131 | | return Ok(new { success = true, values }); |
| | 0 | 132 | | } |
| | | 133 | | |
| | | 134 | | private static ServerGridDefinition<OzonDomains.Models.WarehouseStockSnapshot> WarehouseStockGridDefinition |
| | 0 | 135 | | => JournalGridDefinitions.WarehouseStock; |
| | | 136 | | |
| | | 137 | | [ServiceEndpoint] |
| | | 138 | | [HttpGet("lookup")] |
| | | 139 | | public async Task<IActionResult> Lookup( |
| | | 140 | | string? article = null, |
| | | 141 | | string? manufacturer = null, |
| | | 142 | | CancellationToken cancellationToken = default) |
| | | 143 | | { |
| | 0 | 144 | | if (string.IsNullOrWhiteSpace(article)) |
| | 0 | 145 | | return Ok(new { found = false }); |
| | | 146 | | |
| | 0 | 147 | | var match = await _warehouseStockLookup.LookupAsync(article, manufacturer, cancellationToken); |
| | 0 | 148 | | if (match is not { Found: true }) |
| | 0 | 149 | | return Ok(new { found = false }); |
| | | 150 | | |
| | 0 | 151 | | return Ok(new |
| | 0 | 152 | | { |
| | 0 | 153 | | found = true, |
| | 0 | 154 | | fuzzyManufacturer = match.FuzzyManufacturer, |
| | 0 | 155 | | id = match.Id, |
| | 0 | 156 | | sourceRowNumber = match.SourceRowNumber, |
| | 0 | 157 | | m2Qty = match.M2Qty, |
| | 0 | 158 | | m1Qty = match.M1Qty, |
| | 0 | 159 | | m3Qty = match.M3Qty, |
| | 0 | 160 | | b1Qty = match.B1Qty, |
| | 0 | 161 | | b2Qty = match.B2Qty, |
| | 0 | 162 | | c0 = match.C0 |
| | 0 | 163 | | }); |
| | 0 | 164 | | } |
| | | 165 | | |
| | | 166 | | [AllowAnonymous] |
| | | 167 | | [AssemblyListPublicAccess] |
| | | 168 | | [HttpGet("movement-map")] |
| | | 169 | | public IActionResult MovementMap(string? article, string? accessKey) |
| | | 170 | | { |
| | 0 | 171 | | ViewBag.AccessKey = accessKey ?? string.Empty; |
| | 0 | 172 | | return View(model: (article ?? string.Empty).Trim()); |
| | | 173 | | } |
| | | 174 | | |
| | | 175 | | [AllowAnonymous] |
| | | 176 | | [AssemblyListPublicAccess] |
| | | 177 | | [HttpGet("movement-api")] |
| | | 178 | | public async Task<IActionResult> MovementApi(string? article, CancellationToken cancellationToken = default) |
| | | 179 | | { |
| | 0 | 180 | | if (string.IsNullOrWhiteSpace(article)) |
| | 0 | 181 | | return Ok(new { found = false, error = "article is empty" }); |
| | | 182 | | |
| | | 183 | | try |
| | | 184 | | { |
| | 0 | 185 | | var result = await _oneCDataManager.GetMovementMapAsync(article.Trim()); |
| | 0 | 186 | | if (result == null) |
| | 0 | 187 | | return Ok(new { found = false, error = "Артикул не найден в 1С" }); |
| | | 188 | | |
| | 0 | 189 | | return Ok(new |
| | 0 | 190 | | { |
| | 0 | 191 | | found = true, |
| | 0 | 192 | | article = result.Article, |
| | 0 | 193 | | name = result.NomenclatureName, |
| | 0 | 194 | | currentBalance = (result.CurrentBalance ?? []).Select(b => new { warehouse = b.Warehouse, quantity = b.Q |
| | 0 | 195 | | currentReserve = (result.CurrentReserve ?? []).Select(b => new { warehouse = b.Warehouse, quantity = b.Q |
| | 0 | 196 | | currentReserveLines = User.IsInRole("Admin") |
| | 0 | 197 | | ? (result.CurrentReserveLines ?? []).Select(r => new |
| | 0 | 198 | | { |
| | 0 | 199 | | documentRefKey = r.DocumentRefKey, |
| | 0 | 200 | | lineNumber = r.LineNumber, |
| | 0 | 201 | | warehouse = r.Warehouse, |
| | 0 | 202 | | quantity = r.Quantity |
| | 0 | 203 | | }) |
| | 0 | 204 | | : [], |
| | 0 | 205 | | summary = new |
| | 0 | 206 | | { |
| | 0 | 207 | | receiptQty = result.TotalReceiptQty, |
| | 0 | 208 | | receiptDocs = result.TotalReceiptDocs, |
| | 0 | 209 | | expenseQty = result.TotalExpenseQty, |
| | 0 | 210 | | expenseDocs = result.TotalExpenseDocs, |
| | 0 | 211 | | transferQty = result.TotalTransferQty, |
| | 0 | 212 | | transferDocs = result.TotalTransferDocs, |
| | 0 | 213 | | calculatedBalance = result.TotalReceiptQty - result.TotalExpenseQty |
| | 0 | 214 | | }, |
| | 0 | 215 | | movements = (result.Movements ?? []).Select(m => new |
| | 0 | 216 | | { |
| | 0 | 217 | | date = m.Date.ToString("yyyy-MM-dd HH:mm:ss"), |
| | 0 | 218 | | kind = m.Kind.ToString().ToLower(), |
| | 0 | 219 | | docType = m.DocType, |
| | 0 | 220 | | docNumber = m.DocNumber, |
| | 0 | 221 | | refKey = m.RefKey, |
| | 0 | 222 | | quantity = m.Quantity, |
| | 0 | 223 | | price = m.Price, |
| | 0 | 224 | | warehouseFrom = m.WarehouseFrom, |
| | 0 | 225 | | warehouseTo = m.WarehouseTo, |
| | 0 | 226 | | isDeleted = m.IsDeleted, |
| | 0 | 227 | | isPosted = m.IsPosted |
| | 0 | 228 | | }) |
| | 0 | 229 | | }); |
| | | 230 | | } |
| | 0 | 231 | | catch (Exception ex) |
| | | 232 | | { |
| | 0 | 233 | | return Ok(new { found = false, error = ex.Message }); |
| | | 234 | | } |
| | 0 | 235 | | } |
| | | 236 | | |
| | | 237 | | [HttpPost("reserve-line")] |
| | | 238 | | [Authorize(Roles = "Admin")] |
| | | 239 | | [ValidateAntiForgeryToken] |
| | | 240 | | public async Task<IActionResult> UpdateReserveLine( |
| | | 241 | | [FromForm] ReserveLineUpdateRequest request, |
| | | 242 | | CancellationToken cancellationToken = default) |
| | | 243 | | { |
| | 0 | 244 | | if (string.IsNullOrWhiteSpace(request.Article) || request.DocumentRefKey == Guid.Empty || request.LineNumber <= |
| | 0 | 245 | | return BadRequest(new { success = false, error = "Некорректные данные строки резерва." }); |
| | 0 | 246 | | if (request.Quantity < 0) |
| | 0 | 247 | | return BadRequest(new { success = false, error = "Количество не может быть отрицательным." }); |
| | 0 | 248 | | if (string.IsNullOrWhiteSpace(request.Reason) || request.Reason.Trim().Length < 3) |
| | 0 | 249 | | return BadRequest(new { success = false, error = "Укажите причину изменения (минимум 3 символа)." }); |
| | | 250 | | |
| | | 251 | | try |
| | | 252 | | { |
| | 0 | 253 | | var result = await _oneCDataManager.UpdateReserveLineAsync( |
| | 0 | 254 | | request.Article, request.DocumentRefKey, request.LineNumber, request.Quantity); |
| | | 255 | | |
| | 0 | 256 | | await _audit.LogAsync( |
| | 0 | 257 | | "OneCReserveChanged", |
| | 0 | 258 | | "Document_РезервированиеЗапасов", |
| | 0 | 259 | | $"{request.DocumentRefKey}:line={request.LineNumber}", |
| | 0 | 260 | | System.Text.Json.JsonSerializer.Serialize(new { article = request.Article, quantity = result.OldQuantity |
| | 0 | 261 | | System.Text.Json.JsonSerializer.Serialize(new { article = request.Article, quantity = result.NewQuantity |
| | 0 | 262 | | request.Reason.Trim(), |
| | 0 | 263 | | cancellationToken); |
| | | 264 | | |
| | 0 | 265 | | return Ok(new { success = true, oldQuantity = result.OldQuantity, newQuantity = result.NewQuantity }); |
| | | 266 | | } |
| | 0 | 267 | | catch (Exception ex) |
| | | 268 | | { |
| | 0 | 269 | | return BadRequest(new { success = false, error = ex.Message }); |
| | | 270 | | } |
| | 0 | 271 | | } |
| | | 272 | | |
| | | 273 | | public sealed class ReserveLineUpdateRequest |
| | | 274 | | { |
| | 0 | 275 | | public string Article { get; init; } = string.Empty; |
| | 0 | 276 | | public Guid DocumentRefKey { get; init; } |
| | 0 | 277 | | public int LineNumber { get; init; } |
| | 0 | 278 | | public decimal Quantity { get; init; } |
| | 0 | 279 | | public string Reason { get; init; } = string.Empty; |
| | | 280 | | } |
| | | 281 | | |
| | | 282 | | [HttpPost("stock-adjustment")] |
| | | 283 | | [Authorize(Roles = "Admin")] |
| | | 284 | | [ValidateAntiForgeryToken] |
| | | 285 | | public async Task<IActionResult> AdjustStock( |
| | | 286 | | [FromForm] StockAdjustmentRequest request, |
| | | 287 | | CancellationToken cancellationToken = default) |
| | | 288 | | { |
| | 0 | 289 | | if (string.IsNullOrWhiteSpace(request.Article) || string.IsNullOrWhiteSpace(request.Warehouse)) |
| | 0 | 290 | | return BadRequest(new { success = false, error = "Не указан товар или склад." }); |
| | 0 | 291 | | if (request.Quantity < 0) |
| | 0 | 292 | | return BadRequest(new { success = false, error = "Остаток не может быть отрицательным." }); |
| | 0 | 293 | | if (string.IsNullOrWhiteSpace(request.Reason) || request.Reason.Trim().Length < 3) |
| | 0 | 294 | | return BadRequest(new { success = false, error = "Укажите причину изменения (минимум 3 символа)." }); |
| | | 295 | | |
| | | 296 | | try |
| | | 297 | | { |
| | 0 | 298 | | var result = await _oneCDataManager.AdjustStockByInventoryAsync( |
| | 0 | 299 | | request.Article, request.Warehouse, request.Quantity, request.Reason); |
| | 0 | 300 | | await _audit.LogAsync( |
| | 0 | 301 | | "OneCStockAdjusted", |
| | 0 | 302 | | "Document_ИнвентаризацияЗапасов", |
| | 0 | 303 | | result.DocumentNumber, |
| | 0 | 304 | | System.Text.Json.JsonSerializer.Serialize(new { article = request.Article, warehouse = request.Warehouse |
| | 0 | 305 | | System.Text.Json.JsonSerializer.Serialize(new { article = request.Article, warehouse = request.Warehouse |
| | 0 | 306 | | request.Reason.Trim(), |
| | 0 | 307 | | cancellationToken); |
| | 0 | 308 | | return Ok(new { success = true, documentNumber = result.DocumentNumber }); |
| | | 309 | | } |
| | 0 | 310 | | catch (Exception ex) |
| | | 311 | | { |
| | 0 | 312 | | return BadRequest(new { success = false, error = ex.Message }); |
| | | 313 | | } |
| | 0 | 314 | | } |
| | | 315 | | |
| | | 316 | | public sealed class StockAdjustmentRequest |
| | | 317 | | { |
| | 0 | 318 | | public string Article { get; init; } = string.Empty; |
| | 0 | 319 | | public string Warehouse { get; init; } = string.Empty; |
| | 0 | 320 | | public decimal Quantity { get; init; } |
| | 0 | 321 | | public string Reason { get; init; } = string.Empty; |
| | | 322 | | } |
| | | 323 | | |
| | | 324 | | [ServiceEndpoint] |
| | | 325 | | [HttpGet("onec-lookup")] |
| | | 326 | | public async Task<IActionResult> OneCLookup(string? article, CancellationToken cancellationToken = default) |
| | | 327 | | { |
| | 0 | 328 | | if (string.IsNullOrWhiteSpace(article)) |
| | 0 | 329 | | return Ok(new { found = false, debug = "article is empty" }); |
| | | 330 | | |
| | | 331 | | try |
| | | 332 | | { |
| | 0 | 333 | | var stocks = await _oneCDataManager.GetStockByArticleDirectAsync(article.Trim()); |
| | | 334 | | |
| | | 335 | | // Учётная цена по всем складам: суммарная стоимость на суммарное количество. |
| | 0 | 336 | | var totalQty = stocks.Sum(s => s.Quantity); |
| | 0 | 337 | | var totalCost = stocks.Sum(s => s.Cost ?? 0m); |
| | 0 | 338 | | decimal? unitCost = totalCost > 0 && totalQty > 0 |
| | 0 | 339 | | ? Math.Round(totalCost / totalQty, 2, MidpointRounding.AwayFromZero) |
| | 0 | 340 | | : null; |
| | | 341 | | |
| | 0 | 342 | | return Ok(new |
| | 0 | 343 | | { |
| | 0 | 344 | | found = stocks.Count > 0, |
| | 0 | 345 | | stocks = stocks.Select(s => new |
| | 0 | 346 | | { |
| | 0 | 347 | | warehouse = s.Warehouse, |
| | 0 | 348 | | quantity = s.Quantity, |
| | 0 | 349 | | unitCost = s.UnitCost.HasValue |
| | 0 | 350 | | ? Math.Round(s.UnitCost.Value, 2, MidpointRounding.AwayFromZero) |
| | 0 | 351 | | : (decimal?)null |
| | 0 | 352 | | }), |
| | 0 | 353 | | unitCost, |
| | 0 | 354 | | debug = stocks.Count == 0 ? "no stock found" : (string?)null |
| | 0 | 355 | | }); |
| | | 356 | | } |
| | 0 | 357 | | catch (Exception ex) |
| | | 358 | | { |
| | 0 | 359 | | return Ok(new { found = false, debug = ex.Message }); |
| | | 360 | | } |
| | 0 | 361 | | } |
| | | 362 | | |
| | | 363 | | /// <summary> |
| | | 364 | | /// Пакетная проверка остатков 1С для формы транзакции: агрегация по (карточка номенклатуры, |
| | | 365 | | /// склад) возможна только на сервере, поэтому строки присылаются пачкой, а не по одной. |
| | | 366 | | /// </summary> |
| | | 367 | | [ServiceEndpoint] |
| | | 368 | | [HttpPost("onec-stock-check")] |
| | | 369 | | public async Task<IActionResult> OneCStockCheck( |
| | | 370 | | [FromBody] OneCStockCheckRequest? request, |
| | | 371 | | CancellationToken cancellationToken = default) |
| | | 372 | | { |
| | 0 | 373 | | var lines = request?.Lines ?? []; |
| | 0 | 374 | | if (lines.Count == 0) |
| | 0 | 375 | | return Ok(new { ok = true, unavailable = false, lines = Array.Empty<object>() }); |
| | | 376 | | |
| | | 377 | | try |
| | | 378 | | { |
| | 0 | 379 | | var result = await _stockAvailability.CheckAsync( |
| | 0 | 380 | | lines.Select(l => new StockCheckLine( |
| | 0 | 381 | | l.OrderId, l.Article, l.ProductName, l.Producer, l.Warehouse, l.Quantity)).ToList(), |
| | 0 | 382 | | cancellationToken); |
| | | 383 | | |
| | 0 | 384 | | return Ok(new |
| | 0 | 385 | | { |
| | 0 | 386 | | ok = !result.HasBlocking, |
| | 0 | 387 | | unavailable = result.CheckSkipped, |
| | 0 | 388 | | lines = result.Lines.Select(l => new |
| | 0 | 389 | | { |
| | 0 | 390 | | orderIds = l.OrderIds, |
| | 0 | 391 | | article = l.Article, |
| | 0 | 392 | | productName = l.ProductName, |
| | 0 | 393 | | warehouse = l.WarehouseName, |
| | 0 | 394 | | required = l.Required, |
| | 0 | 395 | | available = l.Available, |
| | 0 | 396 | | status = l.Status.ToString() |
| | 0 | 397 | | }) |
| | 0 | 398 | | }); |
| | | 399 | | } |
| | 0 | 400 | | catch (Exception ex) |
| | | 401 | | { |
| | | 402 | | // Предпросмотр никогда не должен ронять форму — как и onec-lookup выше. |
| | 0 | 403 | | return Ok(new { ok = true, unavailable = true, lines = Array.Empty<object>(), debug = ex.Message }); |
| | | 404 | | } |
| | 0 | 405 | | } |
| | | 406 | | |
| | | 407 | | public sealed class OneCStockCheckRequest |
| | | 408 | | { |
| | 0 | 409 | | public List<OneCStockCheckRequestLine> Lines { get; set; } = []; |
| | | 410 | | } |
| | | 411 | | |
| | | 412 | | public sealed class OneCStockCheckRequestLine |
| | | 413 | | { |
| | 0 | 414 | | public int OrderId { get; set; } |
| | 0 | 415 | | public string? Article { get; set; } |
| | 0 | 416 | | public string? ProductName { get; set; } |
| | 0 | 417 | | public string? Producer { get; set; } |
| | 0 | 418 | | public string? Warehouse { get; set; } |
| | 0 | 419 | | public decimal Quantity { get; set; } |
| | | 420 | | } |
| | | 421 | | |
| | | 422 | | [HttpGet("api")] |
| | | 423 | | public async Task<IActionResult> Api( |
| | | 424 | | string? article = null, |
| | | 425 | | string? manufacturer = null, |
| | | 426 | | string? productName = null, |
| | | 427 | | string? warehouse = null, |
| | | 428 | | decimal? minQty = null, |
| | | 429 | | string sort = "article", |
| | | 430 | | bool desc = false, |
| | | 431 | | int page = 1, |
| | | 432 | | int pageSize = 50, |
| | | 433 | | CancellationToken cancellationToken = default) |
| | | 434 | | { |
| | 0 | 435 | | page = page < 1 ? 1 : page; |
| | 0 | 436 | | pageSize = pageSize <= 0 ? 50 : Math.Min(pageSize, 200); |
| | | 437 | | |
| | 0 | 438 | | var query = BuildFilteredQuery(article, manufacturer, productName, warehouse, minQty); |
| | 0 | 439 | | var totalCount = await query.CountAsync(cancellationToken); |
| | 0 | 440 | | query = ApplySorting(query, sort, desc); |
| | | 441 | | |
| | 0 | 442 | | var items = await query |
| | 0 | 443 | | .Skip((page - 1) * pageSize) |
| | 0 | 444 | | .Take(pageSize) |
| | 0 | 445 | | .Select(x => new |
| | 0 | 446 | | { |
| | 0 | 447 | | x.Id, |
| | 0 | 448 | | x.Manufacturer, |
| | 0 | 449 | | x.Article, |
| | 0 | 450 | | x.ProductName, |
| | 0 | 451 | | x.SaleFlag, |
| | 0 | 452 | | x.M2Qty, |
| | 0 | 453 | | x.M1Qty, |
| | 0 | 454 | | x.M3Qty, |
| | 0 | 455 | | x.B1Qty, |
| | 0 | 456 | | x.B2Qty, |
| | 0 | 457 | | x.SyncedAtUtc |
| | 0 | 458 | | }) |
| | 0 | 459 | | .ToListAsync(cancellationToken); |
| | | 460 | | |
| | 0 | 461 | | return Ok(new |
| | 0 | 462 | | { |
| | 0 | 463 | | totalCount, |
| | 0 | 464 | | page, |
| | 0 | 465 | | pageSize, |
| | 0 | 466 | | items |
| | 0 | 467 | | }); |
| | 0 | 468 | | } |
| | | 469 | | |
| | | 470 | | [AllowAnonymous] |
| | | 471 | | [HttpGet("sync/google")] |
| | | 472 | | public IActionResult SyncGoogleGet([FromQuery] string? code) => |
| | 1 | 473 | | EnqueueGoogleSheetSyncIfAuthorized(code); |
| | | 474 | | |
| | | 475 | | [AllowAnonymous] |
| | | 476 | | [HttpPost("sync/google/start")] |
| | | 477 | | [IgnoreAntiforgeryToken] |
| | | 478 | | public IActionResult SyncGoogleStart([FromForm] string? code) => |
| | 5 | 479 | | EnqueueGoogleSheetSyncIfAuthorized(code); |
| | | 480 | | |
| | | 481 | | [AllowAnonymous] |
| | | 482 | | [HttpGet("sync/google/start")] |
| | | 483 | | public IActionResult SyncGoogleStartGet([FromQuery] string? code) => |
| | 3 | 484 | | EnqueueGoogleSheetSyncIfAuthorized(code); |
| | | 485 | | |
| | | 486 | | private IActionResult EnqueueGoogleSheetSyncIfAuthorized(string? code) |
| | | 487 | | { |
| | 9 | 488 | | var expectedCode = ResolveStartSyncCode(); |
| | 9 | 489 | | if (string.IsNullOrWhiteSpace(expectedCode)) |
| | 2 | 490 | | return BadRequest("Не задан GoogleSheetsStockSync:StartSyncCode."); |
| | | 491 | | |
| | 7 | 492 | | if (!string.Equals(expectedCode, (code ?? string.Empty).Trim(), StringComparison.Ordinal)) |
| | 2 | 493 | | return Unauthorized("Неверный код запуска синхронизации."); |
| | | 494 | | |
| | | 495 | | // Google Sheets → WarehouseStockSnapshot; при успехе WarehouseStockSyncJob ставит RunAllPresetsJob. |
| | 5 | 496 | | _backgroundJobs.Enqueue<WarehouseStockSyncJob>(x => x.ExecuteAsync("AnonymousHttpTrigger")); |
| | 5 | 497 | | return Ok("Синхронизация WarehouseStock поставлена в очередь; при успехе запустятся пресеты BitrixStock."); |
| | | 498 | | } |
| | | 499 | | |
| | | 500 | | private string ResolveStartSyncCode() |
| | | 501 | | { |
| | 9 | 502 | | if (!string.IsNullOrWhiteSpace(_stockSyncOptions.StartSyncCode)) |
| | 7 | 503 | | return _stockSyncOptions.StartSyncCode.Trim(); |
| | | 504 | | |
| | 2 | 505 | | var local = _configuration["GoogleSheetsStockSync:Local:StartSyncCode"]; |
| | 2 | 506 | | if (!string.IsNullOrWhiteSpace(local)) |
| | 0 | 507 | | return local.Trim(); |
| | | 508 | | |
| | 2 | 509 | | var prod = _configuration["GoogleSheetsStockSync:Prod:StartSyncCode"]; |
| | 2 | 510 | | return prod?.Trim() ?? string.Empty; |
| | | 511 | | } |
| | | 512 | | |
| | | 513 | | [Authorize(Roles = "Admin")] |
| | | 514 | | [HttpPost("sync/google")] |
| | | 515 | | [ValidateAntiForgeryToken] |
| | | 516 | | public IActionResult SyncGoogle() |
| | | 517 | | { |
| | 0 | 518 | | var userName = User?.Identity?.Name ?? "ManualTrigger"; |
| | 0 | 519 | | _backgroundJobs.Enqueue<WarehouseStockSyncJob>(x => x.ExecuteAsync(userName)); |
| | 0 | 520 | | TempData["Success"] = "Синхронизация поставлена в очередь и выполняется в фоне."; |
| | 0 | 521 | | return RedirectToAction(nameof(Index)); |
| | | 522 | | } |
| | | 523 | | |
| | | 524 | | [Authorize(Roles = "Admin")] |
| | | 525 | | [HttpPost("sync/excel")] |
| | | 526 | | [ValidateAntiForgeryToken] |
| | | 527 | | public async Task<IActionResult> SyncExcel(IFormFile excelFile, CancellationToken cancellationToken = default) |
| | | 528 | | { |
| | 0 | 529 | | if (excelFile == null || excelFile.Length == 0) |
| | | 530 | | { |
| | 0 | 531 | | TempData["Error"] = "Выберите Excel-файл."; |
| | 0 | 532 | | return RedirectToAction(nameof(Index)); |
| | | 533 | | } |
| | | 534 | | |
| | 0 | 535 | | await _syncService.SyncFromExcelAsync(excelFile, User?.Identity?.Name, cancellationToken); |
| | 0 | 536 | | TempData["Success"] = "Ручной Excel-импорт завершен."; |
| | 0 | 537 | | return RedirectToAction(nameof(Index)); |
| | 0 | 538 | | } |
| | | 539 | | |
| | | 540 | | private IQueryable<OzonDomains.Models.WarehouseStockSnapshot> BuildFilteredQuery( |
| | | 541 | | string? article, |
| | | 542 | | string? manufacturer, |
| | | 543 | | string? productName, |
| | | 544 | | string? warehouse, |
| | | 545 | | decimal? minQty) |
| | | 546 | | { |
| | 0 | 547 | | var query = _context.WarehouseStockSnapshots.AsNoTracking().AsQueryable(); |
| | | 548 | | |
| | 0 | 549 | | if (!string.IsNullOrWhiteSpace(article)) |
| | | 550 | | { |
| | 0 | 551 | | var v = article.Trim(); |
| | 0 | 552 | | query = query.Where(x => x.Article != null && x.Article.Contains(v)); |
| | | 553 | | } |
| | | 554 | | |
| | 0 | 555 | | if (!string.IsNullOrWhiteSpace(manufacturer)) |
| | | 556 | | { |
| | 0 | 557 | | var v = manufacturer.Trim(); |
| | 0 | 558 | | query = query.Where(x => x.Manufacturer != null && x.Manufacturer.Contains(v)); |
| | | 559 | | } |
| | | 560 | | |
| | 0 | 561 | | if (!string.IsNullOrWhiteSpace(productName)) |
| | | 562 | | { |
| | 0 | 563 | | var v = productName.Trim(); |
| | 0 | 564 | | query = query.Where(x => x.ProductName != null && x.ProductName.Contains(v)); |
| | | 565 | | } |
| | | 566 | | |
| | 0 | 567 | | var qtyThreshold = minQty ?? 0m; |
| | 0 | 568 | | if (!string.IsNullOrWhiteSpace(warehouse)) |
| | | 569 | | { |
| | 0 | 570 | | switch (warehouse.Trim().ToUpperInvariant()) |
| | | 571 | | { |
| | | 572 | | case "M2": |
| | 0 | 573 | | query = query.Where(x => (x.M2Qty ?? 0m) >= qtyThreshold); |
| | 0 | 574 | | break; |
| | | 575 | | case "M1": |
| | 0 | 576 | | query = query.Where(x => (x.M1Qty ?? 0m) >= qtyThreshold); |
| | 0 | 577 | | break; |
| | | 578 | | case "M3": |
| | 0 | 579 | | query = query.Where(x => (x.M3Qty ?? 0m) >= qtyThreshold); |
| | 0 | 580 | | break; |
| | | 581 | | case "B1": |
| | 0 | 582 | | query = query.Where(x => (x.B1Qty ?? 0m) >= qtyThreshold); |
| | 0 | 583 | | break; |
| | | 584 | | case "B2": |
| | 0 | 585 | | query = query.Where(x => (x.B2Qty ?? 0m) >= qtyThreshold); |
| | 0 | 586 | | break; |
| | | 587 | | } |
| | | 588 | | } |
| | 0 | 589 | | else if (minQty.HasValue) |
| | | 590 | | { |
| | 0 | 591 | | query = query.Where(x => |
| | 0 | 592 | | (x.M2Qty ?? 0m) >= qtyThreshold || |
| | 0 | 593 | | (x.M1Qty ?? 0m) >= qtyThreshold || |
| | 0 | 594 | | (x.M3Qty ?? 0m) >= qtyThreshold || |
| | 0 | 595 | | (x.B1Qty ?? 0m) >= qtyThreshold || |
| | 0 | 596 | | (x.B2Qty ?? 0m) >= qtyThreshold); |
| | | 597 | | } |
| | | 598 | | |
| | 0 | 599 | | return query; |
| | | 600 | | } |
| | | 601 | | |
| | | 602 | | private static IQueryable<OzonDomains.Models.WarehouseStockSnapshot> ApplySorting( |
| | | 603 | | IQueryable<OzonDomains.Models.WarehouseStockSnapshot> query, |
| | | 604 | | string sort, |
| | | 605 | | bool desc) |
| | | 606 | | { |
| | 0 | 607 | | return (sort ?? string.Empty).ToLowerInvariant() switch |
| | 0 | 608 | | { |
| | 0 | 609 | | "manufacturer" => desc ? query.OrderByDescending(x => x.Manufacturer).ThenByDescending(x => x.Id) : query.Or |
| | 0 | 610 | | "updated" => desc ? query.OrderByDescending(x => x.SyncedAtUtc).ThenByDescending(x => x.Id) : query.OrderBy( |
| | 0 | 611 | | "m2" => desc ? query.OrderByDescending(x => x.M2Qty).ThenByDescending(x => x.Id) : query.OrderBy(x => x.M2Qt |
| | 0 | 612 | | "m1" => desc ? query.OrderByDescending(x => x.M1Qty).ThenByDescending(x => x.Id) : query.OrderBy(x => x.M1Qt |
| | 0 | 613 | | "m3" => desc ? query.OrderByDescending(x => x.M3Qty).ThenByDescending(x => x.Id) : query.OrderBy(x => x.M3Qt |
| | 0 | 614 | | "b1" => desc ? query.OrderByDescending(x => x.B1Qty).ThenByDescending(x => x.Id) : query.OrderBy(x => x.B1Qt |
| | 0 | 615 | | "b2" => desc ? query.OrderByDescending(x => x.B2Qty).ThenByDescending(x => x.Id) : query.OrderBy(x => x.B2Qt |
| | 0 | 616 | | _ => desc ? query.OrderByDescending(x => x.Article).ThenByDescending(x => x.Id) : query.OrderBy(x => x.Artic |
| | 0 | 617 | | }; |
| | | 618 | | } |
| | | 619 | | } |