Erp Document Management

Production engineering guide for erp document management covering patterns, implementation strategies, and operational best practices.

ERP Document Management is the systematic handling of business documents—purchase orders, invoices, work orders, shipping manifests, and service reports—within an enterprise resource planning system. It matters when document flow is critical to operational execution, compliance, and audit readiness, especially in manufacturing, supply chain, and project-based service environments. This is not about storing PDFs in a folder; it’s about linking documents to business entities, enforcing lifecycle rules, and enabling cross-system visibility.

Document Lifecycle and Workflow

Documents in an ERP are not static files; they are first-class entities with states, owners, and dependencies. A purchase order, for instance, begins as a draft, transitions to approved, then to sent to vendor, and finally to received and billed. Each state triggers actions: notifications, approvals, and system updates.

Draft to Approved Transition

To move a document from draft to approved, the system checks:

UPDATE Document 
SET status = 'Approved',
    approvedBy = 'user-42',
    approvedAt = NOW(),
    workflowId = 'PO-APPROVAL-2024'
WHERE id = 'PO-2024-001'
  AND status = 'Draft'
  AND (SELECT COUNT(*) 
        FROM DocumentLine 
        WHERE documentId = 'PO-2024-001'
       ) > 0;

A failed transition logs an error in DocumentAuditLog:

{
  "documentId": "PO-2024-001",
  "action": "approve",
  "timestamp": "2024-04-05T10:23:15Z",
  "status": "failed",
  "error": {
    "code": "MISSING_LINE_ITEMS",
    "message": "Purchase order must have at least one line item",
    "details": {
      "expected": 1,
      "actual": 0
    }
  }
}

The DocumentAuditLog is updated by a background job that polls the Document table every 15 seconds. If a document remains in Draft for more than 48 hours without activity, it is flagged in DocumentAlert:

INSERT INTO DocumentAlert (documentId, type, severity, message, createdAt)
SELECT d.id, 'STALE_DRAFT', 'warning', 
       'Draft document has no activity in 48 hours',
       NOW()
FROM Document d
WHERE d.status = 'Draft'
  AND d.lastModified < NOW() - INTERVAL '48 hours'
  AND d.id NOT IN (
    SELECT documentId FROM DocumentActivity
    WHERE createdAt > NOW() - INTERVAL '48 hours'
  );

Approval Workflow Execution

Approval workflows are defined as sequences of steps in a WorkflowDefinition table. Each step has:

Example workflow for a purchase order:

{
  "id": "PO-APPROVAL-2024",
  "steps": [
    {
      "stepId": 1,
      "assignee": "finance-team",
      "condition": "document.totalAmount > 10000",
      "actions": [
        {
          "actionType": "send-email",
          "parameters": {
            "template": "po-approval-urgent",
            "recipients": ["[email protected]"]
          }
        }
      ]
    },
    {
      "stepId": 2,
      "assignee": "procurement-manager",
      "condition": "true",
      "actions": [
        {
          "actionType": "update-status",
          "parameters": {
            "newStatus": "Awaiting-Procurement-Approval"
          }
        }
      ]
    }
  ]
}

When a step is completed, the workflow engine updates the WorkflowInstance table:

UPDATE WorkflowInstance 
SET currentStep = 2,
    stepStarted = NOW(),
    stepCompleted = NULL,
    status = 'in-progress'
WHERE workflowId = 'PO-APPROVAL-2024'
  AND documentId = 'PO-2024-001';

A step fails if any action returns an HTTP 500 error or a non-zero exit code. The system logs the failure in WorkflowErrorLog:

{
  "workflowId": "PO-APPROVAL-2024",
  "stepId": 1,
  "documentId": "PO-2024-001",
  "timestamp": "2024-04-05T10:45:00Z",
  "error": {
    "code": "EMAIL_SEND_FAILED",
    "message": "Failed to send approval email via SMTP",
    "details": {
      "smtpHost": "mail.company.com",
      "port": 587,
      "response": "550 5.1.1 <[email protected]>: Recipient address rejected: User unknown"
    }
  }
}

