Recorded 2026-05-31 · Calvin confirmed decisions · Applies to ContainerRequest, ContainerBatchUpdate, and document storage
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:
TmpJobContainer@Confirm = 0: returns validation results only — no changes to the job@Confirm = 1: applies container numbers to empty container slots in the job via spContainerAmendmentSQL Server has two kinds of temp tables:
| Type | Name format | Scope | Concurrency |
|---|---|---|---|
| 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 |
In the new system, spContainerBatchUpdate is rewritten as ContainerBatchUpdateService. The C# equivalent of the staging table must be either:
List<ContainerUploadRow>) scoped to the request — safe by construction#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.
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).
// 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) { ... }
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.
Three possible implementations, each with different concurrency properties:
| Approach | How it works | Race 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 |
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.
ContainerRequest numbers follow the same convention as job numbers and consignment notes:
ROT{YYMM}{seq:D4} — e.g., ROT26060001
JobNumberService.GenerateAsync(enterprise, entity, "ROT", date).
// 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", };
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:
JobDoc table| Option | How it works | Trade-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. |
axlogis-docs.
Object key format: {Enterprise}/{Entity}/{JobNo}/{timestamp}_{filename}KLB/KL/I26060001/20260531T143022_bill-of-lading.pdf
| URL type | Expiry | Rationale |
|---|---|---|
| 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. |
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); }
AWSSDK.S3 — MinIO is S3-compatible; the AWS S3 client works against MinIO with a custom endpoint URL. No MinIO-specific SDK needed.
MinIO:Endpoint, MinIO:AccessKey, MinIO:SecretKey, MinIO:BucketName
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; } }
These decisions unlock all four Sprint D deliverables:
| Deliverable | Blocker (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 |