Domain:
- foodmarket.Domain.Inventory.Stock — materialized aggregate per (Product, Store)
with Quantity, ReservedQuantity, computed Available. Unique index on tenant+
product+store.
- foodmarket.Domain.Inventory.StockMovement — append-only journal with signed
quantity, optional UnitCost, MovementType enum (Initial, Supply, RetailSale,
WholesaleSale, CustomerReturn, SupplierReturn, TransferOut, TransferIn,
WriteOff, Enter, InventoryAdjustment), document linkage (type, id, number),
OccurredAt, CreatedBy, Notes.
Application:
- IStockService.ApplyMovementAsync draft — appends movement row + upserts
materialized Stock row in the same unit of work. Callers control SaveChanges
so a posting doc can bundle all lines atomically.
Infrastructure:
- StockService implementation over AppDbContext.
- InventoryConfigurations EF mapping (precision 18,4 on quantities/costs;
indexes for product+time, store+time, document lookup).
- Migration Phase2a_Stock applied to dev DB (tables stocks, stock_movements).
API (GET, read-only for now):
- /api/inventory/stock — filter by store, product, includeZero; joins product +
unit + store names; server-side pagination.
- /api/inventory/movements — journal filtered by store/product/date range;
movement type as string enum for UI labels.
- Both [Authorize] (any authenticated user).
OtherSystem:
- MsCounterparty DTO (name, legalTitle, inn, kpp, companyType, tags...).
- OtherSystemClient.StreamCounterpartiesAsync — paginated like products.
- OtherSystemImportService.ImportCounterpartiesAsync — maps tags → Kind (supplier /
customer / both), companyType → LegalEntity/Individual; dedup by Name;
defensive trim on all string fields; per-item try/catch; batches of 500.
- /api/admin/other-system/import-counterparties endpoint (Admin policy).
Web:
- /inventory/stock list page (store filter, include-zero toggle, search; shows
quantity/reserved/available with red-on-negative, grey-on-zero accents).
- /inventory/movements list page (store filter; colored quantity +/-, Russian
labels for each movement type).
- OtherSystem import page restructured: single token test + two import buttons
(Товары, Контрагенты) + reusable ImportResult panel that handles both.
- Sidebar: new "Остатки" group with Остатки + Движения; icons Boxes + History.
Uses the ListPageShell pattern introduced in 447ac65 — sticky top bar, sticky
table header, only the body scrolls.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
207 lines
8.2 KiB
C#
207 lines
8.2 KiB
C#
using System.Security.Claims;
|
|
using foodmarket.Api.Infrastructure.Tenancy;
|
|
using foodmarket.Api.Seed;
|
|
using foodmarket.Application.Common.Tenancy;
|
|
using foodmarket.Infrastructure.Identity;
|
|
using foodmarket.Infrastructure.Persistence;
|
|
using Microsoft.AspNetCore.Identity;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using OpenIddict.Validation.AspNetCore;
|
|
using Serilog;
|
|
using static OpenIddict.Abstractions.OpenIddictConstants;
|
|
|
|
Log.Logger = new LoggerConfiguration()
|
|
.WriteTo.Console()
|
|
.CreateBootstrapLogger();
|
|
|
|
try
|
|
{
|
|
var builder = WebApplication.CreateBuilder(args);
|
|
|
|
builder.Host.UseSerilog((ctx, services, cfg) =>
|
|
cfg.ReadFrom.Configuration(ctx.Configuration).ReadFrom.Services(services));
|
|
|
|
const string CorsPolicy = "food-market-cors";
|
|
builder.Services.AddCors(opts => opts.AddPolicy(CorsPolicy, p =>
|
|
{
|
|
var origins = builder.Configuration.GetSection("Cors:AllowedOrigins").Get<string[]>()
|
|
?? ["http://localhost:5173"];
|
|
p.WithOrigins(origins).AllowAnyHeader().AllowAnyMethod().AllowCredentials();
|
|
}));
|
|
|
|
builder.Services.AddHttpContextAccessor();
|
|
builder.Services.AddScoped<ITenantContext, HttpContextTenantContext>();
|
|
|
|
builder.Services.AddDbContext<AppDbContext>(opts =>
|
|
{
|
|
opts.UseNpgsql(builder.Configuration.GetConnectionString("Default"),
|
|
npg => npg.MigrationsAssembly(typeof(AppDbContext).Assembly.GetName().Name));
|
|
opts.UseOpenIddict();
|
|
});
|
|
|
|
builder.Services.AddIdentity<User, Role>(opts =>
|
|
{
|
|
opts.Password.RequireDigit = true;
|
|
opts.Password.RequiredLength = 8;
|
|
opts.Password.RequireNonAlphanumeric = false;
|
|
opts.Password.RequireUppercase = false;
|
|
opts.User.RequireUniqueEmail = true;
|
|
})
|
|
.AddEntityFrameworkStores<AppDbContext>()
|
|
.AddDefaultTokenProviders();
|
|
|
|
builder.Services.Configure<IdentityOptions>(opts =>
|
|
{
|
|
opts.ClaimsIdentity.UserNameClaimType = Claims.Name;
|
|
opts.ClaimsIdentity.UserIdClaimType = Claims.Subject;
|
|
opts.ClaimsIdentity.RoleClaimType = Claims.Role;
|
|
});
|
|
|
|
builder.Services.AddOpenIddict()
|
|
.AddCore(opts => opts.UseEntityFrameworkCore().UseDbContext<AppDbContext>())
|
|
.AddServer(opts =>
|
|
{
|
|
opts.SetTokenEndpointUris("/connect/token");
|
|
opts.AllowPasswordFlow().AllowRefreshTokenFlow();
|
|
opts.AcceptAnonymousClients();
|
|
opts.RegisterScopes(Scopes.OpenId, Scopes.Profile, Scopes.Email, Scopes.Roles, "api");
|
|
|
|
// Persistent dev keys: RSA key stored in src/food-market.api/App_Data/openiddict-dev-key.xml.
|
|
// Survives API restarts so issued tokens remain valid across rebuilds.
|
|
// Never commit this file (it's in .gitignore via App_Data/). Production must use real certificates.
|
|
var keyPath = Path.Combine(builder.Environment.ContentRootPath, "App_Data", "openiddict-dev-key.xml");
|
|
var rsa = System.Security.Cryptography.RSA.Create(2048);
|
|
if (File.Exists(keyPath))
|
|
{
|
|
rsa.FromXmlString(File.ReadAllText(keyPath));
|
|
}
|
|
else
|
|
{
|
|
Directory.CreateDirectory(Path.GetDirectoryName(keyPath)!);
|
|
File.WriteAllText(keyPath, rsa.ToXmlString(includePrivateParameters: true));
|
|
}
|
|
var devKey = new Microsoft.IdentityModel.Tokens.RsaSecurityKey(rsa) { KeyId = "food-market-dev" };
|
|
opts.AddEncryptionKey(devKey);
|
|
opts.AddSigningKey(devKey);
|
|
if (builder.Environment.IsDevelopment())
|
|
{
|
|
opts.DisableAccessTokenEncryption();
|
|
}
|
|
|
|
opts.UseAspNetCore()
|
|
.EnableTokenEndpointPassthrough()
|
|
.DisableTransportSecurityRequirement();
|
|
|
|
opts.SetAccessTokenLifetime(TimeSpan.Parse(
|
|
builder.Configuration["OpenIddict:AccessTokenLifetime"] ?? "01:00:00"));
|
|
opts.SetRefreshTokenLifetime(TimeSpan.Parse(
|
|
builder.Configuration["OpenIddict:RefreshTokenLifetime"] ?? "30.00:00:00"));
|
|
})
|
|
.AddValidation(opts =>
|
|
{
|
|
opts.UseLocalServer();
|
|
opts.UseAspNetCore();
|
|
});
|
|
|
|
// Force OpenIddict validation to handle ALL [Authorize] challenges — otherwise AddIdentity's
|
|
// cookie scheme takes over and 401 becomes a 302 redirect to /Account/Login for API calls.
|
|
builder.Services.AddAuthentication(options =>
|
|
{
|
|
options.DefaultScheme = OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme;
|
|
options.DefaultAuthenticateScheme = OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme;
|
|
options.DefaultChallengeScheme = OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme;
|
|
});
|
|
builder.Services.AddAuthorization(opts =>
|
|
{
|
|
// Check the "role" claim explicitly — robust against role-claim-type mismatches between
|
|
// OpenIddict validation identity and the default ClaimTypes.Role uri.
|
|
opts.AddPolicy("AdminAccess", p => p.RequireAssertion(ctx =>
|
|
ctx.User.HasClaim(c => c.Type == Claims.Role && (c.Value == "Admin" || c.Value == "SuperAdmin"))));
|
|
});
|
|
|
|
builder.Services.AddControllers();
|
|
builder.Services.AddEndpointsApiExplorer();
|
|
builder.Services.AddSwaggerGen();
|
|
|
|
// MoySklad import integration. Auto-decompress gzip responses from MoySklad's edge.
|
|
builder.Services.AddHttpClient<foodmarket.Infrastructure.Integrations.MoySklad.MoySkladClient>()
|
|
.ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler
|
|
{
|
|
AutomaticDecompression = System.Net.DecompressionMethods.GZip | System.Net.DecompressionMethods.Deflate,
|
|
});
|
|
builder.Services.AddScoped<foodmarket.Infrastructure.Integrations.MoySklad.MoySkladImportService>();
|
|
|
|
// Inventory
|
|
builder.Services.AddScoped<foodmarket.Application.Inventory.IStockService, foodmarket.Infrastructure.Inventory.StockService>();
|
|
|
|
builder.Services.AddHostedService<OpenIddictClientSeeder>();
|
|
builder.Services.AddHostedService<SystemReferenceSeeder>();
|
|
builder.Services.AddHostedService<DevDataSeeder>();
|
|
// DemoCatalogSeeder disabled: real catalog is imported from MoySklad.
|
|
// Keep the file as reference for anyone starting without MoySklad access —
|
|
// just re-register here to turn demo data back on.
|
|
// builder.Services.AddHostedService<DemoCatalogSeeder>();
|
|
|
|
var app = builder.Build();
|
|
|
|
app.UseSerilogRequestLogging();
|
|
app.UseCors(CorsPolicy);
|
|
app.UseAuthentication();
|
|
app.UseAuthorization();
|
|
|
|
if (app.Environment.IsDevelopment())
|
|
{
|
|
app.UseSwagger();
|
|
app.UseSwaggerUI();
|
|
}
|
|
|
|
app.MapControllers();
|
|
|
|
app.MapGet("/health", () => Results.Ok(new { status = "ok", time = DateTime.UtcNow }));
|
|
|
|
app.MapGet("/api/_debug/whoami", (HttpContext ctx) =>
|
|
{
|
|
var identity = ctx.User.Identity as System.Security.Claims.ClaimsIdentity;
|
|
return Results.Ok(new
|
|
{
|
|
isAuthenticated = ctx.User.Identity?.IsAuthenticated,
|
|
authType = ctx.User.Identity?.AuthenticationType,
|
|
nameClaimType = identity?.NameClaimType,
|
|
roleClaimType = identity?.RoleClaimType,
|
|
isInRoleAdmin = ctx.User.IsInRole("Admin"),
|
|
hasAdminRoleClaim = ctx.User.HasClaim(c => c.Type == Claims.Role && c.Value == "Admin"),
|
|
claims = ctx.User.Claims.Select(c => new { c.Type, c.Value }),
|
|
});
|
|
}).RequireAuthorization();
|
|
|
|
app.MapGet("/api/me", (HttpContext ctx) =>
|
|
{
|
|
var user = ctx.User;
|
|
return Results.Ok(new
|
|
{
|
|
sub = user.FindFirst(Claims.Subject)?.Value,
|
|
name = user.Identity?.Name,
|
|
email = user.FindFirst(Claims.Email)?.Value,
|
|
roles = user.FindAll(Claims.Role).Select(c => c.Value),
|
|
orgId = user.FindFirst(HttpContextTenantContext.OrganizationClaim)?.Value,
|
|
});
|
|
}).RequireAuthorization();
|
|
|
|
if (app.Environment.IsDevelopment())
|
|
{
|
|
using var scope = app.Services.CreateScope();
|
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
|
db.Database.Migrate();
|
|
}
|
|
|
|
app.Run();
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Log.Fatal(ex, "Application terminated unexpectedly");
|
|
}
|
|
finally
|
|
{
|
|
Log.CloseAndFlush();
|
|
}
|