Document-Entity Linking

Documents are not isolated; they are tied to business entities via foreign keys. The Document table has a linkedEntity field that references a BusinessEntity table.

Linking Documents to Purchase Orders

When a purchase order is created, the system creates a Document record and links it to the PurchaseOrder record:

INSERT INTO Document (
  id,
  documentType,
  linkedEntity,
  linkedEntityId,
  status,
  createdAt
) VALUES (
  'PO-2024-001',
  'PurchaseOrder',
  'PurchaseOrder',
  'PO-2024-001',
  'Draft',
  '2024-04-05T09:00:00Z'
);

To retrieve all documents related to a purchase order, use this query:

SELECT d.id, d.documentType, d.status, d.createdAt
FROM Document d
JOIN PurchaseOrder po ON d.linkedEntityId = po.id
WHERE po.id = 'PO-2024-001'
ORDER BY d.createdAt DESC;

The linkedEntity field is a composite key: entityType and entityId. This allows a single document to be linked to multiple types of entities—e.g., a vendor invoice can be linked to both a PurchaseOrder and a Payment.

Document Versioning and Attachments

Each document has a version history stored in DocumentVersion. When a document is updated, a new version is created with a versionId, changeLog, and metadata.

INSERT INTO DocumentVersion (
  documentId,
  versionId,
  versionNumber,
  changeLog,
  createdBy,
  createdAt,
  metadata
) VALUES (
  'PO-2024-001',
  'v2-2024-04-05T11:30:00Z',
  2,
  'Updated delivery date and added line item for 100 units',
  'user-42',
  '2024-04-05T11:30:00Z',
  {
    "fileSize": 1048576,
    "fileType": "application/pdf",
    "checksum": "sha256:abcdef1234567890"
  }
);

Attachments are stored in a DocumentAttachment table with documentId, attachmentId, fileName, mimeType, and blobId. The blobId references a file storage system (e.g., S3, NFS, or a database BLOB column).

A service runs every 5 minutes to synchronize document attachments:

#!/bin/bash


while true; do
  psql -c "
    SELECT d.id, a.attachmentId, a.fileName, a.mimeType, a.blobId
    FROM Document d
    JOIN DocumentAttachment a ON d.id = a.documentId
    WHERE a.syncedAt IS NULL
       OR a.syncedAt < NOW() - INTERVAL '1 hour'
    LIMIT 50
  " | while read line; do
    documentId=$(echo "$line" | cut -d'|' -f1)
    attachmentId=$(echo "$line" | cut -d'|' -f2)
    fileName=$(echo "$line" | cut -d'|' -f3)
    mimeType=$(echo "$line" | cut -d'|' -f4)
    blobId=$(echo "$line" | cut -d'|' -f5)

    # Upload to blob storage via curl
    curl -X PUT \
      -H "Content-Type: $mimeType" \
      --data-binary "@/data/attachments/$blobId" \
      "https://blob-storage.company.com/documents/$documentId/$attachmentId"

    # Update sync status
    psql -c "
      UPDATE DocumentAttachment
      SET syncedAt = NOW()
      WHERE documentId = '$documentId'
        AND attachmentId = '$attachmentId'
    "
  done

  sleep 300
done

Failure Mode: Attachment Sync Drift

If the DocumentAttachment table grows beyond 1 million rows, the sync service becomes a bottleneck. A common failure is that attachments are marked as synced but the blob file is missing. This happens when a file is uploaded to blob storage, but the syncedAt timestamp is set before the upload completes.

To detect this, a health check runs daily:

SELECT 
  da.documentId,
  da.attachmentId,
  da.fileName,
  da.blobId,
  CASE 
    WHEN EXISTS (
      SELECT 1 FROM blob_storage 
      WHERE blobId = da.blobId
    ) THEN 'present'
    ELSE 'missing'
  END AS status
