345 lines
19 KiB
C#
345 lines
19 KiB
C#
using System.Text.Json.Serialization;
|
||
using ERPCore.Infra.Auth;
|
||
using ERPCore.Infra.Auth.AuthHex;
|
||
using ERPCore.Infra.Gl;
|
||
using ERPCore.Infra.Persistence;
|
||
using ERPCore.Infra.Storage;
|
||
using ERPCore.Infra.UoW;
|
||
using ERPCore.Repositories;
|
||
using ERPCore.Repositories.Interfaces;
|
||
using ERPCore.Services;
|
||
using ERPCore.Services.Interfaces;
|
||
using ERPCore.Services.Auth;
|
||
using ERPCore.Services.Hrm;
|
||
using ERPCore.Services.Production;
|
||
using ERPCore.Services.Stock;
|
||
using ERPCore.System.Errors;
|
||
using Microsoft.AspNetCore.Authentication;
|
||
using Microsoft.AspNetCore.Authorization;
|
||
using Microsoft.EntityFrameworkCore;
|
||
using Microsoft.OpenApi;
|
||
using Serilog;
|
||
|
||
var builder = WebApplication.CreateBuilder(args);
|
||
|
||
// Serilog (file sink) — reads levels from configuration, plus a rolling daily file.
|
||
builder.Host.UseSerilog((ctx, cfg) => cfg
|
||
.ReadFrom.Configuration(ctx.Configuration)
|
||
.WriteTo.File("logs/erpcore-.log", rollingInterval: RollingInterval.Day));
|
||
|
||
// Controllers + JSON: serialize enums as their string names (docs/11 §8, camelCase).
|
||
builder.Services.AddControllers(o =>
|
||
{
|
||
o.Filters.Add<ERPCore.Infra.Filters.LowStockAlertFilter>();
|
||
// CSRF double-submit check on every mutating, cookie-authenticated endpoint (docs/02-SECURITY.md §B.2).
|
||
o.Filters.Add<ERPCore.Infra.Auth.CsrfProtectionFilter>();
|
||
})
|
||
.AddJsonOptions(o => o.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter()));
|
||
|
||
// EF Core + MariaDB (Pomelo). The server version is pinned explicitly rather than using
|
||
// ServerVersion.AutoDetect: AutoDetect opens a connection, which would make design-time
|
||
// commands (`dotnet ef migrations add`) and startup depend on the database being reachable.
|
||
// MariaDbServerVersion — not MySqlServerVersion — because the target is MariaDB 10.11, and
|
||
// Pomelo changes its SQL translation based on which it is given.
|
||
// IsNullOrWhiteSpace, not just null: appsettings.json ships the key with an empty value so the
|
||
// shape is self-documenting, and each environment supplies the real one. A blank must fail here
|
||
// with this message rather than reaching UseMySql, which would report an opaque parse error.
|
||
var connectionString = builder.Configuration.GetConnectionString("DefaultConnection");
|
||
if (string.IsNullOrWhiteSpace(connectionString))
|
||
throw new InvalidOperationException(
|
||
"ConnectionStrings:DefaultConnection is not configured. Set it in appsettings.<Environment>.json "
|
||
+ "or as the ConnectionStrings__DefaultConnection environment variable.");
|
||
|
||
// Optional override for a different local server, e.g. "8.0.44-mysql" in user-secrets
|
||
// (Database:ServerVersion) — Pomelo otherwise emits MariaDB-only SQL (INSERT … RETURNING) that
|
||
// MySQL rejects. Unset everywhere else, so the MariaDB 10.11 default above still applies.
|
||
var serverVersionOverride = builder.Configuration["Database:ServerVersion"];
|
||
ServerVersion serverVersion = string.IsNullOrWhiteSpace(serverVersionOverride)
|
||
? new MariaDbServerVersion(new Version(10, 11, 15))
|
||
: ServerVersion.Parse(serverVersionOverride);
|
||
|
||
builder.Services.AddDbContext<ErpDbContext>(o =>
|
||
{
|
||
o.UseMySql(connectionString, serverVersion);
|
||
});
|
||
|
||
// ProblemDetails (RFC 7807) + domain-exception mapping
|
||
builder.Services.AddProblemDetails();
|
||
builder.Services.AddExceptionHandler<DomainExceptionHandler>();
|
||
|
||
// Auth: validate external AuthHex RS256 tokens + ERP door policy (docs/10 A.4)
|
||
builder.Services.AddErpJwtAuth(builder.Configuration);
|
||
|
||
// Per-endpoint RBAC (docs/02-SECURITY.md AR-01) — [Authorize(Policy = NavPolicies.For("..."))].
|
||
builder.Services.AddMemoryCache();
|
||
builder.Services.AddScoped<INavPermissionService, NavPermissionService>();
|
||
builder.Services.AddSingleton<IAuthorizationPolicyProvider, NavPolicyProvider>();
|
||
builder.Services.AddScoped<IAuthorizationHandler, NavAuthorizationHandler>();
|
||
|
||
// AuthController proxy → AuthHex (docs/11 §2.0)
|
||
builder.Services.AddHttpClient<IAuthHexClient, AuthHexClient>(c =>
|
||
{
|
||
var baseUrl = builder.Configuration["AuthHex:BaseUrl"]
|
||
?? throw new InvalidOperationException("AuthHex:BaseUrl is not configured.");
|
||
c.BaseAddress = new Uri(baseUrl);
|
||
});
|
||
builder.Services.AddScoped<IAuthUserService, AuthUserService>();
|
||
builder.Services.AddScoped<IAuthRecoveryService, AuthRecoveryService>();
|
||
builder.Services.AddScoped<IAuthAltService, AuthAltService>();
|
||
|
||
// General Ledger service proxy → external GL microservice (docs/12-GENERAL-LEDGER-INTEGRATION.md)
|
||
builder.Services.AddHttpClient<IGeneralLedgerClient, GeneralLedgerClient>(c =>
|
||
{
|
||
var baseUrl = builder.Configuration["GeneralLedgerService:BaseUrl"];
|
||
if (string.IsNullOrWhiteSpace(baseUrl))
|
||
throw new InvalidOperationException("GeneralLedgerService:BaseUrl is not configured.");
|
||
|
||
// GeneralLedgerClient builds *relative* URIs ("journal-entries", "accounts", …), so the base
|
||
// address must keep its full path — including /api/v1/ — and end in '/'. Without the trailing
|
||
// slash, Uri resolution replaces the last segment and every call lands on /api/journal-entries.
|
||
if (!baseUrl.EndsWith('/')) baseUrl += "/";
|
||
c.BaseAddress = new Uri(baseUrl);
|
||
});
|
||
builder.Services.AddScoped<IGeneralLedgerService, GeneralLedgerService>();
|
||
|
||
// Fail fast on a misconfigured chart of accounts: every Gl*Code in configuration must exist in GL,
|
||
// otherwise the first user to touch that flow eats a 404 mid-transaction.
|
||
builder.Services.AddHostedService<GlAccountCodeValidator>();
|
||
|
||
// Current-user (audit actor). AuthHex has no sub/nameid → a claims transformation
|
||
// JIT-provisions a local shadow user and injects the local `int` id as `nameid`.
|
||
builder.Services.AddHttpContextAccessor();
|
||
builder.Services.AddScoped<ICurrentUser, CurrentUser>();
|
||
builder.Services.AddScoped<ICurrentBranchContext, CurrentBranchContext>();
|
||
builder.Services.AddScoped<IClaimsTransformation, ShadowUserClaimsTransformation>();
|
||
|
||
// Unit of work + generic repository base
|
||
builder.Services.AddScoped<IUnitOfWork, UnitOfWork>();
|
||
builder.Services.AddScoped(typeof(IRepository<>), typeof(Repository<>));
|
||
// Aggregate-specific repositories (bespoke queries on top of the generic base)
|
||
builder.Services.AddScoped<IBrandRepository, BrandRepository>();
|
||
builder.Services.AddScoped<IBundleSaleRepository, BundleSaleRepository>();
|
||
builder.Services.AddScoped<ICashInHandRepository, CashInHandRepository>();
|
||
builder.Services.AddScoped<ICategoryRepository, CategoryRepository>();
|
||
builder.Services.AddScoped<ICountRepository, CountRepository>();
|
||
builder.Services.AddScoped<ICustomerRepository, CustomerRepository>();
|
||
builder.Services.AddScoped<IDayEndReportRepository, DayEndReportRepository>();
|
||
builder.Services.AddScoped<IExpensePaymentRepository, ExpensePaymentRepository>();
|
||
builder.Services.AddScoped<IExpenseRepository, ExpenseRepository>();
|
||
builder.Services.AddScoped<IExpenseSetupRepository, ExpenseSetupRepository>();
|
||
builder.Services.AddScoped<IGrnRepository, GrnRepository>();
|
||
builder.Services.AddScoped<IItemImageRepository, ItemImageRepository>();
|
||
builder.Services.AddScoped<IItemRepository, ItemRepository>();
|
||
builder.Services.AddScoped<IItemTypeRepository, ItemTypeRepository>();
|
||
builder.Services.AddScoped<INumberSequenceRepository, NumberSequenceRepository>();
|
||
builder.Services.AddScoped<IOpeningStockRepository, OpeningStockRepository>();
|
||
builder.Services.AddScoped<IPriceAdjustmentRepository, PriceAdjustmentRepository>();
|
||
builder.Services.AddScoped<IProcurementConfigRepository, ProcurementConfigRepository>();
|
||
builder.Services.AddScoped<IProductConfigRepository, ProductConfigRepository>();
|
||
builder.Services.AddScoped<IPurchaseInvoicePaymentRepository, PurchaseInvoicePaymentRepository>();
|
||
builder.Services.AddScoped<IPurchaseInvoiceRepository, PurchaseInvoiceRepository>();
|
||
builder.Services.AddScoped<IPurchaseOrderRepository, PurchaseOrderRepository>();
|
||
builder.Services.AddScoped<IPurchaseReturnRepository, PurchaseReturnRepository>();
|
||
|
||
// Master-data services (docs/11 §2)
|
||
builder.Services.AddScoped<ICustomerService, CustomerService>();
|
||
builder.Services.AddScoped<IItemService, ItemService>();
|
||
builder.Services.AddScoped<IItemImageService, ItemImageService>();
|
||
builder.Services.AddScoped<IWarrantyPeriodService, WarrantyPeriodService>();
|
||
builder.Services.AddScoped<IUomService, UomService>();
|
||
builder.Services.AddScoped<ICategoryService, CategoryService>();
|
||
builder.Services.AddScoped<IBrandService, BrandService>();
|
||
builder.Services.AddScoped<ITaxRateService, TaxRateService>();
|
||
builder.Services.AddScoped<IItemTypeService, ItemTypeService>();
|
||
//builder.Services.AddScoped<ICompanyProfileService, CompanyProfileService>();
|
||
builder.Services.AddScoped<IProductConfigService, ProductConfigService>();
|
||
builder.Services.AddScoped<IBranchConfigService, BranchConfigService>();
|
||
builder.Services.AddScoped<ISalesConfigService, SalesConfigService>();
|
||
builder.Services.AddScoped<IGrnConfigService, GrnConfigService>();
|
||
builder.Services.AddScoped<IProcurementConfigService, ProcurementConfigService>();
|
||
builder.Services.AddScoped<IAccessConfigService, AccessConfigService>();
|
||
|
||
// Day-end owner report (docs/DAY_END_OWNER_REPORT_PLAN.md). Messages go through AuthHex, which
|
||
// holds the SMTP / SMS credentials; Messaging:Provider = "Log" writes them to the log instead.
|
||
builder.Services.AddScoped<ERPCore.Services.OwnerReport.OwnerReportContentBuilder>();
|
||
builder.Services.AddScoped<ERPCore.Services.OwnerReport.IOwnerReportService, ERPCore.Services.OwnerReport.OwnerReportService>();
|
||
if (string.Equals(builder.Configuration["Messaging:Provider"], "Log", StringComparison.OrdinalIgnoreCase))
|
||
builder.Services.AddScoped<ERPCore.Infra.Messaging.IMessageSender, ERPCore.Infra.Messaging.LogOnlyMessageSender>();
|
||
else
|
||
builder.Services.AddScoped<ERPCore.Infra.Messaging.IMessageSender, ERPCore.Infra.Messaging.AuthHexMessageSender>();
|
||
builder.Services.AddHostedService<ERPCore.Services.OwnerReport.OwnerReportJob>();
|
||
builder.Services.AddScoped<IOpeningBalanceService, OpeningBalanceService>();
|
||
builder.Services.AddScoped<IOpeningStockService, OpeningStockService>();
|
||
builder.Services.AddScoped<IGlAccountConfigService, GlAccountConfigService>();
|
||
builder.Services.AddScoped<IBranchAccessService, BranchAccessService>();
|
||
builder.Services.AddSingleton<INotificationModuleCatalog, NotificationModuleCatalog>();
|
||
builder.Services.AddScoped<INotificationConfigService, NotificationConfigService>();
|
||
builder.Services.AddScoped<INotificationService, NotificationService>();
|
||
builder.Services.AddScoped<IGlobalSearchService, GlobalSearchService>();
|
||
builder.Services.AddScoped<IVendorService, VendorService>();
|
||
builder.Services.AddScoped<IWarehouseService, WarehouseService>();
|
||
builder.Services.AddScoped<ISupportTicketService, SupportTicketService>();
|
||
|
||
// RBAC / sidebar: Role (shadow of AuthHex) + Permission assignment + user management
|
||
builder.Services.AddScoped<IRoleService, RoleService>();
|
||
builder.Services.AddScoped<IUserManagementService, UserManagementService>();
|
||
|
||
// Cross-cutting + procurement services (docs/11 §3)
|
||
builder.Services.AddScoped<INumberSequenceService, NumberSequenceService>();
|
||
builder.Services.AddScoped<IRequisitionService, RequisitionService>();
|
||
builder.Services.AddScoped<IRfqService, RfqService>();
|
||
builder.Services.AddScoped<IPurchaseOrderService, PurchaseOrderService>();
|
||
|
||
// Stock core + goods receipt (docs/11 §4–5)
|
||
builder.Services.AddScoped<IItemMeasure, ItemMeasure>();
|
||
builder.Services.AddScoped<IFifoCostingService, FifoCostingService>();
|
||
builder.Services.AddScoped<IStockService, StockService>();
|
||
builder.Services.AddScoped<IGrnService, GrnService>();
|
||
|
||
// Purchase invoices (vendor invoices consolidating Confirmed GRNs)
|
||
builder.Services.AddScoped<IPurchaseInvoiceService, PurchaseInvoiceService>();
|
||
builder.Services.AddScoped<IPurchaseInvoicePaymentService, PurchaseInvoicePaymentService>();
|
||
|
||
// Accounts: expenses (docs/EXPENSE_MODULE_PLAN.md). ExpenseService is also registered as itself:
|
||
// ExpensePaymentService reuses its post step so Post & Pay is one transaction.
|
||
builder.Services.AddScoped<IExpenseSetupService, ExpenseSetupService>();
|
||
builder.Services.AddScoped<ExpenseService>();
|
||
builder.Services.AddScoped<IExpenseService>(sp => sp.GetRequiredService<ExpenseService>());
|
||
builder.Services.AddScoped<IExpensePaymentService, ExpensePaymentService>();
|
||
|
||
// Accounts: bank deposits of till cash (docs/BANK_DEPOSIT_MODULE_PLAN.md). The business-day clock
|
||
// is shared with the Day-End Sales Report so both agree on what "today" is.
|
||
builder.Services.AddSingleton<BusinessDayClock>();
|
||
builder.Services.AddScoped<ICashInHandService, CashInHandService>();
|
||
builder.Services.AddScoped<IBankDepositService, BankDepositService>();
|
||
|
||
// Sales (Phase 1)
|
||
builder.Services.AddScoped<ISalesPricingService, SalesPricingService>();
|
||
builder.Services.AddScoped<ISalesDomainService, SalesDomainService>();
|
||
builder.Services.AddScoped<ISalesPostingService, SalesPostingService>();
|
||
builder.Services.AddScoped<ISalesDocumentWorkflowService, SalesDocumentWorkflowService>();
|
||
builder.Services.AddScoped<ISalesMappingService, SalesMappingService>();
|
||
builder.Services.AddScoped<ISalesInvoiceService, SalesInvoiceService>();
|
||
builder.Services.AddScoped<ISalesInvoicePaymentService, SalesInvoicePaymentService>();
|
||
builder.Services.AddScoped<ISalesSlipService, SalesSlipService>();
|
||
builder.Services.AddScoped<IBundleSaleService, BundleSaleService>();
|
||
builder.Services.AddScoped<ISalesPromotionSuggestionService, SalesPromotionSuggestionService>();
|
||
builder.Services.AddScoped<ISalesReportService, SalesReportService>();
|
||
builder.Services.AddScoped<ISalesReturnService, SalesReturnService>();
|
||
builder.Services.AddScoped<ISalesDayEndService, SalesDayEndService>();
|
||
builder.Services.AddScoped<IDayEndReportService, DayEndReportService>();
|
||
|
||
// Stock transactions + reference data (docs/11 §5–6)
|
||
builder.Services.AddScoped<IStockMutator, StockMutator>();
|
||
builder.Services.AddScoped<ISerialTrackingService, SerialTrackingService>();
|
||
builder.Services.AddScoped<IItemTrackingService, ItemTrackingService>();
|
||
builder.Services.AddScoped<IReasonCodeService, ReasonCodeService>();
|
||
// AdjustmentService is also registered as itself: CountService submits its variances through it.
|
||
builder.Services.AddScoped<AdjustmentService>();
|
||
builder.Services.AddScoped<IAdjustmentService>(sp => sp.GetRequiredService<AdjustmentService>());
|
||
builder.Services.AddScoped<IApprovalService, ApprovalService>();
|
||
builder.Services.AddScoped<IPriceAdjustmentService, PriceAdjustmentService>();
|
||
builder.Services.AddScoped<ApprovalInboxService>();
|
||
builder.Services.AddScoped<ITransferService, TransferService>();
|
||
builder.Services.AddScoped<ICountService, CountService>();
|
||
builder.Services.AddScoped<IReorderService, ReorderService>();
|
||
// Low-stock alerts: FIFO issues record into the tracker; the global LowStockAlertFilter checks
|
||
// them against reorder points after each successful request and raises "stock.low".
|
||
builder.Services.AddScoped<ERPCore.Services.Stock.LowStockTracker>();
|
||
builder.Services.AddScoped<ERPCore.Services.Stock.LowStockAlertService>();
|
||
builder.Services.AddScoped<ERPCore.Infra.Filters.LowStockAlertFilter>();
|
||
builder.Services.AddScoped<IPurchaseReturnService, PurchaseReturnService>();
|
||
|
||
// Cross-cutting: audit trail + GL-ready journal read access (docs/11 §6 / FR-X-02, FR-STK-13)
|
||
builder.Services.AddScoped<IAuditService, AuditService>();
|
||
|
||
// Dashboard aggregate stats (cross-domain read: stock, GRN, procurement)
|
||
builder.Services.AddScoped<IDashboardService, DashboardService>();
|
||
|
||
// HRM (docs/13-BACKEND-HRM-API.md): org masters, employee core, staff documents
|
||
builder.Services.AddSingleton<IFileStorageService, LocalFileStorageService>();
|
||
builder.Services.AddScoped<IBranchService, BranchService>();
|
||
builder.Services.AddScoped<IDepartmentService, DepartmentService>();
|
||
builder.Services.AddScoped<IDesignationService, DesignationService>();
|
||
builder.Services.AddScoped<IEmploymentTypeService, EmploymentTypeService>();
|
||
builder.Services.AddScoped<IWorkShiftService, WorkShiftService>();
|
||
builder.Services.AddScoped<IEmployeeUserLinkService, EmployeeUserLinkService>();
|
||
builder.Services.AddScoped<IEmployeeService, EmployeeService>();
|
||
builder.Services.AddScoped<IHrDocumentTypeService, HrDocumentTypeService>();
|
||
builder.Services.AddScoped<IEmployeeDocumentService, EmployeeDocumentService>();
|
||
|
||
// HRM: Leave (docs/13-BACKEND-HRM-API.md §5) — LeaveType/LeaveBalance built before
|
||
// LeaveRequest since approval increments balances; Attendance depends on LeaveRequest.
|
||
builder.Services.AddScoped<ILeaveTypeService, LeaveTypeService>();
|
||
builder.Services.AddScoped<ILeaveBalanceService, LeaveBalanceService>();
|
||
builder.Services.AddScoped<ILeaveRequestService, LeaveRequestService>();
|
||
|
||
// HRM: Attendance (docs/13-BACKEND-HRM-API.md §4)
|
||
builder.Services.AddScoped<IAttendanceComputationService, AttendanceComputationService>();
|
||
builder.Services.AddScoped<IAttendanceUploadService, AttendanceUploadService>();
|
||
|
||
// HRM: Payroll (docs/13-BACKEND-HRM-API.md §6) — masters/settings before the
|
||
// calculation service, which composes them; PayrollRunService orchestrates last.
|
||
builder.Services.AddScoped<ISalaryComponentService, SalaryComponentService>();
|
||
builder.Services.AddScoped<IEmployeeSalaryStructureService, EmployeeSalaryStructureService>();
|
||
builder.Services.AddScoped<IEmployeeLoanService, EmployeeLoanService>();
|
||
builder.Services.AddScoped<IPayrollStatutorySettingService, PayrollStatutorySettingService>();
|
||
builder.Services.AddScoped<ITaxSlabService, TaxSlabService>();
|
||
builder.Services.AddScoped<IPayrollCalculationService, PayrollCalculationService>();
|
||
builder.Services.AddScoped<IPayrollRunService, PayrollRunService>();
|
||
builder.Services.AddScoped<IPayslipService, PayslipService>();
|
||
|
||
// HRM: Reports (docs/13-BACKEND-HRM-API.md §6) — read-only, no new entities
|
||
builder.Services.AddScoped<IHrReportService, HrReportService>();
|
||
|
||
// Manufacturing / Production Lines (docs/30-BACKEND-PHASE2.md Part A). Templates stand
|
||
// alone; runs consume FifoCostingService for all stock movement. ProductionGraphValidator
|
||
// is deliberately unregistered — it is a pure static algorithm, not an injected service.
|
||
builder.Services.AddScoped<IProductionTemplateService, ProductionTemplateService>();
|
||
builder.Services.AddScoped<IProductionRunService, ProductionRunService>();
|
||
|
||
// Health checks (EF Core DB)
|
||
builder.Services.AddHealthChecks().AddDbContextCheck<ErpDbContext>();
|
||
|
||
// Swagger / OpenAPI (Swashbuckle v10 → OpenAPI 3.1)
|
||
builder.Services.AddEndpointsApiExplorer();
|
||
builder.Services.AddSwaggerGen(o =>
|
||
{
|
||
o.SwaggerDoc("v1", new OpenApiInfo { Title = "ERPCore API", Version = "v1" });
|
||
o.CustomSchemaIds(t => t.FullName!.Replace("+", "."));
|
||
});
|
||
|
||
var app = builder.Build();
|
||
|
||
// Seed configurable reference data (reason codes) idempotently at startup.
|
||
using (var scope = app.Services.CreateScope())
|
||
{
|
||
var db = scope.ServiceProvider.GetRequiredService<ErpDbContext>();
|
||
// await EnsureMigrationBaselineAsync(db);
|
||
await db.Database.MigrateAsync();
|
||
try
|
||
{
|
||
await DataSeeder.SeedAsync(db, builder.Configuration);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
throw new InvalidOperationException("Database migration succeeded, but startup seeding failed.", ex);
|
||
}
|
||
}
|
||
|
||
app.UseSerilogRequestLogging();
|
||
|
||
if (app.Environment.IsDevelopment())
|
||
{
|
||
app.UseSwagger();
|
||
app.UseSwaggerUI();
|
||
}
|
||
|
||
app.UseExceptionHandler(); // -> DomainExceptionHandler / ProblemDetails
|
||
app.UseAuthentication();
|
||
app.UseAuthorization();
|
||
app.MapControllers();
|
||
app.MapHealthChecks("/health");
|
||
app.Run();
|
||
|