Sprint D Architecture Notes Axlogis Design Decisions

Recorded 2026-05-31 · Calvin confirmed decisions · Applies to ContainerRequest, ContainerBatchUpdate, and document storage

← Back to Project Dashboard
OQ-03 — TmpJobContainer Temp Table Scope (Q-AU-004)
OQ-03 / Q-AU-004

TmpJobContainer — session-scoped vs global temp table

✓ Resolved

Context

In ASolute, spContainerBatchUpdate uses a temp table called TmpJobContainer as a staging area when an ops user uploads a batch of container numbers from Excel or CSV (e.g., 20 containers assigned to one job in a single file upload). The SP:

  • Reads the uploaded data into TmpJobContainer
  • Validates all rows (checks TypeSize, duplicates, date formats, SOC flags)
  • If @Confirm = 0: returns validation results only — no changes to the job
  • If @Confirm = 1: applies container numbers to empty container slots in the job via spContainerAmendment

The question

SQL Server has two kinds of temp tables:

TypeName formatScopeConcurrency
Session-scoped #TmpJobContainer Visible only to the current connection/session ✓ Safe — each upload gets its own private table
Global (shared) ##TmpJobContainer Visible to ALL connections on the SQL Server instance ✗ Unsafe — two simultaneous uploads would overwrite each other's data

Why it matters for Axlogis

In the new system, spContainerBatchUpdate is rewritten as ContainerBatchUpdateService. The C# equivalent of the staging table must be either:

  • An in-memory collection (List<ContainerUploadRow>) scoped to the request — safe by construction
  • A temporary DB table created per-request and dropped after — session-scoped equivalent
  • A global shared structure — only safe with explicit locking
