1
0

Squashed commit of the following:

commit 688c5426e8793b808b9c75c9a19733af0a402fcb
Author: Serghei Cebotari <serghei@cebotari.ru>
Date:   Sun Jul 21 16:25:14 2024 +0300

    Switch to port 8080

commit c39249f6528ec76686a9382d1dc375c07d1d5044
Author: Serghei Cebotari <serghei@cebotari.ru>
Date:   Sun Jul 21 16:24:59 2024 +0300

    Switch to alpine image

commit 5318d7ec3f4f3d205549cf6732fa5b066a1d0a36
Author: Serghei Cebotari <serghei@cebotari.ru>
Date:   Sun Jul 21 15:40:14 2024 +0300

    Add docker

commit b6cd60a973da26bc92cf1fb45b4d2396b7ce56ea
Author: Serghei Cebotari <serghei@cebotari.ru>
Date:   Sun Jul 21 15:00:12 2024 +0300

    Delete asynchrony

commit 44a194e6d27312f3b8dd0b9c9c02d873e06e0b22
Author: Serghei Cebotari <serghei@cebotari.ru>
Date:   Sun Jul 21 14:59:29 2024 +0300

    Add Equals and GetHasCode methods overrides to ProductQuantity class

commit a274eadd313e12f11cc84d32e5030bbc5b187f8c
Author: Serghei Cebotari <serghei@cebotari.ru>
Date:   Sun Jul 21 14:58:37 2024 +0300

    Add parsers tests

commit 4f969e70d9716d8ddb4f4efedd466846289d7e2b
Author: Serghei Cebotari <serghei@cebotari.ru>
Date:   Sun Jul 21 14:57:55 2024 +0300

    Update product tests

commit 2485e20d0e93bed562f929055b6867dc2574a95b
Author: Serghei Cebotari <serghei@cebotari.ru>
Date:   Sat Jul 20 19:34:19 2024 +0300

    Implement Excel parser

commit 30f2e28c87
Author: Serghei Cebotari <serghei@cebotari.ru>
Date:   Sat Jul 20 16:58:35 2024 +0300

    Implement csv parser

commit 08e86b43c0
Author: Serghei Cebotari <serghei@cebotari.ru>
Date:   Thu Jul 18 21:01:28 2024 +0300

    Edit port number
This commit is contained in:
Serghei Cebotari 2024-07-21 16:25:59 +03:00
parent e0313b83a0
commit 6fe0a5e92b
25 changed files with 488 additions and 16 deletions

12
.dockerignore Normal file
View File

