diff --git a/src/api/SIGCM2.Api/Filters/ExceptionFilter.cs b/src/api/SIGCM2.Api/Filters/ExceptionFilter.cs index f43eac9..e936231 100644 --- a/src/api/SIGCM2.Api/Filters/ExceptionFilter.cs +++ b/src/api/SIGCM2.Api/Filters/ExceptionFilter.cs @@ -242,6 +242,31 @@ public sealed class ExceptionFilter : IExceptionFilter context.ExceptionHandled = true; break; + // CAT-002: Rubro Regla de Oro (rama vs hoja) + case RubroPadreEsHojaConAvisosException rubroPadreHojaEx: + context.Result = new ObjectResult(new + { + error = "rubro_padre_es_hoja_con_avisos", + message = rubroPadreHojaEx.Message + }) + { + StatusCode = StatusCodes.Status409Conflict + }; + context.ExceptionHandled = true; + break; + + case RubroEsRamaConHijosActivosException rubroRamaHijosEx: + context.Result = new ObjectResult(new + { + error = "rubro_es_rama_con_hijos_activos", + message = rubroRamaHijosEx.Message + }) + { + StatusCode = StatusCodes.Status409Conflict + }; + context.ExceptionHandled = true; + break; + // ADM-001: Medio exceptions case MedioCodigoDuplicadoException medioCodDupEx: context.Result = new ObjectResult(new diff --git a/src/api/SIGCM2.Application/Abstractions/Persistence/IAvisoQueryRepository.cs b/src/api/SIGCM2.Application/Abstractions/Persistence/IAvisoQueryRepository.cs new file mode 100644 index 0000000..a077780 --- /dev/null +++ b/src/api/SIGCM2.Application/Abstractions/Persistence/IAvisoQueryRepository.cs @@ -0,0 +1,24 @@ +namespace SIGCM2.Application.Abstractions.Persistence; + +/// +/// Query-only access to Aviso counts by Rubro. +/// CAT-002 introduces the contract. The real Dapper-based impl lands in PRD-002 +/// (when dbo.Aviso exists). Until then, NullAvisoQueryRepository is the binding. +/// +public interface IAvisoQueryRepository +{ + /// + /// Returns the count of avisos (active, non-archived) assigned to the given rubro. + /// + Task CountAvisosEnRubroAsync(int rubroId, CancellationToken ct = default); + + /// + /// Returns a dictionary of { rubroId → count } for the provided ids. + /// Used by GetRubroTreeQueryHandler to avoid N+1 when populating TieneAvisos per node. + /// The implementation MUST do a single query; the stub returns an empty dictionary + /// (every rubro gets 0 via dictionary.GetValueOrDefault). + /// + Task> CountAvisosBatchAsync( + IReadOnlyCollection rubroIds, + CancellationToken ct = default); +} diff --git a/src/api/SIGCM2.Application/Avisos/NullAvisoQueryRepository.cs b/src/api/SIGCM2.Application/Avisos/NullAvisoQueryRepository.cs new file mode 100644 index 0000000..223c146 --- /dev/null +++ b/src/api/SIGCM2.Application/Avisos/NullAvisoQueryRepository.cs @@ -0,0 +1,22 @@ +using SIGCM2.Application.Abstractions.Persistence; + +namespace SIGCM2.Application.Avisos; + +/// +/// STUB — PRD-002 reemplaza con AvisoQueryRepository contra dbo.Aviso. +/// Returns 0 / empty dictionary so every handler guard passes and every tree node shows TieneAvisos=false. +/// This is intentional for CAT-002: the mechanism is installed; the data feed arrives in PRD-002. +/// +public sealed class NullAvisoQueryRepository : IAvisoQueryRepository +{ + private static readonly IReadOnlyDictionary Empty = + new Dictionary(capacity: 0); + + public Task CountAvisosEnRubroAsync(int rubroId, CancellationToken ct = default) + => Task.FromResult(0); + + public Task> CountAvisosBatchAsync( + IReadOnlyCollection rubroIds, + CancellationToken ct = default) + => Task.FromResult(Empty); +} diff --git a/src/api/SIGCM2.Application/DependencyInjection.cs b/src/api/SIGCM2.Application/DependencyInjection.cs index 7d7f85f..2b10fbb 100644 --- a/src/api/SIGCM2.Application/DependencyInjection.cs +++ b/src/api/SIGCM2.Application/DependencyInjection.cs @@ -67,6 +67,8 @@ using SIGCM2.Application.Rubros.Move; using SIGCM2.Application.Rubros.GetTree; using SIGCM2.Application.Rubros.GetById; using SIGCM2.Application.Rubros.Dtos; +using SIGCM2.Application.Abstractions.Persistence; +using SIGCM2.Application.Avisos; namespace SIGCM2.Application; @@ -152,7 +154,10 @@ public static class DependencyInjection services.AddScoped>, ListIngresosBrutosQueryHandler>(); services.AddScoped>, GetHistorialIngresosBrutosQueryHandler>(); - // Rubros (CAT-001) + // Rubros (CAT-001 + CAT-002) + // CAT-002: Regla de Oro Rama vs Hoja — stub binding until PRD-002 provides real impl + services.AddScoped(); + services.AddScoped, CreateRubroCommandHandler>(); services.AddScoped, UpdateRubroCommandHandler>(); services.AddScoped, DeactivateRubroCommandHandler>(); diff --git a/src/api/SIGCM2.Application/Rubros/Common/RubroTreeBuilder.cs b/src/api/SIGCM2.Application/Rubros/Common/RubroTreeBuilder.cs index 3e92ddf..04c3fc0 100644 --- a/src/api/SIGCM2.Application/Rubros/Common/RubroTreeBuilder.cs +++ b/src/api/SIGCM2.Application/Rubros/Common/RubroTreeBuilder.cs @@ -12,7 +12,8 @@ public static class RubroTreeBuilder { public static IReadOnlyList Build( IEnumerable flat, - bool incluirInactivos) + bool incluirInactivos, + IReadOnlyDictionary avisoCounts) { var filtered = incluirInactivos ? flat.ToList() @@ -36,6 +37,7 @@ public static class RubroTreeBuilder Activo: r.Activo, ParentId: r.ParentId, TarifarioBaseId: r.TarifarioBaseId, + TieneAvisos: avisoCounts.GetValueOrDefault(r.Id, 0) > 0, Hijos: children); } diff --git a/src/api/SIGCM2.Application/Rubros/Create/CreateRubroCommandHandler.cs b/src/api/SIGCM2.Application/Rubros/Create/CreateRubroCommandHandler.cs index 7332524..f142877 100644 --- a/src/api/SIGCM2.Application/Rubros/Create/CreateRubroCommandHandler.cs +++ b/src/api/SIGCM2.Application/Rubros/Create/CreateRubroCommandHandler.cs @@ -14,17 +14,20 @@ public sealed class CreateRubroCommandHandler : ICommandHandler options) + IOptions options, + IAvisoQueryRepository avisoQuery) { _repo = repo; _audit = audit; _timeProvider = timeProvider; _options = options.Value; + _avisoQuery = avisoQuery; } public async Task Handle(CreateRubroCommand command) @@ -38,6 +41,12 @@ public sealed class CreateRubroCommandHandler : ICommandHandler 0) + throw new RubroPadreEsHojaConAvisosException(command.ParentId.Value, avisosCount); + // Depth check: parent's depth + 1 must not exceed MaxDepth var parentDepth = await _repo.GetDepthAsync(command.ParentId); var newDepth = parentDepth + 1; diff --git a/src/api/SIGCM2.Application/Rubros/Dtos/RubroTreeNodeDto.cs b/src/api/SIGCM2.Application/Rubros/Dtos/RubroTreeNodeDto.cs index 1bf5468..2f4d2c6 100644 --- a/src/api/SIGCM2.Application/Rubros/Dtos/RubroTreeNodeDto.cs +++ b/src/api/SIGCM2.Application/Rubros/Dtos/RubroTreeNodeDto.cs @@ -10,4 +10,5 @@ public sealed record RubroTreeNodeDto( bool Activo, int? ParentId, int? TarifarioBaseId, + bool TieneAvisos, IReadOnlyList Hijos); diff --git a/src/api/SIGCM2.Application/Rubros/GetTree/GetRubroTreeQueryHandler.cs b/src/api/SIGCM2.Application/Rubros/GetTree/GetRubroTreeQueryHandler.cs index 6fcefcc..26e0de1 100644 --- a/src/api/SIGCM2.Application/Rubros/GetTree/GetRubroTreeQueryHandler.cs +++ b/src/api/SIGCM2.Application/Rubros/GetTree/GetRubroTreeQueryHandler.cs @@ -8,15 +8,20 @@ namespace SIGCM2.Application.Rubros.GetTree; public sealed class GetRubroTreeQueryHandler : ICommandHandler> { private readonly IRubroRepository _repo; + private readonly IAvisoQueryRepository _avisoQuery; - public GetRubroTreeQueryHandler(IRubroRepository repo) + public GetRubroTreeQueryHandler(IRubroRepository repo, IAvisoQueryRepository avisoQuery) { _repo = repo; + _avisoQuery = avisoQuery; } public async Task> Handle(GetRubroTreeQuery query) { var all = await _repo.GetAllAsync(query.IncluirInactivos); - return RubroTreeBuilder.Build(all, query.IncluirInactivos); + var ids = all.Select(r => r.Id).ToList(); + // CAT-002: single batch call — avoids N+1 when PRD-002 activates the real implementation + var avisoCounts = await _avisoQuery.CountAvisosBatchAsync(ids); + return RubroTreeBuilder.Build(all, query.IncluirInactivos, avisoCounts); } } diff --git a/src/api/SIGCM2.Application/Rubros/Move/MoveRubroCommandHandler.cs b/src/api/SIGCM2.Application/Rubros/Move/MoveRubroCommandHandler.cs index 8bd32f5..8adeda7 100644 --- a/src/api/SIGCM2.Application/Rubros/Move/MoveRubroCommandHandler.cs +++ b/src/api/SIGCM2.Application/Rubros/Move/MoveRubroCommandHandler.cs @@ -13,17 +13,20 @@ public sealed class MoveRubroCommandHandler : ICommandHandler options) + IOptions options, + IAvisoQueryRepository avisoQuery) { _repo = repo; _audit = audit; _timeProvider = timeProvider; _options = options.Value; + _avisoQuery = avisoQuery; } public async Task Handle(MoveRubroCommand command) @@ -47,6 +50,12 @@ public sealed class MoveRubroCommandHandler : ICommandHandler 0) + throw new RubroPadreEsHojaConAvisosException(command.NuevoParentId.Value, avisosCount); + // Depth check var parentDepth = await _repo.GetDepthAsync(command.NuevoParentId); var newDepth = parentDepth + 1; diff --git a/src/api/SIGCM2.Domain/Exceptions/RubroEsRamaConHijosActivosException.cs b/src/api/SIGCM2.Domain/Exceptions/RubroEsRamaConHijosActivosException.cs new file mode 100644 index 0000000..84bfda6 --- /dev/null +++ b/src/api/SIGCM2.Domain/Exceptions/RubroEsRamaConHijosActivosException.cs @@ -0,0 +1,19 @@ +namespace SIGCM2.Domain.Exceptions; + +/// +/// Thrown when attempting to assign an aviso to a rubro that has active children. +/// Invariante: un nodo con hijos activos es RAMA — no puede recibir avisos directos. → HTTP 409 +/// NOTE: no handler launches this in CAT-002. Consumer: PRD-002 CreateAvisoCommandHandler. +/// +public sealed class RubroEsRamaConHijosActivosException : DomainException +{ + public int RubroId { get; } + public int CantidadHijos { get; } + + public RubroEsRamaConHijosActivosException(int rubroId, int cantidadHijos) + : base($"El destino tiene sub-rubros. No puede contener avisos directos.") + { + RubroId = rubroId; + CantidadHijos = cantidadHijos; + } +} diff --git a/src/api/SIGCM2.Domain/Exceptions/RubroPadreEsHojaConAvisosException.cs b/src/api/SIGCM2.Domain/Exceptions/RubroPadreEsHojaConAvisosException.cs new file mode 100644 index 0000000..143e785 --- /dev/null +++ b/src/api/SIGCM2.Domain/Exceptions/RubroPadreEsHojaConAvisosException.cs @@ -0,0 +1,18 @@ +namespace SIGCM2.Domain.Exceptions; + +/// +/// Thrown when attempting to create or move a child into a rubro that already has avisos assigned. +/// Invariante: un nodo con avisos es HOJA — no puede tener hijos. → HTTP 409 +/// +public sealed class RubroPadreEsHojaConAvisosException : DomainException +{ + public int ParentId { get; } + public int CantidadAvisos { get; } + + public RubroPadreEsHojaConAvisosException(int parentId, int cantidadAvisos) + : base($"El rubro padre contiene {cantidadAvisos} avisos. Muévalos antes de crear sub-rubros.") + { + ParentId = parentId; + CantidadAvisos = cantidadAvisos; + } +} diff --git a/src/web/src/features/rubros/components/CategoryTreeNode.tsx b/src/web/src/features/rubros/components/CategoryTreeNode.tsx index 5cc95e1..058a809 100644 --- a/src/web/src/features/rubros/components/CategoryTreeNode.tsx +++ b/src/web/src/features/rubros/components/CategoryTreeNode.tsx @@ -101,11 +101,14 @@ export function CategoryTreeNode({ {/* Action buttons — only if canEdit */} {canEdit && (
+ {/* CAT-002: disabled when leaf-with-avisos; PRD-002 activates the real data path */}