✓ Decision (confirmed by Calvin 2026-05-31): Session-scoped (#TmpJobContainer). In ASolute production, two ops staff uploading simultaneously have never experienced data collision — which confirms the table is session-scoped. A global temp table (##) would have caused corruption long before reaching production.
C# implementation: Use an in-memory List<ContainerUploadRow> scoped to the request handler — the natural .NET equivalent of a session-scoped temp table. No DB temp table, no locking required. Two-phase flow: validate (returns list of rows with errors) then apply (a second call with confirm: true applies only if zero errors).

Implementation pattern

// POST /api/v1/freight/containers/batch-validate
// POST /api/v1/freight/containers/batch-apply
public record ContainerUploadRow(
    string ContainerNo, string? TypeSize, string? SealNo,
    DateOnly? RequiredDate, string? RequiredTime, bool IsSOC,
    string? ErrorDesc  // null = valid; set by validate phase);

// Validate: returns rows with ErrorDesc populated where invalid
public async Task<IReadOnlyList<ContainerUploadRow>>
    ValidateAsync(string orderNo, IReadOnlyList<ContainerUploadRow> rows) { ... }

// Apply: only called when zero errors; applies containers to job
public async Task<int> ApplyAsync(string orderNo,
    IReadOnlyList<ContainerUploadRow> rows) { ... }
OQ-04 — ContainerRequest Running Number (Q-AU-005)
OQ-04 / Q-AU-005

fnContReqRunningNo — container request number generation algorithm

✓ Resolved

Context

When a transport request (ROT — Release Order from Terminal) is created for a job's containers, the legacy system generates a running number via fnContReqRunningNo. This number identifies the request and is stored in JobContainer.RequestLinkId to track which containers are assigned to which transport request.

The function is called inside spConsolidatePlanSave for each sub-job container in a consolidation plan — meaning it can be called multiple times in rapid succession, making race-condition safety critical.

The question

Three possible implementations, each with different concurrency properties:

ApproachHow it worksRace safety
DB IDENTITY / SEQUENCE SQL Server handles atomicity at engine level ✓ Safe by engine guarantee
String-based increment Read MAX(no)+1, write back — classic SELECT/INSERT pattern ✗ Race condition: two requests can read the same MAX
JobSequences pattern (chosen) Counter row per Enterprise+Entity+Type+Month; EF retry execution strategy; locked increment ✓ Safe — already proven in production for job numbers and consignment notes
✓ Decision (confirmed by Calvin 2026-05-31): Reuse the existing JobSequences table pattern rather than reverse-engineering fnContReqRunningNo. The legacy algorithm's exact implementation is unknown (and likely string-based — a known SQL anti-pattern). Rather than replicate a potentially racy implementation, Axlogis uses its own proven race-safe sequence mechanism.

Number format

ContainerRequest numbers follow the same convention as job numbers and consignment notes:

Format: ROT{YYMM}{seq:D4} — e.g., ROT26060001
Sequence is per Enterprise + Entity + Type ("ROT") + YearMonth. Resets to 0001 each month.
Implemented by reusing JobNumberService.GenerateAsync(enterprise, entity, "ROT", date).

Implementation pattern

// Reuses existing JobNumberService — zero new infrastructure
var requestNo = await _jobNumbers.GenerateAsync(
    tenant.Enterprise, tenant.Entity, "ROT", DateTime.UtcNow, ct);

// ContainerRequest entity
var request = new ContainerRequest {
    RequestNo     = requestNo,
    OrderNo       = cmd.OrderNo,
    HaulierId     = cmd.HaulierId,
    RequestedDate = cmd.RequestedDate,
    Status        = "New",
};
MinIO — Document Storage Decisions
Sprint D

MinIO — bucket strategy, path structure, and pre-signed URL expiry

✓ Decided

What MinIO is for in Axlogis

Logistics jobs accumulate binary documents: Bills of Lading, Packing Lists, Delivery Orders, customs forms, and driver POD photos. These cannot live in SQL Server (BLOBs in relational DBs are slow, expensive, and not CDN-friendly). MinIO is the object store — it is already running in Docker Compose on ports 9000 (API) and 9001 (console).

The upload flow uses pre-signed URLs — the API never handles file bytes directly:

  • Ops uploads → API requests a pre-signed upload URL from MinIO (time-limited) → browser uploads file directly to MinIO
  • Ops views documents → API requests a pre-signed download URL → browser fetches file directly from MinIO
  • API stores only the object key (a path string) in the JobDoc table

Decision 1 — bucket strategy

OptionHow it worksTrade-offs
Per-enterprise buckets One bucket per tenant: axlogis-KLB, axlogis-EXILIAN, … ✗ Requires bucket creation on tenant onboarding. Harder to manage. Pre-signed URLs already expire — no extra isolation benefit.
One shared bucket (chosen) Single axlogis-docs bucket; tenant isolated by path prefix ✓ Simpler. No provisioning on onboarding. Path prefix provides logical isolation. Access is controlled by URL expiry and API authentication.
✓ Decision: One bucket — axlogis-docs. Object key format: {Enterprise}/{Entity}/{JobNo}/{timestamp}_{filename}
Example: KLB/KL/I26060001/20260531T143022_bill-of-lading.pdf

Decision 2 — pre-signed URL expiry

URL typeExpiryRationale
Document download (BL, DO, packing list, etc.) 15 minutes Staff views document immediately after clicking. 15 min is ample for download + print. Short window limits leak risk if URL is copied.
POD photo download / upload 60 minutes Driver captures POD offline, then syncs when reconnected. Network reconnect + background sync + upload can take up to 30–45 min in poor coverage areas. 60 min provides margin.
Upload URLs (all types) 15 minutes Upload should begin immediately after the URL is returned. No need for longer window.

IDocumentService interface

public interface IDocumentService
{
    // Returns a pre-signed URL the browser uses to upload a file directly to MinIO
    Task<string> GetUploadUrlAsync(
        string enterprise, string entity, string jobNo,
        string fileName, string mimeType, CancellationToken ct = default);

    // Returns a pre-signed download URL for an existing object key
    Task<string> GetDownloadUrlAsync(
        string objectKey, TimeSpan? expiry = null,
        CancellationToken ct = default);

    // Deletes an object (soft-delete: marks JobDoc.IsDeleted, then dequeues for async purge)
    Task DeleteAsync(string objectKey, CancellationToken ct = default);
}
NuGet package: AWSSDK.S3 — MinIO is S3-compatible; the AWS S3 client works against MinIO with a custom endpoint URL. No MinIO-specific SDK needed.
Config keys: MinIO:Endpoint, MinIO:AccessKey, MinIO:SecretKey, MinIO:BucketName

JobDoc entity

public class JobDoc : AuditableEntity
{
    public Guid   Id         { get; set; } = Guid.NewGuid();
    public string JobNo      { get; set; }  // FK → Job
    public string ObjectKey  { get; set; }  // MinIO object path
    public string FileName   { get; set; }  // original filename
    public string DocType    { get; set; }  // BOL, DO, POD, PACKING, OTHER
    public string MimeType   { get; set; }
    public long   FileSize   { get; set; }  // bytes
    public string?Remark     { get; set; }
}
Phase 1 note: In Phase 1 the API returns pre-signed URLs directly. In Phase 2, when the Outbox pattern is wired, document deletion should be queued as an Outbox message so MinIO purge is atomic with DB soft-delete. For Phase 1 the delete is best-effort (delete MinIO object, then soft-delete DB record).
Sprint D Scope Summary

These decisions unlock all four Sprint D deliverables:

DeliverableBlocker (before)Status
CargoReceipt + CargoReceiptItem entity + CRUD endpoints None ✓ Ready
JobDoc entity None ✓ Ready
MinIO SDK + IDocumentService + pre-signed URL endpoint Bucket strategy + URL expiry decisions (above) ✓ Decided — build now
ContainerRequest + ContainerRequestItem + CRUD endpoints OQ-03 (temp table scope) + OQ-04 (number generation) ✓ Both resolved — build now