FROM DocumentAttachment da
WHERE da.syncedAt < NOW() - INTERVAL '2 days'
  AND da.blobId IS NOT NULL;

A missing attachment triggers a DocumentAlert with severity critical.

Document Search and Discovery

Documents are searched using a DocumentSearch endpoint that accepts query, filters, and sort.

Search Endpoint

GET /api/document/search
Host: erp.company.com
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
Content-Type: application/json

{
  "query": "Q2024-001",
  "filters": {
    "documentType": "PurchaseOrder",
    "status": ["Approved", "SentToVendor"],
    "createdAt": {
      "from": "2024-04-01T00:00:00Z",
      "to": "2024-04-15T23:59:59Z"
    }
  },
  "sort": [
    { "field": "createdAt", "order": "desc" },
    { "field": "documentId", "order": "asc" }
  ]
}

Response:

{
  "results": [
    {
      "id": "PO-2024-001",
      "documentType": "PurchaseOrder",
      "status": "SentToVendor",
      "linkedEntity": "PurchaseOrder",
      "linkedEntityId": "PO-2024-001",
      "createdAt": "2024-04-05T09:00:00Z",
      "updatedAt": "2024-04-05T11:30:00Z",
      "version": "v2",
      "title": "Purchase Order for 100 Units of Widget A",
      "summary": "PO issued to Vendor X for 100 units of Widget A, delivery due May 15."
    }
  ],
  "total": 1,
  "page": 1,
  "size": 10
}

Indexing and Performance

Search performance degrades when the Document table exceeds 10 million rows. The system uses a DocumentSearchIndex table that stores a denormalized version of document data for fast queries.

An indexing job runs every 10 minutes:

INSERT INTO DocumentSearchIndex (
  documentId,
  title,
  summary,
  documentType,
  status,
  linkedEntity,
  linkedEntityId,
  createdAt,
  updatedAt,
  version
)
SELECT 
  d.id,
  d.title,
  d.summary,
  d.documentType,
  d.status,
  d.linkedEntity,
  d.linkedEntityId,
  d.createdAt,
  d.updatedAt,
  dv.versionNumber
FROM Document d
JOIN DocumentVersion dv ON d.id = dv.documentId
WHERE dv.isCurrent = true
  AND d.updatedAt > NOW() - INTERVAL '10 minutes'
  AND d.id NOT IN (
    SELECT documentId FROM DocumentSearchIndex
    WHERE updatedAt > NOW() - INTERVAL '1 hour'
  )
ON CONFLICT (documentId) DO UPDATE
SET 
  title = EXCLUDED.title,
  summary = EXCLUDED.summary,
  status = EXCLUDED.status,
  updatedAt = EXCLUDED.updatedAt,
  version = EXCLUDED.version
WHERE DocumentSearchIndex.updatedAt < EXCLUDED.updatedAt;

If the indexing job fails to complete within 3 minutes, it logs an error in SystemAlert:

{
  "system": "document-indexer",
  "alertType": "indexing-failed",
  "timestamp": "2024-04-05T11:40:00Z",
  "details": {
    "duration": "3m45s",
    "rowsProcessed": 1234,
    "expected": 2000,
    "message": "Indexing job took longer than 3 minutes"
  }
}

Document Distribution and Integration

Documents are distributed across systems via a DocumentDistribution pipeline that pushes documents to external systems: ERP, CRM, EDI, and file shares.

Distribution Pipeline

A document is eligible for distribution when:

The pipeline uses a DocumentDistributionQueue table to track pending distributions:

INSERT INTO DocumentDistributionQueue (
  documentId,
  targetSystem,
  status,
  attemptCount,
  lastAttempt,
  payload
) SELECT 
  d.id,
  'edifiles',
  'pending',
  0,
  NOW(),
  json_build_object(
    'documentId', d.id,
    'documentType', d.documentType,
    'data', (
      SELECT json_agg(row_to_json(t))
      FROM (
        SELECT
          line.itemCode,
          line.quantity,
          line.unitPrice,
          line.total
        FROM DocumentLine line
        WHERE line.documentId = d.id
      ) t
    )
  )