@ -0,0 +1,12 @@
# directories
**/bin/
**/obj/
**/out/
# files
Dockerfile*
**/*.trx
**/*.md
**/*.ps1
**/*.cmd
**/*.sh

25
Dockerfile Normal file
View File

@ -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"]

View File

@ -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<Product, double> _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<ISkuParser>(file.ContentType);
IEnumerable<ProductQuantity> 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 }));
}
}

View File

@ -0,0 +1,65 @@
using System.Text.RegularExpressions;
namespace RhSolutions.SkuParser.Models;
public record Product
{
/// <summary>
/// Артикул РЕХАУ в заданном формате
/// </summary>
public required string Sku
{
get => _sku;
set
{
_sku = IsValudSku(value)
? value
: throw new ArgumentException("$Неверный артикул: {value}");
}
}
private string _sku = string.Empty;
private const string _parsePattern = @"(?<Lead>[1\s]|^|\b)(?<Article>\d{6})(?<Delimiter>[\s13-])(?<Variant>\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}";
}
}
/// <summary>
/// Проверка строки на наличие в ней артикула РЕХАУ
/// </summary>
/// <param name="value">Входная строка для проверки</param>
/// <param name="product">Артикул, если найден. null - если нет</param>
/// <returns>Если артикул в строке есть возвращает true, Если нет - false</returns>
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;
}

View File

@ -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();
}
}

View File

@ -1,6 +1,12 @@
using RhSolutions.SkuParser.Services;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
builder.Services
.AddKeyedScoped<ISkuParser, CsvParser>("text/csv")
.AddKeyedScoped<ISkuParser, ExcelParser>("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
.AddKeyedScoped<ISkuParser, ExcelParser>("application/vnd.ms-excel.sheet.macroenabled.12");
builder.Services.AddControllers();
var app = builder.Build(); var app = builder.Build();
app.MapControllers();
app.MapGet("/", () => "Hello World!"); app.Run();
app.Run();

View File

@ -4,7 +4,7 @@
"windowsAuthentication": false, "windowsAuthentication": false,
"anonymousAuthentication": true, "anonymousAuthentication": true,
"iisExpress": { "iisExpress": {
"applicationUrl": "http://localhost:35100", "applicationUrl": "http://localhost:8080",
"sslPort": 44355 "sslPort": 44355
} }
}, },
@ -12,17 +12,8 @@
"http": { "http": {
"commandName": "Project", "commandName": "Project",
"dotnetRunMessages": true, "dotnetRunMessages": true,
"launchBrowser": true, "launchBrowser": false,
"applicationUrl": "http://localhost:5087", "applicationUrl": "http://localhost:8080",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "https://localhost:7266;http://localhost:5087",
"environmentVariables": { "environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development" "ASPNETCORE_ENVIRONMENT": "Development"
} }

View File

@ -6,4 +6,9 @@
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup> </PropertyGroup>
<ItemGroup>
<PackageReference Include="ClosedXML" Version="0.102.3" />
<PackageReference Include="CsvHelper" Version="33.0.1" />
</ItemGroup>
</Project> </Project>

View File

@ -0,0 +1,24 @@
using System.Globalization;
using CsvHelper;
using CsvHelper.Configuration;
using RhSolutions.SkuParser.Models;
namespace RhSolutions.SkuParser.Services;
/// <summary>
/// Парсер артикулов и их количества из файлов *.csv
/// </summary>
public class CsvParser : ISkuParser
{
public IEnumerable<ProductQuantity> 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<ProductQuantity>().ToList();
}
}

View File

@ -0,0 +1,76 @@
using ClosedXML.Excel;
using RhSolutions.SkuParser.Models;
namespace RhSolutions.SkuParser.Services;
public class ExcelParser : ISkuParser
{
public IEnumerable<ProductQuantity> 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<ProductQuantity> 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;
}
}

View File

@ -0,0 +1,7 @@
using RhSolutions.SkuParser.Models;
namespace RhSolutions.SkuParser.Services;
public interface ISkuParser
{
public IEnumerable<ProductQuantity> ParseProducts(IFormFile file);
}

View File

@ -0,0 +1,48 @@
using RhSolutions.SkuParser.Services;
namespace RhSolutions.SkuParser.Tests;
public class ExcelParserTests
{
private static readonly List<ProductQuantity> _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);
}
}

View File

@ -0,0 +1,17 @@
using Microsoft.AspNetCore.Http;
using Moq;
namespace RhSolutions.SkuParser.Tests;
public static class FormFileUtil
{
public static Mock<IFormFile> GetMockFormFile(string workbookName)
{
string filepath = "./../../../Workbooks/" + workbookName;
var mockFile = new Mock<IFormFile>();
var memoryStream = new MemoryStream([.. File.ReadAllBytes(filepath)]);
mockFile.Setup(x => x.OpenReadStream())
.Returns(memoryStream);
return mockFile;
}
}

View File

@ -0,0 +1,2 @@
global using NUnit.Framework;
global using RhSolutions.SkuParser.Models;

View File

@ -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<Product> set = new();
if (Product.TryParse(value, out var product))
{
set.Add(product!);
}
else
{
Assert.Fail($"Parsing failed on {value}");
}
Assert.True(set.Contains(product!));
}
}

View File

@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.6.0" />
<PackageReference Include="Moq" Version="4.20.70" />
<PackageReference Include="NUnit" Version="3.13.3" />
<PackageReference Include="NUnit3TestAdapter" Version="4.2.1" />
<PackageReference Include="NUnit.Analyzers" Version="3.6.1" />
<PackageReference Include="coverlet.collector" Version="6.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\RhSolutions.SkuParser.Api\RhSolutions.SkuParser.Api.csproj" />
</ItemGroup>
</Project>

View File

@ -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
1 11303703100 2129,5
2 11303803100 503
3 11303903050 52
4 11080011001 2154
5 11080021001 134
6 11080031001 6
7 11080311001 462
8 11080611001 38
9 11080811001 24
10 11080831001 2

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -5,6 +5,8 @@ VisualStudioVersion = 17.0.31903.59
MinimumVisualStudioVersion = 10.0.40219.1 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}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RhSolutions.SkuParser.Api", "RhSolutions.SkuParser.Api\RhSolutions.SkuParser.Api.csproj", "{5178E712-F984-48F4-9C68-6DC8CCAA4053}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RhSolutions.SkuParser.Tests", "RhSolutions.SkuParser.Tests\RhSolutions.SkuParser.Tests.csproj", "{8EB147E6-E7B9-42AB-B634-BA2EA1A7A542}"
EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU 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}.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.ActiveCfg = Release|Any CPU
{5178E712-F984-48F4-9C68-6DC8CCAA4053}.Release|Any CPU.Build.0 = 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 EndGlobalSection
EndGlobal EndGlobal