DMS TAttachment System
Three integrated capabilities: a template-driven path resolution engine, a one-time Alfresco migration program, and a bulk file ingestion tool. All share the same IStorageProvider abstraction and TATTACHMENT table.
ATTACHMENTOPTION values (TINYINT, CHECK constraint): 0=Alfresco · 1=FileNet · 2=Amazon S3 · 3=File Based. This is the storage discriminator — no separate column needed.
Files Created / Modified
Work Stream 1 — Framework
Work Streams 2 + 3 — DMS Module
Token Registry
All 18 system tokens have keys prefixed with _. They are resolved by SystemTokenResolver from static context (date, login, template master data).
| Token key | Source | Example | Notes |
|---|---|---|---|
| _tenantCode | MCLIENT.CLIENTCODE | ACME | Sanitised |
| _tenantName | MCLIENT.CLIENTNAME | Acme Corp | Sanitised |
| _year | UploadedOn.Year | 2026 | |
| _month | UploadedOn.Month | 07 | 2-digit padded |
| _yyyymm | UploadedOn | 202607 | |
| _yyyymmdd | UploadedOn | 20260704 | |
| _moduleCode | Template.ModuleCode | MM | From SP_ATTACHMENT_LOAD_TEMPLATE |
| _moduleName | Template.ModuleName | Materials | |
| _entityCode | MENTITY.ENTITYCODE | PURCHASEORDER | |
| _entityName | MENTITY.ENTITYNAME | Purchase Order | |
| _docTypeCode | Template.DocumentTypeCode | GRN | |
| _docTypeName | Template.DocumentTypeName | Goods Receipt Note | |
| _docSetCode | Template.DocumentSetCode | DS_PO_001 | |
| _slNo | Context.SlNo | 001 | 3-digit padded |
| _version | Context.Version | 1 | |
| _ext | FileExtension | pdf | Leading dot stripped, lowercased |
| _objectId | Context.ObjectId (or RowGuid in Phase 1) | 12345 | RowGuid used when ObjectId ≤ 0 |
| _rowGuid | Context.RowGuid | a1b2c3d4... | FE-generated UUID |
Dynamic context tokens
Any key in ContextDictionary is resolved by DictionaryTokenResolver. Supports dot-notation for nested objects:
// ContextDictionary sent from FE: { "poNumber": "PO-2026-001", "vendor": { "vendorCode": "V042" } } // In template: {PONo}=poNumber|{VendorCode}=vendor.vendorCode
Template Format
Templates are pipe-separated pairs of {DisplayLabel}=resolverKey. The display label is cosmetic; only the resolver key matters.
-- Folder template (stored in MDOCUMENTSETDETAIL.LOCATIONTEMPLATE) {Tenant}=_tenantCode|{Entity}=_entityCode|{Year}=_year|{Month}=_month|{ObjectId}=_objectId → Result: ACME/PURCHASEORDER/2026/07/12345/ -- File template (stored in MDOCUMENTSETDETAIL.DOCUMENTNAMETEMPLATE) {DocType}=_docTypeCode|{SlNo}=_slNo|{Version}=_version|{Ext}=_ext → Result: GRN_001_1.pdf -- Display name template (stored in MDOCUMENTSETDETAIL.DISPLAYNAMETEMPLATE) {DocTypeName}=_docTypeName|{Date}=_yyyymmdd|{Version}=_version → Result: Goods Receipt Note - 20260704 - 1
Assembly rules
| Type | Join char | Suffix | Fallback on empty segments |
|---|---|---|---|
| Folder | / | Trailing / | "" (empty string) |
| Filename | _ | File extension | attachment_{Guid}.ext |
| Display name | - | None | "Attachment" |
Sanitise rules
All resolved values are passed through SystemTokenResolver.Sanitise(): replaces / \ : * ? " < > | \s with _, strips leading/trailing underscores. null / empty → "_". Unresolved tokens → "_UNKNOWN_".
Phase 1 / Phase 2 Flow
Phase 1 — Upload before entity save
ObjectId=0. RowGuid used as ObjectId placeholder in folder/filename. ISPATHDEFERRED=1.
Entity Save
SP_RESOLVE_ATTACHMENTS_BATCH sets OBJECTID. AttachmentResolutionService calls Phase 2.
Phase 2 — Path resolution
SP_ATTACHMENT_GET_DEFERRED → MoveAsync → SP_ATTACHMENT_UPDATE_RESOLVED_PATH. ISPATHDEFERRED=0.
Phase 2 trigger
AttachmentResolutionEnvelope now carries ObjectHeaderTypeId and HeaderRowGuid. After BulkResolveObjectIdAsync, AttachmentResolutionService calls IAttachmentPathResolutionService.ResolvePhase2Async within the same transaction.
// In AttachmentResolutionService.ResolveAsync — after BulkResolveObjectIdAsync: if (envelope.ObjectHeaderTypeId > 0 && envelope.HeaderRowGuid != Guid.Empty) { await _pathResolution .ResolvePhase2Async(envelope.ObjectHeaderTypeId, envelope.HeaderRowGuid, login, tx, ct) .ConfigureAwait(false); }
Resolution Priority
| Component | Priority 1 | Priority 2 | Fallback |
|---|---|---|---|
| Folder path | LOCATIONTEMPLATE |
TAGFIELDS |
AttachmentPathSettings.DefaultFolderTemplate |
| File name | DOCUMENTNAMETEMPLATE |
PROPERTYFIELDS |
AttachmentPathSettings.DefaultFileTemplate |
| Display name | DISPLAYNAMETEMPLATE |
{DocTypeName} — {yyyyMMdd} — v{Version} | |
Default templates (appsettings / AttachmentPathSettings)
DefaultFolderTemplate: "{TenantCode}=_tenantCode|{EntityCode}=_entityCode|{Year}=_year|{Month}=_month|{ObjectId}=_objectId" DefaultFileTemplate: "{DocTypeCode}=_docTypeCode|{SlNo}=_slNo|{Version}=_version|{Ext}=_ext"
Upload Service
IAttachmentUploadService.UploadAsync orchestrates the full upload pipeline in 7 steps:
| # | Step | Implementation detail |
|---|---|---|
| 1 | Next SlNo + Version | SP_ATTACHMENT_GET_NEXT_SLNO_VERSION |
| 2 | Build resolution context | Fills AttachmentResolutionContext from request + login |
| 3 | Resolve folder + filename | ITemplateResolutionEngine.ResolveAsync() |
| 4 | Buffer stream, MD5 + size | Copies to MemoryStream; MD5.HashData() via stackalloc |
| 5 | Save to storage | IStorageProvider.SaveAsync(StorageKey, contentType) |
| 6 | Resolve DocumentTypeId | SELECT DOCUMENTTYPEID FROM MDOCUMENTSETDETAIL WHERE … |
| 7 | Insert TATTACHMENT row | SP_ATTACHMENT_INSERT → returns AttachmentId |
Provider → ATTACHMENTOPTION mapping: StorageProviderType.S3 → 2 (AmazonS3) · StorageProviderType.Network → 3 (FileBased)
Alfresco Migration — Architecture
Key constraint: Legacy attachments do not exist in TATTACHMENT. They live exclusively in Alfresco. Migration must INSERT new rows — nothing to update.
Entity table discovery (no config table)
MENTITY already links to DBOBJECT (table name) and DBOBJECTFIELDS (PK column). The migration queries this at runtime — no TATTACHMENT_MIGRATION_CONFIG table needed.
-- EntityTableInfoDTO populated by: SELECT E.ENTITYID AS EntityId, E.ENTITYCODE AS EntityCode, DO.DBOBJECTNAME AS SourceTable, DF.DBFIELDNAME AS PKColumn FROM DBO.MENTITY E JOIN DBO.DBOBJECT DO ON DO.DBOBJECTID = E.DBOBJECTID JOIN DBO.DBOBJECTFIELDS DF ON DF.DBOBJECTID = DO.DBOBJECTID AND DF.ISPRIMARYKEY = 1 WHERE E.ENTITYID = @ObjectTypeId
MCOMMONCONFIG keys
| Key | Description |
|---|---|
AlfrescoCMS | Alfresco base URL (e.g. http://alfresco:8080) |
AlfrescoCMSUserId | Admin username for Basic auth |
AlfrescoCMSPassword | Encrypted password (same encryption as legacy) |
Migration Flow
| # | Step | Detail |
|---|---|---|
| 1 | GetEntityTableInfo | MENTITY → DBOBJECT → DBOBJECTFIELDS |
| 2 | GetAlfrescoSettings | MCOMMONCONFIG keys → AlfrescoSettings record |
| 3 | Page source table | SELECT [{pk}] FROM DBO.[{table}] … OFFSET @Offset ROWS FETCH NEXT @PageSize ROWS ONLY |
| 4 | IsAlreadyMigrated check | SELECT COUNT(1) FROM TATTACHMENT WHERE ATTACHMENTOPTION IN (2,3) |
| 5 | DiscoverAlfrescoAttachments | POST CMIS SQL to /cmis/atom/query → parse Atom XML |
| 6 | InsertMigrationLog (Pending) | SP_MIGRATION_LOG_INSERT |
| 7 | DownloadFromAlfresco | GET /alfresco/api/-default-/public/alfresco/versions/1/nodes/{nodeId}/content |
| 8 | Compute MD5 + size | Buffer to MemoryStream → MD5.HashData |
| 9 | Save to IStorageProvider | Path: {EntityCode}/ALF_MIGRATED/{ObjectId}/{nodeId}{ext} |
| 10 | InsertTAttachment | SP_ATTACHMENT_INSERT (SlNo=1, Version=1, Remarks="Migrated from Alfresco") |
| 11 | Update log (Done/Failed) | SP_MIGRATION_LOG_UPDATE; continue on exception |
Dry run
When DryRun=true, steps 7–10 are skipped. Log entry status → 4=Skipped. Lets admins verify file counts before committing writes.
Log status codes
| Value | Name | Meaning |
|---|---|---|
0 | Pending | Log row created, not yet started |
1 | InProgress | Download/save in progress |
2 | Done | TATTACHMENT row inserted successfully |
3 | Failed | Exception; ErrorMessage populated |
4 | Skipped | DryRun=true; no writes made |
Migration Schema
CREATE TABLE DBO.TATTACHMENT_MIGRATION_LOG ( MigrationLogId BIGINT IDENTITY PRIMARY KEY, ObjectTypeId INT NOT NULL, ObjectId INT NOT NULL, AlfrescoNodeId NVARCHAR(200), DisplayFileName NVARCHAR(500), AttachmentId INT NULL, -- set after successful TATTACHMENT insert Status TINYINT NOT NULL DEFAULT 0, ErrorMessage NVARCHAR(2000), StartedOn DATETIME, CompletedOn DATETIME, ClientId INT NOT NULL );
New columns on TATTACHMENT:
| Column | Type | Purpose |
|---|---|---|
ISPATHDEFERRED | TINYINT NOT NULL DEFAULT 0 | 1 = Phase 1 path (RowGuid placeholder); 0 = resolved |
CONTENTHASH | NVARCHAR(64) NULL | MD5 hex for dedup + integrity |
Bulk Ingestion — Modes
| Mode byte | Name | Detection logic |
|---|---|---|
0 | Manifest | ManifestFilePath provided, or manifest.csv found at SourceFolder root |
1 | FolderConvention | No manifest file found |
2 | Auto | Caller passes mode=2; same logic as above |
Both modes delegate to IAttachmentUploadService.UploadAsync. All template resolution, storage, MD5, and TATTACHMENT insertion go through the same path as a normal upload.
Manifest CSV Format
FilePath,ObjectTypeId,ObjectId,DocumentSetDetailId,DisplayName,Tags,ContextJson
/data/attachments/grn_001.pdf,101,12345,4,Goods Receipt PO-001,GRN,"{""poNumber"":""PO-001""}"
/data/attachments/invoice.pdf,101,12345,5,Supplier Invoice,,
/data/attachments/report.xlsx,102,67890,8,Monthly Report,,
| Column | Required | Notes |
|---|---|---|
| FilePath | Yes | Absolute server-side path to the file |
| ObjectTypeId | Yes | MENTITY.ENTITYID |
| ObjectId | Yes | Entity record PK |
| DocumentSetDetailId | No | Defaults to job-level DocumentSetDetailId if omitted or -1 |
| DisplayName | No | Overrides DISPLAYNAMETEMPLATE if provided |
| Tags | No | Stored in TATTACHMENT.TAGS |
| ContextJson | No | JSON-serialised ContextDictionary for dynamic tokens |
Folder Convention
Source folder layout: {SourceFolder}/{EntityCode}/{ObjectId}/
/attachments/
PURCHASEORDER/
12345/
invoice.pdf
grn.pdf
SALESORDER/
67890/
order-confirmation.pdf
EntityCode is resolved to ObjectTypeId via SELECT ENTITYID FROM MENTITY WHERE ENTITYCODE=@Code. Unknown codes log a warning and skip that folder.
API Endpoints
FrameworkSL — Template Attachment
| Method | Route | Description |
|---|---|---|
| POST | /Attachment/Template/Upload |
Multipart form-data upload. Form fields: DocumentSetDetailId, ObjectTypeId, ObjectId, BizTransactionTypeId, RowGuid, AttachmentType, Remarks, Tags, ContextDictionaryJson (JSON string). |
| DEL | /Attachment/Template/Delete/{attachmentId} |
Deletes file from storage + sets TATTACHMENT.STATUS=2. |
DMSSL — Migration
| Method | Route | Body / Params |
|---|---|---|
| POST | /DMS/Migration/StartAlfresco |
Body: { ObjectTypeId, BatchSize=100, DryRun=false } |
| GET | /DMS/Migration/Status?objectTypeId={id} |
Returns MigrationStatusDTO (Total, Done, Failed, Pending, InProgress, Skipped) |
DMSSL — Bulk Ingestion
| Method | Route | Body / Params |
|---|---|---|
| POST | /DMS/BulkIngestion/Start |
Body: { SourceFolder, ManifestFilePath?, DocumentSetDetailId=-1 } |
| GET | /DMS/BulkIngestion/Status/{jobId} |
Returns BulkIngestionStatusDTO (JobId, Status, TotalFiles, ProcessedFiles, FailedFiles) |
Stored Procedures
Path Resolution (migration: 20260605_TATTACHMENT_PathResolution.sql)
- SP_ATTACHMENT_LOAD_TEMPLATEJoins MDOCUMENTSETDETAIL + MDOCUMENTSET + MDOCUMENTTYPE + MMODULE; returns AttachmentTemplateData
- SP_ATTACHMENT_GET_NEXT_SLNO_VERSIONMAX(SLNO)+1 and MAX(VERSION)+1 scoped by ObjectId or RowGuid
- SP_ATTACHMENT_INSERTFull TATTACHMENT insert; SCOPE_IDENTITY() returned as AttachmentId
- SP_ATTACHMENT_GET_DEFERREDReturns ISPATHDEFERRED=1 rows for a given ObjectHeaderTypeId + HeaderRowGuid where OBJECTID > 0
- SP_ATTACHMENT_UPDATE_RESOLVED_PATHBulk update via AttachmentPathResolutionTVP; clears ISPATHDEFERRED=0
- SP_DOCUMENTSET_VALIDATE_TEMPLATEConfig-time uniqueness warnings for template conflicts
Migration (migration: 20260605_TATTACHMENT_Migration.sql)
- SP_MIGRATION_LOG_INSERTInserts a TATTACHMENT_MIGRATION_LOG row (Status=0=Pending); returns SCOPE_IDENTITY()
- SP_MIGRATION_LOG_UPDATEUpdates Status, AttachmentId, ErrorMessage, CompletedOn
- SP_MIGRATION_STATUSAggregated counts (Total, Done, Failed, Pending, InProgress, Skipped) by ObjectTypeId
Bulk Ingestion (migration: 20260605_TBULK_INGESTION.sql)
- SP_INGESTION_JOB_INSERTInserts TBULK_INGESTION_JOB; returns JobId
- SP_INGESTION_JOB_UPDATEUpdates Status, TotalFiles, ProcessedFiles, FailedFiles, CompletedOn
- SP_INGESTION_JOB_GETReturns BulkIngestionStatusDTO for a JobId
- SP_INGESTION_LOG_INSERTInserts TBULK_INGESTION_LOG row (per-file result)
DI Registration
FrameworkSL/Program.cs
// Attachment path settings + storage provider builder.Services.Configure<AttachmentPathSettings>( builder.Configuration.GetSection(AttachmentPathSettings.Section)); builder.Services.AddGB5Storage( builder.Configuration.GetSection("StorageConfiguration")); // IAttachmentUploadService, ITemplateResolutionEngine, // IAttachmentPathResolutionService auto-registered via assembly scan
DMSSL/Program.cs
// Named HttpClient for Alfresco downloads (10-min timeout) builder.Services.AddHttpClient("alfresco", client => { client.Timeout = TimeSpan.FromMinutes(10); }); // Attachment settings (used by BulkIngestionBLL → IAttachmentUploadService) builder.Services.Configure<AttachmentPathSettings>( builder.Configuration.GetSection(AttachmentPathSettings.Section)); // Assembly scan includes FrameworkBLL + FrameworkDAL for IAttachmentUploadService etc. var dmsBLLAssembly = Assembly.Load("DMSBLL"); var dmsDALAssembly = Assembly.Load("DMSDAL"); var frameworkBLLAssembly = Assembly.Load("FrameworkBLL"); var frameworkDALAssembly = Assembly.Load("FrameworkDAL"); builder.Services.Scan(scan => scan .FromAssemblies(dmsBLLAssembly, dmsDALAssembly, frameworkBLLAssembly, frameworkDALAssembly) .AddClasses(c => c.Where(t => !t.IsAbstract && !t.IsInterface)) .AsImplementedInterfaces() .WithScopedLifetime());
StorageConfiguration (appsettings / Vault)
"StorageConfiguration": { "Provider": "Network", // or "S3" "NetworkBasePath": "/mnt/attachments" }