FROM Document d
WHERE d.status IN ('Approved', 'SentToVendor')
  AND d.distributionEnabled = true
  AND d.id NOT IN (
    SELECT documentId FROM DocumentDistributionQueue
    WHERE status = 'pending'
  );

Each distribution target has a DistributionConfig table with system, endpoint, authType, and authConfig.

Example for EDI:

{
  "system": "edifiles",
  "endpoint": "https://edi.company.com/v1/send",
  "authType": "bearer",
  "authConfig": {
    "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c"
  },
  "transform": "edi-940"
}

The distribution service polls the queue every 30 seconds and sends each document via HTTP:

#!/bin/bash
# distribute-documents.sh

while true; do
  psql -c "
    SELECT documentId, targetSystem, payload, endpoint, authType, authConfig
    FROM DocumentDistributionQueue
    JOIN DistributionConfig ON DistributionConfig.system = DocumentDistributionQueue.targetSystem
    WHERE status = 'pending'
    ORDER BY createdAt
    LIMIT 10
  " | while read line; do
    documentId=$(echo "$line" | cut -d'|' -f1)
    targetSystem=$(echo "$line" | cut -d'|' -f2)
    payload=$(echo "$line" | cut -d'|' -f3)
    endpoint=$(echo "$line" | cut -d'|' -f4)
    authType=$(echo "$line" | cut -d'|' -f5)
    authConfig=$(echo "$line" | cut -d'|' -f6)

    # Build auth header
    authHeader=""
    if [ "$authType" = "bearer" ]; then
      token=$(echo "$authConfig" | jq -r '.token')
      authHeader="Authorization: Bearer $token"
    fi

    # Send request
    curl -X POST \
      -H "$authHeader" \
      -H "Content-Type: application/json" \
      -d "$payload" \
      "$endpoint"

    # Update queue
    psql -c "
      UPDATE DocumentDistributionQueue
      SET status = 'sent',
          lastAttempt = NOW(),
          attemptCount = attemptCount + 1
      WHERE documentId = '$documentId'
    "
  done

  sleep 30
done

Silent Failure: Distribution Queue Drift

When the DocumentDistributionQueue grows to over 50,000 entries, the service starts missing messages. A common cause is that the DocumentDistributionQueue table is not vacuumed regularly, leading to index bloat.

A nightly maintenance job runs:

VACUUM ANALYZE DocumentDistributionQueue;

Failure to run this job results in a 30% increase in distribution latency. The system detects this via a health check that compares queueSize to averageDailyDistributionCount.

When queueSize > 2 * averageDailyDistributionCount, a DocumentAlert is generated with type distribution-backlog.

Document Metadata and Custom Fields

Documents can have arbitrary metadata stored in a DocumentMetadata table with documentId, key, value, and dataType.

INSERT INTO DocumentMetadata (documentId, key, value, dataType)
VALUES ('PO-2024-001', 'contractVersion', '2.1', 'string');

The system supports string, number, date, and boolean values. A custom field projectCode is stored as:

{
  "key": "projectCode",
  "value": "PROJ-2024-001",
  "dataType": "string"
}

When a document is queried, the metadata is included in the response:

{
  "id": "PO-2024-001",
  "documentType": "PurchaseOrder",
  "status": "Approved",
  "metadata": {
    "projectCode": "PROJ-2024-001",
    "contractVersion": "2.1",
    "isUrgent": true
  }
}

Metadata can be used for filtering, reporting, and workflow logic. A common failure is that value is stored as a string but expected as a number, leading to incorrect filtering. The system detects this via a daily job that validates metadata types against a schema.

This page was rewritten on 17 October 2026. It replaced a templated version that was not specific to its own title. How these pages are written and checked. If anything here is wrong, tell us at [email protected] and we will correct it.