diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..0aed759 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,12 @@ +# directories +**/bin/ +**/obj/ +**/out/ + +# files +Dockerfile* +**/*.trx +**/*.md +**/*.ps1 +**/*.cmd +**/*.sh \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..da6a91c --- /dev/null +++ b/Dockerfile @@ -0,0 +1,25 @@ +FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:8.0-alpine AS build +ARG TARGETARCH +WORKDIR /source + +COPY RhSolutions.SkuParser.Api/*.csproj . +RUN dotnet restore -a $TARGETARCH + +COPY RhSolutions.SkuParser.Api/. . +RUN dotnet publish -a $TARGETARCH --no-restore -o /app + +FROM mcr.microsoft.com/dotnet/aspnet:8.0-alpine +EXPOSE 8080 + +ENV \ + DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=false \ + LC_ALL=ru_RU.UTF-8 \ + LANG=ru_RU.UTF-8 +RUN apk add --no-cache \ + icu-data-full \ + icu-libs + +WORKDIR /app +COPY --from=build /app . +USER $APP_UID +ENTRYPOINT ["./RhSolutions.SkuParser.Api"] \ No newline at end of file diff --git a/RhSolutions.SkuParser.Api/Controllers/ProductsController.cs b/RhSolutions.SkuParser.Api/Controllers/ProductsController.cs new file mode 100644 index 0000000..d85b01b --- /dev/null +++ b/RhSolutions.SkuParser.Api/Controllers/ProductsController.cs @@ -0,0 +1,48 @@ +using Microsoft.AspNetCore.Mvc; +using RhSolutions.SkuParser.Models; +using RhSolutions.SkuParser.Services; + +namespace RhSolutions.SkuParser.Controllers; + +[ApiController] +[Route("/api/[controller]")] +public class ProductsController : ControllerBase +{ + private IServiceProvider _provider; + private Dictionary _result; + public ProductsController(IServiceProvider provider) + { + _provider = provider; + _result = new(); + } + + [HttpPost] + public IActionResult PostFiles() + { + IFormFileCollection files = Request.Form.Files; + try + { + foreach (var file in files) + { + ISkuParser parser = _provider.GetRequiredKeyedService(file.ContentType); + IEnumerable productQuantities = parser.ParseProducts(file); + foreach (ProductQuantity pq in productQuantities) + { + if (_result.ContainsKey(pq.Product)) + { + _result[pq.Product] += pq.Quantity; + } + else + { + _result.Add(pq.Product, pq.Quantity); + } + } + } + } + catch (Exception ex) + { + return BadRequest(error: $"{ex.Message}\n\n{ex.Source}\n{ex.StackTrace}"); + } + return new JsonResult(_result.Select(x => new { Sku = x.Key.ToString(), Quantity = x.Value })); + } +} \ No newline at end of file diff --git a/RhSolutions.SkuParser.Api/Models/Product.cs b/RhSolutions.SkuParser.Api/Models/Product.cs new file mode 100644 index 0000000..fd9ea45 --- /dev/null +++ b/RhSolutions.SkuParser.Api/Models/Product.cs @@ -0,0 +1,65 @@ +using System.Text.RegularExpressions; + +namespace RhSolutions.SkuParser.Models; + +public record Product +{ + /// + /// Артикул РЕХАУ в заданном формате + /// + public required string Sku + { + get => _sku; + set + { + _sku = IsValudSku(value) + ? value + : throw new ArgumentException("$Неверный артикул: {value}"); + } + } + private string _sku = string.Empty; + private const string _parsePattern = @"(?[1\s]|^|\b)(?
\d{6})(?[\s13-])(?\d{3})(\b|$)"; + private const string _validnessPattern = @"^1\d{6}[1|3]\d{3}$"; + + private static bool IsValudSku(string value) + { + return Regex.IsMatch(value.Trim(), _validnessPattern); + } + private static string GetSku(Match match) + { + string lead = match.Groups["Lead"].Value; + string article = match.Groups["Article"].Value; + string delimiter = match.Groups["Delimiter"].Value; + string variant = match.Groups["Variant"].Value; + + if (lead != "1" && delimiter == "-") + { + return $"1{article}1{variant}"; + } + else + { + return $"{lead}{article}{delimiter}{variant}"; + } + } + + /// + /// Проверка строки на наличие в ней артикула РЕХАУ + /// + /// Входная строка для проверки + /// Артикул, если найден. null - если нет + /// Если артикул в строке есть возвращает true, Если нет - false + public static bool TryParse(string value, out Product? product) + { + product = null; + MatchCollection matches = Regex.Matches(value, _parsePattern); + if (matches.Count == 0) + { + return false; + } + string sku = GetSku(matches.First()); + product = new Product() { Sku = sku }; + return true; + } + public override int GetHashCode() => Sku.GetHashCode(); + public override string ToString() => Sku; +} \ No newline at end of file diff --git a/RhSolutions.SkuParser.Api/Models/ProductQuantity.cs b/RhSolutions.SkuParser.Api/Models/ProductQuantity.cs new file mode 100644 index 0000000..b7b154d --- /dev/null +++ b/RhSolutions.SkuParser.Api/Models/ProductQuantity.cs @@ -0,0 +1,30 @@ +using CsvHelper.Configuration.Attributes; + +namespace RhSolutions.SkuParser.Models; + +public class ProductQuantity +{ + [Index(0)] + public required Product Product { get; set; } + [Index(1)] + public required double Quantity { get; set; } + + public override bool Equals(object? obj) + { + if (obj == null || GetType() != obj.GetType()) + { + return false; + } + ProductQuantity other = (ProductQuantity)obj; + return Product == other.Product && + Quantity == other.Quantity; + } + + public override int GetHashCode() + { + HashCode hash = new(); + hash.Add(Product); + hash.Add(Quantity); + return hash.ToHashCode(); + } +} diff --git a/RhSolutions.SkuParser.Api/Program.cs b/RhSolutions.SkuParser.Api/Program.cs index 1760df1..44c642a 100644 --- a/RhSolutions.SkuParser.Api/Program.cs +++ b/RhSolutions.SkuParser.Api/Program.cs @@ -1,6 +1,12 @@ +using RhSolutions.SkuParser.Services; + var builder = WebApplication.CreateBuilder(args); +builder.Services + .AddKeyedScoped("text/csv") + .AddKeyedScoped("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") + .AddKeyedScoped("application/vnd.ms-excel.sheet.macroenabled.12"); +builder.Services.AddControllers(); + var app = builder.Build(); - -app.MapGet("/", () => "Hello World!"); - -app.Run(); +app.MapControllers(); +app.Run(); \ No newline at end of file diff --git a/RhSolutions.SkuParser.Api/Properties/launchSettings.json b/RhSolutions.SkuParser.Api/Properties/launchSettings.json index 06b56be..09f4eae 100644 --- a/RhSolutions.SkuParser.Api/Properties/launchSettings.json +++ b/RhSolutions.SkuParser.Api/Properties/launchSettings.json @@ -4,7 +4,7 @@ "windowsAuthentication": false, "anonymousAuthentication": true, "iisExpress": { - "applicationUrl": "http://localhost:35100", + "applicationUrl": "http://localhost:8080", "sslPort": 44355 } }, @@ -12,17 +12,8 @@ "http": { "commandName": "Project", "dotnetRunMessages": true, - "launchBrowser": true, - "applicationUrl": "http://localhost:5087", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } - }, - "https": { - "commandName": "Project", - "dotnetRunMessages": true, - "launchBrowser": true, - "applicationUrl": "https://localhost:7266;http://localhost:5087", + "launchBrowser": false, + "applicationUrl": "http://localhost:8080", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } diff --git a/RhSolutions.SkuParser.Api/RhSolutions.SkuParser.Api.csproj b/RhSolutions.SkuParser.Api/RhSolutions.SkuParser.Api.csproj index 1b28a01..d6e06a1 100644 --- a/RhSolutions.SkuParser.Api/RhSolutions.SkuParser.Api.csproj +++ b/RhSolutions.SkuParser.Api/RhSolutions.SkuParser.Api.csproj @@ -6,4 +6,9 @@ enable + + + + + diff --git a/RhSolutions.SkuParser.Api/Services/CsvParser.cs b/RhSolutions.SkuParser.Api/Services/CsvParser.cs new file mode 100644 index 0000000..436a949 --- /dev/null +++ b/RhSolutions.SkuParser.Api/Services/CsvParser.cs @@ -0,0 +1,24 @@ +using System.Globalization; +using CsvHelper; +using CsvHelper.Configuration; +using RhSolutions.SkuParser.Models; + +namespace RhSolutions.SkuParser.Services; + +/// +/// Парсер артикулов и их количества из файлов *.csv +/// +public class CsvParser : ISkuParser +{ + public IEnumerable ParseProducts(IFormFile file) + { + using StreamReader reader = new(file.OpenReadStream()); + var config = new CsvConfiguration(CultureInfo.GetCultureInfo("ru-RU")) + { + HasHeaderRecord = false, + }; + using CsvReader csvReader = new(reader, config); + + return csvReader.GetRecords().ToList(); + } +} diff --git a/RhSolutions.SkuParser.Api/Services/ExcelParser.cs b/RhSolutions.SkuParser.Api/Services/ExcelParser.cs new file mode 100644 index 0000000..27b10bd --- /dev/null +++ b/RhSolutions.SkuParser.Api/Services/ExcelParser.cs @@ -0,0 +1,76 @@ +using ClosedXML.Excel; +using RhSolutions.SkuParser.Models; + +namespace RhSolutions.SkuParser.Services; + +public class ExcelParser : ISkuParser +{ + public IEnumerable ParseProducts(IFormFile file) + { + using XLWorkbook workbook = new(file.OpenReadStream()); + IXLWorksheet ws = workbook.Worksheet(1); + + var leftTop = ws.FirstCellUsed()?.Address; + var rightBottom = ws.LastCellUsed()?.Address; + if (new object?[] { leftTop, rightBottom }.Any(x => x == null)) + { + throw new ArgumentException($"Таблица пуста: {file.FileName}"); + } + + var lookupRange = ws.Range(leftTop, rightBottom).RangeUsed(); + var columns = lookupRange.Columns(); + + var skuColumnQuantity = columns + .Select(column => new + { + Column = column, + Products = column.CellsUsed() + .Select(cell => !cell.HasFormula && Product.TryParse(cell.Value.ToString(), out Product? p) ? p : null) + }) + .Select(c => new { c.Column, SkuCount = c.Products.Count(p => p != null) }) + .Aggregate((l, r) => l.SkuCount > r.SkuCount ? l : r); + var skuColumn = skuColumnQuantity.SkuCount > 0 ? skuColumnQuantity.Column : null; + + if (skuColumn == null) + { + throw new ArgumentException($"Столбец с артикулом не определен: {file.FileName}"); + } + + var quantityColumn = lookupRange.Columns().Skip(skuColumn.ColumnNumber()) + .Select(column => new + { + Column = column, + IsColumnWithNumbers = column.CellsUsed() + .Count(cell => cell.Value.IsNumber == true) > column.CellsUsed().Count() / 4 + }) + .First(x => x.IsColumnWithNumbers) + .Column; + + if (quantityColumn == null) + { + throw new ArgumentException($"Столбец с количеством не определен: {file.FileName}"); + } + + List result = new(); + var rows = quantityColumn.CellsUsed().Select(x => x.Address.RowNumber); + + foreach (var row in rows) + { + var quantity = quantityColumn.Cell(row).Value; + var sku = skuColumn.Cell(row).Value; + + if (quantity.IsNumber + && Product.TryParse(sku.ToString(), out Product? p)) + { + ProductQuantity pq = new() + { + Product = p!, + Quantity = quantity.GetNumber() + }; + result.Add(pq); + } + } + + return result; + } +} diff --git a/RhSolutions.SkuParser.Api/Services/ISkuParser.cs b/RhSolutions.SkuParser.Api/Services/ISkuParser.cs new file mode 100644 index 0000000..98b9d9c --- /dev/null +++ b/RhSolutions.SkuParser.Api/Services/ISkuParser.cs @@ -0,0 +1,7 @@ +using RhSolutions.SkuParser.Models; + +namespace RhSolutions.SkuParser.Services; +public interface ISkuParser +{ + public IEnumerable ParseProducts(IFormFile file); +} diff --git a/RhSolutions.SkuParser.Tests/ExcelParserTests.cs b/RhSolutions.SkuParser.Tests/ExcelParserTests.cs new file mode 100644 index 0000000..83e95c1 --- /dev/null +++ b/RhSolutions.SkuParser.Tests/ExcelParserTests.cs @@ -0,0 +1,48 @@ +using RhSolutions.SkuParser.Services; + +namespace RhSolutions.SkuParser.Tests; + +public class ExcelParserTests +{ + private static readonly List _expected = new() + { + new ProductQuantity() {Product= new Product() {Sku = "11303703100"}, Quantity = 2129.5}, + new ProductQuantity() {Product= new Product() {Sku = "11303803100"}, Quantity = 503}, + new ProductQuantity() {Product= new Product() {Sku = "11303903050"}, Quantity = 52}, + new ProductQuantity() {Product= new Product() {Sku = "11080011001"}, Quantity = 2154}, + new ProductQuantity() {Product= new Product() {Sku = "11080021001"}, Quantity = 134}, + new ProductQuantity() {Product= new Product() {Sku = "11080031001"}, Quantity = 6}, + new ProductQuantity() {Product= new Product() {Sku = "11080311001"}, Quantity = 462}, + new ProductQuantity() {Product= new Product() {Sku = "11080611001"}, Quantity = 38}, + new ProductQuantity() {Product= new Product() {Sku = "11080811001"}, Quantity = 24}, + new ProductQuantity() {Product= new Product() {Sku = "11080831001"}, Quantity = 2}, + }; + + [TestCase("simple.xlsx")] + [TestCase("simpleWithNames.xlsx")] + [TestCase("withHeader.xlsx")] + [TestCase("withHeaderAndGarbage.xlsx")] + [TestCase("twoTables.xlsx")] + [TestCase("rhSolutionsBsTable.xlsx")] + [TestCase("simpleWithFormulas.xlsx")] + public void XlsxTests(string filename) + { + var mockFile = FormFileUtil.GetMockFormFile(filename); + var parser = new ExcelParser(); + var actual = parser.ParseProducts(mockFile.Object); + Assert.That(actual.Count, Is.EqualTo(_expected.Count())); + CollectionAssert.AllItemsAreInstancesOfType(actual, typeof(ProductQuantity)); + CollectionAssert.AreEqual(_expected, actual); + } + + [TestCase("simple.csv")] + public void CsvTests(string filename) + { + var mockFile = FormFileUtil.GetMockFormFile(filename); + var parser = new CsvParser(); + var actual = parser.ParseProducts(mockFile.Object); + Assert.That(actual.Count, Is.EqualTo(_expected.Count())); + CollectionAssert.AllItemsAreInstancesOfType(actual, typeof(ProductQuantity)); + CollectionAssert.AreEqual(_expected, actual); + } +} diff --git a/RhSolutions.SkuParser.Tests/FormFileUtil.cs b/RhSolutions.SkuParser.Tests/FormFileUtil.cs new file mode 100644 index 0000000..aaee7ca --- /dev/null +++ b/RhSolutions.SkuParser.Tests/FormFileUtil.cs @@ -0,0 +1,17 @@ +using Microsoft.AspNetCore.Http; +using Moq; + +namespace RhSolutions.SkuParser.Tests; + +public static class FormFileUtil +{ + public static Mock GetMockFormFile(string workbookName) + { + string filepath = "./../../../Workbooks/" + workbookName; + var mockFile = new Mock(); + var memoryStream = new MemoryStream([.. File.ReadAllBytes(filepath)]); + mockFile.Setup(x => x.OpenReadStream()) + .Returns(memoryStream); + return mockFile; + } +} \ No newline at end of file diff --git a/RhSolutions.SkuParser.Tests/GlobalUsings.cs b/RhSolutions.SkuParser.Tests/GlobalUsings.cs new file mode 100644 index 0000000..139a90f --- /dev/null +++ b/RhSolutions.SkuParser.Tests/GlobalUsings.cs @@ -0,0 +1,2 @@ +global using NUnit.Framework; +global using RhSolutions.SkuParser.Models; \ No newline at end of file diff --git a/RhSolutions.SkuParser.Tests/ProductTests.cs b/RhSolutions.SkuParser.Tests/ProductTests.cs new file mode 100644 index 0000000..12f0944 --- /dev/null +++ b/RhSolutions.SkuParser.Tests/ProductTests.cs @@ -0,0 +1,75 @@ +namespace RhSolutions.SkuParser.Tests; + +public class ProductTests +{ + [TestCase("12222221001")] + [TestCase("12222223001")] + [TestCase("160001-001")] + public void SimpleParse(string value) + { + Assert.True(Product.TryParse(value, out _)); + } + + [TestCase("string 12222221001")] + [TestCase("12222223001 string")] + [TestCase("string 160001-001")] + [TestCase("160001-001 string ")] + [TestCase("11096641001 Трубка РЕХАУ из. нерж. стали для подкл. радиатора, Г-образная 16/250")] + public void AdvancedParse(string value) + { + Assert.True(Product.TryParse(value, out _)); + } + + [TestCase("11600011001")] + [TestCase("160001-001")] + public void ProductIsCorrect(string value) + { + if (Product.TryParse(value, out Product? product)) + { + Assert.That(product!.Sku, Is.EqualTo("11600011001")); + } + else + { + Assert.Fail($"Parsing failed on {value}"); + } + } + + [TestCase("1222222001")] + [TestCase("12222225001")] + public void NotParses(string value) + { + Assert.False(Product.TryParse(value, out _)); + } + + [Test] + public void ProductEquality() + { + string value = "12222223001"; + Product.TryParse(value, out Product? first); + Product.TryParse(value, out Product? second); + if (first == null || second == null) + { + Assert.Fail($"Parsing failed on {value}"); + } + else + { + Assert.True(first.Equals(second)); + } + } + + [Test] + public void HashTest() + { + string value = "12222223001"; + HashSet set = new(); + if (Product.TryParse(value, out var product)) + { + set.Add(product!); + } + else + { + Assert.Fail($"Parsing failed on {value}"); + } + Assert.True(set.Contains(product!)); + } +} \ No newline at end of file diff --git a/RhSolutions.SkuParser.Tests/RhSolutions.SkuParser.Tests.csproj b/RhSolutions.SkuParser.Tests/RhSolutions.SkuParser.Tests.csproj new file mode 100644 index 0000000..069fa02 --- /dev/null +++ b/RhSolutions.SkuParser.Tests/RhSolutions.SkuParser.Tests.csproj @@ -0,0 +1,25 @@ + + + + net8.0 + enable + enable + + false + true + + + + + + + + + + + + + + + + diff --git a/RhSolutions.SkuParser.Tests/Workbooks/rhSolutionsBsTable.xlsx b/RhSolutions.SkuParser.Tests/Workbooks/rhSolutionsBsTable.xlsx new file mode 100644 index 0000000..dd05614 Binary files /dev/null and b/RhSolutions.SkuParser.Tests/Workbooks/rhSolutionsBsTable.xlsx differ diff --git a/RhSolutions.SkuParser.Tests/Workbooks/simple.csv b/RhSolutions.SkuParser.Tests/Workbooks/simple.csv new file mode 100644 index 0000000..51d2c85 --- /dev/null +++ b/RhSolutions.SkuParser.Tests/Workbooks/simple.csv @@ -0,0 +1,10 @@ +11303703100;2129,5 +11303803100;503 +11303903050;52 +11080011001;2154 +11080021001;134 +11080031001;6 +11080311001;462 +11080611001;38 +11080811001;24 +11080831001;2 diff --git a/RhSolutions.SkuParser.Tests/Workbooks/simple.xlsx b/RhSolutions.SkuParser.Tests/Workbooks/simple.xlsx new file mode 100644 index 0000000..05ac907 Binary files /dev/null and b/RhSolutions.SkuParser.Tests/Workbooks/simple.xlsx differ diff --git a/RhSolutions.SkuParser.Tests/Workbooks/simpleWithFormulas.xlsx b/RhSolutions.SkuParser.Tests/Workbooks/simpleWithFormulas.xlsx new file mode 100644 index 0000000..7013e8e Binary files /dev/null and b/RhSolutions.SkuParser.Tests/Workbooks/simpleWithFormulas.xlsx differ diff --git a/RhSolutions.SkuParser.Tests/Workbooks/simpleWithNames.xlsx b/RhSolutions.SkuParser.Tests/Workbooks/simpleWithNames.xlsx new file mode 100644 index 0000000..88a4f25 Binary files /dev/null and b/RhSolutions.SkuParser.Tests/Workbooks/simpleWithNames.xlsx differ diff --git a/RhSolutions.SkuParser.Tests/Workbooks/twoTables.xlsx b/RhSolutions.SkuParser.Tests/Workbooks/twoTables.xlsx new file mode 100644 index 0000000..8532761 Binary files /dev/null and b/RhSolutions.SkuParser.Tests/Workbooks/twoTables.xlsx differ diff --git a/RhSolutions.SkuParser.Tests/Workbooks/withHeader.xlsx b/RhSolutions.SkuParser.Tests/Workbooks/withHeader.xlsx new file mode 100644 index 0000000..cc75854 Binary files /dev/null and b/RhSolutions.SkuParser.Tests/Workbooks/withHeader.xlsx differ diff --git a/RhSolutions.SkuParser.Tests/Workbooks/withHeaderAndGarbage.xlsx b/RhSolutions.SkuParser.Tests/Workbooks/withHeaderAndGarbage.xlsx new file mode 100644 index 0000000..31cc27b Binary files /dev/null and b/RhSolutions.SkuParser.Tests/Workbooks/withHeaderAndGarbage.xlsx differ diff --git a/RhSolutions.SkuParser.sln b/RhSolutions.SkuParser.sln index 65c9d38..05f155d 100644 --- a/RhSolutions.SkuParser.sln +++ b/RhSolutions.SkuParser.sln @@ -5,6 +5,8 @@ VisualStudioVersion = 17.0.31903.59 MinimumVisualStudioVersion = 10.0.40219.1 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RhSolutions.SkuParser.Api", "RhSolutions.SkuParser.Api\RhSolutions.SkuParser.Api.csproj", "{5178E712-F984-48F4-9C68-6DC8CCAA4053}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RhSolutions.SkuParser.Tests", "RhSolutions.SkuParser.Tests\RhSolutions.SkuParser.Tests.csproj", "{8EB147E6-E7B9-42AB-B634-BA2EA1A7A542}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -18,5 +20,9 @@ Global {5178E712-F984-48F4-9C68-6DC8CCAA4053}.Debug|Any CPU.Build.0 = Debug|Any CPU {5178E712-F984-48F4-9C68-6DC8CCAA4053}.Release|Any CPU.ActiveCfg = Release|Any CPU {5178E712-F984-48F4-9C68-6DC8CCAA4053}.Release|Any CPU.Build.0 = Release|Any CPU + {8EB147E6-E7B9-42AB-B634-BA2EA1A7A542}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8EB147E6-E7B9-42AB-B634-BA2EA1A7A542}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8EB147E6-E7B9-42AB-B634-BA2EA1A7A542}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8EB147E6-E7B9-42AB-B634-BA2EA1A7A542}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection EndGlobal