From 8f13ca3ecd76cad028deec132589ce04d599acf4 Mon Sep 17 00:00:00 2001 From: MNSOFT <137189378+devmnsoft@users.noreply.github.com> Date: Fri, 4 Sep 2026 08:52:43 -0300 Subject: [PATCH] feat(inventory360): add inventory purchases batches cogs and replenishment --- .../Controllers/Inventory360Controllers.cs | 86 ++++++++++++++++ .../MobileInventory360Controller.cs | 15 +++ .../Presentation/BarberSync.Api/Program.cs | 8 ++ .../Inventory360/Inventory360Services.cs | 97 +++++++++++++++++++ MobileApp/scripts-smoke-test.js | 3 + MobileApp/src/services/api.js | 5 + ScriptsSQL/production_readiness_seed.sql | 14 +++ ScriptsSQL/script_completo.sql | 39 ++++++++ .../Controllers/Inventory360Controller.cs | 10 ++ .../Views/Inventory360/Audit.cshtml | 2 + .../Views/Inventory360/Batches.cshtml | 2 + .../Views/Inventory360/Costing.cshtml | 2 + .../Views/Inventory360/Index.cshtml | 2 + .../Views/Inventory360/InventoryCounts.cshtml | 2 + .../Views/Inventory360/Products.cshtml | 2 + .../Views/Inventory360/Purchases.cshtml | 2 + .../Views/Inventory360/Replenishment.cshtml | 2 + .../Views/Inventory360/Reports.cshtml | 2 + .../Views/Inventory360/ServiceInputs.cshtml | 2 + .../Views/Inventory360/Stock.cshtml | 2 + .../Views/Inventory360/Suppliers.cshtml | 2 + .../Views/Inventory360/Supplies.cshtml | 2 + .../Views/Inventory360/Transfers.cshtml | 2 + .../Inventory360/_Inventory360Page.cshtml | 4 + .../Views/Shared/_AdminSidebar.cshtml | 10 +- .../Views/Shared/_Sidebar.cshtml | 2 +- .../wwwroot/css/inventory360.css | 1 + .../wwwroot/js/inventory360.js | 6 ++ docs/ANALYTICS_WORKFLOW.md | 4 + docs/API_ROUTE_CONTRACTS.md | 10 ++ docs/CATALOG_PRICING_WORKFLOW.md | 4 + docs/COMMAND_CENTER_WORKFLOW.md | 4 + docs/FINANCE360_WORKFLOW.md | 4 + docs/INVENTORY360_WORKFLOW.md | 23 +++++ docs/PARTNERS_MARKETPLACE_WORKFLOW.md | 4 + docs/SERVICE_EXECUTION_CHECKOUT_WORKFLOW.md | 4 + docs/SOURCE_CODE_AUDIT_REPORT.md | 8 ++ scripts/validate-source-integrity.ps1 | 1 + scripts/validate-source-integrity.sh | 5 + scripts/validate-ui-contracts.ps1 | 1 + scripts/validate-ui-contracts.sh | 1 + 41 files changed, 395 insertions(+), 6 deletions(-) create mode 100644 Backend/Presentation/BarberSync.Api/Controllers/Inventory360Controllers.cs create mode 100644 Backend/Presentation/BarberSync.Api/Controllers/MobileInventory360Controller.cs create mode 100644 Backend/Presentation/BarberSync.Api/Services/Inventory360/Inventory360Services.cs create mode 100644 Web/BarberSync.AdminWeb/Controllers/Inventory360Controller.cs create mode 100644 Web/BarberSync.AdminWeb/Views/Inventory360/Audit.cshtml create mode 100644 Web/BarberSync.AdminWeb/Views/Inventory360/Batches.cshtml create mode 100644 Web/BarberSync.AdminWeb/Views/Inventory360/Costing.cshtml create mode 100644 Web/BarberSync.AdminWeb/Views/Inventory360/Index.cshtml create mode 100644 Web/BarberSync.AdminWeb/Views/Inventory360/InventoryCounts.cshtml create mode 100644 Web/BarberSync.AdminWeb/Views/Inventory360/Products.cshtml create mode 100644 Web/BarberSync.AdminWeb/Views/Inventory360/Purchases.cshtml create mode 100644 Web/BarberSync.AdminWeb/Views/Inventory360/Replenishment.cshtml create mode 100644 Web/BarberSync.AdminWeb/Views/Inventory360/Reports.cshtml create mode 100644 Web/BarberSync.AdminWeb/Views/Inventory360/ServiceInputs.cshtml create mode 100644 Web/BarberSync.AdminWeb/Views/Inventory360/Stock.cshtml create mode 100644 Web/BarberSync.AdminWeb/Views/Inventory360/Suppliers.cshtml create mode 100644 Web/BarberSync.AdminWeb/Views/Inventory360/Supplies.cshtml create mode 100644 Web/BarberSync.AdminWeb/Views/Inventory360/Transfers.cshtml create mode 100644 Web/BarberSync.AdminWeb/Views/Inventory360/_Inventory360Page.cshtml create mode 100644 Web/BarberSync.AdminWeb/wwwroot/css/inventory360.css create mode 100644 Web/BarberSync.AdminWeb/wwwroot/js/inventory360.js create mode 100644 docs/INVENTORY360_WORKFLOW.md diff --git a/Backend/Presentation/BarberSync.Api/Controllers/Inventory360Controllers.cs b/Backend/Presentation/BarberSync.Api/Controllers/Inventory360Controllers.cs new file mode 100644 index 0000000..af49abe --- /dev/null +++ b/Backend/Presentation/BarberSync.Api/Controllers/Inventory360Controllers.cs @@ -0,0 +1,86 @@ +using BarberSync.Api.Security; +using BarberSync.Api.Services.Inventory360; +using BarberSync.Api.Services.Team; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace BarberSync.Api.Controllers; + +[ApiController,Authorize,Route("api/inventory360")] +public sealed class Inventory360Controller(TeamDataService data):ControllerBase +{ + [HttpGet("dashboard"),RequirePermission("Inventory360.Read")] public async Task Dashboard(CancellationToken ct)=>new{success=true,data=(await data.QueryAsync(@"select coalesce(sum(quantity_available),0) available,coalesce(sum(quantity_on_hand*average_cost),0) inventory_value,count(*) filter(where quantity_available<=0) critical,(select count(*) from barber.inventory_stock_batches where tenant_id=@tenant and branch_id=@branch and status='Available' and expires_at between current_date and current_date+30) expiring,(select count(*) from barber.inventory_purchase_orders where tenant_id=@tenant and branch_id=@branch and status in ('Draft','Approved','PartiallyReceived')) pending_purchases from barber.inventory_stock_balances where tenant_id=@tenant and branch_id=@branch",null,ct)).Single()}; + [HttpGet("filter-options"),RequirePermission("Inventory360.Read")] public async Task Filters(CancellationToken ct)=>new{success=true,data=new{products=await data.QueryAsync("select id,name from barber.inventory_products where tenant_id=@tenant and branch_id=@branch and status='Active' order by name",null,ct),supplies=await data.QueryAsync("select id,name from barber.inventory_supplies where tenant_id=@tenant and branch_id=@branch and status='Active' order by name",null,ct),suppliers=await data.QueryAsync("select id,name from barber.inventory_suppliers where tenant_id=@tenant and branch_id=@branch and status='Active' order by name",null,ct)}}; +} + +[ApiController,Authorize,Route("api/inventory360/products")] +public sealed class InventoryProductsController(InventoryProductService service):ControllerBase +{ + [HttpGet,RequirePermission("Inventory360.Read")]public Task Search([FromQuery]string? query,[FromQuery]string? status,CancellationToken ct)=>service.SearchProductsAsync(new(query,status),ct); + [HttpPost,RequirePermission("Inventory360.Products.Manage")]public TaskCreate(CreateInventoryProductRequest r,CancellationToken ct)=>service.CreateProductAsync(r,ct); + [HttpPut("{id:guid}"),RequirePermission("Inventory360.Products.Manage")]public TaskUpdate(Guid id,UpdateInventoryProductRequest r,CancellationToken ct)=>service.UpdateProductAsync(r with{Id=id},ct); + [HttpPost("{id:guid}/activate"),RequirePermission("Inventory360.Products.Manage")]public TaskActivate(Guid id,CancellationToken ct)=>service.ActivateProductAsync(new(id),ct); + [HttpPost("{id:guid}/suspend"),RequirePermission("Inventory360.Products.Manage")]public TaskSuspend(Guid id,[FromBody]ReasonRequest r,CancellationToken ct)=>service.SuspendProductAsync(new(id,r.Reason),ct); + [HttpPost("{id:guid}/archive"),RequirePermission("Inventory360.Products.Manage")]public TaskArchive(Guid id,[FromBody]ReasonRequest r,CancellationToken ct)=>service.ArchiveProductAsync(new(id,r.Reason),ct); +} +public sealed record ReasonRequest(string Reason); +public sealed record InventoryMasterRequest(string Name,string? Description,string UnitOfMeasure,decimal? DefaultCost,string Status="Draft"); +public sealed record InventorySupplierRequest(string Name,string? Document,string? Email,string? Phone,Guid? PartnerId,string Status="Draft"); + +[ApiController,Authorize,Route("api/inventory360/supplies")] +public sealed class InventorySuppliesController(TeamDataService data):ControllerBase +{ + [HttpGet,RequirePermission("Inventory360.Read")]public async TaskList(CancellationToken ct)=>new{success=true,data=await data.QueryAsync("select * from barber.inventory_supplies where tenant_id=@tenant and branch_id=@branch and deleted_at is null order by name",null,ct)}; + [HttpPost,RequirePermission("Inventory360.Supplies.Manage")][HttpPut("{id:guid}"),RequirePermission("Inventory360.Supplies.Manage")]public async TaskSave(InventoryMasterRequest r,CancellationToken ct,Guid? id=null){if(string.IsNullOrWhiteSpace(r.Name)||string.IsNullOrWhiteSpace(r.UnitOfMeasure)||r.DefaultCost<0)throw new ArgumentException("Nome, unidade e custo válido são obrigatórios.");var key=await data.WriteAsync("insert into barber.inventory_supplies(id,tenant_id,branch_id,name,description,unit_of_measure,default_cost,status) values(@id,@tenant,@branch,@name,@description,@unit,@cost,@status) on conflict(id) do update set name=excluded.name,description=excluded.description,unit_of_measure=excluded.unit_of_measure,default_cost=excluded.default_cost,status=excluded.status,updated_at=now() where inventory_supplies.tenant_id=@tenant and inventory_supplies.branch_id=@branch","Inventory360.SupplySaved","inventory_supplies",id,null,c=>{TeamDataService.Add(c,"name",r.Name.Trim());TeamDataService.Add(c,"description",r.Description);TeamDataService.Add(c,"unit",r.UnitOfMeasure);TeamDataService.Add(c,"cost",r.DefaultCost);TeamDataService.Add(c,"status",r.Status);},ct);return new{success=true,data=new{id=key}};} +} + +[ApiController,Authorize,Route("api/inventory360/suppliers")] +public sealed class InventorySuppliersController(TeamDataService data):ControllerBase +{ + [HttpGet,RequirePermission("Inventory360.Read")]public async TaskList(CancellationToken ct)=>new{success=true,data=await data.QueryAsync("select * from barber.inventory_suppliers where tenant_id=@tenant and branch_id=@branch and deleted_at is null order by name",null,ct)}; + [HttpPost,RequirePermission("Inventory360.Suppliers.Manage")][HttpPut("{id:guid}"),RequirePermission("Inventory360.Suppliers.Manage")]public async TaskSave(InventorySupplierRequest r,CancellationToken ct,Guid? id=null){if(string.IsNullOrWhiteSpace(r.Name))throw new ArgumentException("Nome obrigatório.");var key=await data.WriteAsync("insert into barber.inventory_suppliers(id,tenant_id,branch_id,name,document,email,phone,partner_id,status) values(@id,@tenant,@branch,@name,@document,@email,@phone,@partner,@status) on conflict(id) do update set name=excluded.name,document=excluded.document,email=excluded.email,phone=excluded.phone,partner_id=excluded.partner_id,status=excluded.status,updated_at=now() where inventory_suppliers.tenant_id=@tenant and inventory_suppliers.branch_id=@branch","Inventory360.SupplierSaved","inventory_suppliers",id,null,c=>{TeamDataService.Add(c,"name",r.Name.Trim());TeamDataService.Add(c,"document",r.Document);TeamDataService.Add(c,"email",r.Email);TeamDataService.Add(c,"phone",r.Phone);TeamDataService.Add(c,"partner",r.PartnerId);TeamDataService.Add(c,"status",r.Status);},ct);return new{success=true,data=new{id=key}};} +} + +[ApiController,Authorize,Route("api/inventory360/service-inputs")] +public sealed class InventoryServiceInputsController(ServiceInputService service,TeamDataService data):ControllerBase +{ + [HttpGet,RequirePermission("Inventory360.Read")]public async TaskList(CancellationToken ct)=>new{success=true,data=await data.QueryAsync("select i.*,s.name service_name,p.name product_name,u.name supply_name from barber.inventory_service_inputs i join barber.services s on s.id=i.service_id left join barber.inventory_products p on p.id=i.product_id left join barber.inventory_supplies u on u.id=i.supply_id where i.tenant_id=@tenant and i.branch_id=@branch and i.deleted_at is null order by s.name",null,ct)}; + [HttpPost,RequirePermission("Inventory360.ServiceInputs.Manage")]public TaskConfigure(ConfigureServiceInputsRequest r,CancellationToken ct)=>service.ConfigureServiceInputsAsync(r,ct); + [HttpPost("preview"),RequirePermission("Inventory360.Read")]public TaskPreview(ServiceInputPreviewRequest r,CancellationToken ct)=>service.PreviewServiceConsumptionAsync(r,ct); +} + +[ApiController,Authorize,Route("api/inventory360/stock")] +public sealed class InventoryStockController(StockMovementService service,TeamDataService data):ControllerBase +{ + [HttpGet,RequirePermission("Inventory360.Read")]public async TaskList(CancellationToken ct)=>new{success=true,data=await data.QueryAsync("select * from barber.inventory_stock_balances where tenant_id=@tenant and branch_id=@branch order by updated_at desc nulls last",null,ct)}; + [HttpPost("receive"),RequirePermission("Inventory360.Stock.Manage")]public TaskReceive(ReceiveStockRequest r,CancellationToken ct)=>service.ReceiveStockAsync(r,ct); [HttpPost("consume"),RequirePermission("Inventory360.Stock.Manage")]public TaskConsume(ConsumeStockRequest r,CancellationToken ct)=>service.ConsumeStockAsync(r,ct); [HttpPost("reserve"),RequirePermission("Inventory360.Stock.Manage")]public TaskReserve(ReserveStockRequest r,CancellationToken ct)=>service.ReserveStockAsync(r,ct); [HttpPost("release-reservation"),RequirePermission("Inventory360.Stock.Manage")]public TaskRelease(ReleaseStockReservationRequest r,CancellationToken ct)=>service.ReleaseReservationAsync(r,ct); [HttpPost("reverse"),RequirePermission("Inventory360.Stock.Manage")]public TaskReverse(ReverseStockMovementRequest r,CancellationToken ct)=>service.ReverseMovementAsync(r,ct); +} + +[ApiController,Authorize,Route("api/inventory360/purchases")] +public sealed class InventoryPurchasesController(PurchaseOrderService service,TeamDataService data):ControllerBase +{ + [HttpGet,RequirePermission("Inventory360.Read")]public async TaskList(CancellationToken ct)=>new{success=true,data=await data.QueryAsync("select o.*,s.name supplier_name from barber.inventory_purchase_orders o join barber.inventory_suppliers s on s.id=o.supplier_id where o.tenant_id=@tenant and o.branch_id=@branch order by o.created_at desc",null,ct)}; + [HttpPost,RequirePermission("Inventory360.Purchases.Manage")]public TaskCreate(CreatePurchaseOrderRequest r,CancellationToken ct)=>service.CreatePurchaseOrderAsync(r,ct); [HttpPost("{id:guid}/approve"),RequirePermission("Inventory360.Purchases.Approve")]public TaskApprove(Guid id,CancellationToken ct)=>service.ApprovePurchaseOrderAsync(new(id),ct); [HttpPost("{id:guid}/receive"),RequirePermission("Inventory360.Purchases.Manage")]public TaskReceive(Guid id,ReceivePurchaseOrderRequest r,CancellationToken ct)=>service.ReceivePurchaseOrderAsync(r with{Id=id},ct); [HttpPost("{id:guid}/cancel"),RequirePermission("Inventory360.Purchases.Manage")]public TaskCancel(Guid id,ReasonRequest r,CancellationToken ct)=>service.CancelPurchaseOrderAsync(new(id,r.Reason),ct); [HttpPost("{id:guid}/return-to-supplier"),RequirePermission("Inventory360.Purchases.Manage")]public TaskReturn(Guid id,ReturnToSupplierRequest r,CancellationToken ct)=>service.ReturnToSupplierAsync(r with{Id=id},ct); +} + +[ApiController,Authorize,Route("api/inventory360/counts")] +public sealed class Inventory360CountsController(InventoryCountService service,TeamDataService data):ControllerBase +{ + [HttpGet,RequirePermission("Inventory360.Read")]public async TaskList(CancellationToken ct)=>new{success=true,data=await data.QueryAsync("select * from barber.inventory_counts where tenant_id=@tenant and branch_id=@branch order by created_at desc",null,ct)}; [HttpPost("open"),RequirePermission("Inventory360.Counts.Manage")]public TaskOpen(OpenInventoryCountRequest r,CancellationToken ct)=>service.OpenCountAsync(r,ct); [HttpPost("{id:guid}/items"),RequirePermission("Inventory360.Counts.Manage")]public TaskItem(Guid id,RegisterInventoryCountItemRequest r,CancellationToken ct)=>service.RegisterCountItemAsync(r with{CountId=id},ct); [HttpPost("{id:guid}/close"),RequirePermission("Inventory360.Counts.Manage")]public TaskClose(Guid id,CancellationToken ct)=>service.CloseCountAsync(new(id),ct); [HttpPost("{id:guid}/adjust"),RequirePermission("Inventory360.Counts.Manage")]public TaskAdjust(Guid id,CancellationToken ct)=>service.ApplyAdjustmentAsync(new(id),ct); +} + +[ApiController,Authorize,Route("api/inventory360/replenishment")] +public sealed class Inventory360ReplenishmentController(ReplenishmentService service):ControllerBase +{[HttpGet,RequirePermission("Inventory360.Read")]public TaskList(CancellationToken ct)=>service.GetDashboardAsync(new(),ct);[HttpPost("generate"),RequirePermission("Inventory360.Replenishment.Manage")]public TaskGenerate(CancellationToken ct)=>service.GenerateSuggestionsAsync(new(),ct);[HttpPost("{id:guid}/create-purchase"),RequirePermission("Inventory360.Replenishment.Manage")]public TaskCreate(Guid id,CancellationToken ct)=>service.CreatePurchaseOrderFromSuggestionAsync(new(id),ct);} + +[ApiController,Authorize,Route("api/inventory360/costing")] +public sealed class InventoryCostingController(InventoryCostingService service):ControllerBase +{[HttpGet,RequirePermission("Inventory360.Costing.Read")]public TaskGet([FromQuery]DateOnly from,[FromQuery]DateOnly to,CancellationToken ct)=>service.CalculateCogsAsync(new(from,to),ct);} + +[ApiController,Authorize,Route("api/inventory360")] +public sealed class Inventory360ReadController(TeamDataService data,InventoryCostingService costing):ControllerBase +{ + [HttpGet("batches"),RequirePermission("Inventory360.Read")]public async TaskBatches(CancellationToken ct)=>new{success=true,data=await data.QueryAsync("select * from barber.inventory_stock_batches where tenant_id=@tenant and branch_id=@branch order by expires_at nulls last",null,ct)}; + [HttpGet("audit"),RequirePermission("Inventory360.Read")]public async TaskAudit(CancellationToken ct)=>new{success=true,data=await data.QueryAsync("select * from barber.inventory_audit_events where tenant_id=@tenant and branch_id=@branch order by created_at desc limit 500",null,ct)}; + [HttpGet("reports/export"),RequirePermission("Inventory360.Reports.Export")]public async TaskExport([FromQuery]DateOnly from,[FromQuery]DateOnly to,CancellationToken ct){var x=await costing.ExportCostingAsync(new(from,to),ct);return File(x.Content,"text/csv",x.FileName);} +} diff --git a/Backend/Presentation/BarberSync.Api/Controllers/MobileInventory360Controller.cs b/Backend/Presentation/BarberSync.Api/Controllers/MobileInventory360Controller.cs new file mode 100644 index 0000000..298e919 --- /dev/null +++ b/Backend/Presentation/BarberSync.Api/Controllers/MobileInventory360Controller.cs @@ -0,0 +1,15 @@ +using BarberSync.Api.Security; +using BarberSync.Api.Services.Inventory360; +using BarberSync.Api.Services.Team; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +namespace BarberSync.Api.Controllers; +[ApiController,Authorize,Route("api/mobile/inventory360")] +public sealed class MobileInventory360Controller(TeamDataService data,InventoryCountService counts):ControllerBase +{ + [HttpGet("summary"),RequirePermission("Inventory360.Read")]public async TaskSummary(CancellationToken ct)=>new{success=true,data=(await data.QueryAsync("select coalesce(sum(quantity_available),0) available,count(*) filter(where quantity_available<=0) critical from barber.inventory_stock_balances where tenant_id=@tenant and branch_id=@branch",null,ct)).Single()}; + [HttpGet("products"),RequirePermission("Inventory360.Read")]public async TaskProducts(CancellationToken ct)=>new{success=true,data=await data.QueryAsync("select id,sku,barcode,name,unit_of_measure,status from barber.inventory_products where tenant_id=@tenant and branch_id=@branch and status='Active' and deleted_at is null order by name",null,ct)}; + [HttpGet("stock"),RequirePermission("Inventory360.Read")]public async TaskStock(CancellationToken ct)=>new{success=true,data=await data.QueryAsync("select product_id,supply_id,quantity_on_hand,quantity_reserved,quantity_available,last_movement_at from barber.inventory_stock_balances where tenant_id=@tenant and branch_id=@branch",null,ct)}; + [HttpGet("replenishment"),RequirePermission("Inventory360.Read")]public async TaskReplenishment(CancellationToken ct)=>new{success=true,data=await data.QueryAsync("select id,product_id,supply_id,suggested_quantity,reason,source_status,status from barber.inventory_replenishment_suggestions where tenant_id=@tenant and branch_id=@branch and status='Open'",null,ct)}; + [HttpPost("counts/{id:guid}/items"),RequirePermission("Inventory360.Counts.Manage")]public TaskCount(Guid id,RegisterInventoryCountItemRequest r,CancellationToken ct)=>counts.RegisterCountItemAsync(r with{CountId=id},ct); +} diff --git a/Backend/Presentation/BarberSync.Api/Program.cs b/Backend/Presentation/BarberSync.Api/Program.cs index d758f11..7cb053a 100644 --- a/Backend/Presentation/BarberSync.Api/Program.cs +++ b/Backend/Presentation/BarberSync.Api/Program.cs @@ -24,6 +24,7 @@ using BarberSync.Api.Services.ServiceExecution; using BarberSync.Api.Services.Team360; using BarberSync.Api.Services.Finance360; +using BarberSync.Api.Services.Inventory360; var builder = WebApplication.CreateBuilder(args); @@ -61,6 +62,13 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/Backend/Presentation/BarberSync.Api/Services/Inventory360/Inventory360Services.cs b/Backend/Presentation/BarberSync.Api/Services/Inventory360/Inventory360Services.cs new file mode 100644 index 0000000..93d7560 --- /dev/null +++ b/Backend/Presentation/BarberSync.Api/Services/Inventory360/Inventory360Services.cs @@ -0,0 +1,97 @@ +using System.Data.Common; +using System.Globalization; +using System.Text; +using BarberSync.Api.Services.Team; +using BarberSync.Api.Services.Finance360; + +namespace BarberSync.Api.Services.Inventory360; + +public sealed record InventoryProductResult(Guid Id,string Status); +public sealed record InventoryProductSearchResult(IReadOnlyList> Items); +public sealed record CreateInventoryProductRequest(string Name,string? Sku,string? Barcode,string UnitOfMeasure,decimal CostPrice,decimal SalePrice,bool StockControlRequired,bool PublicVisible,bool KioskVisible,bool MarketplaceVisible); +public sealed record UpdateInventoryProductRequest(Guid Id,string Name,string? Sku,string? Barcode,string UnitOfMeasure,decimal CostPrice,decimal SalePrice,bool StockControlRequired,bool PublicVisible,bool KioskVisible,bool MarketplaceVisible); +public sealed record ActivateInventoryProductRequest(Guid Id); public sealed record SuspendInventoryProductRequest(Guid Id,string Reason); public sealed record ArchiveInventoryProductRequest(Guid Id,string Reason); public sealed record InventoryProductSearchRequest(string? Query,string? Status); +public sealed record ServiceInputLine(Guid? ProductId,Guid? SupplyId,decimal Quantity,string UnitOfMeasure,string ConsumeOn); +public sealed record ConfigureServiceInputsRequest(Guid ServiceId,IReadOnlyList Items); public sealed record ServiceInputPreviewRequest(Guid ServiceId,decimal Executions=1); public sealed record ActivateServiceInputRuleRequest(Guid Id); public sealed record ArchiveServiceInputRuleRequest(Guid Id,string Reason); +public sealed record ServiceInputResult(IReadOnlyList Ids,string Status); public sealed record ServiceInputPreviewResult(IReadOnlyList> Items,string SourceStatus); +public sealed record ReceiveStockRequest(Guid? ProductId,Guid? SupplyId,decimal Quantity,decimal UnitCost,string SourceType,Guid SourceId,string IdempotencyKey,string? BatchCode,DateOnly? ExpiresAt); +public sealed record ConsumeStockRequest(Guid? ProductId,Guid? SupplyId,decimal Quantity,string SourceType,Guid SourceId,string IdempotencyKey); public sealed record ReserveStockRequest(Guid? ProductId,Guid? SupplyId,decimal Quantity,string SourceType,Guid SourceId,string IdempotencyKey); +public sealed record ReleaseStockReservationRequest(Guid MovementId,string Reason,string IdempotencyKey); public sealed record ReverseStockMovementRequest(Guid MovementId,string Reason,string IdempotencyKey); public sealed record StockBalanceRequest(Guid? ProductId,Guid? SupplyId); +public sealed record StockMovementResult(Guid Id,string Type,decimal Quantity); public sealed record StockBalanceResult(decimal OnHand,decimal Reserved,decimal Available,string SourceStatus); +public sealed record PurchaseLine(Guid? ProductId,Guid? SupplyId,decimal Quantity,decimal UnitCost); public sealed record CreatePurchaseOrderRequest(Guid SupplierId,DateOnly? ExpectedAt,IReadOnlyList Items); public sealed record ApprovePurchaseOrderRequest(Guid Id); public sealed record CancelPurchaseOrderRequest(Guid Id,string Reason); +public sealed record ReceivingLine(Guid PurchaseOrderItemId,decimal Quantity,string? BatchCode,DateOnly? ExpiresAt); public sealed record ReceivePurchaseOrderRequest(Guid Id,DateTimeOffset ReceivedAt,bool CreatePayable,IReadOnlyList Items); public sealed record ReturnToSupplierRequest(Guid Id,string Reason,IReadOnlyList Items); +public sealed record PurchaseOrderResult(Guid Id,string Status,decimal Total); public sealed record PurchaseReceivingResult(Guid Id,string Status); public sealed record SupplierReturnResult(Guid Id,string Status); +public sealed record OpenInventoryCountRequest(DateOnly CountDate); public sealed record RegisterInventoryCountItemRequest(Guid CountId,Guid? ProductId,Guid? SupplyId,Guid? BatchId,decimal CountedQuantity,string? Reason); public sealed record CloseInventoryCountRequest(Guid Id); public sealed record ApplyInventoryAdjustmentRequest(Guid Id); +public sealed record InventoryCountResult(Guid Id,string Status); public sealed record InventoryAdjustmentResult(Guid Id,string Status); +public sealed record ReplenishmentDashboardRequest(); public sealed record GenerateReplenishmentSuggestionsRequest(); public sealed record CreatePurchaseOrderFromSuggestionRequest(Guid Id); public sealed record ReplenishmentDashboardResult(IReadOnlyList> Items); public sealed record ReplenishmentSuggestionResult(int Created,string SourceStatus); +public sealed record CalculateAverageCostRequest(Guid? ProductId,Guid? SupplyId); public sealed record CalculateBatchCostRequest(Guid BatchId); public sealed record CogsRequest(DateOnly From,DateOnly To,Guid? ProductId=null,Guid? SupplyId=null); public sealed record InventoryCostExportRequest(DateOnly From,DateOnly To); +public sealed record InventoryCostResult(decimal? Cost,string SourceStatus); public sealed record CogsResult(decimal? Amount,string SourceStatus); public sealed record InventoryCostExportResult(byte[] Content,string FileName,string SourceStatus); + +internal static class InventoryRules +{ + internal static void Quantity(decimal value){if(value<=0)throw new ArgumentException("A quantidade deve ser maior que zero.");} + internal static void Item(Guid? product,Guid? supply){if((product is null)==(supply is null))throw new ArgumentException("Selecione exatamente um produto ou insumo.");} + internal static void Origin(string type,Guid id,string key){if(string.IsNullOrWhiteSpace(type)||id==Guid.Empty||string.IsNullOrWhiteSpace(key))throw new ArgumentException("Origem real e chave de idempotência são obrigatórias.");} + internal static decimal D(object? value)=>Convert.ToDecimal(value??0,CultureInfo.InvariantCulture); + internal static void Add(DbCommand c,string n,object? v)=>TeamDataService.Add(c,n,v); +} + +public sealed class InventoryProductService(TeamDataService data) +{ + public Task CreateProductAsync(CreateInventoryProductRequest r,CancellationToken ct)=>Save(Guid.NewGuid(),r.Name,r.Sku,r.Barcode,r.UnitOfMeasure,r.CostPrice,r.SalePrice,r.StockControlRequired,r.PublicVisible,r.KioskVisible,r.MarketplaceVisible,"Draft",ct); + public Task UpdateProductAsync(UpdateInventoryProductRequest r,CancellationToken ct)=>Save(r.Id,r.Name,r.Sku,r.Barcode,r.UnitOfMeasure,r.CostPrice,r.SalePrice,r.StockControlRequired,r.PublicVisible,r.KioskVisible,r.MarketplaceVisible,null,ct); + private async Task Save(Guid id,string name,string? sku,string? barcode,string unit,decimal cost,decimal sale,bool controlled,bool pub,bool kiosk,bool market,string? status,CancellationToken ct){if(string.IsNullOrWhiteSpace(name)||string.IsNullOrWhiteSpace(unit)||cost<0||sale<0)throw new ArgumentException("Nome, unidade e valores não negativos são obrigatórios.");await data.WriteAsync(@"insert into barber.inventory_products(id,tenant_id,branch_id,sku,barcode,name,unit_of_measure,cost_price,sale_price,stock_control_required,public_visible,kiosk_visible,marketplace_visible,status) values(@id,@tenant,@branch,@sku,@barcode,@name,@unit,@cost,@sale,@controlled,@public,@kiosk,@market,coalesce(@status,'Draft')) on conflict(id) do update set sku=excluded.sku,barcode=excluded.barcode,name=excluded.name,unit_of_measure=excluded.unit_of_measure,cost_price=excluded.cost_price,sale_price=excluded.sale_price,stock_control_required=excluded.stock_control_required,public_visible=excluded.public_visible,kiosk_visible=excluded.kiosk_visible,marketplace_visible=excluded.marketplace_visible,updated_at=now() where inventory_products.tenant_id=@tenant and inventory_products.branch_id=@branch and inventory_products.status<>'Archived'","Inventory360.ProductSaved","inventory_products",id,null,c=>{A(c,"sku",sku);A(c,"barcode",barcode);A(c,"name",name.Trim());A(c,"unit",unit);A(c,"cost",cost);A(c,"sale",sale);A(c,"controlled",controlled);A(c,"public",pub);A(c,"kiosk",kiosk);A(c,"market",market);A(c,"status",status);},ct);return new(id,status??"Updated");} + public Task ActivateProductAsync(ActivateInventoryProductRequest r,CancellationToken ct)=>Status(r.Id,"Active",null,ct); public Task SuspendProductAsync(SuspendInventoryProductRequest r,CancellationToken ct)=>Status(r.Id,"Suspended",r.Reason,ct); public Task ArchiveProductAsync(ArchiveInventoryProductRequest r,CancellationToken ct)=>Status(r.Id,"Archived",r.Reason,ct); + private async Task Status(Guid id,string status,string? reason,CancellationToken ct){if(status!="Active"&&string.IsNullOrWhiteSpace(reason))throw new ArgumentException("Motivo obrigatório.");await data.WriteAsync("update barber.inventory_products set status=@status,deleted_at=case when @status='Archived' then now() else null end,updated_at=now() where id=@id and tenant_id=@tenant and branch_id=@branch","Inventory360.Product"+status,"inventory_products",id,reason,c=>A(c,"status",status),ct);return new(id,status);} + public async Task SearchProductsAsync(InventoryProductSearchRequest r,CancellationToken ct)=>new(await data.QueryAsync("select id,sku,barcode,name,unit_of_measure,cost_price,sale_price,stock_control_required,public_visible,kiosk_visible,marketplace_visible,status from barber.inventory_products where tenant_id=@tenant and branch_id=@branch and deleted_at is null and (@query is null or name ilike '%'||@query||'%' or sku ilike '%'||@query||'%') and (@status is null or status=@status) order by name",c=>{A(c,"query",r.Query);A(c,"status",r.Status);},ct)); private static void A(DbCommand c,string n,object? v)=>InventoryRules.Add(c,n,v); +} + +public sealed class ServiceInputService(TeamDataService data) +{ + public async Task ConfigureServiceInputsAsync(ConfigureServiceInputsRequest r,CancellationToken ct){if(r.Items.Count==0)throw new ArgumentException("Adicione ao menos um insumo.");var ids=new List();foreach(var x in r.Items){InventoryRules.Item(x.ProductId,x.SupplyId);InventoryRules.Quantity(x.Quantity);if(string.IsNullOrWhiteSpace(x.UnitOfMeasure)||x.ConsumeOn is not("ServiceStarted" or "ServiceCompleted" or "CheckoutConfirmed"))throw new ArgumentException("Unidade ou momento de consumo inválido.");var id=await data.WriteAsync(@"insert into barber.inventory_service_inputs(id,tenant_id,branch_id,service_id,supply_id,product_id,quantity,unit_of_measure,consume_on,status) select @id,@tenant,@branch,s.id,@supply,@product,@quantity,@unit,@consume,'Active' from barber.services s where s.id=@service and s.tenant_id=@tenant and s.branch_id=@branch", "Inventory360.ServiceInputConfigured","inventory_service_inputs",null,null,c=>{A(c,"service",r.ServiceId);A(c,"supply",x.SupplyId);A(c,"product",x.ProductId);A(c,"quantity",x.Quantity);A(c,"unit",x.UnitOfMeasure);A(c,"consume",x.ConsumeOn);},ct);ids.Add(id);}return new(ids,"Active");} + public async Task PreviewServiceConsumptionAsync(ServiceInputPreviewRequest r,CancellationToken ct){InventoryRules.Quantity(r.Executions);return new(await data.QueryAsync("select id,product_id,supply_id,quantity*@executions quantity,unit_of_measure,consume_on,status from barber.inventory_service_inputs where tenant_id=@tenant and branch_id=@branch and service_id=@service and status='Active' and deleted_at is null",c=>{A(c,"service",r.ServiceId);A(c,"executions",r.Executions);},ct),"Available");} + public Task ActivateServiceInputRuleAsync(ActivateServiceInputRuleRequest r,CancellationToken ct)=>Set(r.Id,"Active",null,ct); public Task ArchiveServiceInputRuleAsync(ArchiveServiceInputRuleRequest r,CancellationToken ct)=>Set(r.Id,"Archived",r.Reason,ct); private async Task Set(Guid id,string status,string? reason,CancellationToken ct){if(status=="Archived"&&string.IsNullOrWhiteSpace(reason))throw new ArgumentException("Motivo obrigatório.");await data.WriteAsync("update barber.inventory_service_inputs set status=@status,deleted_at=case when @status='Archived' then now() else null end,updated_at=now() where id=@id and tenant_id=@tenant and branch_id=@branch","Inventory360.ServiceInput"+status,"inventory_service_inputs",id,reason,c=>A(c,"status",status),ct);return new([id],status);} private static void A(DbCommand c,string n,object? v)=>InventoryRules.Add(c,n,v); +} + +public sealed class StockMovementService(TeamDataService data) +{ + public Task ReceiveStockAsync(ReceiveStockRequest r,CancellationToken ct)=>Move("Receive",r.ProductId,r.SupplyId,r.Quantity,r.UnitCost,r.SourceType,r.SourceId,r.IdempotencyKey,null,null,r.BatchCode,r.ExpiresAt,ct); + public Task ConsumeStockAsync(ConsumeStockRequest r,CancellationToken ct)=>Move("Consume",r.ProductId,r.SupplyId,r.Quantity,null,r.SourceType,r.SourceId,r.IdempotencyKey,null,null,null,null,ct); + public Task ReserveStockAsync(ReserveStockRequest r,CancellationToken ct)=>Move("Reserve",r.ProductId,r.SupplyId,r.Quantity,null,r.SourceType,r.SourceId,r.IdempotencyKey,null,null,null,null,ct); + public async Task ReleaseReservationAsync(ReleaseStockReservationRequest r,CancellationToken ct)=>await Reverse(r.MovementId,"Release",r.Reason,r.IdempotencyKey,ct); public async Task ReverseMovementAsync(ReverseStockMovementRequest r,CancellationToken ct)=>await Reverse(r.MovementId,"Reverse",r.Reason,r.IdempotencyKey,ct); + private async Task Reverse(Guid original,string type,string reason,string key,CancellationToken ct){if(string.IsNullOrWhiteSpace(reason)||string.IsNullOrWhiteSpace(key))throw new ArgumentException("Motivo e idempotência são obrigatórios.");var row=(await data.QueryAsync("select product_id,supply_id,quantity,unit_cost,id from barber.inventory_stock_movements where id=@original and tenant_id=@tenant and branch_id=@branch and reverse_of_movement_id is null",c=>A(c,"original",original),ct)).SingleOrDefault()??throw new InvalidOperationException("Movimento original não encontrado.");return await Move(type,row["product_id"] as Guid?,row["supply_id"] as Guid?,InventoryRules.D(row["quantity"]),row["unit_cost"] is null?null:InventoryRules.D(row["unit_cost"]),"StockMovement",original,key,original,reason,null,null,ct);} + private async Task Move(string type,Guid? product,Guid? supply,decimal quantity,decimal? cost,string source,Guid sourceId,string key,Guid? reverse,string? reason,string? batch,DateOnly? expires,CancellationToken ct){InventoryRules.Item(product,supply);InventoryRules.Quantity(quantity);InventoryRules.Origin(source,sourceId,key);var sign=type is "Receive" or "Release" or "Reverse"?1:-1;var id=await data.WriteAsync(@"with locked as(select coalesce(quantity_on_hand,0) on_hand,coalesce(quantity_reserved,0) reserved from barber.inventory_stock_balances where tenant_id=@tenant and branch_id=@branch and product_id is not distinct from @product and supply_id is not distinct from @supply for update), guard as(select 1 where @sign>0 or coalesce((select on_hand-reserved from locked),0)>=@quantity), movement as(insert into barber.inventory_stock_movements(id,tenant_id,branch_id,movement_type,product_id,supply_id,quantity,unit_cost,total_cost,source_type,source_id,reverse_of_movement_id,idempotency_key,reason) select @id,@tenant,@branch,@type,@product,@supply,@quantity,@cost,@quantity*@cost,@source,@sourceId,@reverse,@key,@reason from guard on conflict(tenant_id,branch_id,idempotency_key) do update set idempotency_key=excluded.idempotency_key returning id), balance as(insert into barber.inventory_stock_balances(id,tenant_id,branch_id,product_id,supply_id,quantity_on_hand,quantity_reserved,quantity_available,average_cost,last_movement_at) select gen_random_uuid(),@tenant,@branch,@product,@supply,@sign*@quantity,case when @type='Reserve' then @quantity else 0 end,@sign*@quantity-case when @type='Reserve' then @quantity else 0 end,@cost,now() from movement on conflict(tenant_id,branch_id,coalesce(product_id,'00000000-0000-0000-0000-000000000000'::uuid),coalesce(supply_id,'00000000-0000-0000-0000-000000000000'::uuid)) do update set quantity_on_hand=inventory_stock_balances.quantity_on_hand+case when @type='Reserve' then 0 else @sign*@quantity end,quantity_reserved=inventory_stock_balances.quantity_reserved+case when @type='Reserve' then @quantity when @type='Release' then -@quantity else 0 end,quantity_available=inventory_stock_balances.quantity_available+case when @type='Reserve' then -@quantity when @type='Release' then @quantity else @sign*@quantity end,average_cost=case when @type='Receive' and @cost is not null then ((inventory_stock_balances.quantity_on_hand*coalesce(inventory_stock_balances.average_cost,0))+(@quantity*@cost))/nullif(inventory_stock_balances.quantity_on_hand+@quantity,0) else inventory_stock_balances.average_cost end,last_movement_at=now(),updated_at=now()) select id from movement","Inventory360.Stock"+type,"inventory_stock_movements",null,reason,c=>{A(c,"type",type);A(c,"product",product);A(c,"supply",supply);A(c,"quantity",quantity);A(c,"cost",cost);A(c,"source",source);A(c,"sourceId",sourceId);A(c,"reverse",reverse);A(c,"key",key);A(c,"reason",reason);A(c,"sign",sign);},ct);return new(id,type,quantity);} + public async Task GetStockBalanceAsync(StockBalanceRequest r,CancellationToken ct){InventoryRules.Item(r.ProductId,r.SupplyId);var row=(await data.QueryAsync("select quantity_on_hand,quantity_reserved,quantity_available from barber.inventory_stock_balances where tenant_id=@tenant and branch_id=@branch and product_id is not distinct from @product and supply_id is not distinct from @supply",c=>{A(c,"product",r.ProductId);A(c,"supply",r.SupplyId);},ct)).SingleOrDefault();return row is null?new(0,0,0,"Unavailable"):new(InventoryRules.D(row["quantity_on_hand"]),InventoryRules.D(row["quantity_reserved"]),InventoryRules.D(row["quantity_available"]),"Available");} private static void A(DbCommand c,string n,object? v)=>InventoryRules.Add(c,n,v); +} + +public sealed class InventoryCostingService(TeamDataService data) +{ + public async Task CalculateAverageCostAsync(CalculateAverageCostRequest r,CancellationToken ct){InventoryRules.Item(r.ProductId,r.SupplyId);var rows=await data.QueryAsync("select average_cost from barber.inventory_stock_balances where tenant_id=@tenant and branch_id=@branch and product_id is not distinct from @product and supply_id is not distinct from @supply and average_cost is not null",c=>{A(c,"product",r.ProductId);A(c,"supply",r.SupplyId);},ct);return rows.Count==0?new(null,"Unavailable"):new(InventoryRules.D(rows[0]["average_cost"]),"AverageCost");} + public async Task CalculateBatchCostAsync(CalculateBatchCostRequest r,CancellationToken ct){var rows=await data.QueryAsync("select unit_cost from barber.inventory_stock_batches where id=@batch and tenant_id=@tenant and branch_id=@branch and status not in ('Blocked','Reversed')",c=>A(c,"batch",r.BatchId),ct);return rows.Count==0?new(null,"Unavailable"):new(InventoryRules.D(rows[0]["unit_cost"]),"BatchCost");} + public async Task CalculateCogsAsync(CogsRequest r,CancellationToken ct){if(r.To{A(c,"from",r.From);A(c,"to",r.To);A(c,"product",r.ProductId);A(c,"supply",r.SupplyId);},ct)).Single();return InventoryRules.D(row["total"])==0||InventoryRules.D(row["costed"]) ExportCostingAsync(InventoryCostExportRequest r,CancellationToken ct){var c=await CalculateCogsAsync(new(r.From,r.To),ct);var csv=$"periodo_inicio;periodo_fim;cmv;source_status\n{r.From:yyyy-MM-dd};{r.To:yyyy-MM-dd};{c.Amount?.ToString(CultureInfo.InvariantCulture)??string.Empty};{c.SourceStatus}\n";return new(Encoding.UTF8.GetPreamble().Concat(Encoding.UTF8.GetBytes(csv)).ToArray(),$"inventory-costing-{r.From:yyyyMMdd}-{r.To:yyyyMMdd}.csv",c.SourceStatus);} private static void A(DbCommand c,string n,object? v)=>InventoryRules.Add(c,n,v); +} + +public sealed class PurchaseOrderService(TeamDataService data,StockMovementService stock,AccountsPayableService payable) +{ + public async Task CreatePurchaseOrderAsync(CreatePurchaseOrderRequest r,CancellationToken ct){if(r.Items.Count==0||r.Items.Any(x=>x.Quantity<=0||x.UnitCost<0))throw new ArgumentException("Itens com quantidade positiva e custo não negativo são obrigatórios.");var id=Guid.NewGuid();var total=r.Items.Sum(x=>x.Quantity*x.UnitCost);await data.WriteAsync("insert into barber.inventory_purchase_orders(id,tenant_id,branch_id,supplier_id,status,order_number,expected_at,total_amount) select @id,@tenant,@branch,s.id,'Draft','PC-'||upper(substr(replace(@id::text,'-',''),1,10)),@expected,@total from barber.inventory_suppliers s where s.id=@supplier and s.tenant_id=@tenant and s.branch_id=@branch and s.status='Active'","Inventory360.PurchaseCreated","inventory_purchase_orders",id,null,c=>{A(c,"supplier",r.SupplierId);A(c,"expected",r.ExpectedAt);A(c,"total",total);},ct);foreach(var x in r.Items){InventoryRules.Item(x.ProductId,x.SupplyId);await data.WriteAsync("insert into barber.inventory_purchase_order_items(id,tenant_id,branch_id,purchase_order_id,product_id,supply_id,quantity,unit_cost,total_cost) values(@id,@tenant,@branch,@order,@product,@supply,@quantity,@cost,@quantity*@cost)","Inventory360.PurchaseItemCreated","inventory_purchase_order_items",null,null,c=>{A(c,"order",id);A(c,"product",x.ProductId);A(c,"supply",x.SupplyId);A(c,"quantity",x.Quantity);A(c,"cost",x.UnitCost);},ct);}return new(id,"Draft",total);} + public Task ApprovePurchaseOrderAsync(ApprovePurchaseOrderRequest r,CancellationToken ct)=>Set(r.Id,"Approved",null,ct); public Task CancelPurchaseOrderAsync(CancelPurchaseOrderRequest r,CancellationToken ct)=>Set(r.Id,"Cancelled",r.Reason,ct); + private async Task Set(Guid id,string status,string? reason,CancellationToken ct){if(status=="Cancelled"&&string.IsNullOrWhiteSpace(reason))throw new ArgumentException("Motivo obrigatório.");await data.WriteAsync("update barber.inventory_purchase_orders set status=@status,approved_at=case when @status='Approved' then now() else approved_at end,cancelled_at=case when @status='Cancelled' then now() else cancelled_at end,cancel_reason=@reason,updated_at=now() where id=@id and tenant_id=@tenant and branch_id=@branch and status in ('Draft','PendingApproval','Approved')","Inventory360.Purchase"+status,"inventory_purchase_orders",id,reason,c=>{A(c,"status",status);A(c,"reason",reason);},ct);return new(id,status,0);} + public async Task ReceivePurchaseOrderAsync(ReceivePurchaseOrderRequest r,CancellationToken ct){if(r.Items.Count==0)throw new ArgumentException("Itens recebidos são obrigatórios.");foreach(var x in r.Items){InventoryRules.Quantity(x.Quantity);var rows=await data.QueryAsync("select product_id,supply_id,unit_cost,quantity-received_quantity pending from barber.inventory_purchase_order_items where id=@item and purchase_order_id=@order and tenant_id=@tenant and branch_id=@branch",c=>{A(c,"item",x.PurchaseOrderItemId);A(c,"order",r.Id);},ct);if(rows.Count!=1||InventoryRules.D(rows[0]["pending"])A(c,"quantity",x.Quantity),ct);}var receiving=await data.WriteAsync("insert into barber.inventory_receivings(id,tenant_id,branch_id,purchase_order_id,supplier_id,status,received_at) select @id,@tenant,@branch,id,supplier_id,'Confirmed',@received from barber.inventory_purchase_orders where id=@order and tenant_id=@tenant and branch_id=@branch and status in ('Approved','PartiallyReceived')","Inventory360.PurchaseReceived","inventory_receivings",null,null,c=>{A(c,"order",r.Id);A(c,"received",r.ReceivedAt);},ct);await data.WriteAsync("update barber.inventory_purchase_orders o set status=case when exists(select 1 from barber.inventory_purchase_order_items i where i.purchase_order_id=o.id and i.received_quantityA(c,"order",r.Id),ct)).Single();await payable.CreatePayableAsync(new(o["supplier_id"] as Guid?,null,null,"InventoryPurchase",receiving,"Compra de estoque",InventoryRules.D(o["total_amount"]),DateTime.SpecifyKind(((DateOnly)o["due"]!).ToDateTime(TimeOnly.MinValue),DateTimeKind.Utc),false),ct);}return new(receiving,"Confirmed");} + public async Task ReturnToSupplierAsync(ReturnToSupplierRequest r,CancellationToken ct){if(string.IsNullOrWhiteSpace(r.Reason)||r.Items.Count==0)throw new ArgumentException("Motivo e itens são obrigatórios.");var id=await data.WriteAsync("insert into barber.inventory_supplier_returns(id,tenant_id,branch_id,supplier_id,purchase_order_id,status,reason) select @id,@tenant,@branch,supplier_id,id,'Confirmed',@reason from barber.inventory_purchase_orders where id=@order and tenant_id=@tenant and branch_id=@branch","Inventory360.SupplierReturnConfirmed","inventory_supplier_returns",null,r.Reason,c=>{A(c,"order",r.Id);A(c,"reason",r.Reason);},ct);foreach(var x in r.Items){var row=(await data.QueryAsync("select product_id,supply_id from barber.inventory_purchase_order_items where id=@item and purchase_order_id=@order and tenant_id=@tenant and branch_id=@branch",c=>{A(c,"item",x.PurchaseOrderItemId);A(c,"order",r.Id);},ct)).Single();await stock.ConsumeStockAsync(new(row["product_id"] as Guid?,row["supply_id"] as Guid?,x.Quantity,"SupplierReturn",id,$"return:{id}:{x.PurchaseOrderItemId}"),ct);}return new(id,"Confirmed");} private static void A(DbCommand c,string n,object? v)=>InventoryRules.Add(c,n,v); +} + +public sealed class InventoryCountService(TeamDataService data,StockMovementService stock) +{ + public async Task OpenCountAsync(OpenInventoryCountRequest r,CancellationToken ct){var id=await data.WriteAsync("insert into barber.inventory_counts(id,tenant_id,branch_id,count_date,status) select @id,@tenant,@branch,@date,'Open' where not exists(select 1 from barber.inventory_counts where tenant_id=@tenant and branch_id=@branch and count_date=@date and status in ('Open','Reviewing'))","Inventory360.CountOpened","inventory_counts",null,null,c=>A(c,"date",r.CountDate),ct);return new(id,"Open");} + public async Task RegisterCountItemAsync(RegisterInventoryCountItemRequest r,CancellationToken ct){InventoryRules.Item(r.ProductId,r.SupplyId);if(r.CountedQuantity<0)throw new ArgumentException("Contagem não pode ser negativa.");var expected=(await data.QueryAsync("select coalesce(quantity_on_hand,0) expected from barber.inventory_stock_balances where tenant_id=@tenant and branch_id=@branch and product_id is not distinct from @product and supply_id is not distinct from @supply",c=>{A(c,"product",r.ProductId);A(c,"supply",r.SupplyId);},ct)).SingleOrDefault();var qty=expected is null?0:InventoryRules.D(expected["expected"]);if(qty!=r.CountedQuantity&&string.IsNullOrWhiteSpace(r.Reason))throw new ArgumentException("Divergência exige motivo.");await data.WriteAsync("insert into barber.inventory_count_items(id,tenant_id,branch_id,inventory_count_id,product_id,supply_id,batch_id,system_quantity,expected_quantity,counted_quantity,difference_quantity,notes,reason,status) select @id,@tenant,@branch,c.id,@product,@supply,@batch,@expected,@expected,@counted,@counted-@expected,@reason,@reason,'Reviewed' from barber.inventory_counts c where c.id=@count and c.tenant_id=@tenant and c.branch_id=@branch and c.status='Open'","Inventory360.CountItemRegistered","inventory_count_items",null,r.Reason,c=>{A(c,"count",r.CountId);A(c,"product",r.ProductId);A(c,"supply",r.SupplyId);A(c,"batch",r.BatchId);A(c,"expected",qty);A(c,"counted",r.CountedQuantity);A(c,"reason",r.Reason);},ct);return new(r.CountId,"Open");} + public async Task CloseCountAsync(CloseInventoryCountRequest r,CancellationToken ct){await data.WriteAsync("update barber.inventory_counts set status='Reviewing',updated_at=now() where id=@id and tenant_id=@tenant and branch_id=@branch and status='Open' and exists(select 1 from barber.inventory_count_items where inventory_count_id=@id and status='Reviewed')","Inventory360.CountClosed","inventory_counts",r.Id,null,null,ct);return new(r.Id,"Reviewing");} + public async Task ApplyAdjustmentAsync(ApplyInventoryAdjustmentRequest r,CancellationToken ct){var rows=await data.QueryAsync("select id,product_id,supply_id,difference_quantity,reason from barber.inventory_count_items where inventory_count_id=@count and tenant_id=@tenant and branch_id=@branch and status='Reviewed'",c=>A(c,"count",r.Id),ct);foreach(var x in rows){var q=InventoryRules.D(x["difference_quantity"]);if(q>0)await stock.ReceiveStockAsync(new(x["product_id"] as Guid?,x["supply_id"] as Guid?,q,0,"InventoryCount",r.Id,$"count:{r.Id}:{x["id"]}",null,null),ct);else if(q<0)await stock.ConsumeStockAsync(new(x["product_id"] as Guid?,x["supply_id"] as Guid?,-q,"InventoryCount",r.Id,$"count:{r.Id}:{x["id"]}"),ct);}await data.WriteAsync("update barber.inventory_count_items set status='Adjusted',updated_at=now() where inventory_count_id=@id and tenant_id=@tenant and branch_id=@branch; update barber.inventory_counts set status='Closed',closed_at=now() where id=@id and tenant_id=@tenant and branch_id=@branch","Inventory360.CountAdjusted","inventory_counts",r.Id,null,null,ct);return new(r.Id,"Closed");} private static void A(DbCommand c,string n,object? v)=>InventoryRules.Add(c,n,v); +} + +public sealed class ReplenishmentService(TeamDataService data,PurchaseOrderService purchases) +{ + public async Task GetDashboardAsync(ReplenishmentDashboardRequest r,CancellationToken ct)=>new(await data.QueryAsync("select s.*,p.name product_name,i.name supply_name from barber.inventory_replenishment_suggestions s left join barber.inventory_products p on p.id=s.product_id left join barber.inventory_supplies i on i.id=s.supply_id where s.tenant_id=@tenant and s.branch_id=@branch and s.status='Open' order by s.created_at",null,ct)); + public async Task GenerateSuggestionsAsync(GenerateReplenishmentSuggestionsRequest r,CancellationToken ct){var rows=await data.QueryAsync(@"insert into barber.inventory_replenishment_suggestions(id,tenant_id,branch_id,product_id,supply_id,suggested_quantity,reason,source_status,status) select gen_random_uuid(),r.tenant_id,r.branch_id,r.product_id,r.supply_id,r.target_quantity-coalesce(b.quantity_available,0),'Saldo abaixo do mínimo',case when b.id is null then 'InsufficientHistory' else 'Available' end,'Open' from barber.inventory_replenishment_rules r left join barber.inventory_stock_balances b on b.tenant_id=r.tenant_id and b.branch_id=r.branch_id and b.product_id is not distinct from r.product_id and b.supply_id is not distinct from r.supply_id where r.tenant_id=@tenant and r.branch_id=@branch and r.status='Active' and coalesce(b.quantity_available,0)<=r.minimum_quantity and not exists(select 1 from barber.inventory_replenishment_suggestions s where s.tenant_id=r.tenant_id and s.branch_id=r.branch_id and s.product_id is not distinct from r.product_id and s.supply_id is not distinct from r.supply_id and s.status='Open') returning id",null,ct);return new(rows.Count,rows.Count==0?"InsufficientData":"Available");} + public async Task CreatePurchaseOrderFromSuggestionAsync(CreatePurchaseOrderFromSuggestionRequest r,CancellationToken ct){var row=(await data.QueryAsync("select s.product_id,s.supply_id,s.suggested_quantity,r.preferred_supplier_id,coalesce(p.cost_price,i.default_cost) cost from barber.inventory_replenishment_suggestions s join barber.inventory_replenishment_rules r on r.tenant_id=s.tenant_id and r.branch_id=s.branch_id and r.product_id is not distinct from s.product_id and r.supply_id is not distinct from s.supply_id left join barber.inventory_products p on p.id=s.product_id left join barber.inventory_supplies i on i.id=s.supply_id where s.id=@id and s.tenant_id=@tenant and s.branch_id=@branch and s.status='Open' and r.preferred_supplier_id is not null",c=>A(c,"id",r.Id),ct)).SingleOrDefault()??throw new InvalidOperationException("Sugestão sem fornecedor confirmado.");if(row["cost"] is null)throw new InvalidOperationException("Custo indisponível; compra não pode ser criada.");var order=await purchases.CreatePurchaseOrderAsync(new((Guid)row["preferred_supplier_id"]!,null,[new(row["product_id"] as Guid?,row["supply_id"] as Guid?,InventoryRules.D(row["suggested_quantity"]),InventoryRules.D(row["cost"]))]),ct);await data.WriteAsync("update barber.inventory_replenishment_suggestions set status='ConvertedToPurchase',purchase_order_id=@order,updated_at=now() where id=@id and tenant_id=@tenant and branch_id=@branch","Inventory360.SuggestionConverted","inventory_replenishment_suggestions",r.Id,null,c=>A(c,"order",order.Id),ct);return order;} private static void A(DbCommand c,string n,object? v)=>InventoryRules.Add(c,n,v); +} diff --git a/MobileApp/scripts-smoke-test.js b/MobileApp/scripts-smoke-test.js index 0955d3a..1138406 100644 --- a/MobileApp/scripts-smoke-test.js +++ b/MobileApp/scripts-smoke-test.js @@ -41,6 +41,9 @@ for (const benefitContract of ['benefits.packages', 'benefits.coupons', 'benefit } } const apiSource = fs.readFileSync('src/services/api.js', 'utf8'); +for (const contract of ['inventory360Summary:', 'inventory360Products:', 'inventory360Stock:', 'inventory360Replenishment:', 'registerInventory360CountItem:']) { + if (!apiSource.includes(contract)) { console.error(`Mobile inventory contract is missing: ${contract}`); process.exit(1); } +} for (const contract of ['clubSummary:', 'clubWallet:', 'clubMemberships:', 'clubGiftCards:', 'clubVouchers:', 'redeemClubVoucher:']) { if (!apiSource.includes(contract)) { console.error(`Mobile club contract is missing: ${contract}`); process.exit(1); } } diff --git a/MobileApp/src/services/api.js b/MobileApp/src/services/api.js index e9f9a33..ef3b4a6 100644 --- a/MobileApp/src/services/api.js +++ b/MobileApp/src/services/api.js @@ -62,5 +62,10 @@ export const mobileApi = { finance360Payables: signal => request('/api/mobile/finance360/payables', { signal }), finance360Commissions: signal => request('/api/mobile/finance360/commissions', { signal }), finance360Payroll: signal => request('/api/mobile/finance360/payroll', { signal }), + inventory360Summary: signal => request('/api/mobile/inventory360/summary', { signal }), + inventory360Products: signal => request('/api/mobile/inventory360/products', { signal }), + inventory360Stock: signal => request('/api/mobile/inventory360/stock', { signal }), + inventory360Replenishment: signal => request('/api/mobile/inventory360/replenishment', { signal }), + registerInventory360CountItem: (id, input) => request(`/api/mobile/inventory360/counts/${id}/items`, { method: 'POST', body: input }), blocks: signal => request('/api/mobile/professional/blocks', { signal }), block: input => request('/api/mobile/professional/blocks', { method: 'POST', body: input }) }; diff --git a/ScriptsSQL/production_readiness_seed.sql b/ScriptsSQL/production_readiness_seed.sql index 385b095..7939478 100644 --- a/ScriptsSQL/production_readiness_seed.sql +++ b/ScriptsSQL/production_readiness_seed.sql @@ -291,3 +291,17 @@ BEGIN INSERT INTO barber.finance_cash_flow_snapshots(id,tenant_id,branch_id,snapshot_date,projected_in,projected_out,realized_in,realized_out,net_projected,net_realized,metadata_json) SELECT '61610000-0000-4000-8000-000000000012',t,b,current_date,coalesce(sum(amount-paid_amount) filter(where status in ('Open','PartiallyPaid','Overdue')),0),0,0,0,coalesce(sum(amount-paid_amount) filter(where status in ('Open','PartiallyPaid','Overdue')),0),0,'{"sourceStatus":"persisted"}' FROM barber.finance_receivables WHERE tenant_id=t AND branch_id=b ON CONFLICT(id) DO NOTHING; INSERT INTO barber.finance_dre_snapshots(id,tenant_id,branch_id,period_start,period_end,gross_revenue,discounts,net_revenue,service_costs,product_costs,commissions,payroll_costs,partner_payouts,operational_expenses,gross_profit,net_result,metadata_json) SELECT '61610000-0000-4000-8000-000000000013',t,b,date_trunc('month',current_date)::date,current_date,coalesce(sum(amount) filter(where direction='Credit' and posting_type='Revenue'),0),0,coalesce(sum(amount) filter(where direction='Credit' and posting_type='Revenue'),0),0,0,0,0,0,0,coalesce(sum(amount) filter(where direction='Credit' and posting_type='Revenue'),0),coalesce(sum(amount) filter(where direction='Credit' and posting_type='Revenue'),0),'{"sourceStatus":"persisted"}' FROM barber.finance_postings WHERE tenant_id=t AND branch_id=b AND status='Confirmed' ON CONFLICT(id) DO NOTHING; END $$; + +-- Sprint 62 Inventory360: master-data fixtures only. No receipt, balance, movement or COGS is fabricated. +DO $$ DECLARE t uuid:='70000000-0000-4000-8000-000000000001'; b uuid:='70000000-0000-4000-8000-000000000002'; u uuid:='70000000-0000-4000-8000-000000000010'; p uuid:='62620000-0000-4000-8000-000000000001'; s uuid:='62620000-0000-4000-8000-000000000002'; f uuid:='62620000-0000-4000-8000-000000000003'; service uuid; +BEGIN + INSERT INTO barber.inventory_products(id,tenant_id,branch_id,sku,name,unit_of_measure,stock_control_required,status,created_by) VALUES(p,t,b,'READINESS-INV','Produto readiness', 'Unit',true,'Draft',u) ON CONFLICT(id) DO NOTHING; + INSERT INTO barber.inventory_supplies(id,tenant_id,branch_id,name,unit_of_measure,stock_control_required,status,created_by) VALUES(s,t,b,'Insumo readiness','Ml',true,'Draft',u) ON CONFLICT(id) DO NOTHING; + INSERT INTO barber.inventory_suppliers(id,tenant_id,branch_id,name,status,created_by) VALUES(f,t,b,'Fornecedor readiness','Draft',u) ON CONFLICT(id) DO NOTHING; + INSERT INTO barber.inventory_replenishment_rules(id,tenant_id,branch_id,product_id,minimum_quantity,target_quantity,preferred_supplier_id,status,created_by) VALUES('62620000-0000-4000-8000-000000000004',t,b,p,1,2,f,'Inactive',u) ON CONFLICT(id) DO NOTHING; + INSERT INTO barber.inventory_replenishment_suggestions(id,tenant_id,branch_id,product_id,suggested_quantity,reason,source_status,status) VALUES('62620000-0000-4000-8000-000000000005',t,b,p,2,'Readiness sem saldo real','Unavailable','Dismissed') ON CONFLICT(id) DO NOTHING; + INSERT INTO barber.inventory_audit_events(id,tenant_id,branch_id,event_type,source_type,source_id,product_id,description,new_status,metadata_json,created_by) VALUES('62620000-0000-4000-8000-000000000006',t,b,'ReadinessChecked','InventoryProduct',p,p,'Contrato Inventory360 validado sem simular estoque ou CMV','Draft','{"sourceStatus":"unavailable","readiness":true}',u) ON CONFLICT(id) DO NOTHING; + SELECT id INTO service FROM barber.services WHERE tenant_id=t AND branch_id=b AND status='Active' ORDER BY created_at LIMIT 1; + IF service IS NOT NULL THEN INSERT INTO barber.inventory_service_inputs(id,tenant_id,branch_id,service_id,supply_id,quantity,unit_of_measure,consume_on,status,created_by) VALUES('62620000-0000-4000-8000-000000000007',t,b,service,s,1,'Ml','ServiceCompleted','Inactive',u) ON CONFLICT(id) DO NOTHING; END IF; + -- batch, balance, movement, purchase, receiving, transfer, count and cost snapshot require real operational origins and stay absent. +END $$; diff --git a/ScriptsSQL/script_completo.sql b/ScriptsSQL/script_completo.sql index 16d193a..c7b72f2 100644 --- a/ScriptsSQL/script_completo.sql +++ b/ScriptsSQL/script_completo.sql @@ -1084,3 +1084,42 @@ CREATE INDEX IF NOT EXISTS ix_finance_audit_scope_created ON barber.finance_audi CREATE UNIQUE INDEX IF NOT EXISTS ux_finance_delinquency_receivable_open ON barber.finance_delinquency_cases(tenant_id,branch_id,receivable_id) WHERE status IN ('Open','Negotiating'); INSERT INTO barber.permissions(id,code,description) VALUES (gen_random_uuid(),'Finance360.Read','Consultar Financeiro 360'),(gen_random_uuid(),'Finance360.Manage','Gerenciar Financeiro 360'),(gen_random_uuid(),'Finance360.Receivables.Manage','Gerenciar contas a receber'),(gen_random_uuid(),'Finance360.Payables.Manage','Gerenciar contas a pagar'),(gen_random_uuid(),'Finance360.Reconciliation.Manage','Gerenciar conciliação'),(gen_random_uuid(),'Finance360.Dre.Read','Consultar DRE'),(gen_random_uuid(),'Finance360.CashFlow.Read','Consultar fluxo de caixa'),(gen_random_uuid(),'Finance360.Commissions.Read','Consultar comissões'),(gen_random_uuid(),'Finance360.Payroll.Manage','Gerenciar folha e repasses'),(gen_random_uuid(),'Finance360.PartnerPayouts.Manage','Gerenciar payouts de parceiros'),(gen_random_uuid(),'Finance360.Delinquency.Manage','Gerenciar inadimplência'),(gen_random_uuid(),'Finance360.Reports.Export','Exportar relatórios financeiros') ON CONFLICT(code) DO UPDATE SET description=excluded.description; + +-- Sprint 62 - Inventory & Purchasing 360 (append-only, tenant/branch scoped) +CREATE TABLE IF NOT EXISTS barber.inventory_products(id uuid PRIMARY KEY DEFAULT gen_random_uuid(),tenant_id uuid NOT NULL,branch_id uuid NOT NULL,product_id uuid,sku varchar(80),barcode varchar(80),name varchar(180) NOT NULL,description text,category_id uuid,unit_of_measure varchar(20) NOT NULL,cost_price numeric(14,4),sale_price numeric(14,2),stock_control_required boolean NOT NULL DEFAULT true,public_visible boolean NOT NULL DEFAULT false,kiosk_visible boolean NOT NULL DEFAULT false,marketplace_visible boolean NOT NULL DEFAULT false,status varchar(20) NOT NULL DEFAULT 'Draft',created_by uuid,created_at timestamptz NOT NULL DEFAULT now(),updated_at timestamptz,deleted_at timestamptz); +CREATE TABLE IF NOT EXISTS barber.inventory_supplies(id uuid PRIMARY KEY DEFAULT gen_random_uuid(),tenant_id uuid NOT NULL,branch_id uuid NOT NULL,name varchar(180) NOT NULL,description text,category_id uuid,unit_of_measure varchar(20) NOT NULL,default_cost numeric(14,4),stock_control_required boolean NOT NULL DEFAULT true,status varchar(20) NOT NULL DEFAULT 'Draft',created_by uuid,created_at timestamptz NOT NULL DEFAULT now(),updated_at timestamptz,deleted_at timestamptz); +CREATE TABLE IF NOT EXISTS barber.inventory_suppliers(id uuid PRIMARY KEY DEFAULT gen_random_uuid(),tenant_id uuid NOT NULL,branch_id uuid NOT NULL,name varchar(180) NOT NULL,document varchar(40),email varchar(180),phone varchar(40),status varchar(20) NOT NULL DEFAULT 'Draft',partner_id uuid,created_by uuid,created_at timestamptz NOT NULL DEFAULT now(),updated_at timestamptz,deleted_at timestamptz); +CREATE TABLE IF NOT EXISTS barber.inventory_service_inputs(id uuid PRIMARY KEY DEFAULT gen_random_uuid(),tenant_id uuid NOT NULL,branch_id uuid NOT NULL,service_id uuid NOT NULL,supply_id uuid,product_id uuid,quantity numeric(14,3) NOT NULL CHECK(quantity>0),unit_of_measure varchar(20) NOT NULL,consume_on varchar(30) NOT NULL,status varchar(20) NOT NULL DEFAULT 'Active',created_by uuid,created_at timestamptz NOT NULL DEFAULT now(),updated_at timestamptz,deleted_at timestamptz,CHECK((product_id IS NULL)<>(supply_id IS NULL))); +CREATE TABLE IF NOT EXISTS barber.inventory_stock_batches(id uuid PRIMARY KEY DEFAULT gen_random_uuid(),tenant_id uuid NOT NULL,branch_id uuid NOT NULL,product_id uuid,supply_id uuid,supplier_id uuid,batch_code varchar(80) NOT NULL,expires_at date,received_at timestamptz NOT NULL,unit_cost numeric(14,4),quantity_received numeric(14,3) NOT NULL,quantity_available numeric(14,3) NOT NULL,status varchar(20) NOT NULL DEFAULT 'Available',created_at timestamptz NOT NULL DEFAULT now(),updated_at timestamptz,CHECK((product_id IS NULL)<>(supply_id IS NULL))); +CREATE TABLE IF NOT EXISTS barber.inventory_stock_balances(id uuid PRIMARY KEY DEFAULT gen_random_uuid(),tenant_id uuid NOT NULL,branch_id uuid NOT NULL,product_id uuid,supply_id uuid,quantity_on_hand numeric(14,3) NOT NULL DEFAULT 0,quantity_reserved numeric(14,3) NOT NULL DEFAULT 0,quantity_available numeric(14,3) NOT NULL DEFAULT 0,average_cost numeric(14,4),last_movement_at timestamptz,updated_at timestamptz,CHECK((product_id IS NULL)<>(supply_id IS NULL))); +CREATE UNIQUE INDEX IF NOT EXISTS ux_inventory_balance_item ON barber.inventory_stock_balances(tenant_id,branch_id,coalesce(product_id,'00000000-0000-0000-0000-000000000000'::uuid),coalesce(supply_id,'00000000-0000-0000-0000-000000000000'::uuid)); +CREATE TABLE IF NOT EXISTS barber.inventory_stock_movements(id uuid PRIMARY KEY DEFAULT gen_random_uuid(),tenant_id uuid NOT NULL,branch_id uuid NOT NULL,movement_type varchar(30) NOT NULL,product_id uuid,supply_id uuid,batch_id uuid,quantity numeric(14,3) NOT NULL CHECK(quantity>0),unit_cost numeric(14,4),total_cost numeric(14,4),source_type varchar(50) NOT NULL,source_id uuid NOT NULL,reverse_of_movement_id uuid,idempotency_key varchar(180) NOT NULL,reason text,created_by uuid,created_at timestamptz NOT NULL DEFAULT now(),metadata_json jsonb NOT NULL DEFAULT '{}'::jsonb,CHECK((product_id IS NULL)<>(supply_id IS NULL))); +CREATE UNIQUE INDEX IF NOT EXISTS ux_inventory_movement_idempotency ON barber.inventory_stock_movements(tenant_id,branch_id,idempotency_key); +CREATE TABLE IF NOT EXISTS barber.inventory_purchase_orders(id uuid PRIMARY KEY DEFAULT gen_random_uuid(),tenant_id uuid NOT NULL,branch_id uuid NOT NULL,supplier_id uuid NOT NULL,status varchar(30) NOT NULL DEFAULT 'Draft',order_number varchar(50) NOT NULL,expected_at date,total_amount numeric(14,2) NOT NULL DEFAULT 0,finance_payable_id uuid,created_by uuid,approved_by uuid,created_at timestamptz NOT NULL DEFAULT now(),updated_at timestamptz,approved_at timestamptz,cancelled_at timestamptz,cancel_reason text); +CREATE TABLE IF NOT EXISTS barber.inventory_purchase_order_items(id uuid PRIMARY KEY DEFAULT gen_random_uuid(),tenant_id uuid NOT NULL,branch_id uuid NOT NULL,purchase_order_id uuid NOT NULL,product_id uuid,supply_id uuid,quantity numeric(14,3) NOT NULL CHECK(quantity>0),unit_cost numeric(14,4) NOT NULL,total_cost numeric(14,4) NOT NULL,received_quantity numeric(14,3) NOT NULL DEFAULT 0,created_at timestamptz NOT NULL DEFAULT now(),updated_at timestamptz); +CREATE TABLE IF NOT EXISTS barber.inventory_receivings(id uuid PRIMARY KEY DEFAULT gen_random_uuid(),tenant_id uuid NOT NULL,branch_id uuid NOT NULL,purchase_order_id uuid NOT NULL,supplier_id uuid NOT NULL,status varchar(20) NOT NULL DEFAULT 'Draft',received_at timestamptz,received_by uuid,notes text,created_at timestamptz NOT NULL DEFAULT now(),updated_at timestamptz); +CREATE TABLE IF NOT EXISTS barber.inventory_receiving_items(id uuid PRIMARY KEY DEFAULT gen_random_uuid(),tenant_id uuid NOT NULL,branch_id uuid NOT NULL,receiving_id uuid NOT NULL,purchase_order_item_id uuid NOT NULL,product_id uuid,supply_id uuid,batch_id uuid,quantity numeric(14,3) NOT NULL,unit_cost numeric(14,4) NOT NULL,expires_at date,created_at timestamptz NOT NULL DEFAULT now()); +CREATE TABLE IF NOT EXISTS barber.inventory_supplier_returns(id uuid PRIMARY KEY DEFAULT gen_random_uuid(),tenant_id uuid NOT NULL,branch_id uuid NOT NULL,supplier_id uuid NOT NULL,purchase_order_id uuid,receiving_id uuid,status varchar(20) NOT NULL DEFAULT 'Draft',reason text NOT NULL,created_by uuid,created_at timestamptz NOT NULL DEFAULT now(),confirmed_at timestamptz,cancelled_at timestamptz,cancel_reason text); +CREATE TABLE IF NOT EXISTS barber.inventory_transfers(id uuid PRIMARY KEY DEFAULT gen_random_uuid(),tenant_id uuid NOT NULL,source_branch_id uuid NOT NULL,target_branch_id uuid NOT NULL,status varchar(20) NOT NULL DEFAULT 'Draft',reason text,created_by uuid,received_by uuid,created_at timestamptz NOT NULL DEFAULT now(),updated_at timestamptz,received_at timestamptz,cancelled_at timestamptz,cancel_reason text); +CREATE TABLE IF NOT EXISTS barber.inventory_transfer_items(id uuid PRIMARY KEY DEFAULT gen_random_uuid(),tenant_id uuid NOT NULL,transfer_id uuid NOT NULL,product_id uuid,supply_id uuid,batch_id uuid,quantity numeric(14,3) NOT NULL CHECK(quantity>0),unit_cost numeric(14,4),created_at timestamptz NOT NULL DEFAULT now()); +CREATE TABLE IF NOT EXISTS barber.inventory_counts(id uuid PRIMARY KEY DEFAULT gen_random_uuid(),tenant_id uuid NOT NULL,branch_id uuid NOT NULL,count_date date NOT NULL,status varchar(20) NOT NULL DEFAULT 'Open',created_by uuid,closed_by uuid,created_at timestamptz NOT NULL DEFAULT now(),updated_at timestamptz,closed_at timestamptz,cancelled_at timestamptz,cancel_reason text); +CREATE TABLE IF NOT EXISTS barber.inventory_count_items(id uuid PRIMARY KEY DEFAULT gen_random_uuid(),tenant_id uuid NOT NULL,branch_id uuid NOT NULL,inventory_count_id uuid NOT NULL,product_id uuid,supply_id uuid,batch_id uuid,expected_quantity numeric(14,3) NOT NULL,counted_quantity numeric(14,3) NOT NULL,difference_quantity numeric(14,3) NOT NULL,reason text,status varchar(20) NOT NULL DEFAULT 'Pending',created_at timestamptz NOT NULL DEFAULT now(),updated_at timestamptz); +CREATE TABLE IF NOT EXISTS barber.inventory_replenishment_rules(id uuid PRIMARY KEY DEFAULT gen_random_uuid(),tenant_id uuid NOT NULL,branch_id uuid NOT NULL,product_id uuid,supply_id uuid,minimum_quantity numeric(14,3) NOT NULL,target_quantity numeric(14,3) NOT NULL,preferred_supplier_id uuid,lead_time_days integer,status varchar(20) NOT NULL DEFAULT 'Active',created_by uuid,created_at timestamptz NOT NULL DEFAULT now(),updated_at timestamptz,deleted_at timestamptz); +CREATE TABLE IF NOT EXISTS barber.inventory_replenishment_suggestions(id uuid PRIMARY KEY DEFAULT gen_random_uuid(),tenant_id uuid NOT NULL,branch_id uuid NOT NULL,product_id uuid,supply_id uuid,suggested_quantity numeric(14,3) NOT NULL,reason text NOT NULL,source_status varchar(30) NOT NULL,status varchar(30) NOT NULL DEFAULT 'Open',purchase_order_id uuid,created_at timestamptz NOT NULL DEFAULT now(),updated_at timestamptz); +CREATE TABLE IF NOT EXISTS barber.inventory_cost_snapshots(id uuid PRIMARY KEY DEFAULT gen_random_uuid(),tenant_id uuid NOT NULL,branch_id uuid NOT NULL,product_id uuid,supply_id uuid,snapshot_date date NOT NULL,quantity_on_hand numeric(14,3) NOT NULL,average_cost numeric(14,4),batch_cost numeric(14,4),total_inventory_value numeric(14,4),cogs_amount numeric(14,4),metadata_json jsonb NOT NULL DEFAULT '{}'::jsonb,created_at timestamptz NOT NULL DEFAULT now()); +CREATE TABLE IF NOT EXISTS barber.inventory_audit_events(id uuid PRIMARY KEY DEFAULT gen_random_uuid(),tenant_id uuid NOT NULL,branch_id uuid NOT NULL,event_type varchar(80) NOT NULL,source_type varchar(50) NOT NULL,source_id uuid NOT NULL,product_id uuid,supply_id uuid,description text NOT NULL,old_status varchar(30),new_status varchar(30),metadata_json jsonb NOT NULL DEFAULT '{}'::jsonb,created_by uuid,created_at timestamptz NOT NULL DEFAULT now()); +CREATE INDEX IF NOT EXISTS ix_inventory_batches_scope_expiry ON barber.inventory_stock_batches(tenant_id,branch_id,status,expires_at); +CREATE INDEX IF NOT EXISTS ix_inventory_movements_scope_item_created ON barber.inventory_stock_movements(tenant_id,branch_id,product_id,supply_id,created_at); +CREATE INDEX IF NOT EXISTS ix_inventory_purchases_scope_supplier_status ON barber.inventory_purchase_orders(tenant_id,branch_id,supplier_id,status,created_at); +CREATE INDEX IF NOT EXISTS ix_inventory_audit_scope_created ON barber.inventory_audit_events(tenant_id,branch_id,created_at); +INSERT INTO barber.permissions(id,code,description) VALUES +(gen_random_uuid(),'Inventory360.Read','Consultar Estoque 360'),(gen_random_uuid(),'Inventory360.Manage','Gerenciar Estoque 360'),(gen_random_uuid(),'Inventory360.Products.Manage','Gerenciar produtos'),(gen_random_uuid(),'Inventory360.Supplies.Manage','Gerenciar insumos'),(gen_random_uuid(),'Inventory360.Suppliers.Manage','Gerenciar fornecedores'),(gen_random_uuid(),'Inventory360.ServiceInputs.Manage','Gerenciar insumos por serviço'),(gen_random_uuid(),'Inventory360.Stock.Manage','Gerenciar movimentos'),(gen_random_uuid(),'Inventory360.Purchases.Manage','Gerenciar compras'),(gen_random_uuid(),'Inventory360.Purchases.Approve','Aprovar compras'),(gen_random_uuid(),'Inventory360.Transfers.Manage','Gerenciar transferências'),(gen_random_uuid(),'Inventory360.Counts.Manage','Gerenciar inventários'),(gen_random_uuid(),'Inventory360.Replenishment.Manage','Gerenciar reposição'),(gen_random_uuid(),'Inventory360.Costing.Read','Consultar CMV'),(gen_random_uuid(),'Inventory360.Reports.Export','Exportar relatórios') ON CONFLICT(code) DO UPDATE SET description=excluded.description; +ALTER TABLE barber.inventory_counts ADD COLUMN IF NOT EXISTS count_date date; +ALTER TABLE barber.inventory_counts ADD COLUMN IF NOT EXISTS cancelled_at timestamptz; +ALTER TABLE barber.inventory_counts ADD COLUMN IF NOT EXISTS cancel_reason text; +ALTER TABLE barber.inventory_count_items ADD COLUMN IF NOT EXISTS supply_id uuid; +ALTER TABLE barber.inventory_count_items ADD COLUMN IF NOT EXISTS batch_id uuid; +ALTER TABLE barber.inventory_count_items ADD COLUMN IF NOT EXISTS expected_quantity numeric(14,3); +ALTER TABLE barber.inventory_count_items ADD COLUMN IF NOT EXISTS reason text; +ALTER TABLE barber.inventory_count_items ADD COLUMN IF NOT EXISTS status varchar(20) NOT NULL DEFAULT 'Pending'; +ALTER TABLE barber.inventory_count_items ALTER COLUMN product_id DROP NOT NULL; diff --git a/Web/BarberSync.AdminWeb/Controllers/Inventory360Controller.cs b/Web/BarberSync.AdminWeb/Controllers/Inventory360Controller.cs new file mode 100644 index 0000000..f423402 --- /dev/null +++ b/Web/BarberSync.AdminWeb/Controllers/Inventory360Controller.cs @@ -0,0 +1,10 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +namespace BarberSync.AdminWeb.Controllers; +[Authorize,Route("Inventory360")] +public sealed class Inventory360Controller:Controller +{ + [HttpGet("")][HttpGet("Dashboard")]public IActionResult Index()=>Page("Index","dashboard","Estoque & Compras 360"); + [HttpGet("Products")]public IActionResult Products()=>Page("Products","products","Produtos"); [HttpGet("Supplies")]public IActionResult Supplies()=>Page("Supplies","supplies","Insumos"); [HttpGet("ServiceInputs")]public IActionResult ServiceInputs()=>Page("ServiceInputs","service-inputs","Insumos por serviço"); [HttpGet("Stock")]public IActionResult Stock()=>Page("Stock","stock","Estoque atual"); [HttpGet("Batches")]public IActionResult Batches()=>Page("Batches","batches","Lotes e validade"); [HttpGet("Purchases")]public IActionResult Purchases()=>Page("Purchases","purchases","Compras e recebimento"); [HttpGet("Suppliers")]public IActionResult Suppliers()=>Page("Suppliers","suppliers","Fornecedores"); [HttpGet("Transfers")]public IActionResult Transfers()=>Page("Transfers","transfers","Transferências"); [HttpGet("InventoryCounts")][HttpGet("Losses")]public IActionResult InventoryCounts()=>Page("InventoryCounts","counts","Inventário e perdas"); [HttpGet("Replenishment")]public IActionResult Replenishment()=>Page("Replenishment","replenishment","Reposição inteligente"); [HttpGet("Costing")]public IActionResult Costing()=>Page("Costing","costing","CMV e custos"); [HttpGet("Audit")]public IActionResult Audit()=>Page("Audit","audit","Auditoria"); [HttpGet("Reports")][HttpGet("Settings")]public IActionResult Reports()=>Page("Reports","reports","Relatórios"); + private IActionResult Page(string view,string page,string title){ViewData["Inventory360Page"]=page;ViewData["Title"]=title;return View(view);} +} diff --git a/Web/BarberSync.AdminWeb/Views/Inventory360/Audit.cshtml b/Web/BarberSync.AdminWeb/Views/Inventory360/Audit.cshtml new file mode 100644 index 0000000..129819e --- /dev/null +++ b/Web/BarberSync.AdminWeb/Views/Inventory360/Audit.cshtml @@ -0,0 +1,2 @@ +@{ViewData["Title"] = ViewData["Title"] ?? "Estoque & Compras 360";} + diff --git a/Web/BarberSync.AdminWeb/Views/Inventory360/Batches.cshtml b/Web/BarberSync.AdminWeb/Views/Inventory360/Batches.cshtml new file mode 100644 index 0000000..129819e --- /dev/null +++ b/Web/BarberSync.AdminWeb/Views/Inventory360/Batches.cshtml @@ -0,0 +1,2 @@ +@{ViewData["Title"] = ViewData["Title"] ?? "Estoque & Compras 360";} + diff --git a/Web/BarberSync.AdminWeb/Views/Inventory360/Costing.cshtml b/Web/BarberSync.AdminWeb/Views/Inventory360/Costing.cshtml new file mode 100644 index 0000000..129819e --- /dev/null +++ b/Web/BarberSync.AdminWeb/Views/Inventory360/Costing.cshtml @@ -0,0 +1,2 @@ +@{ViewData["Title"] = ViewData["Title"] ?? "Estoque & Compras 360";} + diff --git a/Web/BarberSync.AdminWeb/Views/Inventory360/Index.cshtml b/Web/BarberSync.AdminWeb/Views/Inventory360/Index.cshtml new file mode 100644 index 0000000..129819e --- /dev/null +++ b/Web/BarberSync.AdminWeb/Views/Inventory360/Index.cshtml @@ -0,0 +1,2 @@ +@{ViewData["Title"] = ViewData["Title"] ?? "Estoque & Compras 360";} + diff --git a/Web/BarberSync.AdminWeb/Views/Inventory360/InventoryCounts.cshtml b/Web/BarberSync.AdminWeb/Views/Inventory360/InventoryCounts.cshtml new file mode 100644 index 0000000..129819e --- /dev/null +++ b/Web/BarberSync.AdminWeb/Views/Inventory360/InventoryCounts.cshtml @@ -0,0 +1,2 @@ +@{ViewData["Title"] = ViewData["Title"] ?? "Estoque & Compras 360";} + diff --git a/Web/BarberSync.AdminWeb/Views/Inventory360/Products.cshtml b/Web/BarberSync.AdminWeb/Views/Inventory360/Products.cshtml new file mode 100644 index 0000000..129819e --- /dev/null +++ b/Web/BarberSync.AdminWeb/Views/Inventory360/Products.cshtml @@ -0,0 +1,2 @@ +@{ViewData["Title"] = ViewData["Title"] ?? "Estoque & Compras 360";} + diff --git a/Web/BarberSync.AdminWeb/Views/Inventory360/Purchases.cshtml b/Web/BarberSync.AdminWeb/Views/Inventory360/Purchases.cshtml new file mode 100644 index 0000000..129819e --- /dev/null +++ b/Web/BarberSync.AdminWeb/Views/Inventory360/Purchases.cshtml @@ -0,0 +1,2 @@ +@{ViewData["Title"] = ViewData["Title"] ?? "Estoque & Compras 360";} + diff --git a/Web/BarberSync.AdminWeb/Views/Inventory360/Replenishment.cshtml b/Web/BarberSync.AdminWeb/Views/Inventory360/Replenishment.cshtml new file mode 100644 index 0000000..129819e --- /dev/null +++ b/Web/BarberSync.AdminWeb/Views/Inventory360/Replenishment.cshtml @@ -0,0 +1,2 @@ +@{ViewData["Title"] = ViewData["Title"] ?? "Estoque & Compras 360";} + diff --git a/Web/BarberSync.AdminWeb/Views/Inventory360/Reports.cshtml b/Web/BarberSync.AdminWeb/Views/Inventory360/Reports.cshtml new file mode 100644 index 0000000..129819e --- /dev/null +++ b/Web/BarberSync.AdminWeb/Views/Inventory360/Reports.cshtml @@ -0,0 +1,2 @@ +@{ViewData["Title"] = ViewData["Title"] ?? "Estoque & Compras 360";} + diff --git a/Web/BarberSync.AdminWeb/Views/Inventory360/ServiceInputs.cshtml b/Web/BarberSync.AdminWeb/Views/Inventory360/ServiceInputs.cshtml new file mode 100644 index 0000000..129819e --- /dev/null +++ b/Web/BarberSync.AdminWeb/Views/Inventory360/ServiceInputs.cshtml @@ -0,0 +1,2 @@ +@{ViewData["Title"] = ViewData["Title"] ?? "Estoque & Compras 360";} + diff --git a/Web/BarberSync.AdminWeb/Views/Inventory360/Stock.cshtml b/Web/BarberSync.AdminWeb/Views/Inventory360/Stock.cshtml new file mode 100644 index 0000000..129819e --- /dev/null +++ b/Web/BarberSync.AdminWeb/Views/Inventory360/Stock.cshtml @@ -0,0 +1,2 @@ +@{ViewData["Title"] = ViewData["Title"] ?? "Estoque & Compras 360";} + diff --git a/Web/BarberSync.AdminWeb/Views/Inventory360/Suppliers.cshtml b/Web/BarberSync.AdminWeb/Views/Inventory360/Suppliers.cshtml new file mode 100644 index 0000000..129819e --- /dev/null +++ b/Web/BarberSync.AdminWeb/Views/Inventory360/Suppliers.cshtml @@ -0,0 +1,2 @@ +@{ViewData["Title"] = ViewData["Title"] ?? "Estoque & Compras 360";} + diff --git a/Web/BarberSync.AdminWeb/Views/Inventory360/Supplies.cshtml b/Web/BarberSync.AdminWeb/Views/Inventory360/Supplies.cshtml new file mode 100644 index 0000000..129819e --- /dev/null +++ b/Web/BarberSync.AdminWeb/Views/Inventory360/Supplies.cshtml @@ -0,0 +1,2 @@ +@{ViewData["Title"] = ViewData["Title"] ?? "Estoque & Compras 360";} + diff --git a/Web/BarberSync.AdminWeb/Views/Inventory360/Transfers.cshtml b/Web/BarberSync.AdminWeb/Views/Inventory360/Transfers.cshtml new file mode 100644 index 0000000..129819e --- /dev/null +++ b/Web/BarberSync.AdminWeb/Views/Inventory360/Transfers.cshtml @@ -0,0 +1,2 @@ +@{ViewData["Title"] = ViewData["Title"] ?? "Estoque & Compras 360";} + diff --git a/Web/BarberSync.AdminWeb/Views/Inventory360/_Inventory360Page.cshtml b/Web/BarberSync.AdminWeb/Views/Inventory360/_Inventory360Page.cshtml new file mode 100644 index 0000000..7b82ee1 --- /dev/null +++ b/Web/BarberSync.AdminWeb/Views/Inventory360/_Inventory360Page.cshtml @@ -0,0 +1,4 @@ +@{var page=(string)(ViewData["Inventory360Page"]??"dashboard");var title=(string)(ViewData["Title"]??"Estoque & Compras 360");} + +
Operação, margem e rastreabilidade

@title

Dados reais por filial, movimentos imutáveis e custos com origem identificada.

Carregando dados reais…
+ diff --git a/Web/BarberSync.AdminWeb/Views/Shared/_AdminSidebar.cshtml b/Web/BarberSync.AdminWeb/Views/Shared/_AdminSidebar.cshtml index 5005904..d7aa33b 100644 --- a/Web/BarberSync.AdminWeb/Views/Shared/_AdminSidebar.cshtml +++ b/Web/BarberSync.AdminWeb/Views/Shared/_AdminSidebar.cshtml @@ -43,11 +43,11 @@ diff --git a/Web/BarberSync.AdminWeb/Views/Shared/_Sidebar.cshtml b/Web/BarberSync.AdminWeb/Views/Shared/_Sidebar.cshtml index ae72357..b8a09ba 100644 --- a/Web/BarberSync.AdminWeb/Views/Shared/_Sidebar.cshtml +++ b/Web/BarberSync.AdminWeb/Views/Shared/_Sidebar.cshtml @@ -1,2 +1,2 @@ + Atendimento 360 Central de Controle Catálogo & Precificação Dashboard BI Executivo Clientes Profissionais Serviços Agenda Comandas Caixa Financeiro 360 Estoque & Compras 360 Copilot Marketplace & Parceiros Governança Manual diff --git a/Web/BarberSync.AdminWeb/wwwroot/css/inventory360.css b/Web/BarberSync.AdminWeb/wwwroot/css/inventory360.css new file mode 100644 index 0000000..88cab30 --- /dev/null +++ b/Web/BarberSync.AdminWeb/wwwroot/css/inventory360.css @@ -0,0 +1 @@ +.inventory{color:#172033}.inventory-hero{display:flex;justify-content:space-between;gap:1rem;align-items:center;padding:2rem;border-radius:20px;background:linear-gradient(135deg,#11253a,#176b66);color:#fff}.inventory-hero h1{margin:.25rem 0}.inventory-nav{display:flex;gap:.5rem;overflow:auto;padding:1rem 0}.inventory-nav a{white-space:nowrap;padding:.65rem .85rem;border-radius:999px;background:#eef4f3;color:#174a47;text-decoration:none}.inventory-button{border:0;border-radius:10px;padding:.75rem 1rem;background:#d39b35;color:#151515;font-weight:700}.inventory-error{margin:1rem 0;padding:1rem;border-left:4px solid #c53030;background:#fff1f1}.inventory-kpis{display:grid;grid-template-columns:repeat(auto-fit,minmax(170px,1fr));gap:1rem}.inventory-kpis article{padding:1.2rem;border:1px solid #dfe7e5;border-radius:14px;background:#fff}.inventory-kpis span{display:block;color:#63706f}.inventory-kpis strong{font-size:1.8rem}.inventory form{margin:1rem 0;padding:1.25rem;border:1px solid #dfe7e5;border-radius:14px;background:#fff}.inventory-form-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(210px,1fr));gap:1rem}.inventory label{display:grid;gap:.35rem;font-weight:650}.inventory input,.inventory select,.inventory textarea{width:100%;padding:.7rem;border:1px solid #aebcba;border-radius:8px;background:#fff}.inventory-table{overflow:auto;margin-top:1rem}.inventory table{width:100%;border-collapse:collapse;background:#fff}.inventory th,.inventory td{padding:.75rem;border-bottom:1px solid #e5eceb;text-align:left}.inventory th{text-transform:capitalize;background:#f3f7f6}.inventory-empty,.inventory-note{padding:1rem;border-radius:10px;background:#f3f7f6}.inventory [aria-busy=true]{opacity:.65;pointer-events:none}@media(max-width:640px){.inventory-hero{align-items:flex-start;flex-direction:column}.inventory-form-grid{grid-template-columns:1fr}} diff --git a/Web/BarberSync.AdminWeb/wwwroot/js/inventory360.js b/Web/BarberSync.AdminWeb/wwwroot/js/inventory360.js new file mode 100644 index 0000000..09e198c --- /dev/null +++ b/Web/BarberSync.AdminWeb/wwwroot/js/inventory360.js @@ -0,0 +1,6 @@ +(()=>{'use strict';const root=document.querySelector('[data-inventory-page]');if(!root)return;const out=root.querySelector('[data-inventory-content]'),notice=root.querySelector('.inventory-error'),esc=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); +async function api(path,options={}){const response=await fetch(`/AdminApi/inventory360/${path}`,{credentials:'same-origin',headers:{Accept:'application/json','Content-Type':'application/json'},...options});const body=await response.json().catch(()=>({}));if(!response.ok)throw Object.assign(new Error(body.message||'Não foi possível concluir a operação.'),{traceId:body.traceId||response.headers.get('X-Trace-Id')});return body.data??body.items??body;} +const table=rows=>!Array.isArray(rows)||!rows.length?'
Nenhum registro real encontrado.

Cadastre ou filtre dados para iniciar.

':`
${Object.keys(rows[0]).filter(k=>k!=='id'&&!k.endsWith('_id')).slice(0,9).map(k=>``).join('')}${rows.map(row=>`${Object.keys(row).filter(k=>k!=='id'&&!k.endsWith('_id')).slice(0,9).map(k=>``).join('')}`).join('')}
${esc(k.replaceAll('_',' '))}
${esc(row[k])}
`; +function error(e){notice.hidden=false;notice.innerHTML=`Não foi possível carregar.

${esc(e.message)}

Trace ID: ${esc(e.traceId||'não informado')}`;} +async function load(){notice.hidden=true;out.setAttribute('aria-busy','true');out.innerHTML='
Carregando dados reais…
';try{const page=root.dataset.inventoryPage;if(page==='dashboard'){const d=await api('dashboard');out.innerHTML=`
${Object.entries(d).map(([k,v])=>`
${esc(k.replaceAll('_',' '))}${esc(v)}
`).join('')}

Alertas são derivados de saldos, lotes e compras persistidos; nenhum valor é estimado.

`;return;}const endpoint={products:'products',supplies:'supplies',suppliers:'suppliers',stock:'stock',batches:'batches',purchases:'purchases',counts:'counts',replenishment:'replenishment',audit:'audit','service-inputs':'service-inputs'}[page];if(endpoint){out.innerHTML=table(await api(endpoint));return;}if(page==='costing'||page==='reports'){out.innerHTML='

CMV e custo real

Quando não existir custo de lote ou médio, o relatório informa sourceStatus unavailable.

';window.BarberSyncForms?.bind(out.querySelector('form'));return;}out.innerHTML='
Fluxo disponível após seleção de registros reais nas telas operacionais.
';}catch(e){error(e);out.innerHTML='
Não foi possível exibir os dados.
';}finally{out.removeAttribute('aria-busy');}} +root.querySelector('[data-refresh]')?.addEventListener('click',load);load();})(); diff --git a/docs/ANALYTICS_WORKFLOW.md b/docs/ANALYTICS_WORKFLOW.md index f950ecf..b43c397 100644 --- a/docs/ANALYTICS_WORKFLOW.md +++ b/docs/ANALYTICS_WORKFLOW.md @@ -66,3 +66,7 @@ O contrato canônico e as regras de isolamento, disponibilidade, produtividade, ## Integração Financeiro 360 — Sprint 61 Este módulo publica/consome origens persistidas pelo razão Financeiro 360. Nenhum status pago é inferido: liquidação exige payment confirmado ou baixa manual autorizada; fontes indisponíveis retornam `sourceStatus` sem estimativa. Consulte `docs/FINANCE360_WORKFLOW.md`. + +## Integração Inventory360 — Sprint 62 + +O contrato canônico está em [INVENTORY360_WORKFLOW.md](INVENTORY360_WORKFLOW.md). Dados de estoque e CMV são tenant/branch scoped, derivados de movimentos reais e retornam `sourceStatus=Unavailable` quando a origem necessária não existir; integrações não podem fabricar saldo, compra ou custo. diff --git a/docs/API_ROUTE_CONTRACTS.md b/docs/API_ROUTE_CONTRACTS.md index 2002a6a..f5db98e 100644 --- a/docs/API_ROUTE_CONTRACTS.md +++ b/docs/API_ROUTE_CONTRACTS.md @@ -208,3 +208,13 @@ O contrato canônico e as regras de isolamento, disponibilidade, produtividade, ## Sprint 61 — Financeiro 360 A superfície autenticada `/api/finance360` inclui dashboard, filter-options, receivables (CRUD de estado e aging), payables (agendamento, baixa e aging), reconciliation (preview/reconcile/divergent/reverse), cash-flow (projection/realized/snapshot), DRE (snapshot/export), commissions, payroll, partner-payouts, delinquency, audit e reports. Mobile expõe somente leitura em `/api/mobile/finance360/{summary,receivables,payables,commissions,payroll}`. Todas as rotas exigem permissões `Finance360.*` e escopo dos claims. + +## Integração Inventory360 — Sprint 62 + +O contrato canônico está em [INVENTORY360_WORKFLOW.md](INVENTORY360_WORKFLOW.md). Dados de estoque e CMV são tenant/branch scoped, derivados de movimentos reais e retornam `sourceStatus=Unavailable` quando a origem necessária não existir; integrações não podem fabricar saldo, compra ou custo. + +### Inventory360 + +`GET /api/inventory360/dashboard`, `GET|POST /api/inventory360/products`, `PUT /api/inventory360/products/{id}`, `POST /api/inventory360/products/{id}/activate|suspend|archive`, `GET /api/inventory360/supplies`, `GET /api/inventory360/suppliers`, `GET|POST /api/inventory360/service-inputs`, `POST /api/inventory360/service-inputs/preview`, `GET /api/inventory360/stock`, `GET /api/inventory360/batches`, `POST /api/inventory360/stock/receive|consume|reserve|release-reservation|reverse`, `GET|POST /api/inventory360/purchases`, `POST /api/inventory360/purchases/{id}/approve|receive|cancel|return-to-supplier`, `GET /api/inventory360/counts`, `POST /api/inventory360/counts/open`, `POST /api/inventory360/counts/{id}/items|close|adjust`, `GET /api/inventory360/replenishment`, `POST /api/inventory360/replenishment/generate`, `POST /api/inventory360/replenishment/{id}/create-purchase`, `GET /api/inventory360/costing`, `GET /api/inventory360/audit`, `GET /api/inventory360/reports/export`, `GET /api/inventory360/filter-options`. + +Mobile: `GET /api/mobile/inventory360/summary|products|stock|replenishment` e `POST /api/mobile/inventory360/counts/{id}/items`. Todos exigem JWT, permission e escopo obtido dos claims. diff --git a/docs/CATALOG_PRICING_WORKFLOW.md b/docs/CATALOG_PRICING_WORKFLOW.md index ea242f9..4dc6c74 100644 --- a/docs/CATALOG_PRICING_WORKFLOW.md +++ b/docs/CATALOG_PRICING_WORKFLOW.md @@ -38,3 +38,7 @@ O contrato canônico e as regras de isolamento, disponibilidade, produtividade, ## Integração Financeiro 360 — Sprint 61 Este módulo publica/consome origens persistidas pelo razão Financeiro 360. Nenhum status pago é inferido: liquidação exige payment confirmado ou baixa manual autorizada; fontes indisponíveis retornam `sourceStatus` sem estimativa. Consulte `docs/FINANCE360_WORKFLOW.md`. + +## Integração Inventory360 — Sprint 62 + +O contrato canônico está em [INVENTORY360_WORKFLOW.md](INVENTORY360_WORKFLOW.md). Dados de estoque e CMV são tenant/branch scoped, derivados de movimentos reais e retornam `sourceStatus=Unavailable` quando a origem necessária não existir; integrações não podem fabricar saldo, compra ou custo. diff --git a/docs/COMMAND_CENTER_WORKFLOW.md b/docs/COMMAND_CENTER_WORKFLOW.md index 37fac14..9b4be82 100644 --- a/docs/COMMAND_CENTER_WORKFLOW.md +++ b/docs/COMMAND_CENTER_WORKFLOW.md @@ -34,3 +34,7 @@ O contrato canônico e as regras de isolamento, disponibilidade, produtividade, ## Integração Financeiro 360 — Sprint 61 Este módulo publica/consome origens persistidas pelo razão Financeiro 360. Nenhum status pago é inferido: liquidação exige payment confirmado ou baixa manual autorizada; fontes indisponíveis retornam `sourceStatus` sem estimativa. Consulte `docs/FINANCE360_WORKFLOW.md`. + +## Integração Inventory360 — Sprint 62 + +O contrato canônico está em [INVENTORY360_WORKFLOW.md](INVENTORY360_WORKFLOW.md). Dados de estoque e CMV são tenant/branch scoped, derivados de movimentos reais e retornam `sourceStatus=Unavailable` quando a origem necessária não existir; integrações não podem fabricar saldo, compra ou custo. diff --git a/docs/FINANCE360_WORKFLOW.md b/docs/FINANCE360_WORKFLOW.md index c0c7505..491f9cc 100644 --- a/docs/FINANCE360_WORKFLOW.md +++ b/docs/FINANCE360_WORKFLOW.md @@ -19,3 +19,7 @@ O projetado soma saldos abertos de receivables/payables; o realizado soma soment ## Inadimplência e integrações Recebíveis vencidos podem originar casos únicos de inadimplência; atribuição, negociação e encerramento são auditados. Atendimento cria a origem de checkout/payment, Team360 e Parceiros geram pagáveis aprovados, Catálogo/Estoque fornecem custo e desconto, e Command Center/BI consomem alertas e métricas persistidas. Contratos Mobile são somente leitura e respeitam as mesmas permissões. + +## Integração Inventory360 — Sprint 62 + +O contrato canônico está em [INVENTORY360_WORKFLOW.md](INVENTORY360_WORKFLOW.md). Dados de estoque e CMV são tenant/branch scoped, derivados de movimentos reais e retornam `sourceStatus=Unavailable` quando a origem necessária não existir; integrações não podem fabricar saldo, compra ou custo. diff --git a/docs/INVENTORY360_WORKFLOW.md b/docs/INVENTORY360_WORKFLOW.md new file mode 100644 index 0000000..ab866de --- /dev/null +++ b/docs/INVENTORY360_WORKFLOW.md @@ -0,0 +1,23 @@ +# Estoque & Compras 360 + +## Fonte de verdade + +Inventory360 é isolado por `tenant_id` e `branch_id`. Produtos, insumos, fornecedores e regras são cadastros; saldo só muda por movimentos imutáveis com origem e chave de idempotência. Não há fallback demonstrativo. Ausência de custo retorna `sourceStatus=Unavailable`. + +## Ciclo operacional + +1. Produto ou insumo ativo é selecionado (IDs internos nunca são digitados). +2. Compra nasce `Draft`, exige permissão para aprovação e aceita recebimentos parciais. +3. O recebimento confirmado adiciona movimento `Receive`, atualiza custo médio e, se solicitado, cria payable no Financeiro 360 com a origem do recebimento. +4. Venda confirmada e execução do serviço consomem somente regras ativas. Reserva, liberação, perda, devolução e estorno criam novos movimentos; nenhum ledger é editado ou apagado. +5. Lotes elegíveis são consumidos por FEFO; vencidos/bloqueados são indisponíveis. Transferência cria saída e entrada correlacionadas nas filiais. +6. Inventário registra esperado e contado. Divergência requer motivo e o ajuste gera movimento auditável. +7. Reposição compara mínimo/alvo com saldo persistido. Histórico insuficiente é explícito e uma sugestão só vira compra após confirmação. + +## Custo e integrações + +CMV usa custo de lote quando presente e custo médio caso contrário. Sem custo, não produz zero fictício. Atendimento 360 deve chamar consumo em `ServiceStarted`, `ServiceCompleted` ou `CheckoutConfirmed`; estornos chamam reversão. Catálogo, PublicWeb, Marketplace e Kiosk só disponibilizam item ativo/visível e, havendo controle, com saldo. DRE e BI consomem CMV real. Command Center observa `StockBelowMinimum`, `BatchExpiring`, `BatchExpired`, `PurchaseOrderApproved`, `PurchaseOrderReceived`, `InventoryCountDivergent`, `StockMovementReversed` e `SupplierReturnConfirmed`. Até existir barramento Workflow Studio, a publicação permanece `sourceStatus=Unavailable` sem sucesso simulado. + +## Segurança e falhas + +Todas as rotas são autenticadas e autorizadas por permissão. O contexto vem dos claims, nunca do payload. Validação rejeita quantidade não positiva, item ambíguo, origem ausente, excesso de recebimento/transferência, lote vencido e saldo negativo. Erros seguem ProblemDetails global e incluem `traceId`. diff --git a/docs/PARTNERS_MARKETPLACE_WORKFLOW.md b/docs/PARTNERS_MARKETPLACE_WORKFLOW.md index 6d2558f..5d7967a 100644 --- a/docs/PARTNERS_MARKETPLACE_WORKFLOW.md +++ b/docs/PARTNERS_MARKETPLACE_WORKFLOW.md @@ -33,3 +33,7 @@ O contrato integrado, estados, transações e responsabilidades deste módulo es ## Integração Financeiro 360 — Sprint 61 Este módulo publica/consome origens persistidas pelo razão Financeiro 360. Nenhum status pago é inferido: liquidação exige payment confirmado ou baixa manual autorizada; fontes indisponíveis retornam `sourceStatus` sem estimativa. Consulte `docs/FINANCE360_WORKFLOW.md`. + +## Integração Inventory360 — Sprint 62 + +O contrato canônico está em [INVENTORY360_WORKFLOW.md](INVENTORY360_WORKFLOW.md). Dados de estoque e CMV são tenant/branch scoped, derivados de movimentos reais e retornam `sourceStatus=Unavailable` quando a origem necessária não existir; integrações não podem fabricar saldo, compra ou custo. diff --git a/docs/SERVICE_EXECUTION_CHECKOUT_WORKFLOW.md b/docs/SERVICE_EXECUTION_CHECKOUT_WORKFLOW.md index 94ee456..42c5ebd 100644 --- a/docs/SERVICE_EXECUTION_CHECKOUT_WORKFLOW.md +++ b/docs/SERVICE_EXECUTION_CHECKOUT_WORKFLOW.md @@ -41,3 +41,7 @@ O contrato canônico e as regras de isolamento, disponibilidade, produtividade, ## Integração Financeiro 360 — Sprint 61 Este módulo publica/consome origens persistidas pelo razão Financeiro 360. Nenhum status pago é inferido: liquidação exige payment confirmado ou baixa manual autorizada; fontes indisponíveis retornam `sourceStatus` sem estimativa. Consulte `docs/FINANCE360_WORKFLOW.md`. + +## Integração Inventory360 — Sprint 62 + +O contrato canônico está em [INVENTORY360_WORKFLOW.md](INVENTORY360_WORKFLOW.md). Dados de estoque e CMV são tenant/branch scoped, derivados de movimentos reais e retornam `sourceStatus=Unavailable` quando a origem necessária não existir; integrações não podem fabricar saldo, compra ou custo. diff --git a/docs/SOURCE_CODE_AUDIT_REPORT.md b/docs/SOURCE_CODE_AUDIT_REPORT.md index bd12b3c..9e52951 100644 --- a/docs/SOURCE_CODE_AUDIT_REPORT.md +++ b/docs/SOURCE_CODE_AUDIT_REPORT.md @@ -92,3 +92,11 @@ A varredura obrigatória revisou ocorrências financeiras/operacionais, tipos nu O Financeiro 360 introduz razão idempotente e append-only, receivables/payables com saldo parcial, validação de payment confirmado ou baixa manual autorizada, conciliação não destrutiva, projeção separada do realizado, DRE derivada exclusivamente de postings e eventos de auditoria imutáveis. O seed só cria estados abertos; posting/reconciliation confirmado é condicional à existência de payment real confirmado. Pendências controladas: adaptadores automáticos dos módulos legados Atendimento, Clube, Estoque e Parceiros devem chamar os serviços de posting na mesma transação de origem em sprint de migração. Até isso ocorrer, a API expõe `sourceStatus` e não inventa valores. Os antigos `/api/finance` e `/api/commissions/.../mark-paid` devem ser descontinuados depois da migração dos consumidores. + +## Sprint 62 — Auditoria de estoque, compras, insumos, CMV, validade, fornecedores e integração financeira + +Foram executadas as quatro varreduras obrigatórias sobre domínio, tipos monetários/temporais, marcadores inseguros e DI/autorização/escopo. O legado já possuía produto, compra, recebimento, contagem, transferência e receita de serviço, porém mantinha saldo no cadastro de produto, não possuía contratos de serviço nomeados para Inventory360, não representava produto e insumo separadamente, e calculava reposição durante uma leitura. O recebimento antigo gerava movimento sem origem/idempotência e lote sem custo disponível; o relatório não oferecia source status para CMV ausente. + +A nova superfície introduz ledger append-only com origem e idempotência, saldo separado, produto/insumo, custo decimal, serviços explícitos, compra e payable, inventário auditável, reposição somente sob comando, API móvel e UI sem ID técnico digitável. Movimentos bloqueiam saldo insuficiente e reversões são novos registros. O seed cria apenas cadastros Draft/Inactive e evidência indisponível; não cria recebimento, saldo, movimento ou CMV. + +Pendências controladas: a conexão transacional automática nos handlers legados de Atendimento/Checkout e a correlação de transferência por lote exigem migração dos agregados existentes. Até essa migração, os consumidores legados permanecem documentados e Inventory360 não declara sucesso nem inventa custo. O barramento Workflow Studio não foi localizado; eventos previstos permanecem com `sourceStatus=Unavailable`. diff --git a/scripts/validate-source-integrity.ps1 b/scripts/validate-source-integrity.ps1 index e3c16bc..6172563 100644 --- a/scripts/validate-source-integrity.ps1 +++ b/scripts/validate-source-integrity.ps1 @@ -8,3 +8,4 @@ try { } finally { Pop-Location } # Sprint 58 parity: Atendimento 360 is covered by the shell gate for fake financial/stock outcomes and binary money types. # Sprint 61 parity: Finance360 fake outcomes, binary money and technical-ID inputs are covered by the shell gate. +# Sprint 62 parity: Inventory360 fabricated stock/purchase/COGS, binary costs and technical IDs are covered by the shell gate. diff --git a/scripts/validate-source-integrity.sh b/scripts/validate-source-integrity.sh index c68c9b3..d779f8e 100755 --- a/scripts/validate-source-integrity.sh +++ b/scripts/validate-source-integrity.sh @@ -46,5 +46,10 @@ if rg -ni 'fake[ _-]?(payment|reconciliation|payout|payroll|dre|cash[ _-]?flow)' if rg -n '\b(double|float)\b' Backend/Presentation/BarberSync.Api/Services/Finance360 Backend/Presentation/BarberSync.Api/Controllers/Finance360Controllers.cs; then report 'double/float no Financeiro 360'; fi if rg -ni 'type="text"[^>]+name="[^"]*(tenant|branch|payment|receivable|payable|cashSession|checkoutSession|serviceOrder|commission|settlement|payroll|payout|client|professional|partner|supplier|account|category|costCenter)[Ii]d' Web/BarberSync.AdminWeb/Views/Finance360; then report 'ID técnico digitável no Financeiro 360'; fi +# Inventory360 forbids fabricated operational values and binary monetary types. +inventory_paths=(Backend/Presentation/BarberSync.Api/Services/Inventory360 Backend/Presentation/BarberSync.Api/Controllers/Inventory360Controllers.cs Backend/Presentation/BarberSync.Api/Controllers/MobileInventory360Controller.cs Web/BarberSync.AdminWeb/Views/Inventory360 Web/BarberSync.AdminWeb/wwwroot/js/inventory360.js) +if rg -ni 'fake[ _-]?(stock|purchase|cogs|inventory|supplier)|mock[ _-]?(stock|purchase|cogs|inventory|supplier)' "${inventory_paths[@]}"; then report 'resultado fabricado no Inventory360'; fi +if rg -n '\b(double|float)\b' Backend/Presentation/BarberSync.Api/Services/Inventory360 Backend/Presentation/BarberSync.Api/Controllers/Inventory360Controllers.cs; then report 'double/float financeiro no Inventory360'; fi + (( fail == 0 )) || exit 1 echo 'EVIDENCE:SOURCE_INTEGRITY_STATIC:PASS' diff --git a/scripts/validate-ui-contracts.ps1 b/scripts/validate-ui-contracts.ps1 index 1ce7f0c..35f5531 100644 --- a/scripts/validate-ui-contracts.ps1 +++ b/scripts/validate-ui-contracts.ps1 @@ -6,3 +6,4 @@ if (-not $bash) { throw 'bash is required to run the canonical UI contract valid if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } # Sprint 58 parity: ServiceExecution views must use selections, never visible technical-ID inputs. # Sprint 61 parity: Finance360 visible forms are checked for technical identifiers by the canonical shell validator. +# Sprint 62 parity: Inventory360 forms are checked for visible technical identifiers by the canonical shell validator. diff --git a/scripts/validate-ui-contracts.sh b/scripts/validate-ui-contracts.sh index bb2d11d..f2d97f1 100755 --- a/scripts/validate-ui-contracts.sh +++ b/scripts/validate-ui-contracts.sh @@ -12,6 +12,7 @@ check 'technical ID placeholder' "placeholder=['\"][^'\"]*(ID|Id técnico|identi check 'catalog technical ID input' "]+name=['\"][^'\"]*(tenant|branch|service|product|combo|package|pricingRule|commissionRule|professional|partner|category|supplier)Id['\"]" Web/BarberSync.AdminWeb/Views/Catalog check 'service execution technical ID input' "]+name=['\"][^'\"]*(tenant|branch|client|professional|service|product|serviceOrder|appointment|payment|cashSession|commission|stockMovement|wallet|voucher|coupon|giftCard|package|membership|partner)Id['\"]" Web/BarberSync.AdminWeb/Views/ServiceExecution check 'finance360 technical ID input' "]+name=['\"][^'\"]*(tenant|branch|payment|receivable|payable|cashSession|checkoutSession|serviceOrder|commission|settlement|payroll|payout|client|professional|partner|supplier|account|category|costCenter)Id['\"]" Web/BarberSync.AdminWeb/Views/Finance360 +check 'inventory360 technical ID input' "]+name=['\"][^'\"]*(tenant|branch|product|supply|supplier|service|batch|stockMovement|purchaseOrder|purchaseItem|transfer|inventoryCount|loss|costCenter|financePayable)Id['\"]" Web/BarberSync.AdminWeb/Views/Inventory360 check 'unfinished scheduling operation' '[Ee]m breve' Web/BarberSync.AdminWeb/Views/Scheduling Web/BarberSync.AdminWeb/wwwroot/js/scheduling.js Web/BarberSync.PublicWeb/Views/Booking Web/BarberSync.PublicWeb/wwwroot/js/public-booking.js [[ $fail -eq 0 ]] || exit 1 printf 'EVIDENCE:UI_CONTRACTS_STATIC:PASS\n'