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