diff --git a/BunkFy.slnx b/BunkFy.slnx index e3934de8..3214bc03 100644 --- a/BunkFy.slnx +++ b/BunkFy.slnx @@ -84,6 +84,7 @@ + @@ -113,6 +114,7 @@ + @@ -143,6 +145,7 @@ + @@ -187,6 +190,7 @@ + diff --git a/docs/README.md b/docs/README.md index 2b17c00c..5122bd94 100644 --- a/docs/README.md +++ b/docs/README.md @@ -37,6 +37,7 @@ Useful entry points: - [Properties Follow-Up Notes](planning/properties-follow-up-notes.md) - [GMA Access Control and Properties Alignment](planning/gma-access-control-properties-alignment.md) - [Properties Topology Lifecycle Task](planning/properties-topology-lifecycle-task.md) +- [Properties Deployed Topology Lifecycle Proof Task](planning/properties-deployed-topology-lifecycle-proof-task.md) - [Properties Mutation Serialization Task](planning/properties-mutation-serialization-task.md) - [Properties Management Create Idempotency Task](planning/properties-management-create-idempotency-task.md) - [Properties Details Update Idempotency Task](planning/properties-details-update-idempotency-task.md) @@ -53,6 +54,7 @@ Useful entry points: - [Inventory Allocation Mutation Serialization Task](planning/inventory-allocation-mutation-serialization-task.md) - [Inventory Room Sales Mode Idempotency Task](planning/inventory-room-sales-mode-idempotency-task.md) - [Inventory Manual Block Idempotency Task](planning/inventory-manual-block-idempotency-task.md) +- [Inventory Manual Block Group Convergence Task](planning/inventory-manual-block-group-convergence-task.md) - [Inventory Retirement Idempotency Task](planning/inventory-retirement-idempotency-task.md) - [Inventory Retirement Outcome Convergence Task](planning/inventory-retirement-outcome-convergence-task.md) - [Inventory Retirement Cancellation Recovery Task](planning/inventory-retirement-cancellation-recovery-task.md) @@ -68,6 +70,7 @@ Useful entry points: - [Staff Record Retention Task](planning/staff-record-retention-task.md) - [Staff Retention Prerequisite Transaction Boundary Task](planning/staff-retention-prerequisite-transaction-boundary-task.md) - [Guest Records Module Task](planning/guest-records-module-task.md) +- [Guests Deployed Stay-History Proof Task](planning/guests-deployed-stay-history-proof-task.md) - [Guests Management Create Idempotency Task](planning/guests-management-create-idempotency-task.md) - [Guests Management Update And Archive Idempotency Task](planning/guests-management-update-archive-idempotency-task.md) - [Guests Read Surface Hardening Task](planning/guests-read-surface-hardening-task.md) @@ -85,6 +88,7 @@ Useful entry points: - [Staff Manual-Create Account-Link Authority Task](planning/staff-manual-create-account-link-authority-task.md) - [Staff Account-Link Authorization Task](planning/staff-account-link-authorization-task.md) - [Staff Employment Lifecycle Idempotency Task](planning/staff-employment-lifecycle-idempotency-task.md) +- [Staff Deployed Employment Lifecycle Proof Task](planning/staff-deployed-employment-lifecycle-proof-task.md) - [Staff Immediate Effective-Date Integrity Task](planning/staff-immediate-effective-date-integrity-task.md) - [Staff Identity Bootstrap Replay Envelope Task](planning/staff-identity-bootstrap-replay-envelope-task.md) - [Staff Property Assignment Idempotency Task](planning/staff-property-assignment-idempotency-task.md) diff --git a/docs/planning/inventory-manual-block-group-convergence-task.md b/docs/planning/inventory-manual-block-group-convergence-task.md new file mode 100644 index 00000000..2fcc659f --- /dev/null +++ b/docs/planning/inventory-manual-block-group-convergence-task.md @@ -0,0 +1,116 @@ +# Inventory Manual Block Group Convergence Task + +Status: implementation and local verification complete; draft publication pending +Date: 2026-08-12 + +## Goal + +Make broad manual Inventory blocks a bounded, previewable, confirmable, and +recoverable operator workflow without weakening the existing atomic +availability contract. + +## Ownership + +- Inventory owns the block-group definition, resolved membership, maximum + impact, selection digest, mutation versions, operation receipts, actor + provenance, and tenant lifecycle. +- Properties remains authoritative for physical topology. Inventory previews + and commits against its local versioned topology projection. +- Reservations continues to consume the existing per-child block events. This + slice does not redefine a committed Inventory mutation as downstream + projection convergence. +- The workflow is BunkFy-specific. GMA receives no Inventory block concepts. +- Failed transactional results rely on the provider-neutral rollback-reset hook + from GMA Framework PR #24. The backend pins that exact framework commit so a + later command in the same scope cannot persist entities or domain events left + tracked by a rolled-back attempt; this is generic unit-of-work hygiene, not an + Inventory concept in the framework. + +## Frozen Contract + +- A synchronous block group contains at most 500 child blocks. `500` succeeds; + `501` returns a stable impact-limit error and creates no group, child, + operation receipt, outbox message, or Inventory revision. +- For targets within the limit, preview returns the normalized target, exact + affected count, maximum, bounded member evidence, and a deterministic digest + of the selection and selection-affecting versions. Above the limit it returns + `TooLarge`, a bounded lower count of at least 501, and no exact count or + confirmation digest; it does not perform an unbounded count scan. A count + alone never confirms membership. +- Create requires the preview digest, expected count, and explicit + confirmation. The server resolves the target again under the mutation + fences and rejects a stale preview atomically. +- A block group is a first-class, versioned parent. Its read model reports the + original target, active/released/total counts, status, predecessor/successor + relationship, timestamps, and actor provenance. Legacy parents identify + their original target as unknown instead of inferring intent from mutable + topology. +- Group definitions are immutable. Replace atomically releases the predecessor + and creates a successor with a new group id, linked in both read models and + in the receipt. A semantic no-op may retain the same group id and version. +- Replace and release bind the caller-observed group version and require + explicit confirmation. Receipts report the observed predecessor and actual + result, including released-now, already-released, remaining, and affected + counts where applicable. +- Exact operation replay returns its committed receipt before mutable topology + or group-state checks. Changed operation-id reuse is a conflict. Failed + preview, confirmation, impact-limit, stale-version, or availability checks do + not bind the operation id. + +## Operator Surfaces + +Public and Admin API expose the same Inventory contracts: + +- `POST /properties/{propertyId}/block-groups/preview`; +- `GET /properties/{propertyId}/block-groups`; +- `GET /properties/{propertyId}/block-groups/{blockGroupId}`; +- `GET /properties/{propertyId}/block-groups/{blockGroupId}/members`; +- `POST /properties/{propertyId}/block-groups`; +- `PUT /properties/{propertyId}/block-groups/{blockGroupId}`; +- `POST /properties/{propertyId}/block-groups/{blockGroupId}/release`; +- `GET /properties/{propertyId}/block-group-create-operations/{operationId}`; +- `GET /properties/{propertyId}/block-groups/{blockGroupId}/operations/{operationId}`. + +Group and member directories use opaque keyset cursors and bounded page sizes; +they never return an unbounded nested child collection. The create-operation +read is property-scoped because a client does not know the generated group id +after a lost response. With a success-only receipt journal, `404` means no +committed outcome is visible and does not assert that the request failed; the +safe recovery action is an exact retry with the same operation id and payload. + +Every public and Admin response, including errors, remains `no-store`. Stable +problem codes distinguish invalid input/confirmation, not found, stale preview +or version, impact over 500, closed workspace, and unavailable admission. Human +messages are display text; clients branch and localize on the code. + +Admin API and Admin CLI use distinct `inventory.block-groups.*` audit operation +names and propagate the authenticated actor into Inventory's durable evidence. +The new property-scoped, sensitive `inventory.block-groups.manage` permission is +granted to the Manager seed and is delegable with its prerequisites. It is not +silently added to legacy Front desk or Housekeeping access. CLI mutations +require `--yes` and support the same preview, reads, keyset traversal, create, +replace, release, and operation-recovery workflow as HTTP. + +## Scale And Failure Semantics + +The service does not silently split an over-limit request into active batches. +That would expose partially blocked availability and permit allocations to race +between batches. Operators narrow the target and preview again. + +An asynchronous staged workflow is deferred. It would require inactive +prepared children, explicit progress/retry/cancel states, conflict revalidation, +an atomic activation gate, and downstream convergence semantics. Those are not +implicit in this synchronous contract. + +## Completion Criteria + +- unit and provider tests prove `0`, `500`, and `501`, deterministic digest and + stale-selection behavior, atomic replace/release, exact replay, lost-response + recovery, keyset traversal, tenant isolation, and no partial persistence; +- public, Admin API, and Admin CLI authorization tests prove the new permission, + property scope, confirmation, actor attribution, stable errors, and no-store; +- generated OpenAPI and TypeScript contracts match the runtime endpoints; +- Inventory tenant export, destruction, and personal-data metadata include the + first-class group and persisted actor evidence; +- the implementation is repository-verified; deployment and production + readiness remain separate evidence gates. diff --git a/docs/planning/inventory-operational-surface-hardening-task.md b/docs/planning/inventory-operational-surface-hardening-task.md index a50a407f..4fa0366e 100644 --- a/docs/planning/inventory-operational-surface-hardening-task.md +++ b/docs/planning/inventory-operational-surface-hardening-task.md @@ -36,7 +36,10 @@ Make Inventory's ordinary operator surfaces truthful, bounded, cache-safe, and i ## Deferred -- A first-class persisted manual-block-group aggregate and group-paged history. The current storage model records one block per unit, so changing history pagination semantics safely requires preserving the original target intent rather than inferring it from mutable topology. +- A first-class persisted manual-block-group aggregate and group-paged history + were deferred by this historical slice and are addressed by + `inventory-manual-block-group-convergence-task.md`, which preserves original + target intent and marks legacy intent unknown rather than inferring it. - Server-side searchable block-target discovery for unusually large properties. - Partial availability paging or search. That requires a reservation-selection UX and consistency contract of its own. - Provider-specific query tuning or partitioning before measurements justify it. diff --git a/gma/framework b/gma/framework index fa1afd9f..67ce38a9 160000 --- a/gma/framework +++ b/gma/framework @@ -1 +1 @@ -Subproject commit fa1afd9fbc31c0f0684673c7569926785c839f9f +Subproject commit 67ce38a9a8726703d3495b8ef65b6343d070bb4c diff --git a/src/Modules/Inventory/BunkFy.Modules.Inventory.Admin.Contracts/Operations/InventoryAdminOperationNames.cs b/src/Modules/Inventory/BunkFy.Modules.Inventory.Admin.Contracts/Operations/InventoryAdminOperationNames.cs index f101a1a3..58c44684 100644 --- a/src/Modules/Inventory/BunkFy.Modules.Inventory.Admin.Contracts/Operations/InventoryAdminOperationNames.cs +++ b/src/Modules/Inventory/BunkFy.Modules.Inventory.Admin.Contracts/Operations/InventoryAdminOperationNames.cs @@ -8,6 +8,15 @@ public static class InventoryAdminOperationNames public const string BlocksList = "inventory.blocks.list"; public const string BlocksCreate = "inventory.blocks.create"; public const string BlocksRelease = "inventory.blocks.release"; + public const string BlockGroupsPreview = "inventory.block-groups.preview"; + public const string BlockGroupsList = "inventory.block-groups.list"; + public const string BlockGroupsGet = "inventory.block-groups.get"; + public const string BlockGroupMembersList = "inventory.block-groups.members.list"; + public const string BlockGroupsCreate = "inventory.block-groups.create"; + public const string BlockGroupsReplace = "inventory.block-groups.replace"; + public const string BlockGroupsRelease = "inventory.block-groups.release"; + public const string BlockGroupCreateOperationsGet = "inventory.block-group-create-operations.get"; + public const string BlockGroupOperationsGet = "inventory.block-group-operations.get"; public const string BedRetirementsGet = "inventory.bed-retirements.get"; public const string BedRetirementsRequest = "inventory.bed-retirements.request"; public const string BedRetirementsRetry = "inventory.bed-retirements.retry"; diff --git a/src/Modules/Inventory/BunkFy.Modules.Inventory.Admin.Contracts/Permissions/InventoryAdminPermissions.cs b/src/Modules/Inventory/BunkFy.Modules.Inventory.Admin.Contracts/Permissions/InventoryAdminPermissions.cs index 4dc20758..41034360 100644 --- a/src/Modules/Inventory/BunkFy.Modules.Inventory.Admin.Contracts/Permissions/InventoryAdminPermissions.cs +++ b/src/Modules/Inventory/BunkFy.Modules.Inventory.Admin.Contracts/Permissions/InventoryAdminPermissions.cs @@ -8,5 +8,6 @@ public static class InventoryAdminPermissions public static readonly AdminPermission Read = AdminPermission.Create(InventoryAdminPermissionCodes.Read); public static readonly AdminPermission Configure = AdminPermission.Create(InventoryAdminPermissionCodes.Configure); public static readonly AdminPermission BlocksManage = AdminPermission.Create(InventoryAdminPermissionCodes.BlocksManage); + public static readonly AdminPermission BlockGroupsManage = AdminPermission.Create(InventoryAdminPermissionCodes.BlockGroupsManage); public static readonly AdminPermission Retire = AdminPermission.Create(InventoryAdminPermissionCodes.Retire); } diff --git a/src/Modules/Inventory/BunkFy.Modules.Inventory.AdminApi/InventoryAdminApiModule.cs b/src/Modules/Inventory/BunkFy.Modules.Inventory.AdminApi/InventoryAdminApiModule.cs index e89d2e87..c4710eba 100644 --- a/src/Modules/Inventory/BunkFy.Modules.Inventory.AdminApi/InventoryAdminApiModule.cs +++ b/src/Modules/Inventory/BunkFy.Modules.Inventory.AdminApi/InventoryAdminApiModule.cs @@ -15,8 +15,11 @@ namespace BunkFy.Modules.Inventory.AdminApi; using Gma.Framework.ModuleComposition; using Gma.Framework.Pagination; using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Hosting; public sealed class InventoryAdminApiModule : IAdminApiModule @@ -26,6 +29,8 @@ public sealed class InventoryAdminApiModule : IAdminApiModule public void AddServices(IHostApplicationBuilder builder) { builder.SelectModuleProfile(InventoryProfiles.Default, "BunkFy.Modules.Inventory.AdminApi"); + builder.Services.TryAddEnumerable( + ServiceDescriptor.Singleton()); builder.Services.AddInventoryApplication(); builder.AddInventoryPersistence(); } @@ -161,12 +166,116 @@ await executor.ExecuteAsync( request.InventoryUnitId, request.Arrival, request.Departure, - request.Reason), + request.Reason, + Actor(httpContext)), token), cancellationToken, errorStatusCodes: AdminErrorStatusCodes).ConfigureAwait(false)) .Produces(StatusCodes.Status200OK); + inventory.MapPost("/properties/{propertyId:guid}/block-groups/preview", async ( + Guid propertyId, + PreviewManualBlockGroupRequest request, + HttpContext httpContext, + AdminApiExecutor executor, + IRequestDispatcher dispatcher, + CancellationToken cancellationToken) => + await executor.ExecuteAsync( + httpContext, + AdminOperation.Create( + InventoryAdminOperationNames.BlockGroupsPreview, + InventoryAdminPermissions.BlockGroupsManage), + requireTenant: true, + token => dispatcher.QueryAsync( + new PreviewManualInventoryBlockGroupQuery( + propertyId, + request.Target, + request.Arrival, + request.Departure, + request.Reason, + request.BlockGroupId, + request.ExpectedVersion), + token), + cancellationToken, + errorStatusCodes: AdminErrorStatusCodes).ConfigureAwait(false)) + .Produces(StatusCodes.Status200OK); + + inventory.MapGet("/properties/{propertyId:guid}/block-groups", async ( + Guid propertyId, + ManualInventoryBlockGroupStatus? status, + string? cursor, + int? pageSize, + HttpContext httpContext, + AdminApiExecutor executor, + IRequestDispatcher dispatcher, + CancellationToken cancellationToken) => + await executor.ExecuteAsync( + httpContext, + AdminOperation.Create( + InventoryAdminOperationNames.BlockGroupsList, + InventoryAdminPermissions.Read), + requireTenant: true, + token => dispatcher.QueryAsync( + new ListManualInventoryBlockGroupsQuery( + propertyId, + status, + cursor, + pageSize ?? PageRequest.DefaultPageSize), + token), + cancellationToken, + errorStatusCodes: AdminErrorStatusCodes).ConfigureAwait(false)) + .Produces(StatusCodes.Status200OK); + + inventory.MapGet("/properties/{propertyId:guid}/block-groups/{blockGroupId:guid}", async ( + Guid propertyId, + Guid blockGroupId, + HttpContext httpContext, + AdminApiExecutor executor, + IRequestDispatcher dispatcher, + CancellationToken cancellationToken) => + await executor.ExecuteAsync( + httpContext, + AdminOperation.Create( + InventoryAdminOperationNames.BlockGroupsGet, + InventoryAdminPermissions.Read), + requireTenant: true, + token => dispatcher.QueryAsync( + new GetManualInventoryBlockGroupQuery( + propertyId, + blockGroupId), + token), + cancellationToken, + errorStatusCodes: AdminErrorStatusCodes).ConfigureAwait(false)) + .Produces(StatusCodes.Status200OK); + + inventory.MapGet("/properties/{propertyId:guid}/block-groups/{blockGroupId:guid}/members", async ( + Guid propertyId, + Guid blockGroupId, + ManualInventoryBlockStatus? status, + string? cursor, + int? pageSize, + HttpContext httpContext, + AdminApiExecutor executor, + IRequestDispatcher dispatcher, + CancellationToken cancellationToken) => + await executor.ExecuteAsync( + httpContext, + AdminOperation.Create( + InventoryAdminOperationNames.BlockGroupMembersList, + InventoryAdminPermissions.Read), + requireTenant: true, + token => dispatcher.QueryAsync( + new ListManualInventoryBlockGroupMembersQuery( + propertyId, + blockGroupId, + status, + cursor, + pageSize ?? PageRequest.DefaultPageSize), + token), + cancellationToken, + errorStatusCodes: AdminErrorStatusCodes).ConfigureAwait(false)) + .Produces(StatusCodes.Status200OK); + inventory.MapPost("/properties/{propertyId:guid}/block-groups", async ( Guid propertyId, CreateManualBlockGroupRequest request, @@ -176,7 +285,9 @@ await executor.ExecuteAsync( CancellationToken cancellationToken) => await executor.ExecuteAsync( httpContext, - AdminOperation.Create(InventoryAdminOperationNames.BlocksCreate, InventoryAdminPermissions.BlocksManage), + AdminOperation.Create( + InventoryAdminOperationNames.BlockGroupsCreate, + InventoryAdminPermissions.BlockGroupsManage), requireTenant: true, token => dispatcher.SendAsync( new CreateManualInventoryBlockGroupCommand( @@ -185,7 +296,44 @@ await executor.ExecuteAsync( request.Target, request.Arrival, request.Departure, - request.Reason), + request.Reason, + request.ExpectedSelectionDigest, + request.ExpectedAffectedBlockCount, + request.Confirmed, + Actor(httpContext)), + token), + cancellationToken, + errorStatusCodes: AdminErrorStatusCodes).ConfigureAwait(false)) + .Produces(StatusCodes.Status200OK); + + inventory.MapPut("/properties/{propertyId:guid}/block-groups/{blockGroupId:guid}", async ( + Guid propertyId, + Guid blockGroupId, + ReplaceManualBlockGroupRequest request, + HttpContext httpContext, + AdminApiExecutor executor, + IRequestDispatcher dispatcher, + CancellationToken cancellationToken) => + await executor.ExecuteAsync( + httpContext, + AdminOperation.Create( + InventoryAdminOperationNames.BlockGroupsReplace, + InventoryAdminPermissions.BlockGroupsManage), + requireTenant: true, + token => dispatcher.SendAsync( + new ReplaceManualInventoryBlockGroupCommand( + request.OperationId, + propertyId, + blockGroupId, + request.ExpectedVersion, + request.Target, + request.Arrival, + request.Departure, + request.Reason, + request.ExpectedSelectionDigest, + request.ExpectedAffectedBlockCount, + request.Confirmed, + Actor(httpContext)), token), cancellationToken, errorStatusCodes: AdminErrorStatusCodes).ConfigureAwait(false)) @@ -201,14 +349,17 @@ await executor.ExecuteAsync( CancellationToken cancellationToken) => await executor.ExecuteAsync( httpContext, - AdminOperation.Create(InventoryAdminOperationNames.BlocksRelease, InventoryAdminPermissions.BlocksManage), + AdminOperation.Create( + InventoryAdminOperationNames.BlocksRelease, + InventoryAdminPermissions.BlocksManage), requireTenant: true, token => dispatcher.SendAsync( new ReleaseManualInventoryBlockCommand( request.OperationId, propertyId, blockId, - request.ExpectedVersion), + request.ExpectedVersion, + Actor(httpContext)), token), cancellationToken, errorStatusCodes: AdminErrorStatusCodes).ConfigureAwait(false)) @@ -224,18 +375,69 @@ await executor.ExecuteAsync( CancellationToken cancellationToken) => await executor.ExecuteAsync( httpContext, - AdminOperation.Create(InventoryAdminOperationNames.BlocksRelease, InventoryAdminPermissions.BlocksManage), + AdminOperation.Create( + InventoryAdminOperationNames.BlockGroupsRelease, + InventoryAdminPermissions.BlockGroupsManage), requireTenant: true, token => dispatcher.SendAsync( new ReleaseManualInventoryBlockGroupCommand( request.OperationId, propertyId, - blockGroupId), + blockGroupId, + request.ExpectedVersion, + request.Confirmed, + Actor(httpContext)), token), cancellationToken, errorStatusCodes: AdminErrorStatusCodes).ConfigureAwait(false)) .Produces(StatusCodes.Status200OK); + inventory.MapGet("/properties/{propertyId:guid}/block-group-create-operations/{operationId:guid}", async ( + Guid propertyId, + Guid operationId, + HttpContext httpContext, + AdminApiExecutor executor, + IRequestDispatcher dispatcher, + CancellationToken cancellationToken) => + await executor.ExecuteAsync( + httpContext, + AdminOperation.Create( + InventoryAdminOperationNames.BlockGroupCreateOperationsGet, + InventoryAdminPermissions.BlockGroupsManage), + requireTenant: true, + token => dispatcher.QueryAsync( + new GetManualInventoryBlockGroupCreateOperationQuery( + propertyId, + operationId), + token), + cancellationToken, + errorStatusCodes: AdminErrorStatusCodes).ConfigureAwait(false)) + .Produces(StatusCodes.Status200OK); + + inventory.MapGet("/properties/{propertyId:guid}/block-groups/{blockGroupId:guid}/operations/{operationId:guid}", async ( + Guid propertyId, + Guid blockGroupId, + Guid operationId, + HttpContext httpContext, + AdminApiExecutor executor, + IRequestDispatcher dispatcher, + CancellationToken cancellationToken) => + await executor.ExecuteAsync( + httpContext, + AdminOperation.Create( + InventoryAdminOperationNames.BlockGroupOperationsGet, + InventoryAdminPermissions.BlockGroupsManage), + requireTenant: true, + token => dispatcher.QueryAsync( + new GetManualInventoryBlockGroupOperationQuery( + propertyId, + blockGroupId, + operationId), + token), + cancellationToken, + errorStatusCodes: AdminErrorStatusCodes).ConfigureAwait(false)) + .Produces(StatusCodes.Status200OK); + inventory.MapPost("/properties/{propertyId:guid}/rooms/{roomId:guid}/beds/{bedId:guid}/retirement", async ( Guid propertyId, Guid roomId, @@ -434,11 +636,34 @@ public sealed record CreateManualBlockGroupRequest( InventoryBlockTarget Target, DateOnly Arrival, DateOnly Departure, - string Reason); + string Reason, + string ExpectedSelectionDigest, + int ExpectedAffectedBlockCount, + bool Confirmed); + public sealed record PreviewManualBlockGroupRequest( + InventoryBlockTarget Target, + DateOnly Arrival, + DateOnly Departure, + string Reason, + Guid? BlockGroupId = null, + long? ExpectedVersion = null); + public sealed record ReplaceManualBlockGroupRequest( + Guid OperationId, + long ExpectedVersion, + InventoryBlockTarget Target, + DateOnly Arrival, + DateOnly Departure, + string Reason, + string ExpectedSelectionDigest, + int ExpectedAffectedBlockCount, + bool Confirmed); public sealed record ReleaseManualBlockRequest( Guid OperationId, long ExpectedVersion); - public sealed record ReleaseManualBlockGroupRequest(Guid OperationId); + public sealed record ReleaseManualBlockGroupRequest( + Guid OperationId, + long ExpectedVersion, + bool Confirmed); public sealed record RequestBedRetirementRequest( Guid OperationId, bool Confirmed, @@ -473,8 +698,14 @@ private static void MarkSensitiveResponse(HttpContext context) private static readonly ApiErrorStatusCodeMap AdminErrorStatusCodes = ApiErrorStatusCodeMap.Create( new(InventoryApplicationErrors.ConfirmationRequired.Code, StatusCodes.Status400BadRequest), + new(InventoryApplicationErrors.BlockGroupConfirmationRequired.Code, StatusCodes.Status400BadRequest), + new(InventoryApplicationErrors.BlockGroupSelectionDigestInvalid.Code, StatusCodes.Status400BadRequest), + new(InventoryApplicationErrors.BlockGroupCursorInvalid.Code, StatusCodes.Status400BadRequest), new(InventoryApplicationErrors.ManagementOperationInvalid.Code, StatusCodes.Status400BadRequest), new(InventoryApplicationErrors.ManagementOperationConflict.Code, StatusCodes.Status409Conflict), + new(InventoryApplicationErrors.BlockGroupSelectionMismatch.Code, StatusCodes.Status409Conflict), + new(InventoryApplicationErrors.BlockGroupTargetTooLarge.Code, StatusCodes.Status422UnprocessableEntity), + new(Domain.Errors.InventoryDomainErrors.BlockGroupActorInvalid.Code, StatusCodes.Status400BadRequest), new(InventoryApplicationErrors.RetirementRequestConflict.Code, StatusCodes.Status409Conflict), new(InventoryApplicationErrors.WorkspaceProcessingRestricted.Code, StatusCodes.Status423Locked), new(InventoryApplicationErrors.WorkspaceProcessingAdmissionUnavailable.Code, StatusCodes.Status503ServiceUnavailable), @@ -490,6 +721,7 @@ private static void MarkSensitiveResponse(HttpContext context) new(InventoryApplicationErrors.InventoryUnitNotSellable.Code, StatusCodes.Status409Conflict), new(InventoryApplicationErrors.BlockNotFound.Code, StatusCodes.Status404NotFound), new(InventoryApplicationErrors.BlockGroupNotFound.Code, StatusCodes.Status404NotFound), + new(InventoryApplicationErrors.BlockGroupOperationNotFound.Code, StatusCodes.Status404NotFound), new(InventoryApplicationErrors.BlockTargetInvalid.Code, StatusCodes.Status400BadRequest), new(InventoryApplicationErrors.BlockTargetEmpty.Code, StatusCodes.Status409Conflict), new(InventoryApplicationErrors.BlockOverlap.Code, StatusCodes.Status409Conflict), diff --git a/src/Modules/Inventory/BunkFy.Modules.Inventory.AdminApi/InventoryAdminNoStoreStartupFilter.cs b/src/Modules/Inventory/BunkFy.Modules.Inventory.AdminApi/InventoryAdminNoStoreStartupFilter.cs new file mode 100644 index 00000000..e7ba3a15 --- /dev/null +++ b/src/Modules/Inventory/BunkFy.Modules.Inventory.AdminApi/InventoryAdminNoStoreStartupFilter.cs @@ -0,0 +1,42 @@ +namespace BunkFy.Modules.Inventory.AdminApi; + +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; + +internal sealed class InventoryAdminNoStoreStartupFilter : IStartupFilter +{ + private static readonly PathString InventoryPath = new("/api/admin/inventory"); + + public Action Configure(Action next) + { + ArgumentNullException.ThrowIfNull(next); + + return applicationBuilder => + { + applicationBuilder.Use(nextMiddleware => context => + { + if (context.Request.Path.StartsWithSegments(InventoryPath)) + { + MarkNoStore(context.Response); + context.Response.OnStarting(static state => + { + MarkNoStore((HttpResponse)state); + return Task.CompletedTask; + }, context.Response); + } + + return nextMiddleware(context); + }); + + next(applicationBuilder); + }; + } + + private static void MarkNoStore(HttpResponse response) + { + response.Headers.CacheControl = "no-store"; + response.Headers.Pragma = "no-cache"; + response.Headers.Expires = "0"; + } +} diff --git a/src/Modules/Inventory/BunkFy.Modules.Inventory.AdminCli/InventoryAdminCliModule.cs b/src/Modules/Inventory/BunkFy.Modules.Inventory.AdminCli/InventoryAdminCliModule.cs index 69bc0d73..ac9e8205 100644 --- a/src/Modules/Inventory/BunkFy.Modules.Inventory.AdminCli/InventoryAdminCliModule.cs +++ b/src/Modules/Inventory/BunkFy.Modules.Inventory.AdminCli/InventoryAdminCliModule.cs @@ -46,6 +46,9 @@ public void MapCommands(IAdminCliCommandRegistry commands) CreateBlockCommand(commands.Services, globalOptions), CreateReleaseBlockCommand(commands.Services, globalOptions) }, + InventoryBlockGroupAdminCliCommandGroup.Create( + commands.Services, + globalOptions), InventoryBedRetirementAdminCliCommandGroup.Create(commands.Services, globalOptions), InventoryRoomRetirementAdminCliCommandGroup.Create(commands.Services, globalOptions) }; @@ -254,7 +257,8 @@ private static Command CreateBlockCommand(IServiceProvider services, AdminCliGlo parseResult.GetValue(unitOption), arrival, departure, - parseResult.GetValue(reasonOption) ?? string.Empty), + parseResult.GetValue(reasonOption) ?? string.Empty, + ResolveActor(parseResult, globalOptions)), token).ConfigureAwait(false); if (result.IsSuccess) { @@ -304,7 +308,8 @@ private static Command CreateReleaseBlockCommand(IServiceProvider services, Admi parseResult.GetValue(operationOption), parseResult.GetValue(propertyOption), parseResult.GetValue(blockOption), - parseResult.GetValue(expectedVersionOption)), + parseResult.GetValue(expectedVersionOption), + ResolveActor(parseResult, globalOptions)), token).ConfigureAwait(false); if (result.IsSuccess) { @@ -388,6 +393,17 @@ private static bool TryParseSalesMode(string? value, out InventorySalesMode sale return salesMode != InventorySalesMode.Unknown; } + private static string ResolveActor( + ParseResult parse, + AdminCliGlobalOptions globalOptions) + { + string actor = string.IsNullOrWhiteSpace( + parse.GetValue(globalOptions.ActorOption)) + ? $"{Environment.UserDomainName}\\{Environment.UserName}" + : parse.GetValue(globalOptions.ActorOption)!.Trim(); + return $"admin-cli:{actor}"; + } + private static bool TryParseStayRange( string? arrivalValue, string? departureValue, diff --git a/src/Modules/Inventory/BunkFy.Modules.Inventory.AdminCli/InventoryBlockGroupAdminCliCommandGroup.cs b/src/Modules/Inventory/BunkFy.Modules.Inventory.AdminCli/InventoryBlockGroupAdminCliCommandGroup.cs new file mode 100644 index 00000000..2a7ab7a9 --- /dev/null +++ b/src/Modules/Inventory/BunkFy.Modules.Inventory.AdminCli/InventoryBlockGroupAdminCliCommandGroup.cs @@ -0,0 +1,734 @@ +namespace BunkFy.Modules.Inventory.AdminCli; + +using System.CommandLine; +using System.CommandLine.Parsing; +using System.Globalization; +using BunkFy.Modules.Inventory.Admin.Contracts; +using BunkFy.Modules.Inventory.Application; +using BunkFy.Modules.Inventory.Application.Commands; +using BunkFy.Modules.Inventory.Application.Queries; +using BunkFy.Modules.Inventory.Contracts; +using Gma.Framework.Administration; +using Gma.Framework.Administration.Cli; +using Gma.Framework.Cqrs; +using Gma.Framework.Pagination; +using Gma.Framework.Results; +using Microsoft.Extensions.DependencyInjection; + +internal static class InventoryBlockGroupAdminCliCommandGroup +{ + public static Command Create( + IServiceProvider services, + AdminCliGlobalOptions globalOptions) => + new("block-groups", "Manage bounded manual inventory block groups.") + { + CreatePreviewCommand(services, globalOptions), + CreateListCommand(services, globalOptions), + CreateGetCommand(services, globalOptions), + CreateMembersCommand(services, globalOptions), + CreateCreateCommand(services, globalOptions), + CreateReplaceCommand(services, globalOptions), + CreateReleaseCommand(services, globalOptions), + CreateGetCreateOperationCommand(services, globalOptions), + CreateGetOperationCommand(services, globalOptions) + }; + + private static Command CreatePreviewCommand( + IServiceProvider services, + AdminCliGlobalOptions globalOptions) + { + Option property = RequiredId("--property-id"); + TargetOptions target = TargetOptions.Create(); + StayOptions stay = StayOptions.Create(); + Option reason = RequiredString("--reason"); + Option blockGroup = new("--block-group-id"); + Option expectedVersion = new("--expected-version"); + Command command = new( + "preview", + $"Preview an atomic block group of at most {InventoryContractLimits.MaximumManualInventoryBlockGroupMembers} members."); + AddOptions( + command, + [property, .. target.Options, .. stay.Options, reason, blockGroup, expectedVersion]); + command.SetAction((parse, cancellationToken) => ExecuteAsync( + services, + globalOptions, + parse, + InventoryAdminOperationNames.BlockGroupsPreview, + InventoryAdminPermissions.BlockGroupsManage, + (dispatcher, token) => ParseStayAndExecute( + parse, + stay, + (arrival, departure) => dispatcher.QueryAsync( + new PreviewManualInventoryBlockGroupQuery( + parse.GetValue(property), + target.Read(parse), + arrival, + departure, + parse.GetRequiredValue(reason), + parse.GetValue(blockGroup), + parse.GetValue(expectedVersion)), + token)), + cancellationToken, + WritePreview)); + return command; + } + + private static Command CreateListCommand( + IServiceProvider services, + AdminCliGlobalOptions globalOptions) + { + Option property = RequiredId("--property-id"); + Option status = new("--status"); + Option cursor = new("--cursor"); + Option pageSize = new("--page-size") + { + DefaultValueFactory = _ => PageRequest.DefaultPageSize + }; + Command command = new("list", "List block-group history.") + { + property, + status, + cursor, + pageSize + }; + command.SetAction((parse, cancellationToken) => ExecuteAsync( + services, + globalOptions, + parse, + InventoryAdminOperationNames.BlockGroupsList, + InventoryAdminPermissions.Read, + (dispatcher, token) => dispatcher.QueryAsync( + new ListManualInventoryBlockGroupsQuery( + parse.GetValue(property), + parse.GetValue(status), + parse.GetValue(cursor), + parse.GetValue(pageSize)), + token), + cancellationToken, + WriteGroupList)); + return command; + } + + private static Command CreateGetCommand( + IServiceProvider services, + AdminCliGlobalOptions globalOptions) + { + Option property = RequiredId("--property-id"); + Option group = RequiredId("--block-group-id"); + Command command = new("get", "Get one authoritative block group.") + { + property, + group + }; + command.SetAction((parse, cancellationToken) => ExecuteAsync( + services, + globalOptions, + parse, + InventoryAdminOperationNames.BlockGroupsGet, + InventoryAdminPermissions.Read, + (dispatcher, token) => dispatcher.QueryAsync( + new GetManualInventoryBlockGroupQuery( + parse.GetValue(property), + parse.GetValue(group)), + token), + cancellationToken, + WriteGroup)); + return command; + } + + private static Command CreateMembersCommand( + IServiceProvider services, + AdminCliGlobalOptions globalOptions) + { + Option property = RequiredId("--property-id"); + Option group = RequiredId("--block-group-id"); + Option status = new("--status"); + Option cursor = new("--cursor"); + Option pageSize = new("--page-size") + { + DefaultValueFactory = _ => PageRequest.DefaultPageSize + }; + Command command = new("members", "List block-group members by keyset cursor.") + { + property, + group, + status, + cursor, + pageSize + }; + command.SetAction((parse, cancellationToken) => ExecuteAsync( + services, + globalOptions, + parse, + InventoryAdminOperationNames.BlockGroupMembersList, + InventoryAdminPermissions.Read, + (dispatcher, token) => dispatcher.QueryAsync( + new ListManualInventoryBlockGroupMembersQuery( + parse.GetValue(property), + parse.GetValue(group), + parse.GetValue(status), + parse.GetValue(cursor), + parse.GetValue(pageSize)), + token), + cancellationToken, + WriteMemberList)); + return command; + } + + private static Command CreateCreateCommand( + IServiceProvider services, + AdminCliGlobalOptions globalOptions) + { + Option operation = RequiredId("--operation-id"); + Option property = RequiredId("--property-id"); + TargetOptions target = TargetOptions.Create(); + StayOptions stay = StayOptions.Create(); + Option reason = RequiredString("--reason"); + Option digest = RequiredString("--selection-digest"); + Option count = new("--expected-affected-count") { Required = true }; + Option yes = new("--yes"); + Command command = new("create", "Create the confirmed previewed block group atomically."); + AddOptions( + command, + [operation, property, .. target.Options, .. stay.Options, reason, digest, count, yes]); + command.SetAction((parse, cancellationToken) => ExecuteAsync( + services, + globalOptions, + parse, + InventoryAdminOperationNames.BlockGroupsCreate, + InventoryAdminPermissions.BlockGroupsManage, + (dispatcher, token) => ParseStayAndExecute( + parse, + stay, + (arrival, departure) => dispatcher.SendAsync( + new CreateManualInventoryBlockGroupCommand( + parse.GetValue(operation), + parse.GetValue(property), + target.Read(parse), + arrival, + departure, + parse.GetRequiredValue(reason), + parse.GetRequiredValue(digest), + parse.GetValue(count), + parse.GetValue(yes), + ResolveActor(parse, globalOptions)), + token)), + cancellationToken, + WriteMutationReceipt)); + return command; + } + + private static Command CreateReplaceCommand( + IServiceProvider services, + AdminCliGlobalOptions globalOptions) + { + Option operation = RequiredId("--operation-id"); + Option property = RequiredId("--property-id"); + Option group = RequiredId("--block-group-id"); + Option expectedVersion = new("--expected-version") + { + Required = true + }; + TargetOptions target = TargetOptions.Create(); + StayOptions stay = StayOptions.Create(); + Option reason = RequiredString("--reason"); + Option digest = RequiredString("--selection-digest"); + Option count = new("--expected-affected-count") { Required = true }; + Option yes = new("--yes"); + Command command = new( + "replace", + "Atomically release a group and create its confirmed immutable successor."); + AddOptions( + command, + [operation, property, group, expectedVersion, .. target.Options, .. stay.Options, reason, digest, count, yes]); + command.SetAction((parse, cancellationToken) => ExecuteAsync( + services, + globalOptions, + parse, + InventoryAdminOperationNames.BlockGroupsReplace, + InventoryAdminPermissions.BlockGroupsManage, + (dispatcher, token) => ParseStayAndExecute( + parse, + stay, + (arrival, departure) => dispatcher.SendAsync( + new ReplaceManualInventoryBlockGroupCommand( + parse.GetValue(operation), + parse.GetValue(property), + parse.GetValue(group), + parse.GetValue(expectedVersion), + target.Read(parse), + arrival, + departure, + parse.GetRequiredValue(reason), + parse.GetRequiredValue(digest), + parse.GetValue(count), + parse.GetValue(yes), + ResolveActor(parse, globalOptions)), + token)), + cancellationToken, + WriteMutationReceipt)); + return command; + } + + private static Command CreateReleaseCommand( + IServiceProvider services, + AdminCliGlobalOptions globalOptions) + { + Option operation = RequiredId("--operation-id"); + Option property = RequiredId("--property-id"); + Option group = RequiredId("--block-group-id"); + Option expectedVersion = new("--expected-version") + { + Required = true + }; + Option yes = new("--yes"); + Command command = new("release", "Release the confirmed block group atomically.") + { + operation, + property, + group, + expectedVersion, + yes + }; + command.SetAction((parse, cancellationToken) => ExecuteAsync( + services, + globalOptions, + parse, + InventoryAdminOperationNames.BlockGroupsRelease, + InventoryAdminPermissions.BlockGroupsManage, + (dispatcher, token) => dispatcher.SendAsync( + new ReleaseManualInventoryBlockGroupCommand( + parse.GetValue(operation), + parse.GetValue(property), + parse.GetValue(group), + parse.GetValue(expectedVersion), + parse.GetValue(yes), + ResolveActor(parse, globalOptions)), + token), + cancellationToken, + WriteMutationReceipt)); + return command; + } + + private static Command CreateGetCreateOperationCommand( + IServiceProvider services, + AdminCliGlobalOptions globalOptions) + { + Option property = RequiredId("--property-id"); + Option operation = RequiredId("--operation-id"); + Command command = new( + "get-create-operation", + "Recover a committed create receipt after a lost response.") + { + property, + operation + }; + command.SetAction((parse, cancellationToken) => ExecuteAsync( + services, + globalOptions, + parse, + InventoryAdminOperationNames.BlockGroupCreateOperationsGet, + InventoryAdminPermissions.BlockGroupsManage, + (dispatcher, token) => dispatcher.QueryAsync( + new GetManualInventoryBlockGroupCreateOperationQuery( + parse.GetValue(property), + parse.GetValue(operation)), + token), + cancellationToken, + WriteOperation)); + return command; + } + + private static Command CreateGetOperationCommand( + IServiceProvider services, + AdminCliGlobalOptions globalOptions) + { + Option property = RequiredId("--property-id"); + Option group = RequiredId("--block-group-id"); + Option operation = RequiredId("--operation-id"); + Command command = new("get-operation", "Get a committed group-operation receipt.") + { + property, + group, + operation + }; + command.SetAction((parse, cancellationToken) => ExecuteAsync( + services, + globalOptions, + parse, + InventoryAdminOperationNames.BlockGroupOperationsGet, + InventoryAdminPermissions.BlockGroupsManage, + (dispatcher, token) => dispatcher.QueryAsync( + new GetManualInventoryBlockGroupOperationQuery( + parse.GetValue(property), + parse.GetValue(group), + parse.GetValue(operation)), + token), + cancellationToken, + WriteOperation)); + return command; + } + + private static Task ExecuteAsync( + IServiceProvider services, + AdminCliGlobalOptions globalOptions, + ParseResult parse, + string operationName, + AdminPermission permission, + Func>> execute, + CancellationToken cancellationToken, + Action? writeOutput = null) => + services.GetRequiredService().ExecuteAsync( + parse, + AdminOperation.Create(operationName, permission), + parse.GetValue(globalOptions.TenantOption), + requireTenant: true, + async (provider, token) => + { + Result result = await execute( + provider.GetRequiredService(), + token).ConfigureAwait(false); + if (result.IsSuccess) + { + string output = + parse.GetValue(globalOptions.OutputOption) ?? + AdminCliOutput.Table; + if (writeOutput is null) + { + AdminCliOutput.WriteObject(result.Value, output); + } + else + { + writeOutput(result.Value, output); + } + } + else + { + AdminCliOutput.WriteErrorInline($"{result.Error.Code}: "); + } + + return result; + }, + cancellationToken); + + private static void WritePreview( + ManualInventoryBlockGroupSelectionPreviewDto preview, + string output) + { + if (IsJson(output)) + { + AdminCliOutput.WriteObject(preview, output); + return; + } + + AdminCliOutput.WriteRows( + [preview], + output, + [ + ("Status", item => item.Status.ToString()), + ("Affected", item => Value(item.AffectedBlockCount)), + ("AtLeast", item => Value(item.AtLeastAffectedBlockCount)), + ("Maximum", item => Value(item.MaximumAffectedBlockCount)), + ("TooLarge", item => item.ExceedsMaximumAffectedBlockCount.ToString()), + ("ManualConflict", item => item.HasManualBlockConflict.ToString()), + ("AllocationConflict", item => item.HasActiveAllocationConflict.ToString()), + ("SelectionDigest", item => item.SelectionDigest ?? string.Empty), + ("GroupId", item => Value(item.BlockGroupId)), + ("GroupVersion", item => Value(item.BlockGroupVersion)), + ("NoOp", item => item.IsNoOpReplacement.ToString()) + ]); + if (preview.Members.Count > 0) + { + AdminCliOutput.WriteRows( + preview.Members, + output, + [ + ("UnitId", item => item.InventoryUnitId.ToString("D")), + ("RoomId", item => item.RoomId.ToString("D")), + ("Room", item => item.RoomName), + ("Unit", item => item.UnitLabel ?? string.Empty) + ]); + } + + if (preview.HasMoreMembers) + { + AdminCliOutput.WriteMessage("Preview member evidence is truncated."); + } + } + + private static void WriteGroupList( + ManualInventoryBlockGroupListResponse response, + string output) + { + if (IsJson(output)) + { + AdminCliOutput.WriteObject(response, output); + return; + } + + WriteGroups(response.BlockGroups, output); + WriteNextCursor(response.NextCursor); + } + + private static void WriteGroup( + ManualInventoryBlockGroupDto group, + string output) + { + if (IsJson(output)) + { + AdminCliOutput.WriteObject(group, output); + return; + } + + WriteGroups([group], output); + AdminCliOutput.WriteRows( + [group], + output, + [ + ("Reason", item => item.Reason), + ("SelectionDigest", item => item.SelectionDigest ?? string.Empty), + ("MembershipDigest", item => item.MembershipDigest), + ("CreatedBy", item => item.CreatedByActorId ?? string.Empty), + ("LastModifiedBy", item => item.LastModifiedByActorId ?? string.Empty), + ("ReleasedAtUtc", item => Value(item.ReleasedAtUtc)) + ]); + } + + private static void WriteGroups( + IReadOnlyCollection groups, + string output) => + AdminCliOutput.WriteRows( + groups, + output, + [ + ("GroupId", item => item.BlockGroupId.ToString("D")), + ("Status", item => item.Status.ToString()), + ("Target", item => FormatTarget(item.Target)), + ("Arrival", item => item.Arrival.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)), + ("Departure", item => item.Departure.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)), + ("Total", item => item.InitialBlockCount.ToString(CultureInfo.InvariantCulture)), + ("Active", item => item.ActiveBlockCount.ToString(CultureInfo.InvariantCulture)), + ("Version", item => item.Version.ToString(CultureInfo.InvariantCulture)), + ("Replaces", item => Value(item.ReplacesGroupId)), + ("ReplacedBy", item => Value(item.ReplacedByGroupId)), + ("UpdatedAtUtc", item => Value(item.UpdatedAtUtc ?? item.CreatedAtUtc)) + ]); + + private static void WriteMemberList( + ManualInventoryBlockGroupMemberListResponse response, + string output) + { + if (IsJson(output)) + { + AdminCliOutput.WriteObject(response, output); + return; + } + + AdminCliOutput.WriteRows( + response.Blocks, + output, + [ + ("BlockId", item => item.BlockId.ToString("D")), + ("UnitId", item => item.InventoryUnitId.ToString("D")), + ("Status", item => item.Status.ToString()), + ("Version", item => item.Version.ToString(CultureInfo.InvariantCulture)), + ("Arrival", item => item.Arrival.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)), + ("Departure", item => item.Departure.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)), + ("ReleasedAtUtc", item => Value(item.ReleasedAtUtc)) + ]); + WriteNextCursor(response.NextCursor); + } + + private static void WriteMutationReceipt( + ManualInventoryBlockGroupMutationReceiptDto receipt, + string output) + { + if (IsJson(output)) + { + AdminCliOutput.WriteObject(receipt, output); + return; + } + + AdminCliOutput.WriteRows( + [receipt], + output, + [ + ("ResultGroupId", item => item.ResultBlockGroupId.ToString("D")), + ("PreviousGroupId", item => Value(item.PreviousBlockGroupId)), + ("Status", item => Value(item.Status)), + ("Version", item => Value(item.Version)), + ("Affected", item => Value(item.AffectedBlockCount)), + ("Total", item => Value(item.TotalBlockCount)), + ("Active", item => Value(item.ActiveBlockCount)), + ("ReleasedNow", item => Value(item.ReleasedNowBlockCount)), + ("AlreadyReleased", item => Value(item.AlreadyReleasedBlockCount)), + ("CreatedNow", item => Value(item.CreatedNowBlockCount)), + ("MembershipDigest", item => item.MembershipDigest ?? string.Empty) + ]); + } + + private static void WriteOperation( + ManualInventoryBlockGroupOperationDto operation, + string output) + { + if (IsJson(output)) + { + AdminCliOutput.WriteObject(operation, output); + return; + } + + AdminCliOutput.WriteRows( + [operation], + output, + [ + ("OperationId", item => item.OperationId.ToString("D")), + ("Kind", item => item.Kind.ToString()), + ("Status", item => item.Status.ToString()), + ("RequestedGroupId", item => Value(item.RequestedBlockGroupId)), + ("ResultGroupId", item => item.Receipt.ResultBlockGroupId.ToString("D")), + ("PreviousGroupId", item => Value(item.Receipt.PreviousBlockGroupId)), + ("Version", item => Value(item.Receipt.Version)), + ("Total", item => Value(item.Receipt.TotalBlockCount)), + ("Active", item => Value(item.Receipt.ActiveBlockCount)), + ("ReleasedNow", item => Value(item.Receipt.ReleasedNowBlockCount)), + ("AlreadyReleased", item => Value(item.Receipt.AlreadyReleasedBlockCount)), + ("CreatedNow", item => Value(item.Receipt.CreatedNowBlockCount)), + ("CompletedAtUtc", item => Value(item.CompletedAtUtc)) + ]); + } + + private static void WriteNextCursor(string? cursor) => + AdminCliOutput.WriteMessage( + cursor is null + ? "End of results." + : $"Next cursor: {cursor}"); + + private static bool IsJson(string output) => + string.Equals( + AdminCliOutput.NormalizeFormat(output), + AdminCliOutput.Json, + StringComparison.Ordinal); + + private static string FormatTarget(InventoryBlockTarget target) => + target.Kind switch + { + InventoryBlockTargetKind.Property => "property", + InventoryBlockTargetKind.Building => $"building:{target.BuildingLabel}", + InventoryBlockTargetKind.Floor => $"floor:{target.BuildingLabel}/{target.FloorLabel}", + InventoryBlockTargetKind.Room => $"room:{Value(target.RoomId)}", + InventoryBlockTargetKind.Unit => $"unit:{Value(target.InventoryUnitId)}", + _ => "unknown" + }; + + private static string Value(object? value) => + value switch + { + null => string.Empty, + Guid id => id.ToString("D"), + DateTimeOffset timestamp => timestamp.ToString("O", CultureInfo.InvariantCulture), + IFormattable formattable => formattable.ToString(null, CultureInfo.InvariantCulture), + _ => value.ToString() ?? string.Empty + }; + + private static Task> ParseStayAndExecute( + ParseResult parse, + StayOptions stay, + Func>> execute) => + TryParseStayRange( + parse.GetRequiredValue(stay.Arrival), + parse.GetRequiredValue(stay.Departure), + out DateOnly arrival, + out DateOnly departure) + ? execute(arrival, departure) + : Task.FromResult(Result.Failure( + InventoryApplicationErrors.StayRangeInvalid)); + + private static bool TryParseStayRange( + string arrivalValue, + string departureValue, + out DateOnly arrival, + out DateOnly departure) + { + bool arrivalParsed = DateOnly.TryParseExact( + arrivalValue, + "yyyy-MM-dd", + CultureInfo.InvariantCulture, + DateTimeStyles.None, + out arrival); + bool departureParsed = DateOnly.TryParseExact( + departureValue, + "yyyy-MM-dd", + CultureInfo.InvariantCulture, + DateTimeStyles.None, + out departure); + return arrivalParsed && departureParsed && arrival < departure; + } + + private static Option RequiredId(string name) => new(name) + { + Required = true + }; + + private static Option RequiredString(string name) => new(name) + { + Required = true + }; + + private static void AddOptions( + Command command, + IEnumerable