Compare commits
52
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
18905b0067
|
||
|
|
9db6d4bb59 | ||
|
|
9f581996d0 | ||
|
|
c9d324acd4 | ||
|
|
cd2128f78f | ||
|
|
3d336c3dfe
|
||
|
|
73411423df | ||
|
|
cccd0ac0ab
|
||
|
|
2681447b07 | ||
|
|
55a28c7031
|
||
|
|
2491669f23 | ||
|
|
36f1b939b4
|
||
|
|
77d63135af
|
||
|
|
603e518a34 | ||
|
|
d1eb78c1df
|
||
|
|
2810e00e9c | ||
|
|
89a95737a5
|
||
|
|
86b8dbf13d | ||
|
|
4471a13e78
|
||
|
|
c39d78901c | ||
|
|
1e25fe54d2
|
||
|
|
0fe99db48d | ||
|
|
b08057180b
|
||
|
|
79b85a163d | ||
|
|
4e32aebc00
|
||
|
|
7bafab62cc
|
||
|
|
a1914f7553
|
||
|
|
51a5eaef74 | ||
|
|
47c3aac1b2
|
||
|
|
bf8f40959a | ||
|
|
f7c031b793 | ||
|
|
cd9423e966 | ||
|
|
d97418447b
|
||
|
|
c15872a373
|
||
|
|
fc800b8124 | ||
|
|
30ac598952
|
||
|
|
21b0e61cbb | ||
|
|
5afeefb1c2
|
||
|
|
b9ea954e1f | ||
|
|
ea4c48ad66
|
||
|
|
09b77ec122 | ||
|
|
4f8916246a
|
||
|
|
a4dca9046b
|
||
|
|
2122fafed5 | ||
|
|
0a98f34c5e
|
||
|
|
00b91243a6 | ||
|
|
0d5d24392e | ||
|
|
88c6c8e278 | ||
|
|
fece01d7df
|
||
|
|
2915f47740
|
||
|
|
bddba1eb8c | ||
|
|
94c459b150 |
No files matched your search
+11
-57
@@ -1,60 +1,14 @@
|
||||
version: 2.1
|
||||
|
||||
setup: true
|
||||
orbs:
|
||||
qqq-orb: kingsrook/qqq-orb@0.6.8
|
||||
|
||||
commands:
|
||||
configure-package-install:
|
||||
steps:
|
||||
- run:
|
||||
name: Keep package installation unattended
|
||||
command: |
|
||||
# These build machines are discarded after the job.
|
||||
sudo install -d /etc/needrestart/conf.d
|
||||
printf '%s\n' '$nrconf{restart} = "l";' | sudo tee /etc/needrestart/conf.d/99-qqq-ci.conf >/dev/null
|
||||
|
||||
qqq-orb: kingsrook/qqq-orb@0.7.12
|
||||
workflows:
|
||||
test_only:
|
||||
jobs:
|
||||
- qqq-orb/mvn_test_only:
|
||||
pre-steps:
|
||||
- configure-package-install
|
||||
context: [qqq-maven-registry-credentials]
|
||||
filters:
|
||||
branches:
|
||||
ignore: /(develop|release\/.*)/
|
||||
|
||||
publish_snapshot:
|
||||
jobs:
|
||||
- qqq-orb/mvn_publish:
|
||||
pre-steps:
|
||||
- configure-package-install
|
||||
context: [qqq-maven-registry-credentials]
|
||||
branch_type: snapshot
|
||||
filters:
|
||||
branches:
|
||||
only: [develop]
|
||||
|
||||
publish_release_candidate:
|
||||
jobs:
|
||||
- qqq-orb/mvn_publish:
|
||||
pre-steps:
|
||||
- configure-package-install
|
||||
context: [qqq-maven-registry-credentials]
|
||||
branch_type: release_candidate
|
||||
filters:
|
||||
branches:
|
||||
only: [/release\/.*/]
|
||||
|
||||
publish_release:
|
||||
jobs:
|
||||
- qqq-orb/mvn_publish:
|
||||
pre-steps:
|
||||
- configure-package-install
|
||||
context: [qqq-maven-registry-credentials]
|
||||
branch_type: release
|
||||
filters:
|
||||
branches:
|
||||
ignore: /.*/
|
||||
tags:
|
||||
only: [/^v\d+\.\d+\.\d+$/]
|
||||
setup:
|
||||
jobs:
|
||||
- qqq-orb/generate_pipeline:
|
||||
orb_version: 0.7.12
|
||||
filters:
|
||||
branches:
|
||||
ignore: automation/qbit-rc-pending
|
||||
tags:
|
||||
only: /^v\d+\.\d+\.\d+$/
|
||||
@@ -0,0 +1,23 @@
|
||||
name: CodeQL
|
||||
'on':
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- develop
|
||||
- release/**
|
||||
- hotfix/**
|
||||
pull_request: {}
|
||||
schedule:
|
||||
- cron: 41 16 * * 0
|
||||
workflow_dispatch: {}
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
security-events: write
|
||||
packages: read
|
||||
concurrency:
|
||||
group: codeql-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
jobs:
|
||||
analyze:
|
||||
uses: QRun-IO/qqq-orb/.github/workflows/qbit-codeql.yml@c288a8864fe9054f68410e1d78f5863aea4c4d22
|
||||
@@ -1,25 +1,74 @@
|
||||
# Cut the next release candidate (QRun-IO/qqq docs/QRUN-QBIT-REPO-STANDARD.md, C6):
|
||||
# run this workflow by hand, or merge a develop PR labelled `release:rc`. It merges develop into
|
||||
# the release line and the release/* push runs CircleCI publish_release_candidate.
|
||||
name: Release candidate
|
||||
|
||||
on:
|
||||
'on':
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry_run:
|
||||
description: Print the plan without merging or creating branches
|
||||
description: Plan a cut without changing release state
|
||||
type: boolean
|
||||
default: false
|
||||
finalize:
|
||||
description: Retry finalization of an existing cut; do not cut another RC
|
||||
type: boolean
|
||||
default: false
|
||||
recover:
|
||||
description: Recover the same published RC after a deployment timeout; never
|
||||
reupload
|
||||
type: boolean
|
||||
default: false
|
||||
circle_build:
|
||||
description: Original Circle publisher build number (recovery only)
|
||||
type: string
|
||||
default: ''
|
||||
merge_sha:
|
||||
description: Existing cut merge_sha (finalize retry only)
|
||||
type: string
|
||||
default: ''
|
||||
bump_sha:
|
||||
description: Existing cut bump_sha (finalize retry only)
|
||||
type: string
|
||||
default: ''
|
||||
branch:
|
||||
description: Existing cut branch (finalize retry only)
|
||||
type: string
|
||||
default: ''
|
||||
version:
|
||||
description: Existing cut version (finalize retry only)
|
||||
type: string
|
||||
default: ''
|
||||
pull_request:
|
||||
types: [closed]
|
||||
branches: [develop]
|
||||
|
||||
types:
|
||||
- closed
|
||||
branches:
|
||||
- develop
|
||||
repository_dispatch:
|
||||
types:
|
||||
- qbit-rc-published
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
actions: read
|
||||
checks: read
|
||||
jobs:
|
||||
release-rc:
|
||||
if: github.event_name == 'workflow_dispatch' || (github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'release:rc'))
|
||||
uses: QRun-IO/.github/.github/workflows/qbit-release-rc.yml@main
|
||||
if: github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch'
|
||||
|| (github.event_name == 'pull_request' && github.event.pull_request.merged
|
||||
== true && github.event.pull_request.base.ref == 'develop' && contains(github.event.pull_request.labels.*.name,
|
||||
'release:rc'))
|
||||
uses: QRun-IO/qqq-orb/.github/workflows/qbit-release-rc.yml@cc30f599e63c8e41629c99d23395bac1adb8bc44
|
||||
with:
|
||||
dry_run: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run }}
|
||||
dry_run: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run || false
|
||||
}}
|
||||
finalize: ${{ github.event_name == 'repository_dispatch' || (github.event_name
|
||||
== 'workflow_dispatch' && inputs.finalize) }}
|
||||
recover: ${{ github.event_name == 'workflow_dispatch' && inputs.recover || false
|
||||
}}
|
||||
circle_build: ${{ github.event_name == 'workflow_dispatch' && inputs.circle_build
|
||||
|| '' }}
|
||||
merge_sha: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.merge_sha
|
||||
|| inputs.merge_sha || '' }}
|
||||
bump_sha: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.bump_sha
|
||||
|| inputs.bump_sha || '' }}
|
||||
branch: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.branch
|
||||
|| inputs.branch || '' }}
|
||||
version: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.version
|
||||
|| inputs.version || '' }}
|
||||
secrets: inherit
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
profile: qbit
|
||||
orb_version: 0.7.12
|
||||
default_branch: develop
|
||||
java_version: '21'
|
||||
browsers: true
|
||||
api_checks: false
|
||||
docs_only: false
|
||||
release_on_main: false
|
||||
release_tag_policy: stable
|
||||
test_branch_policy: non-release
|
||||
publish:
|
||||
snapshot: true
|
||||
release_candidate: true
|
||||
release: true
|
||||
asciidoc: false
|
||||
release_completion: true
|
||||
@@ -52,8 +52,8 @@ The package layout per phase is in `docs/DESIGN.md`.
|
||||
## Dependencies
|
||||
|
||||
Java 21; parent `com.kingsrook:qbit-build-parent:2.0.0`. Default-active `qqq-snapshot` imports QQQ
|
||||
`4.1.0-RC.5` ahead of the parent's 4.0.0 BOM (ADR-0007); move to each new RC within a week (G7).
|
||||
Middleware Javalin is optional. Planned: StAEDI and as2-lib (both Apache-2.0).
|
||||
`4.1.0-RC.6` ahead of the parent's 4.0.0 BOM (ADR-0007); move to each new RC within a week (G7).
|
||||
Middleware Javalin is optional. Also StAEDI (X12) and as2-lib with BouncyCastle (AS2), all Apache-2.0/MIT-style licensed.
|
||||
|
||||
## Release
|
||||
|
||||
|
||||
@@ -4,8 +4,51 @@ Following [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and [Semantic
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Changed
|
||||
|
||||
- EDI home is business-only: System, Dead letters, Certificates expiring and Setup status move to `EdiHome.ADMIN_WIDGET_NAMES` for the host's Admin home (gated to `edi.admin.hasAccess`, full width); every home widget now requires `edi.hasAccess` and is hidden without it. A new "Inbound documents are delayed" alert links to the delayed inbound documents once the oldest is older than `inboundDelayedAfterMinutes` (default 60). Setup counts that fail show "Unavailable" and are logged instead of -1.
|
||||
|
||||
- UI API extension edi 1.1.0 (requires QQQ 4.1.0-RC.15, UI API 1.6.0): operationIds, standard error responses with BasicErrorResponseV1, extension tags, and retained deprecated EdiErrorV1 schema.
|
||||
|
||||
- Derive qbit metadata version from the Maven artifact version, keeping release metadata and help bundle versions in sync.
|
||||
|
||||
- Require QQQ 4.1.0-RC.7 for core help-bundle registration and coverage validation.
|
||||
|
||||
- X12 routing and sending honor identity `isActive`, matching EDIFACT.
|
||||
|
||||
### Added
|
||||
|
||||
- Complete, host-neutral `help/qbit-edi.yaml` bundle registered with QQQ core: app, tables, fields, actions, widgets and possible-value sources, with a coverage gate and HOST overrides. Table/app fallback remains available without `helpContent`.
|
||||
- Setup status section on the EDI home (partner, identities, agreement, agreement documents, transport, certificates, guides, maps) with the next step; it counts only the active rows the runtime uses.
|
||||
- "Install sample maps" action on Maps: standard streamed ETL, insert-only by name, installs the 32 bundled synthetic maps as draft, unbound maps bound to the built-in guide.
|
||||
- `EdiTablesGuardrailTest`: every table is used at runtime, seeded or listed as starting empty, and has help.
|
||||
|
||||
- Phase f1 `edi` 1.0.0 spec'd UI API: guide tree, map target/transform metadata, encrypted base64 sample preview with unsaved rule overrides, and per-rule validation under `/qqq/v1/qbit/edi`.
|
||||
- Versioned `Edi...V1` schemas and generated OpenAPI fragment with the RC.6 contract/compatibility checks; real HTTP strict-mode permission, PHI, upload, isolation and validation tests.
|
||||
- Shared sandbox/record-action preview service, current/original guide selection, caller-session transforms, structured revision/expected diffs and value-free PHI diagnostics.
|
||||
- Transform registry argument schemas, including host-supplied schemas. Preview Map record actions now enforce the documented exact `edi.map.preview` permission through core's full-name permission checker.
|
||||
|
||||
- Phase g EDIFACT syntax 3/4 envelopes (UNA/UNB, optional UNG/UNE, UNH/UNT, UNZ), release escaping, native envelope findings, UNB identity routing, 14-digit UN control allocation and duplicate detection.
|
||||
- Original D96A/D01B ORDERS, ORDRSP, DESADV, INVOIC and CONTRL guides; ORDERS inbound and retail response outbound sample maps with composite fields and segment groups.
|
||||
- Standard-neutral document validation/mapping, mixed X12/EDIFACT inbox framing, CONTRL generation and reconciliation with overdue resolution and rejection alerts, native outbound envelopes and archived resend-as-new.
|
||||
- Lindqvist Retail AB demo partner with ORDERS application, ORDRSP/DESADV/INVOIC delivery and CONTRL in both directions; EDIFACT map preview and HTTPS media type.
|
||||
- Phase e original HIPAA baselines for 837P/I, 835, 270/271, 276/277 and 834; exact SNIP 3 balancing, occurrence-scoped SNIP 4 rules, SNIP 5 formats and a host `ExternalCodeSetProvider` SPI.
|
||||
- Test-only healthcare maps, classified sample tables, seeded synthetic fixtures, Cedar Health Clearinghouse demo traffic and a PHI canary scanner covering both ingress paths, 999s, apply/replay/resend, widgets, query exports, alerts and logs.
|
||||
- Permission-checked, session-bound Download Raw Document actions for inbox/interchange rows, with a value-free user audit event.
|
||||
|
||||
- Phase d warehouse and logistics baseline guides for 940, 945, 214 and 204 in 004010 and 005010 (syntax notes, situational control-total and stop rules, AT7 pairing), inbound/outbound sample maps (940 in, 945 out/in, 214 in, 204 in/out), DATEPART/TIMEPART outbound transforms and `+`-joined inbound paths for date/time pairs, and Harbor Logistics and Ridgeline Freight demo traffic.
|
||||
|
||||
- Phase c AS2: `As2Transport` send and receive (RFC 4130) with S/MIME signing (SHA-256/384/512; SHA1 flagged), encryption (AES-128/256 CBC or GCM; 3DES flagged), optional compression, synchronous and asynchronous signed MDNs with MIC verification, Test Connection, optional TLS pinning and VAN transports with no partner. Built on as2-lib 5.1.8 and BouncyCastle, entirely in memory (no keystore or temporary files).
|
||||
- `POST /qqq/hooks/edi/as2/{transport}` and `/{transport}/mdn` route provider: identity, decryption (ACTIVE or NEXT keys), signature and MIC checks, Message-ID dedupe returning the original MDN, error MDNs with no inbox row, and a queued, retried asynchronous MDN sender.
|
||||
- Read-only `edi_as2_mdn` table with "AS2 MDNs" child lists on outbound and inbound messages; raw MDNs in the encrypted payload store; `edi_outbound_message.transport_mic`; `edi_transport.weak_crypto`; `EdiQBitConfig.publicBaseUrl`.
|
||||
- Certificates: parsed on save (private keys refused), keys from environment PEM PKCS#8 or base64 PKCS#12, ACTIVE/NEXT rollover, record actions Promote Certificate and Export Public Certificate, expiry alerts for ACTIVE and NEXT, and the `edi.mdn.overdue` alert.
|
||||
- Demo: Pacific Wholesale over AS2 against an in-process BouncyCastle peer with in-memory certificates; AS2 acceptance tests in both directions against our own route and an independent peer.
|
||||
- Phase b retail baseline guides for 855/856/810/846/860 in 004010 and 005010; inbound/outbound sample maps and nested outbound associations with SEQUENCE/HL transforms.
|
||||
- Transactional RECORD_CHANGE and manual enqueue, host-wired ESB process, batch schedules, validated encrypted envelopes, send workers, retries and dead-letter alerts.
|
||||
- Instance transport registry, MINA SFTP streaming push/poll with pinned host keys, gateway OUTBOX stand-in/download delivery, and authenticated HTTPS ingress/egress using core Standard Webhooks.
|
||||
- Ack and certificate monitoring, functional-ack exception resolution, resend/re-envelope/cancel actions, transport tests and outbound queue home statistics.
|
||||
- Test-scope H2/Next UI demo with synthetic retail traffic, in-memory gateway and public self-signed certificates; local HTTP/SFTP and application acceptance tests.
|
||||
|
||||
- Phase a2 inbound vertical slice: gateway intake, fenced inbox claims/retries, dead letters and core alerts; routing, validation, duplicates, safe events and exception occurrence counts.
|
||||
- Versioned inbound maps, wildcard associations, transforms, permission-enforced transactional upsert and original/current revision replay.
|
||||
- Queued enveloped TA1/997/999 and inbound acknowledgement reconciliation; outbound request/message tables for phase b.
|
||||
@@ -23,3 +66,23 @@ Following [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and [Semantic
|
||||
- Per-instance runtime services and configuration, synthetic generators, exact acknowledgement fixtures, concurrent SQL tests, and payload tests that verify no temporary files are created.
|
||||
- Repository scaffold to the QRun qbit repository standard: `qbit-build-parent` 2.0.0, QQQ 4.1.0-RC.5, Maven wrapper, Checkstyle, JaCoCo gate, CircleCI qqq-orb 0.6.8, release-candidate workflow, Dependabot, community files.
|
||||
- `EdiQBitProducer` and `EdiQBitConfig` (table prefix `edi_`, permission base `edi`).
|
||||
|
||||
### Changed
|
||||
|
||||
- QQQ baseline is now 4.1.0-RC.6. EDI tables/fields and healthcare test fixtures use core `DataClassification` through `AiTableMetaData`/`AiFieldMetaData`, preserving PHI, PII, CONFIDENTIAL and PHI AI-exposure restrictions.
|
||||
- Exception Assign and new/reopened partner rejection/dead-letter exceptions send value-free core notifications after commit, with capturing-sink and PHI canary coverage.
|
||||
- Retain `PhiLogScope` for core #1147 until RC.7: RC.6 still logs RDBMS query filters and has no redaction hook or `FieldAccessHelper`. Contingent dependency-behavior APIs are also absent; shared-choice filters remain tested.
|
||||
|
||||
### Removed
|
||||
|
||||
- Reflective `EdiClassifications` RC.5 stand-in. File-gateway stand-ins remain until its API is published; the object-storage API integration is unchanged.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Shared-choice filters group partner-or-shared alternatives beneath an AND root, so core search criteria cannot admit another partner's transports.
|
||||
|
||||
- 999 now accepts SNIP 3+ findings with `E` instead of rejecting every ERROR. HIPAA acknowledgement selection follows the implementation version and enabled agreement expectation; outbound HIPAA ST carries its implementation identifier.
|
||||
- Qualified HL/NM1 anchors distinguish healthcare hierarchy levels; repeated loop names under different parents retain separate occurrences. Situational rules inherit direct ancestor segments without borrowing values from sibling occurrences.
|
||||
- Review/Result records expose identifiers only. A marked RC.5 PHI logging bridge prevents core upsert query diagnostics from copying claim/member values into logs.
|
||||
|
||||
- Inbound apply no longer fails with a null-pointer error on a target table that has no associations (flat tables such as the 214 status sample).
|
||||
@@ -52,8 +52,8 @@ The package layout per phase is in `docs/DESIGN.md`.
|
||||
## Dependencies
|
||||
|
||||
Java 21; parent `com.kingsrook:qbit-build-parent:2.0.0`. Default-active `qqq-snapshot` imports QQQ
|
||||
`4.1.0-RC.5` ahead of the parent's 4.0.0 BOM (ADR-0007); move to each new RC within a week (G7).
|
||||
Middleware Javalin is optional. Planned: StAEDI and as2-lib (both Apache-2.0).
|
||||
`4.1.0-RC.6` ahead of the parent's 4.0.0 BOM (ADR-0007); move to each new RC within a week (G7).
|
||||
Middleware Javalin is optional. Also StAEDI (X12) and as2-lib with BouncyCastle (AS2), all Apache-2.0/MIT-style licensed.
|
||||
|
||||
## Release
|
||||
|
||||
|
||||
@@ -1,18 +1,40 @@
|
||||
# QBit EDI
|
||||
|
||||
EDI for QQQ: trading partners, agreements, X12 validation, encrypted payloads, inbound mapping
|
||||
and application, and TA1/997/999 acknowledgements. See [the design](docs/DESIGN.md).
|
||||
EDI for QQQ: trading partners, agreements, X12 and EDIFACT validation, encrypted payloads, inbound mapping
|
||||
and application, and TA1/997/999/CONTRL acknowledgements. See [the design](docs/DESIGN.md).
|
||||
|
||||
## Status
|
||||
|
||||
Phases a1/a2 implement the inbound vertical slice. A gateway 850 (004010 or 005010) becomes
|
||||
a durable inbox record, validated document, permission-enforced target record with children,
|
||||
and an enveloped 997 queued for delivery. HIPAA 005010X uses 999. Outbound sending, outbound
|
||||
business-document building and real transport servers remain phase b and later.
|
||||
Phases a1 to e and g implement inbound/outbound retail, logistics and healthcare EDI (X12 and EDIFACT) over gateway, SFTP, HTTPS and AS2. Original baseline guides cover
|
||||
850, 855, 856, 810, 846 and 860 in 004010/005010, plus 997/999. Source records and associations
|
||||
produce validated, encrypted outbound interchanges, with control-number tracking, retries,
|
||||
acknowledgement monitoring and operator resend/cancel actions. AS2 (signed, encrypted, compressed, synchronous and
|
||||
asynchronous MDN, certificate rollover) is built in; email sending remains disabled until the qbit-email API ships.
|
||||
A runnable synthetic [demo](docs/DEMO.md) includes Next UI and needs no external services.
|
||||
|
||||
EDIFACT syntax versions 3 and 4 support UNA/UNB, optional UNG groups, UNH messages and their
|
||||
trailers, release-character escaping, mixed-standard payloads, and CONTRL syntax responses.
|
||||
Original D96A/D01B baselines cover ORDERS, ORDRSP, DESADV, INVOIC and CONTRL. ORDERS maps to
|
||||
sample orders and lines; the other three retail documents map outbound. APERAK remains future work.
|
||||
|
||||
Configure `standard=EDIFACT` on identities and agreements. `interchangeId` and
|
||||
`interchangeQualifier` carry the UNB party identifiers and qualifiers. Set `interchangeVersion`
|
||||
to `3` or `4`, `syntaxIdentifier` to `UNOC`, and outbound separators explicitly: element `+`,
|
||||
component `:`, segment `'`, release `?`, decimal `.` (or `,`); syntax 4 also uses the configured
|
||||
repetition separator. Document versions are `D96A` or `D01B`, with `ackExpectation=CONTRL`.
|
||||
UNB11=`1` uses a test agreement and respects `applyTestDocuments`.
|
||||
|
||||
Existing schemas need these additional nullable agreement columns before using this version:
|
||||
|
||||
```sql
|
||||
ALTER TABLE edi_agreement ADD COLUMN release_character VARCHAR(255);
|
||||
ALTER TABLE edi_agreement ADD COLUMN decimal_mark VARCHAR(255);
|
||||
ALTER TABLE edi_agreement ADD COLUMN syntax_identifier VARCHAR(255);
|
||||
```
|
||||
|
||||
## Requirements and installation
|
||||
|
||||
Java 21, QQQ **4.1.0-RC.5**, an RDBMS backend, and `com.kingsrook.qbits:qbit-edi:0.1.0-SNAPSHOT`.
|
||||
Java 21, QQQ **4.1.0-RC.7+**, an RDBMS backend, and `com.kingsrook.qbits:qbit-edi:0.1.0-SNAPSHOT`.
|
||||
Apply [the PostgreSQL schema](docs/ddl/edi-postgres.sql). Targets and their associations must
|
||||
share the EDI backend so each transaction set can commit or roll back atomically.
|
||||
|
||||
@@ -21,20 +43,40 @@ new EdiQBitProducer()
|
||||
.withQBitConfig(new EdiQBitConfig()
|
||||
.withBackendName("rdbms")
|
||||
.withSchedulerName("scheduler") // optional
|
||||
.withSendAsEdiTable("sample_order") // host-defined table, optional manual action
|
||||
.withServiceSessionSupplier(() -> makeEdiServiceSession()))
|
||||
.produce(qInstance);
|
||||
```
|
||||
|
||||
Call `EdiRuntime.current().initialize()` in the host's authorized startup context to install
|
||||
baseline guides. Define partners, identities, active agreements, inbound agreement documents
|
||||
baseline guides and data-driven schedules. After defining batch/poll settings, call
|
||||
`OutboundProcesses.installSchedules()` before starting the host scheduler; rebuild/restart scheduling
|
||||
after changing those settings. Define partners, identities, active agreements, inbound agreement documents
|
||||
and active maps as normal QQQ records. `makeEdiServiceSession()` is a host function returning
|
||||
a least-privilege `QSession` with target and association permissions. Applying without a supplier
|
||||
produces an APPLY exception: `no service session configured`.
|
||||
|
||||
The `edi` app has Activity/Setup sections, typed home widgets, record screens and standard
|
||||
Review/Result actions. Main tables have RC.5 search-field metadata; `EdiIntegrations.mainTables()`
|
||||
Review/Result actions. Main tables have RC.6 search-field metadata; `EdiIntegrations.mainTables()`
|
||||
also exposes the list for host search registration.
|
||||
|
||||
## In-app help
|
||||
|
||||
The producer registers [help/qbit-edi.yaml](src/main/resources/help/qbit-edi.yaml), a `QBIT`
|
||||
bundle versioned with this artifact. It covers the EDI app, tables, fields, actions, widgets,
|
||||
and possible-value sources. Live `{{...}}` references follow the current metadata.
|
||||
|
||||
Hosts can register their own `layer: HOST` bundle with `HelpBundles.register(qInstance,
|
||||
"help/my-host.yaml")`, using the same keys and roles to override selected entries. Enable
|
||||
core's `helpContent` provider with provenance fields, then call
|
||||
`QInstanceHelpContentManager.loadHelpContent(qInstance)` and `HelpBundles.syncOnStartup(qInstance)`
|
||||
after defining metadata. Core preserves user edits during sync. If you configure a different
|
||||
EDI app name, use that name in your host's app help key.
|
||||
|
||||
Registration is safe without `helpContent`; those hosts retain the table/app help fallback.
|
||||
`EdiHelpBundleTest` runs the core `HelpBundleAssertions` coverage gate and checks widget and
|
||||
possible-value-source coverage during `./mvnw verify`, without host-specific exclusions.
|
||||
|
||||
## Tables
|
||||
|
||||
There are **26 persisted tables**, whose SQL names equal their QQQ names.
|
||||
@@ -48,9 +90,60 @@ There are **26 persisted tables**, whose SQL names equal their QQQ names.
|
||||
| Storage/queue | `edi_payload`, `edi_outbound_request`, `edi_outbound_message` |
|
||||
|
||||
`edi_preview_download` is a **storage-only metadata alias**, not a SQL table. Supporting tables
|
||||
appear as child lists. Partner-scoped form choices use RC.5 contingent filters, including shared
|
||||
appear as child lists. Partner-scoped form choices use RC.6 contingent filters, including shared
|
||||
transports/guides and local certificates/identities.
|
||||
|
||||
## UI API
|
||||
|
||||
With optional Javalin present, the producer registers the `edi` extension, version `1.1.0`,
|
||||
under `/qqq/v1/qbit/edi`, requiring QQQ 4.1.0-RC.15 (UI API 1.6.0). All routes are endpoint specs and pass strict UI-route auditing.
|
||||
|
||||
| Method | Path after `/qqq/v1/qbit/edi` | Result |
|
||||
|---|---|---|
|
||||
| GET | `/guides/{guideId}/tree` | Nested loops, ordered segments, elements and up to 200 active codes per element |
|
||||
| GET | `/maps/{mapId}/target` | Target fields, direct associations, current rules and registered transform argument schemas |
|
||||
| POST | `/sandbox/preview` | Parse counts, positional findings, acknowledgement, mapped records and revision/expected diffs |
|
||||
| POST | `/maps/{mapId}/validate` | Per-rule path, transform and required-argument errors |
|
||||
|
||||
Preview accepts `{mapId, sampleId?, sampleBase64?, sampleName?, ruleOverrides?, guideRevision?}`.
|
||||
Supply exactly one sample source. Uploads are base64 JSON, at most **5 MiB decoded**, archived as
|
||||
encrypted `edi_map_sample` records through the same storage as Preview Map. No runtime files are
|
||||
created. Configure the host's Javalin `http.maxRequestSize` to at least 8 MiB to allow base64
|
||||
overhead and request metadata. Oversized decoded samples return 400; a smaller host limit returns 413.
|
||||
|
||||
`ruleOverrides` replaces the entire rule list in memory, including an empty list; it never saves
|
||||
rules. `CURRENT` (default) selects the newest active revision of the pinned guide's name, standard,
|
||||
version, transaction set and partner; `ORIGINAL` uses the map's pinned guide. Validate accepts
|
||||
`{rules: [...]}` and checks the pinned guide. Rule fields and `transformArgs` have the same shape
|
||||
as core table CRUD (`transformArgs` is a JSON object encoded as a string).
|
||||
|
||||
Every read enforces table permissions and partner locks with the caller's session, regardless of
|
||||
the host's default core-enforcement setting. Preview additionally requires **`edi.map.preview`**,
|
||||
shared with both Preview Map record actions. Reading target metadata requires target/association
|
||||
table read permissions; guide definitions, code lists, map rules and stored samples require their
|
||||
own table reads. LOOKUP and host transforms run as the caller, without service-account elevation.
|
||||
Only the internal PHI classification check and encrypted sample storage use the EDI system writer.
|
||||
|
||||
For PHI, `result` and `ack.text` are null and both diff lists are empty; findings retain positions
|
||||
and codes with generic messages. Mapping failures are value-free `MAP` findings. Preview never
|
||||
writes target records. Diffs contain differing document records as `{documentIndex, before, after}`;
|
||||
acknowledgements for multiple groups are concatenated, with `type: MIXED` for different ack types.
|
||||
|
||||
The mapper saves `edi_map_rule` through the **core table API**, using its normal
|
||||
`edi.map_rule.insert|edit|delete` permissions and record locks. There are no custom rule-write routes.
|
||||
The Next mapper page is separate phase-f work.
|
||||
|
||||
The generated contract is `src/main/resources/openapi/qqq-v1/edi.yaml`; extension component names are
|
||||
`Edi...V1`, alongside core `BasicErrorResponseV1`. `EdiErrorV1` remains deprecated for compatibility. Regenerate with the locally installed migration build:
|
||||
|
||||
```powershell
|
||||
./mvnw.cmd -o -B '-Dqqq.snapshot.version=4.1.99-SNAPSHOT' '-Dtest=EdiApiContractTest' '-Dedi.openapi.update=true' test
|
||||
```
|
||||
|
||||
Core provides `UiApiContract.check`, used to compare generated and checked-in YAML, validate
|
||||
operation IDs and references, and run `OpenApiCompatibilityChecker` against the committed fragment baseline. The extension also appears
|
||||
in the host's `/qqq/v1/openapi.yaml` and `/qqq/v1/version`.
|
||||
|
||||
## Inbound operation
|
||||
|
||||
`InboundSink.receive(InboundPayload)` stores encrypted bytes and SHA-256 before returning a durable
|
||||
@@ -61,10 +154,10 @@ Storage failure requests retry in one minute.
|
||||
|
||||
`InboxWorker.runOnce()` processes up to 20 messages with compare-and-set claims, fenced database
|
||||
checkpoints and five-minute stale-claim recovery. Retry delays are 1m, 5m, 15m, 1h and 6h; the next
|
||||
failure becomes DEAD_LETTER. Each mapped accepted ST applies in its own transaction through QQQ
|
||||
actions with explicit permission enforcement. ISA15=T applies only with `applyTestDocuments`.
|
||||
failure becomes DEAD_LETTER. Each mapped accepted ST or UNH applies in its own transaction through QQQ
|
||||
actions with explicit permission enforcement. ISA15=T or UNB11=1 applies only with `applyTestDocuments`.
|
||||
|
||||
Routing checks ISA identities, qualifiers, standard and usage. Linked gateway usernames must match
|
||||
Routing checks ISA or UNB identities, qualifiers, standard and usage. Linked gateway usernames must match
|
||||
the routed partner. Findings and failures become exceptions with occurrence counts and value-free
|
||||
timeline events. Replies allocate controls and remain QUEUED, recording `ACK_QUEUED`. Incoming
|
||||
acknowledgements reconcile outbound groups and resolve overdue alerts.
|
||||
@@ -79,17 +172,42 @@ EDI paths reuse the guide rule language. Targets support `field` and `associatio
|
||||
wildcards such as `PO1[*]/PO107` produce child records. `matchKeyFields` controls upsert, with
|
||||
`onMatch` UPDATE/SKIP/EXCEPTION. [MAPS.md](docs/MAPS.md) documents transform arguments.
|
||||
|
||||
The two `/edi/maps/850-*-sample.json` resources map onto `sample_order` and `sample_order_line`;
|
||||
those tables exist **only in test sources**, never in host installations.
|
||||
Resources under `/edi/maps` include inbound 850/860 and outbound 855/856/810/846 maps.
|
||||
The order, shipment/carton/item, invoice/line and inventory schemas exist **only in test sources**.
|
||||
Outbound maps support nested association paths; inbound target paths retain the one-association limit.
|
||||
|
||||
Preview shows parse counts, SNIP findings, the would-be ack, mapped JSON, expected-result and
|
||||
previous-revision comparisons. It writes no target records. PHI previews suppress document values.
|
||||
A session-authorized encrypted JSON report is downloadable. Select a stored sample or upload
|
||||
synthetic EDI on the standard Review step. Browser uploads require the host's RC.5 Javalin
|
||||
synthetic EDI on the standard Review step. Browser uploads require the host's RC.6 Javalin
|
||||
`uploadedFileArchiveTableName` to be `edi_preview_download` and multipart handling without disk
|
||||
staging. The alias encrypts streams and persists samples; this process accepts no filesystem archive.
|
||||
Samples must contain synthetic data only.
|
||||
|
||||
## Setup and starter data
|
||||
|
||||
No Liquibase changelog or host include is needed: the qbit seeds its own reference data. When the
|
||||
host calls `EdiRuntime.initialize()` at startup (after the schema exists) the 41 built-in guides
|
||||
load, with their segments, elements, rules, code lists and code values. It only changes a guide when
|
||||
the bundled content changes, so edits to your own rows are kept.
|
||||
|
||||
Everything else is host or runtime data, and every table explains itself when empty (help bundle
|
||||
`help/qbit-edi.yaml`). Set up in this order:
|
||||
|
||||
1. Partner.
|
||||
2. Identities: one of yours (no partner) and one per partner.
|
||||
3. Agreement: pairs your identity with the partner's, for a standard and test or production.
|
||||
4. Agreement documents: one per transaction set and direction, with the guide and optionally a map.
|
||||
5. Transport (AS2, SFTP, HTTPS), linked on the agreement. Certificates only for AS2 signing and encryption.
|
||||
|
||||
The **Setup status** widget (`edi_home_setup`, for the host's Admin home) shows each step, whether
|
||||
it is done (counting only the active rows the runtime uses) and the next step. A count that fails
|
||||
shows "Unavailable" and is logged.
|
||||
|
||||
Maps start empty. The **Install sample maps** action on Maps adds the 32 bundled synthetic examples
|
||||
as draft maps with no partner and no agreement document, by name and never over an existing map. Point
|
||||
a map at your own table, preview it, activate it, then select it on an agreement document.
|
||||
|
||||
## Permissions
|
||||
|
||||
Full table permissions include `edi.partner.read|insert|edit|delete`, `edi.map.*`,
|
||||
@@ -103,10 +221,23 @@ Full table permissions include `edi.partner.read|insert|edit|delete`, `edi.map.*
|
||||
| Assign / Ignore / Mark Resolved | `edi.exception.assign` / `edi.exception.ignore` / `edi.exception.resolve` |
|
||||
| Create Partner Override / Activate Guide | `edi.guide.override` / `edi.guide.activate` |
|
||||
| Activate Map | `edi.map.activate` |
|
||||
| Scheduled worker | `edi.admin` |
|
||||
| Send As EDI | `edi.send` |
|
||||
| Resend / Resend As New | `edi.resend` |
|
||||
| Cancel | `edi.cancel` |
|
||||
| Test Connection | `edi.transport.test` |
|
||||
| Promote Certificate | `edi.certificate.promote` |
|
||||
| Export Public Certificate | `edi.certificate.read` |
|
||||
| Scheduled workers / ESB enqueue | `edi.admin` |
|
||||
|
||||
Partner locks use `edi_partner` with `edi.admin` all-access. Home queries explicitly enforce
|
||||
the viewing user's permissions. Processes are table/record actions, never navigation entries.
|
||||
the viewing user's permissions.
|
||||
|
||||
The EDI home shows business work only. Its widgets (`EdiHome.HOME_WIDGET_NAMES`) need
|
||||
`edi.hasAccess` and are hidden without it. "Inbound documents delayed" appears only when the oldest
|
||||
unprocessed inbound document is older than `inboundDelayedAfterMinutes` (default 60), with a link to
|
||||
those documents. System widgets (`EdiHome.ADMIN_WIDGET_NAMES`: System, Dead letters, Certificates
|
||||
expiring, Setup status) are not on the EDI home; they need `edi.admin.hasAccess`, are hidden without
|
||||
it, and a host places them on its Admin home. Processes are table/record actions, never navigation entries.
|
||||
|
||||
## Configuration
|
||||
|
||||
@@ -115,12 +246,14 @@ the viewing user's permissions. Processes are table/record actions, never naviga
|
||||
| `backendName` | Required host RDBMS backend |
|
||||
| `appName`, `appLabel`, `parentAppName` | `edi`, `EDI`, no parent |
|
||||
| `schedulerName` | Null; configure for 15-second polling |
|
||||
| `serviceSessionSupplier` | Null; required when a map actually applies |
|
||||
| `serviceSessionSupplier` | Null; required for inbound application and outbound source reads/mapping |
|
||||
| `withSendAsEdiTable(name)` | Opt a host table into Send As EDI |
|
||||
| `partnerLocks` | `false` |
|
||||
| `maxSegmentsPerTransaction` | 100000 |
|
||||
| `payloadStore` | `DATABASE` or `OBJECT_STORAGE` |
|
||||
| `payloadBucketAlias` | `edi` |
|
||||
| `secretsResolver` | Environment-variable lookup |
|
||||
| `publicBaseUrl` | Null; the deployment base URL (for example `https://edi.example.com`), required for ASYNC AS2 receipts |
|
||||
| `clock` | UTC system clock |
|
||||
| `esbDestinationName` | Null; optional core ESB destination |
|
||||
|
||||
@@ -131,17 +264,170 @@ in memory. Database storage remains the default for demos/tests.
|
||||
|
||||
For object storage, add/install optional `qbit-object-storage:0.1.0-RC.2`, configure its bucket
|
||||
alias and select `OBJECT_STORAGE`. EDI uses only its public API and system access, with keys
|
||||
`edi/yyyy/MM/dd/kind/uuid`. Its transitive QQQ dependencies are excluded to retain RC.5.
|
||||
`edi/yyyy/MM/dd/kind/uuid`. Its transitive QQQ dependencies are excluded to retain RC.6.
|
||||
|
||||
Optional `qqq-esb` publishes setup/activity events after commit. Document events contain only ID
|
||||
and status because RC.5's standard factory otherwise copies PHI fields. Alert names are
|
||||
`edi.inbox.dead-letter`, `edi.ack.overdue` and `edi.ack.rejected` (RC.5 requires dotted names).
|
||||
and status because RC.6's standard factory otherwise copies PHI fields. Alert names are
|
||||
`edi.inbox.dead-letter`, `edi.ack.overdue`, `edi.ack.rejected`, `edi.send.failed`,
|
||||
`edi.certificate.expiring`, `edi.certificate.expired` and `edi.mdn.overdue` (RC.6 requires dotted names).
|
||||
|
||||
RC.5 lacks the planned core `DataClassification` API. `EdiClassifications` marks PHI/PII/confidential
|
||||
fields and PHI fields deny AI exposure. Hosts must supply secured-data protection, database
|
||||
encryption at rest and access policies for PHI columns; markers alone do not mask fields.
|
||||
Core `DataClassification` metadata marks PHI/PII/CONFIDENTIAL fields through `AiFieldMetaData`,
|
||||
with a CONFIDENTIAL table default through `AiTableMetaData`. PHI fields deny AI exposure.
|
||||
Hosts must supply secured-data protection, database encryption at rest and access policies for PHI columns; markers alone do not mask fields.
|
||||
The home reports installed integration and storage state.
|
||||
|
||||
Assign sends a value-free `QAlerts.notify` notification to the assignee after commit. Newly opened
|
||||
or reopened REJECTED_ACK/DEAD_LETTER exceptions notify distinct assignees of OPEN/IN_PROGRESS
|
||||
exceptions for the same partner; these existing assignments define the partner's recipients.
|
||||
Repeated occurrences do not send another notification. Titles and bodies contain no document,
|
||||
partner or exception-summary values. Without an alerts integration, core uses its logging sink.
|
||||
|
||||
## Outbound and transports
|
||||
|
||||
Active OUTBOUND agreement documents select the source table, map, guide and trigger. RECORD_CHANGE
|
||||
uses core's instance listener and the writer's transaction. Source and EDI tables must share the
|
||||
backend. `triggerFilter` is a serialized QQQ `QQueryFilter`; MANUAL also honors that filter.
|
||||
RC.6 catches listener errors without failing the source write: successful enqueues roll back with
|
||||
the source transaction, but hosts must monitor failed listener execution.
|
||||
|
||||
`edi_outbound_enqueue` is the ESB boundary. Configure an RC.6 `EsbTrigger` to invoke it with
|
||||
`sourceTableName`, `sourceRecordId` and `eventId`; broker setup belongs to the host. Event IDs are
|
||||
unique per agreement document. RC.6's consumer runner uses JMS, so broker-free acceptance calls
|
||||
this process directly.
|
||||
|
||||
`edi_outbound_worker` and `edi_send_worker` run every 15 seconds when a scheduler is configured.
|
||||
IMMEDIATE produces one interchange per request. BATCH cuts up to 100 requests for one agreement
|
||||
document at its UTC `batchSchedule` into one group/interchange. Mapping/enveloping and sending
|
||||
use database row locks to fence replicas; crashes release the lock. Remote delivery is at least
|
||||
once: a lost connection after remote acceptance can resend the same bytes and control numbers.
|
||||
Retry waits are 1m, 5m, 15m, 1h, 3h, 6h, 12h, 24h, capped at the last delay; `maxAttempts` defaults
|
||||
to 8. Permanent transport rejection is FAILED; retry exhaustion is DEAD_LETTER. Both open a
|
||||
TRANSPORT exception and raise `edi.send.failed`.
|
||||
|
||||
| Transport | Behavior and settings |
|
||||
| --- | --- |
|
||||
| GATEWAY | `gatewayEndpointName`; marked `FileGatewayOutbox` stand-in with a provider registration hook. Put is SENT; `EdiFileGatewayConsumer.onDownloaded` is DELIVERED. |
|
||||
| SFTP | MINA client from core's filesystem module; required `sftpHostKeyFingerprint` in MINA SHA256 format, username and password/private-key env name. Streams upload to `.tmp`, then rename. `sftpPollCron` schedules remote pickup; `sftpFilePattern` is a Java regex (default `.*\.x12`). DELETE/MOVE occurs only after durable inbox commit. |
|
||||
| HTTPS | POST with NONE/BASIC/BEARER/HMAC; TLS required except loopback. BASIC's secret is `user:password`; BEARER's is the token; HMAC uses core Standard Webhooks `whsec_` format. `httpsHeadersJson` holds nonsecret headers only. Redirects are refused; 2xx is DELIVERED, 429/5xx retry. |
|
||||
| AS2 | See [AS2](#as2). |
|
||||
| EMAIL | Settings validation only. Sending returns a permanent unavailable result until the qbit-email API ships. |
|
||||
|
||||
Optional Javalin registers `POST /qqq/hooks/edi/https/{transport}`. `httpsInboundAuth` must be
|
||||
BASIC, BEARER or HMAC with `httpsSecretEnvVar`. It authenticates exact bytes, caps intake at
|
||||
50 MiB and returns 202 only after durable storage. HMAC verifies timestamps and deduplicates by
|
||||
transport plus webhook ID. TLS terminates at the host ingress.
|
||||
|
||||
The transport registry is `EdiTransportPluginsMetaData`, attached to `QInstance`. Hosts can
|
||||
register/replace implementations with `withPlugin`. All transports open encrypted PayloadStore
|
||||
streams; production code never writes local files.
|
||||
|
||||
Ack deadlines start on the first successful send. `edi_ack_monitor` runs every five minutes,
|
||||
raises overdue alerts and exceptions, and checks certificate expiry at 30/14/7 days and expiry.
|
||||
A functional ack resolves its alert and exception; TA1 updates interchange acknowledgement only.
|
||||
Resend retains archived bytes/control numbers. Resend As New re-envelopes the archived document
|
||||
without re-running its map and links `originalInterchangeId`. Cancel accepts QUEUED/RETRY_WAIT only.
|
||||
|
||||
Existing a2 databases need `edi_interchange.original_interchange_id INTEGER` and a unique index
|
||||
on `edi_outbound_request(agreement_document_id, event_id)` in addition to their prior schema.
|
||||
|
||||
## AS2
|
||||
|
||||
`As2Transport` (type `AS2`) sends and receives AS2 (RFC 4130) with S/MIME signing and encryption
|
||||
(SHA-256/384/512, AES-128/256 CBC or GCM; SHA1 and 3DES only when set explicitly and flagged by
|
||||
the read-only `weakCrypto` field), optional zlib compression, and synchronous or asynchronous
|
||||
signed MDNs. Everything is in memory: no keystore, key or temporary file is ever written.
|
||||
|
||||
**Transport settings** (`edi_transport`, type AS2): `as2LocalId`, `as2PartnerId`, `as2Url` (HTTPS;
|
||||
plain HTTP only on loopback), `as2Subject`, `signAlgorithm` (blank = unsigned), `encryptAlgorithm`
|
||||
(blank = unencrypted), `compress`, `mdnMode` (`SYNC` default, `ASYNC`, `NONE`), `mdnSigned`,
|
||||
`localCertificateId` (ours), `partnerCertificateId`, optional `partnerTlsCertificateId` (pins the
|
||||
partner TLS certificate), and optional `httpAuthUserEnvVar` / `httpAuthPasswordEnvVar` for HTTP
|
||||
Basic. For inbound, a set `signAlgorithm` / `encryptAlgorithm` means signed / encrypted messages are
|
||||
required (otherwise `insufficient-message-security`). A VAN is an AS2 transport with `isVan` true
|
||||
and no `partnerId`; its partner certificate belongs to the VAN operator's partner record, and
|
||||
routing to trading partners stays by ISA identity.
|
||||
|
||||
**Certificates** (`edi_certificate`): paste the public PEM only; subject, issuer, serial,
|
||||
SHA-256 fingerprint and validity are parsed on save, and a pasted private key is refused. Our private
|
||||
key is never stored: `privateKeyEnvVar` names an environment variable holding a PEM PKCS#8 key or a
|
||||
base64 PKCS#12 store (with `privateKeyPasswordEnvVar` for its password). A missing or mismatched
|
||||
variable fails plainly, naming only the variable. Status `ACTIVE` signs and encrypts outbound;
|
||||
inbound accepts `ACTIVE` and `NEXT` (ours for decryption, the partner's for verification), so a
|
||||
rollover is: add the new certificate as `NEXT`, tell the partner, then run **Promote Certificate**
|
||||
(`NEXT` becomes `ACTIVE`, the previous `ACTIVE` of the same partner and usage becomes `RETIRED`).
|
||||
**Export Public Certificate** downloads the PEM to send to a partner. The monitor alerts at 30, 14
|
||||
and 7 days before expiry and at expiry for `ACTIVE` and `NEXT` certificates.
|
||||
|
||||
**Hook URLs** (outside `/qqq/v1`, authenticated per transport by AS2 identity, signature and
|
||||
certificate; give your partner the first, and the second is sent in `Receipt-Delivery-Option`):
|
||||
|
||||
| URL | Purpose |
|
||||
| --- | --- |
|
||||
| `POST {base}/qqq/hooks/edi/as2/{transport}` | Receive messages; answers with the MDN, or 200 and a queued asynchronous MDN |
|
||||
| `POST {base}/qqq/hooks/edi/as2/{transport}/mdn` | Receive asynchronous MDNs for messages we sent |
|
||||
|
||||
`{transport}` is the transport `name`. Set `EdiQBitConfig.withPublicBaseUrl(...)` so `ASYNC`
|
||||
transports can announce the second URL. Bodies are limited by the largest `maxInterchangeBytes` of
|
||||
the agreements on the transport (50 MB default). A retransmitted Message-ID is answered with the
|
||||
original MDN and is not reprocessed; a bad signature, decryption or MIC returns an MDN with
|
||||
`processed/error: authentication-failed`, `decryption-failed` or `integrity-check-failed` and creates
|
||||
no inbox row; a storage failure returns `unexpected-processing-error` so the partner retries. Every MDN
|
||||
sent or received is kept in the read-only `edi_as2_mdn` table (AS2 MDNs child lists on the
|
||||
outbound message and the inbound message) with the raw MDN in the encrypted payload store.
|
||||
`ASYNC` messages stay SENT until the MDN arrives; the `edi_as2_mdn_sender` step delivers the MDNs we
|
||||
owe, retrying with the send backoff, and `edi.mdn.overdue` warns when an `ASYNC` MDN is later than
|
||||
the agreement document `ackDueMinutes` (60 when unset).
|
||||
|
||||
Existing databases need the new `edi_as2_mdn` table, `edi_outbound_message.transport_mic` and
|
||||
`edi_transport.weak_crypto` (see `docs/ddl/edi-postgres.sql`).
|
||||
|
||||
## HIPAA and PHI
|
||||
|
||||
Original **QQQ baseline guides** cover 837P (005010X222A1), 837I (005010X223A2),
|
||||
835 (005010X221A1), 270/271 (005010X279A1), 276/277 (005010X212), 834 (005010X220A1)
|
||||
and 999 (005010X231A1). These are in-house structure and usage baselines, not licensed TR3s.
|
||||
No TR3 text or full licensed code lists ship. Hosts may import their licensed guides.
|
||||
|
||||
SNIP 1–2 covers envelopes and the active guide. SNIP 3 uses exact decimals for claim charges,
|
||||
claim/line CAS adjustments and payments less signed PLB adjustments (zero tolerance). SNIP 4
|
||||
rules run per loop occurrence, including dependent patients, claim/line dates, diagnoses,
|
||||
subscriber demographics and benefits-or-rejection responses. SNIP 3+ findings produce `E` and
|
||||
an exception; SNIP 1–2 errors produce `R`. HIPAA replies use 999 when acknowledgement is enabled.
|
||||
SNIP 5 includes ICD-10 diagnosis shape, NPI Luhn with the 80840 prefix, and US ZIP shape.
|
||||
`ExternalCodeSetProvider.isValid(codeSet, code, asOf)` supplies licensed membership checks for
|
||||
EXTERNAL code lists; without it, only formats are checked. The System widget reports that state.
|
||||
|
||||
Healthcare agreement documents default to PHI and SNIP 4. Clinical keys go only to
|
||||
`edi_transaction.phiBusinessKey`; validation excerpts, acknowledgements, events, exceptions,
|
||||
alerts and process summaries contain no submitted clinical values. Healthcare sample maps and
|
||||
host tables live **only in test sources/resources** and use seeded synthetic patients and claims.
|
||||
Their NPI `1234567893` is a checksum test fixture, not an asserted provider identity.
|
||||
|
||||
**The host must encrypt the database at rest and should install qbit-secured-data.** Payloads
|
||||
are always AES-GCM encrypted; PHI columns rely on host database encryption. RC.6 core
|
||||
classification metadata marks PHI/PII/CONFIDENTIAL and denies PHI AI exposure, but does not
|
||||
provide secured-data field tiers. Without secured-data policies, a user with table access can
|
||||
read PHI columns.
|
||||
The home explicitly reports this limitation. Use record-level audits on host PHI target tables
|
||||
until core classified-value audit protection is available.
|
||||
|
||||
RC.6 still includes query criteria in RDBMS failure logs and has no logging redaction hook.
|
||||
The marked `PhiLogScope` bridge suppresses synchronous logger diagnostics during PHI mapping/application and preview; ordinary value-free EDI events
|
||||
remain persisted. It changes no logger levels and does not suppress other threads. The bridge
|
||||
tracks core #1147 (secured-data logging), with a removal/review deadline of 4.1.0-RC.7.
|
||||
Map samples use record-level audits to avoid copying expected-result PHI into audit details. Host code outside these scopes must protect its
|
||||
own logging, audits and asynchronous work.
|
||||
|
||||
Download Raw Document uses a session-bound process grant, rechecks `edi.raw.download` and record
|
||||
read/partner access at download time, streams decrypted bytes without local files, and records
|
||||
`RAW_DOWNLOADED` with the user and row references, never content. Download filenames are generic.
|
||||
|
||||
`PhiCanaryTest` scans every five-character marker window through gateway/HTTPS intake, forced
|
||||
SNIP 2/3/4 errors, mapping/apply, 999, replay/resend, Review/Result, alerts/notifications, DEBUG logs, all EDI
|
||||
query exports and all home/child widgets. FULL exports exempt only PHI-classified columns;
|
||||
RC.6 HIDDEN field-lock exports have no exemptions. MASKED tiers and the optional Quick Search
|
||||
qbit require the newer secured-data integration and are not claimed by these RC.6 tests.
|
||||
|
||||
## Build and verification
|
||||
|
||||
```bash
|
||||
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
# Upgrade notes
|
||||
|
||||
## Unreleased
|
||||
|
||||
- No schema change and no Liquibase include.
|
||||
- Behavior change: an inactive identity (`isActive` false) now stops X12 routing and sending, as it already did for EDIFACT. Inbound documents from or to it are rejected as an unknown partner, and outbound requests for its agreements wait until it is active. Identities default to active, so only identities someone switched off are affected.
|
||||
- Hosts that already call `EdiRuntime.initialize()` at startup keep getting the 41 built-in guides and code lists. A host that never called it will now see "Needs setup" for Guides and code lists on the home.
|
||||
- New widget "Setup status" (`edi_home_setup`).
|
||||
- Behavior change: the EDI home shows business widgets only (`EdiHome.HOME_WIDGET_NAMES`), each now requiring `edi.hasAccess` (hidden without it). `edi_home_system`, `edi_home_dead`, `edi_home_certificates` and `edi_home_setup` left the EDI home: they are listed in `EdiHome.ADMIN_WIDGET_NAMES`, require `edi.admin.hasAccess` (hidden without it), and the host places them on its Admin home.
|
||||
- New business widget `edi_home_delayed` ("Inbound documents are delayed"), shown only when the oldest unprocessed inbound document is older than the new config value `inboundDelayedAfterMinutes` (default 60).
|
||||
- New table action "Install sample maps" on Maps, permission `edi.admin`. It is never run automatically and only adds draft maps whose name does not exist yet.
|
||||
@@ -0,0 +1,82 @@
|
||||
# EDI local demo
|
||||
|
||||
From this worktree with Java 21:
|
||||
|
||||
```powershell
|
||||
./mvnw.cmd -B test-compile exec:java '-Dexec.classpathScope=test' '-Dexec.mainClass=com.kingsrook.qbits.edi.demo.EdiDemoServer' '-Dexec.args=8099'
|
||||
```
|
||||
|
||||
Open http://localhost:8099/app/edi. Stop with Ctrl+C. On Unix use `./mvnw`.
|
||||
Next UI table routes are `/app/<tableName>` and record routes are `/app/<tableName>/<id>`.
|
||||
Each launch creates a fresh in-memory H2 database, an in-memory synthetic payload key and a
|
||||
fully anonymous demo session. No external credentials or services are required. Demo auth is
|
||||
in test sources only and must not be installed in a real host.
|
||||
|
||||
The seed includes Northwind Retail (gateway), Harbor Logistics (SFTP) and Summit Market (HTTPS),
|
||||
with codes NORTHWIND, HARBOR and SUMMIT and synthetic contacts at `example.invalid`.
|
||||
It includes identities/agreements, guides/maps, orders with lines, shipments with cartons/items,
|
||||
invoices and inventory. Forty synthetic inbound documents go through intake and the real inbox worker:
|
||||
850s, 860s, validation errors, an unknown sender and a byte duplicate. Northwind's 855/856/810/846
|
||||
pass the outbound worker and an in-memory gateway; incoming 997s reconcile some sent groups.
|
||||
An overdue group, an exhausted send and several open exceptions provide operational examples.
|
||||
Public self-signed certificates are generated in memory (two for expiry alerts, three for AS2); private keys are never persisted.
|
||||
|
||||
Pacific Wholesale (code PACIFIC) is the AS2 partner. An in-process BouncyCastle peer listens on a
|
||||
random loopback port and the seed generates synthetic RSA certificates in memory (our ACTIVE and NEXT
|
||||
certificates and the partner's; the private keys live only in the demo secrets map and are never
|
||||
written to disk). The seed sends three 850s to the application's own AS2 hook (signed, encrypted;
|
||||
two with base64 transfer encoding), which answers with signed MDNs (`edi_as2_mdn` rows, shown as
|
||||
"AS2 MDNs" on the inbound message); the 997s go back over AS2 as well. An 855 built from a source record is sent
|
||||
over AS2 to the peer, which verifies the signature and MIC and returns a signed synchronous MDN
|
||||
(the outbound message becomes DELIVERED with its `transport_mic`). One more message goes to the
|
||||
asynchronous transport `pacific-as2-async`: it stays SENT until the peer posts the MDN to
|
||||
`/qqq/hooks/edi/as2/pacific-as2-async/mdn`. The running demo serves
|
||||
`POST /qqq/hooks/edi/as2/pacific-as2` (and `.../mdn`) on the same port; Test Connection,
|
||||
Export Public Certificate and Promote Certificate (on `demo-as2-next`) work on the AS2 records.
|
||||
|
||||
Phase d adds warehouse and logistics traffic, seeded last so the retail scenarios above are
|
||||
unchanged. Harbor Logistics (the 3PL) gets a gateway OUTBOX transport (the partner pulls over SFTP)
|
||||
and sends three valid 940 shipping orders and one with a bad unit; the orders and lines land in
|
||||
`sample_wms_order`, and two shipments (one short) go out as 945s. A fourth partner, Ridgeline Freight
|
||||
(code RIDGELINE, gateway transport), receives two 204 load tenders (the second a change) and sends
|
||||
four 214 status events (one rejected with a bad status code) that build `sample_shipment_status`.
|
||||
The in-memory gateway accepts every send, and the rejected documents produce rejecting 997s.
|
||||
|
||||
Phase g adds a fifth partner, **Lindqvist Retail AB** (LINDQVIST), after the existing scenarios.
|
||||
Its D96A test agreement receives three synthetic ORDERS documents through the durable inbox;
|
||||
orders and lines appear in `sample_order`, and CONTRLs enter the gateway queue. ORDRSP, DESADV
|
||||
and INVOIC are mapped from the retail sample records, delivered through the in-memory gateway,
|
||||
and reconciled by incoming accepted CONTRLs. Look for ORDERS with status APPLIED and the three
|
||||
outbound message types with status ACKNOWLEDGED. Released delimiters in buyer/item identifiers
|
||||
exercise the same parser used for partner input. All contacts remain synthetic `example.invalid`.
|
||||
|
||||
The SFTP/HTTPS partner settings are illustrative `.invalid` destinations. Their agreements have
|
||||
no outbound transport, so they cannot contact a real partner; resulting acknowledgement send
|
||||
retries are visible. Northwind, Harbor, Ridgeline and Lindqvist have configured outbound documents; the healthcare scenario adds its own. The in-memory gateway
|
||||
is restored after the intentional dead-letter scenario. Standard Send As EDI, Test Connection,
|
||||
Resend, Resend As New and Cancel actions are available. The gateway stores bytes in memory;
|
||||
it does not expose a separate partner download UI.
|
||||
|
||||
Acceptance boots a random-port QApplicationJavalinServer, loads Next UI, renders and serializes
|
||||
every registered widget through core RenderWidgetAction/JsonUtils, and POSTs every home and
|
||||
child-list widget over HTTP using seeded parent records. It also checks configuration JSON,
|
||||
metadata, table queries, labels, field order, foreign-key value sources and PHI-safe record labels.
|
||||
Next UI defaults table queries to descending ID (newest records first); activity child lists use
|
||||
descending creation date and ID. The home exception queue puts OPEN before IN_PROGRESS, newest
|
||||
first within each status. RC.5 has no table-level default-sort metadata to override Next's sort.
|
||||
Inbound message labels use external IDs because filenames are classified as PHI.
|
||||
Light/dark visual layout and production scheduler/gateway deployment remain host acceptance.
|
||||
|
||||
Phase e adds a fifth partner, **Cedar Health Clearinghouse** (CEDAR, PHI enabled), after the existing
|
||||
retail and logistics scenarios. It sends seeded synthetic professional and institutional claims,
|
||||
a remittance, a professional claim with a one-dollar balancing error (SNIP 3), and a professional
|
||||
claim missing its dependent patient loop (SNIP 4). Both business-rule errors are accepted with
|
||||
errors (`E`) and open exceptions. Value-free 999s go to the in-memory clearinghouse outbox.
|
||||
A synthetic eligibility request goes out as a 270 and a matching-trace 271 arrives. Clinical keys
|
||||
appear only in the classified PHI key column, not the ordinary document key or record labels.
|
||||
|
||||
The healthcare sample maps and tables are test-only. Patients are seeded `Testpatient Alpha`
|
||||
fixtures, member IDs begin with `SYN`, and `1234567893` is a documented NPI checksum test value.
|
||||
The host database must be encrypted for real PHI; this disposable demo uses synthetic data only.
|
||||
The System widget shows format-only external code validation until a provider is registered.
|
||||
Inbox and interchange records offer the audited **Download Raw Document** action.
|
||||
+225
-56
@@ -130,7 +130,7 @@ Package layout (`com.kingsrook.qbits.edi`):
|
||||
|
||||
| Dependency | Scope | Why |
|
||||
|---|---|---|
|
||||
| QQQ `4.1.0-RC.5` (then each new RC, G7) | compile | core, `QAlerts.raise/resolve` (RC.5) |
|
||||
| QQQ `4.1.0-RC.6` (then each new RC, G7) | compile | core classification, `QAlerts.raise/resolve/notify` |
|
||||
| `qqq-middleware-javalin` | optional | hooks route provider; UI API extension (RC.6) |
|
||||
| `qqq-esb` | optional | outbound trigger from ESB table events |
|
||||
| `qqq-backend-module-filesystem` (SFTP) | compile | SFTP push and VAN polling. This is core's SFTP client, the one qbit-sftp-data-integration uses; EDI uses it directly instead of depending on that qbit (G1). Remote SFTP only; nothing local. |
|
||||
@@ -139,13 +139,20 @@ Package layout (`com.kingsrook.qbits.edi`):
|
||||
| `qbit-object-storage` (its `api` package only) | optional | raw payload storage (section 10) |
|
||||
| `qbit-file-gateway` (its `api` package only) | optional | inbound SFTP and partner-pull OUTBOX (section 5.3) |
|
||||
|
||||
Core features that arrive in RC.6, and how EDI bridges until then:
|
||||
Core features checked against the `v4.1.0-RC.6` tag:
|
||||
|
||||
| Need | Core | Until RC.6 |
|
||||
| Need | Core | RC.6 status |
|
||||
|---|---|---|
|
||||
| `DataClassification` on fields (PHI) | `actions.ai.DataClassification` (#1142) | `EdiClassifications` stand-in, `STAND-IN(core=QRun-IO/qqq#1142, removeBy=4.1.0-RC.6)`, sets the classification reflectively when the class is present |
|
||||
| User notifications | `QAlerts.notify` (#1125) | alerts only via `QAlerts.raise` (RC.5); notifications added with the RC.6 bump |
|
||||
| UI API extension point | `MiddlewareSpecExtension` (#1128) | the sandbox is a record action (section 8.3); spec'd endpoints come in phase f |
|
||||
| `DataClassification` on fields (PHI) | `actions.ai.DataClassification` (#1142) | Uses `AiFieldMetaData.withClassification` and CONFIDENTIAL `AiTableMetaData` defaults; classification stand-in removed, including healthcare test fixtures |
|
||||
| User notifications | `QAlerts.notify` (#1125) | Assign notifies the assignee; new/reopened rejection and dead-letter exceptions notify current partner assignees (section 4.4) |
|
||||
| UI API extension point | `MiddlewareSpecExtension` (#1128) | Available in core; sandbox remains a record action until phase f |
|
||||
| Contingent choice dependency behavior | `PossibleValueDependencyBehavior`, `possibleValueSourceDependsOn` | Neither API exists in the tag; existing filters retain shared choices before a partner is selected |
|
||||
| Secured-data tiers and log redaction | `FieldAccessHelper`, logging hook (#1147) | Spec only; no implementation in the tag. Retain `PhiLogScope`, `STAND-IN(core=QRun-IO/qqq#1147, removeBy=4.1.0-RC.7)` |
|
||||
|
||||
**RC.6 baseline:** classifications and notifications now use core APIs. The PHI logging bridge
|
||||
remains because RDBMS query failures still log filters and QLogger has no classified-value
|
||||
redaction hook; review/remove it by RC.7 under #1147. FULL and HIDDEN field-lock canaries run;
|
||||
MASKED tiers await `FieldAccessHelper`. Host logging/audits outside PHI scopes still need protection.
|
||||
|
||||
The file-gateway `api` ships in that qbit's phase (a). Until it is on a published SNAPSHOT, EDI uses
|
||||
`GatewayStandIn` with the identical shape, marked `STAND-IN(core=QRun-IO/qbit-file-gateway,
|
||||
@@ -241,13 +248,17 @@ One row per document, version and direction that the agreement carries.
|
||||
- Each outbound functional group whose agreement document expects a 997 or 999 gets `ackDueAt`
|
||||
(sent time + `ackDueMinutes`).
|
||||
- The overdue monitor (a scheduled process, every 5 minutes) calls `QAlerts.raise(AlertSignal)`
|
||||
with alert type `edi_ack_overdue`, severity WARNING, dedup key `edi_functional_group:<id>`, a
|
||||
with alert type `edi.ack.overdue`, severity WARNING, dedup key `edi_functional_group:<id>`, a
|
||||
record reference to the group and a link to the interchange. qbit-alerts delivers it (inbox,
|
||||
channels, quiet hours); without qbit-alerts, core's logging sink records it.
|
||||
- When the ack arrives, `QAlerts.resolve("edi_ack_overdue", dedupKey, note)`.
|
||||
- A rejecting ack (AK9/IK9 `R`, or partial `P`) raises `edi_ack_rejected` (ERROR) and opens a
|
||||
- When the ack arrives, `QAlerts.resolve("edi.ack.overdue", dedupKey, note)`.
|
||||
- A rejecting ack (AK9/IK9 `R`, or partial `P`) raises `edi.ack.rejected` (ERROR) and opens a
|
||||
`REJECTED_ACK` exception.
|
||||
- From RC.6, assignees of exceptions also get a `QAlerts.notify` notification.
|
||||
- Assign sends the assignee a value-free `QAlerts.notify` notification after the update commits.
|
||||
New or reopened `REJECTED_ACK`/`DEAD_LETTER` exceptions notify distinct assignees of
|
||||
OPEN/IN_PROGRESS exceptions for the same partner (including the reopened row's assignee).
|
||||
Existing active exception assignments define partner recipients; no new assignment model is
|
||||
introduced. Coalesced repeat occurrences do not notify again. Rollbacks discard notifications.
|
||||
|
||||
## 5. Transports
|
||||
|
||||
@@ -284,8 +295,8 @@ Transports are data. **`edi_transport`** [Transport] (main, nav):
|
||||
`privateKeyEnvVar` (ours only: the env var holding a PEM or base64 PKCS#12),
|
||||
`privateKeyPasswordEnvVar`, `status` (ACTIVE, NEXT, RETIRED). `NEXT` supports rollover: inbound
|
||||
accepts either key, outbound switches when "Promote Certificate" runs.
|
||||
- Expiry: the monitor raises `edi_certificate_expiring` at 30, 14 and 7 days (dedup per
|
||||
certificate and threshold), and `edi_certificate_expired` at expiry.
|
||||
- Expiry: the monitor raises `edi.certificate.expiring` at 30, 14 and 7 days (dedup per
|
||||
certificate and threshold), and `edi.certificate.expired` at expiry.
|
||||
- Record actions: "Promote Certificate", "Export Public Certificate" (download the PEM to send to a
|
||||
partner). Private keys never enter a table, a log or a response.
|
||||
|
||||
@@ -349,30 +360,61 @@ For partners and VANs that host their own SFTP, EDI connects out with core's SFT
|
||||
not depend on it (G1) but uses the same core client, so behaviour and host-key handling match.
|
||||
|
||||
### 5.5 AS2 (phase c)
|
||||
Built on as2-lib (Apache-2.0):
|
||||
- **Send:** `AS2Client` with partnership settings built from `edi_transport` and `edi_certificate`
|
||||
(a custom partnership factory and certificate factory backed by our tables, keys from env vars, no
|
||||
keystore file). Sign, then encrypt, optional compression; the `Message-ID` is recorded. Sync MDN:
|
||||
verify the signature and the MIC against the sent content; a mismatch or a failed disposition
|
||||
fails the send. Async MDN: `Receipt-Delivery-Option` is
|
||||
`https://<host>/qqq/hooks/edi/as2/{transport}/mdn`; the message stays SENT until the MDN arrives,
|
||||
then becomes DELIVERED or FAILED. An MDN not received within `ackDueMinutes` raises
|
||||
`edi_mdn_overdue`.
|
||||
- **Receive:** `POST /qqq/hooks/edi/as2/{transport}` (a route provider outside `/qqq/v1`). The
|
||||
handler reads the body up to the agreement's `maxInterchangeBytes` (in memory up to 8 MB, then
|
||||
streamed to object storage, never to a temp file), uses as2-lib's receive helpers to decrypt,
|
||||
verify the signature against the partner's certificate, decompress and compute the MIC, then calls
|
||||
`InboundSink.receive` and returns the signed sync MDN, or sends the async MDN after commit. A
|
||||
message that cannot be stored gets a failure MDN (`processed/error:
|
||||
unexpected-processing-error`) so the partner retries.
|
||||
- The AS2 `Message-ID` is the inbox dedupe key: a retransmitted message gets the original MDN again
|
||||
and is not reprocessed.
|
||||
- MDNs, sent and received, are stored in **`edi_as2_mdn`** [AS2 MDN] (child of the outbound message
|
||||
or the inbox row): `direction`, `messageId`, `originalMessageId`, `disposition`, `micAlgorithm`,
|
||||
`micValue`, `signed`, `signatureValid`, `payloadRef` (the raw MDN in object storage),
|
||||
`receivedAt`.
|
||||
- TLS: outbound uses the JVM trust store plus optional pinning (`partnerTlsCertificateId`). Inbound
|
||||
TLS terminates at the host's ingress.
|
||||
Implemented by `As2Transport` (send, Test Connection) and `As2RouteProvider` (receive). All of it is
|
||||
in memory; no keystore, key or temporary file is written, and as2-lib's file-backed pieces are not used.
|
||||
- **Libraries.** as2-lib 5.1.8 supplies the algorithm tables (`ECryptoAlgorithmSign/Crypt`), the
|
||||
`MIC` and `DispositionOptions` types and its BouncyCastle S/MIME signer. as2-lib's decrypt and
|
||||
verify stage content through `AS2ResourceHelper` temporary files and its receive side needs
|
||||
servlet classes, so decryption, signature verification, compression, encryption and the MIC
|
||||
(RFC 4130, RFC 5751, RFC 5402) use BouncyCastle (`bcpkix`, `bcjmail`) directly in `As2Crypto`,
|
||||
and MDNs are built and parsed in `As2Mdn`. The MIC is the digest of the exact bytes of the signed
|
||||
MIME entity (headers included), or of the transmitted entity when unsigned, so any partner
|
||||
transfer encoding (binary or base64) agrees.
|
||||
- **Send:** the message is compressed (optional), signed, then encrypted; `Message-ID` is stable per
|
||||
outbound message (`edi-<id>-<digest>@<as2LocalId>`) and recorded as `transportMessageId`, and the MIC
|
||||
we computed is kept in `edi_outbound_message.transportMic`. Configuration problems (a missing key
|
||||
variable, an expired certificate) are RETRY results that name only the problem. Sync MDN: verify the
|
||||
signature (required when `mdnSigned`), compare Received-Content-MIC, and require disposition
|
||||
`processed`; a mismatch or failed disposition is FAILED with the (sanitized) disposition text.
|
||||
`NONE` leaves the message SENT. Async MDN: `Receipt-Delivery-Option` is
|
||||
`{publicBaseUrl}/qqq/hooks/edi/as2/{transport}/mdn`; the message stays SENT until the MDN arrives,
|
||||
then becomes DELIVERED or FAILED; an MDN not received within the agreement document `ackDueMinutes`
|
||||
(60 when unset) raises the warning `edi.mdn.overdue`, resolved by the MDN.
|
||||
- **Receive:** `POST /qqq/hooks/edi/as2/{transport}` (outside `/qqq/v1`). The handler checks
|
||||
`AS2-From`/`AS2-To` against the transport (403 otherwise), reads the body up to the largest
|
||||
`maxInterchangeBytes` of the agreements on the transport (50 MB default, in memory), decrypts with any
|
||||
of our ACTIVE or NEXT keys, verifies the signature against the partner's ACTIVE and NEXT certificates
|
||||
(signed is required when `signAlgorithm` is set, encrypted when `encryptAlgorithm` is set), unwraps
|
||||
compression, computes the MIC, calls `InboundSink.receive` (`transportType` AS2, `externalId` =
|
||||
Message-ID, scoped by transport id if another partner already used the same id), and answers with the
|
||||
MDN: synchronous in the response (signed with our ACTIVE key when requested or `mdnSigned`), or, when
|
||||
`Receipt-Delivery-Option` is present, 200 at once and a queued `edi_as2_mdn` row that
|
||||
`As2MdnSender` (scheduled `edi_as2_mdn_sender`, every 15 seconds) posts with the send backoff and
|
||||
`maxAttempts`, failing with `edi.send.failed`. An asynchronous receipt address must be HTTPS
|
||||
(loopback excepted) and, unless signatures are required, on the partner's own host; failures before
|
||||
the sender is authenticated are always answered synchronously.
|
||||
- A retransmitted Message-ID returns the original MDN again, byte for byte, and is not reprocessed
|
||||
(an asynchronous one is queued again). Failures return MDNs with `processed/error:
|
||||
authentication-failed` (bad or unverifiable signature), `integrity-check-failed` (digest mismatch),
|
||||
`decryption-failed`, `insufficient-message-security` or `unexpected-processing-error` (storage, or a
|
||||
key we cannot load, so the partner retries); no inbox row exists for a rejected message.
|
||||
- **Certificates:** `CertificateService` resolves our key from the `privateKeyEnvVar` variable (PEM
|
||||
PKCS#8 or base64 PKCS#12 with `privateKeyPasswordEnvVar`), proves it matches the certificate, and
|
||||
never caches or logs it. Outbound signs and encrypts with the ACTIVE certificate (the referenced one
|
||||
when ACTIVE, otherwise the ACTIVE one of the same partner and usage), inbound accepts ACTIVE and NEXT.
|
||||
"Promote Certificate" makes NEXT ACTIVE and retires the previous ACTIVE one of the same partner and
|
||||
usage. A VAN transport (`isVan`, no `partnerId`) has its partner certificate on the VAN operator's
|
||||
partner record, or with no partner for a shared certificate (no rollover candidates).
|
||||
- MDNs, sent and received, are stored in **`edi_as2_mdn`** [AS2 MDN] (read-only; child list "AS2 MDNs"
|
||||
of the outbound message and the inbox row; record label `<direction> MDN <originalMessageId>`):
|
||||
`direction` (INBOUND = received, OUTBOUND = we sent it), `messageId`, `originalMessageId`,
|
||||
`disposition`, `micAlgorithm`, `micValue`, `signed`, `signatureValid`, `payloadRef` (the raw MDN,
|
||||
encrypted), `receivedAt`, `transportId`, `partnerId`, `outboundMessageId`, `inboxId`, and the
|
||||
queue columns `deliveryUrl`, `deliveryStatus` (NONE, PENDING, SENT, FAILED), `deliveryAttempts`,
|
||||
`nextAttemptAt`, `deliveredAt`.
|
||||
- TLS: outbound uses the JVM trust store, or only the pinned certificate when `partnerTlsCertificateId`
|
||||
is set. Inbound TLS terminates at the host's ingress. SHA1 and 3DES are accepted only when set
|
||||
explicitly and set the read-only `weakCrypto` flag on the transport.
|
||||
|
||||
### 5.6 HTTPS and email
|
||||
- **HTTPS out:** POST the interchange (`application/edi-x12` or `application/EDIFACT`) with the
|
||||
@@ -389,10 +431,10 @@ Built on as2-lib (Apache-2.0):
|
||||
- **Email in:** through qbit-email's phase 2 inbound (an `InboundEmailHandler` registered by EDI).
|
||||
Designed here, built when qbit-email inbound ships.
|
||||
|
||||
## 6. Parsing (X12 now, EDIFACT later)
|
||||
## 6. Parsing (X12 and EDIFACT)
|
||||
|
||||
- StAEDI (`EDIInputFactory`, `EDIStreamReader`) reads X12 as a stream, with no whole-document tree,
|
||||
so a 50 MB 837 batch is processed in bounded memory per transaction set.
|
||||
- StAEDI (`EDIInputFactory`, `EDIStreamReader`) tokenizes X12 and EDIFACT. Inbox framing buffers
|
||||
one interchange at a time, capped at 50 MiB, and each transaction retains a bounded segment list.
|
||||
- One payload may hold several interchanges; each ISA/IEA becomes an `edi_interchange` row. Each
|
||||
ST/SE is buffered as its own segment list (bounded by the guide's maximum segment count, default
|
||||
100,000) for validation, ack building and mapping.
|
||||
@@ -402,9 +444,15 @@ Built on as2-lib (Apache-2.0):
|
||||
(005010 extended). Unrecognised bytes are an envelope error, never silently replaced.
|
||||
- Writing uses an in-memory serializer with the agreement's separators, fixed-width ISA
|
||||
padding, component/repetition handling, and optional line endings.
|
||||
- EDIFACT: StAEDI reads UNA/UNB/UNG/UNH and validates against EDIFACT schemas. It is designed into
|
||||
the model now (`standard` columns, UNB fields on `edi_identity`, CONTRL as an ack type) and built
|
||||
in phase g.
|
||||
- EDIFACT is built: StAEDI reads syntax 3/4 UNA/UNB/UNG/UNH envelopes. UNA advice and release
|
||||
characters survive round trips; UNT/UNE/UNZ counts and references produce findings. A payload
|
||||
may contain both X12 and EDIFACT interchanges. In-house D96A/D01B ORDERS/ORDRSP/DESADV/INVOIC
|
||||
and CONTRL guides use the shared composite and loop grammar.
|
||||
- EDIFACT routing uses the identity's `interchangeId`/`interchangeQualifier` as UNB identifiers;
|
||||
UNB11=1 selects test usage. The agreement's syntax identifier, version and service characters
|
||||
control output. UNB/UNH control references use the allocator's EDIFACT levels (up to 14 digits).
|
||||
CONTRL reconciles outbound status and resolves overdue alerts; rejected responses open
|
||||
REJECTED_ACK and raise `edi.ack.rejected`. APERAK remains a later document type.
|
||||
|
||||
## 7. Implementation guides as data
|
||||
|
||||
@@ -528,16 +576,41 @@ upload is stored as a sample), and the standard Review/Result lines show:
|
||||
5. the diff against the previous map revision's result and against `expectedResultJson`.
|
||||
|
||||
Nothing is written to target tables: the preview runs the whole pipeline with a no-op applier.
|
||||
Phase f adds spec'd endpoints (`POST /qqq/v1/qbit/edi/sandbox/preview` and related) for the mapper
|
||||
UI.
|
||||
Phase f1 provides the `edi` 1.0.0 `MiddlewareSpecExtension`, with component schemas named
|
||||
`Edi...V1` and a generated, contract-tested fragment at `openapi/qqq-v1/edi.yaml`:
|
||||
|
||||
### 8.4 No-code mapper UI (phase f; specified now, built later)
|
||||
- `GET /qqq/v1/qbit/edi/guides/{guideId}/tree`
|
||||
- `GET /qqq/v1/qbit/edi/maps/{mapId}/target`
|
||||
- `POST /qqq/v1/qbit/edi/sandbox/preview`
|
||||
- `POST /qqq/v1/qbit/edi/maps/{mapId}/validate`
|
||||
|
||||
Preview Map and the HTTP preview share `MapPreviewService`. HTTP preview takes `mapId`, exactly
|
||||
one of `sampleId`/`sampleBase64`, optional `sampleName`, a complete in-memory `ruleOverrides`
|
||||
replacement, and `guideRevision`. Uploads are limited to 5 MiB decoded and stored encrypted as
|
||||
samples; the host must allow at least 8 MiB HTTP bodies for base64 overhead. `CURRENT` (HTTP default)
|
||||
selects the newest active revision in the pinned guide family and partner; `ORIGINAL` uses the
|
||||
map's pinned guide, as the record action does.
|
||||
|
||||
The response contains parse counts, positional findings, acknowledgement type/text, mapped records
|
||||
and associations, structured per-document diffs, and `phiSuppressed`. PHI returns null result/ack
|
||||
text, empty diffs and generic finding messages. Mapping errors are value-free `MAP` findings.
|
||||
The preview action permission is exactly `edi.map.preview`, with no `.hasAccess` suffix.
|
||||
Guide, sample and rule reads explicitly enforce permissions and record locks; transforms run in
|
||||
the caller session. Only classification and encrypted sample storage use the internal system writer.
|
||||
|
||||
### 8.4 No-code mapper UI (phase f1 API built; Next page follows)
|
||||
A custom Next UI page (custom UIs may be nav entries):
|
||||
- left, the guide tree (loops, segments, elements with usage and code lists); right, the target
|
||||
table's fields and associations; drag to create a rule, then choose a transform;
|
||||
- a live preview pane on a chosen sample (the sandbox endpoint), with errors inline;
|
||||
- revisions: edit a DRAFT, activate, compare revisions;
|
||||
- reads and writes only through `/qqq/v1` (CRUD on `edi_map_rule` plus the extension endpoints).
|
||||
- reads and writes only through `/qqq/v1`: the guide-tree and map-target GETs above, the sandbox
|
||||
preview POST and the map validate POST; writes remain core CRUD on `edi_map_rule`, enforcing
|
||||
`edi.map_rule.insert|edit|delete` and record locks.
|
||||
- target metadata includes direct associations and transform argument JSON Schemas from the
|
||||
registry. Validate accepts `{rules: [...]}`, returns indexed per-rule errors against the pinned
|
||||
guide/target and never executes transforms or saves rules. Guide code lists return at most
|
||||
200 active codes per element.
|
||||
|
||||
## 9. Flows
|
||||
|
||||
@@ -603,7 +676,7 @@ so one bad document does not block the others):
|
||||
`status` (QUEUED, SENDING, SENT, DELIVERED, RETRY_WAIT, FAILED, DEAD_LETTER, CANCELLED),
|
||||
`attempts`, `nextAttemptAt`, `transportMessageId`, `sentAt`, `deliveredAt`, `lastError`. The
|
||||
sender claims rows the same way and retries per the transport's policy; DEAD_LETTER raises
|
||||
`edi_send_failed` and opens a `TRANSPORT` exception.
|
||||
`edi.send.failed` and opens a `TRANSPORT` exception.
|
||||
- **Reconcile:** an MDN or delivery confirmation sets DELIVERED; the expected 997/999 sets the
|
||||
functional group's `ackStatus` (4.4); the home and the timeline show both.
|
||||
|
||||
@@ -719,6 +792,13 @@ Non-HIPAA documents validate at level 2 by default and HIPAA documents at level
|
||||
document can raise or lower its level.
|
||||
|
||||
### 11.4 External code sets
|
||||
|
||||
JSON element constraints support `source: "EXTERNAL"` and `externalCodeSet`. Normalized code-list
|
||||
rows preserve both properties. Register the provider with
|
||||
`EdiQBitConfig.withExternalCodeSetProvider(...)`; it is excluded from configuration JSON.
|
||||
Membership failures are positional SNIP 5 findings. The effective date uses a service date,
|
||||
then a payment date, otherwise the configured clock. Provider failures disclose no values.
|
||||
|
||||
`ExternalCodeSetProvider` (SPI: `boolean isValid(String codeSet, String code, LocalDate asOf)`),
|
||||
registered by the host, for example backed by a licensed table the host loads. Code lists with
|
||||
`source = EXTERNAL` delegate to it. Without a provider they check the format only, and the home says
|
||||
@@ -726,7 +806,7 @@ so.
|
||||
|
||||
## 12. HIPAA and PHI
|
||||
|
||||
- **Classification** (`DataClassification`, core #1142; the stand-in until RC.6):
|
||||
- **Classification** (`DataClassification`, core #1142; native RC.6 metadata):
|
||||
- PHI: `edi_transaction.phiBusinessKey`, `edi_transaction_error.valueExcerpt` (never written for
|
||||
PHI documents, but classified anyway), `edi_map_sample.expectedResultJson`, `edi_inbox.fileName`
|
||||
(file names often carry member or claim ids), and the payloads themselves (encrypted, never in a
|
||||
@@ -747,13 +827,17 @@ so.
|
||||
- **Minimum necessary:** PHI maps write only the mapped fields. The raw document stays encrypted and
|
||||
can be downloaded only with the record action "Download Raw Document" (permission
|
||||
`edi.raw.download`), which is audited and, with secured-data, written to its access log.
|
||||
- **Canary egress tests** (phase e, the core SECURED-DATA procedure): synthetic 837P and 835
|
||||
documents carry `CANARY-PHI-<random>` in the patient name, member id, claim number and a
|
||||
diagnosis. They run through receive, validate (with forced errors), 999, map, apply, exception,
|
||||
reprocess, resend, the home widgets, exports of every `edi_` table, Quick Search, alerts and
|
||||
captured logs, as a FULL, a MASKED and a HIDDEN user. The test fails on any substring of the canary
|
||||
longer than 4 characters outside the encrypted payloads and the PHI-classified columns, and for the
|
||||
MASKED and HIDDEN users anywhere at all.
|
||||
- **Canary egress tests** (phase e, the core SECURED-DATA procedure): two randomized markers per
|
||||
patient-name, member-id, claim-number and diagnosis field run through gateway and HTTPS hooks,
|
||||
validation with SNIP 2/3/4 failures, 999, map/apply, exceptions, reprocess/resend, every home and
|
||||
child widget, every persisted `edi_` table's QueryAction export, Review/Result, captured alerts and notifications
|
||||
and an in-memory DEBUG log appender. Every five-character window is scanned, detecting all
|
||||
longer substrings. Only explicit PHI value/display-value columns are exempt for FULL exports;
|
||||
ciphertext bytes are checked for plaintext. RC.6 HIDDEN field locks are separately exercised
|
||||
without exemptions. The storage-only download alias has no query surface. MASKED tiers and
|
||||
the optional Quick Search index await their host/core integration; they are not simulated.
|
||||
Long diagnosis markers use a widened test-only guide element so accepted-with-error fixtures
|
||||
can also reach mapping/apply; separate malformed monetary elements force structural rejection.
|
||||
- No real PHI anywhere in the repo: samples are synthetic, produced by a seeded generator.
|
||||
|
||||
## 13. App home and navigation
|
||||
@@ -803,6 +887,10 @@ All counts run as the viewing user, so permissions and partner record locks appl
|
||||
|
||||
## 15. Demo
|
||||
|
||||
Phase b includes a test-scope `EdiDemoServer` with the Next UI and synthetic H2 seed;
|
||||
see [DEMO.md](DEMO.md) for launch and acceptance details. The broader host demo below remains
|
||||
a deployment example.
|
||||
|
||||
No kof22 adoption. The demo is a module in `qqq-sample-project` (in QRun-IO/qqq): a small orders,
|
||||
shipments and invoices schema; a retailer partner over the file gateway or SFTP and a 3PL over AS2
|
||||
against a local as2-lib peer; seeded synthetic documents; and a healthcare clearinghouse partner with
|
||||
@@ -890,10 +978,91 @@ queued acknowledgements are implemented. These RC.5 adaptations refine the execu
|
||||
Object storage uses only its published API.
|
||||
- Standard optional ESB publication follows RC.5. Document events use the core envelope/publisher
|
||||
with ID/status only because the standard factory copies every field, including PHI keys.
|
||||
- The a1 classification stand-in and host responsibility for PHI field protection remain unchanged.
|
||||
|
||||
Synthetic H2 verification covers permissions, association rollback, retry/stale claims, gateway
|
||||
storage failures, original/current replay, 997/999, ack reconciliation, previews and home renderers.
|
||||
The object-storage facade is final with restricted constructors: key/encryption metadata are tested
|
||||
without provider internals. Live S3, deployed gateway/scheduler and light/dark browser layout remain
|
||||
deployment validation. Outbound messages intentionally stay QUEUED.
|
||||
|
||||
|
||||
## Phase b implementation notes (2026-10-13)
|
||||
|
||||
RC.5 adaptations implemented in this worktree:
|
||||
|
||||
- Record-change listeners are registered in instance metadata and receive the source writer's transaction. Successful enqueues participate in that transaction (rollback is tested). RC.5 catches listener exceptions, so a failed enqueue cannot veto a source write; hosts requiring strict delivery guarantees must use the explicit enqueue boundary in their transaction.
|
||||
- RC.5 ESB consumption requires JMS. `edi_outbound_enqueue` accepts `sourceTableName`, `sourceRecordId`, and `eventId`; a host EsbTrigger can invoke that process. Tests exercise that boundary without a broker.
|
||||
- The filesystem module supplies Apache MINA SSHD. SFTP uses its streaming client directly with mandatory pinned host-key verification, remote temporary upload/rename, and durable inbox receipt before remote cleanup. No runtime local files are created.
|
||||
- HTTPS uses core `WebhookSigner`/`WebhookVerifier` for Standard Webhooks HMAC. The optional Javalin provider exposes the non-UI `/qqq/hooks/edi/https/{transport}` endpoint with a 50 MiB limit.
|
||||
- Worker claims use database row locks within the processing transaction. A crashed worker rolls back its claim; remote-send ambiguity remains at-least-once. Batch schedules are installed from active agreement documents during runtime initialization, with up to 100 transactions from one agreement document per envelope. Schedule changes require host scheduler refresh/restart. SFTP poll schedules are installed the same way.
|
||||
- Acknowledgement deadlines start on the first successful send. Resend reuses the archived bytes; Resend As New re-envelopes the archive with new ISA/GS controls and linked tracking snapshots, without remapping changed source records.
|
||||
- The test-scope demo uses `QApplicationJavalinServer`, Next UI 1.0.0-RC.14, fresh in-memory H2, anonymous demo authentication, synthetic certificates generated in memory, and an in-memory gateway OUTBOX provider. See `DEMO.md`. A host scheduler and production transport credentials remain host responsibilities.
|
||||
|
||||
|
||||
## Phase d implementation notes (2026-10-14)
|
||||
|
||||
- Guides for 940, 945, 214 and 204 are in-house baselines in both releases, with the same structure in 004010 and 005010 (as in phase b). Code lists carry only the values the sample maps and tests use; ID elements without a list are unconstrained. Control totals (940 W7601 against the W0101 lines, 945 W0301 against the W1203 lines) and the 204 stop rules (first stop LD, at least one UL) are guide-level `SITUATIONAL` rules, because the rule evaluator reads whole-document values; per-segment relationships use syntax notes (AT7 status/reason and date/time pairing, W12 difference needs ordered and shipped) and the MS1 segment `condition`, which is evaluated per LX occurrence.
|
||||
- The 204 stop-level N1 loop has its own loop id (`S5N1`, parent `S5`) so it does not merge with the header N1 loop; map paths name it (`S5[*]/S5N1/N102`).
|
||||
- Mapping needed two small engine additions, documented in `MAPS.md`: inbound paths may join elements with `+` so `DATETIME` receives a date and a time from separate elements, and outbound `DATEPART`/`TIMEPART` split a QQQ timestamp. Optional inbound DECIMAL/DATETIME rules use a `condition` because a transform with no input still fails the rule (unchanged phase a behavior).
|
||||
- `InboundApplier` assumed every target table has associations; a flat table (the 214 status sample) hit a null pointer. It is now null-safe.
|
||||
- The 214 sample map records the first LX event per transaction into a flat status table and always inserts (no match key), so repeated statuses form a history; a host that wants every LX event maps an association.
|
||||
- Demo: Harbor Logistics gets a gateway OUTBOX transport for 945s (the partner pulls over SFTP, per 5.3) after the retail seed has run, so its earlier acknowledgements keep their visible retries; the fourth partner Ridgeline Freight is added, so `EdiDemoServerTest` now expects four partners.
|
||||
|
||||
|
||||
## Phase c implementation notes (2026-10-14)
|
||||
|
||||
- as2-lib's decrypt and verify write temporary files and its receive helpers need servlet classes, so only
|
||||
its enum tables, `MIC`, `DispositionOptions` and signer are used; the rest is BouncyCastle in memory
|
||||
(section 5.5). Its dependency tree pulls `bcjmail` 1.85 (Jakarta Mail), so `bcpkix` is pinned to 1.85 and
|
||||
promoted from test scope; no other QQQ dependency changed.
|
||||
- The MIC is computed over the exact signed bytes (headers included) instead of as2-lib's
|
||||
decode-then-re-encode, which differs for base64 content; verified against an independent
|
||||
BouncyCastle peer with base64 and binary transfer encodings.
|
||||
- Encryption wraps the exact bytes that were hashed (CMS enveloped data over the serialized signed
|
||||
entity), so the MIC cannot drift between signing and encryption.
|
||||
- `edi_as2_mdn` doubles as the asynchronous MDN queue (`deliveryStatus`, `deliveryAttempts`,
|
||||
`nextAttemptAt`) instead of a separate table; the scheduled step is `edi_as2_mdn_sender`.
|
||||
- New columns: `edi_outbound_message.transport_mic` (the MIC we sent, for asynchronous comparison) and
|
||||
`edi_transport.weak_crypto`. New config: `EdiQBitConfig.publicBaseUrl`.
|
||||
- The alert is `edi.mdn.overdue` (RC.5 rejects underscores); `SendWorker` now records the transport
|
||||
failure reason (value-free by contract: an HTTP status, an AS2 disposition or a variable name) in
|
||||
`lastError` instead of a generic text.
|
||||
- Not implemented: EC and RSA-PSS signing keys (RSA only), MIC comparison under a different algorithm than
|
||||
the one requested, CAQH/Peppol profiles, and AS2 over a host-mounted client certificate (`MTLS`).
|
||||
|
||||
|
||||
## Phase e implementation notes (2026-10-14)
|
||||
|
||||
- See the RC.6 baseline in section 3. Map samples use record-level audits because expected-result
|
||||
JSON can contain PHI. Raw downloads use a session-bound storage grant and an `edi_event` audit;
|
||||
a secured-data access log remains host integration work.
|
||||
- Situational rules bind to their occurrence and inherit only direct ancestor segments, so a
|
||||
sibling line's service date cannot satisfy another line. Fixed loop-head qualifiers distinguish
|
||||
HL/NM1 roles; the same 2300/2400 names can occur under subscriber and patient parents.
|
||||
- The 271 baseline requires benefits or an AAA rejection for a traced response; it does not
|
||||
perform cross-document 270/271 correlation. Provider differences that cannot be inferred from
|
||||
the submitted document belong in a companion guide or host rule; no rendering provider is
|
||||
invented from an absent identifier.
|
||||
- Healthcare maps and sample target tables are test-only, per the phase-e scope. The 837 sample
|
||||
map is a single-claim example with service-line associations; the validator supports multiple
|
||||
claims and checks each independently. The 835 and 834 samples map multiple claim/member
|
||||
associations. These are baseline examples, not a complete licensed TR3 implementation.
|
||||
- Child-list widgets explicitly enforce their nested queries on RC.5; the generic renderer
|
||||
otherwise bypasses field locks inside the already-authorized widget action scope.
|
||||
- MASKED-user response scanning and Quick Search indexing are not available on the pinned RC.6
|
||||
test classpath. FULL and actual HIDDEN-field-lock scans run now; no simulated masking is used.
|
||||
|
||||
## Phase g implementation notes (2026-10-14)
|
||||
|
||||
- Outbound EDIFACT is ungrouped. Readers/writers preserve optional UNG/UNE groups. Ungrouped
|
||||
interchanges have a logical `edi_functional_group` tracking row, keyed by UNB reference, so
|
||||
the existing send-time deadline/overdue monitor also covers CONTRL. `groupCount` remains zero
|
||||
for those wire envelopes. GROUP_ONLY means an interchange-level CONTRL in this case.
|
||||
- Payload framing buffers one interchange (hard cap 50 MiB) before StAEDI; retained message
|
||||
segments respect `maxSegmentsPerTransaction`. It uses no filesystem. This follows the existing
|
||||
buffered storage adapters rather than the original bounded-memory streaming aspiration.
|
||||
- Retail SG labels are local guide identifiers shared by the two baseline versions, as documented
|
||||
in GUIDE-FORMAT.md; the baselines are deliberately small, not complete directory content.
|
||||
- Existing databases require the three agreement columns listed in README.md. Existing X12
|
||||
separator defaults remain unchanged; EDIFACT agreements explicitly select their outbound UNA
|
||||
characters. CONTRL action 8 is receipt-only and does not clear a functional acknowledgement deadline.
|
||||
+28
-1
@@ -1,6 +1,6 @@
|
||||
# EDI guide JSON
|
||||
|
||||
The five files in `src/main/resources/edi/guides/` are original QQQ baseline guides, not licensed
|
||||
The files in `src/main/resources/edi/guides/` are original QQQ baseline guides, not licensed
|
||||
X12/TR3 documentation or complete code sets. JSON loads into `guide.Guide` with
|
||||
`BuiltInGuideLoader.read(InputStream)`. `GuideRepository.load(id)` reconstructs the same model from
|
||||
the normalized `edi_guide*` and code-list tables.
|
||||
@@ -49,3 +49,30 @@ historical rows and their children remain addressable by id. A reverted resource
|
||||
new revision. Built-in guide writes require the loader's security key (`edi.guide.loader`), or a
|
||||
system service session. Configuration editors can receive `CUSTOM`/`IMPORTED` values of the
|
||||
`edi_guide_source` key without receiving permission to edit `BUILT_IN` rows.
|
||||
|
||||
## EDIFACT baselines
|
||||
|
||||
Set `standard` to `EDIFACT`, `transactionSet` to the UNH message type, and `version` to the
|
||||
concatenated UNH version/release (`D96A`, `D01B`). The ten bundled resources are in-house retail
|
||||
subsets with short field names and only the code values needed by the maps. They contain no
|
||||
copied directory descriptions and are not complete UNECE directories or EANCOM certifications.
|
||||
|
||||
UNH and UNT appear as actual guide segments. UNB/UNZ and UNG/UNE are checked by the envelope
|
||||
reader. UNH association codes, such as `EAN008`, remain available as component 5 of UNH02.
|
||||
EDIFACT mandatory (`M`) maps to `REQUIRED`; conditional (`C`) maps to `SITUATIONAL` with
|
||||
`condition` or rules where needed. A component uses the existing `position` plus
|
||||
`componentPosition`, for example NAD02 component 1. EDIFACT decimal values accept `.` or `,`;
|
||||
DECIMAL/UNIT inbound transforms normalize them and outbound DECIMAL uses the agreement's mark.
|
||||
|
||||
Segment groups use the existing loop tree: SG1 starts with RFF, SG2 with NAD, and the retail
|
||||
line loop SG25 starts with LIN. These are stable local guide loop labels, not a claim that every
|
||||
message/directory assigns the same published group numbers. DESADV uses SG10 (CPS) containing
|
||||
SG17 (LIN), matching carton/item associations. Map examples are `NAD[NAD01=BY]/NAD02-1`,
|
||||
`SG25/LIN/LIN03-1` and `SG25[*]/QTY/QTY01-2`. Named loops take precedence over element parsing,
|
||||
so `SG25` is a loop rather than an element of a hypothetical `SG` segment.
|
||||
|
||||
CONTRL baselines include UCI, optional UCF group responses, UCM message responses and nested
|
||||
UCS/UCD positional errors. Shared validator diagnostics are translated to EDIFACT 0085 codes;
|
||||
submitted erroneous values are not copied into the acknowledgement. `GROUP_ONLY` reports the
|
||||
interchange outcome, `ALL_TRANSACTIONS` includes accepted messages, and `ERRORS_ONLY` includes
|
||||
rejected message details. A CONTRL is never itself acknowledged. APERAK is deferred.
|
||||
+59
-2
@@ -1,4 +1,4 @@
|
||||
# Inbound maps
|
||||
# EDI maps
|
||||
|
||||
A map comprises an `edi_map` row and ordered `edi_map_rule` rows. EDI paths resolve through the
|
||||
guide's loop tree; target paths support one association level. `condition`, `defaultValue` and
|
||||
@@ -6,11 +6,18 @@ guide's loop tree; target paths support one association level. `condition`, `def
|
||||
|
||||
`transformArgs` is a JSON object with string values. Omitted transforms copy the first value.
|
||||
|
||||
An inbound `ediPath` may join several element paths with `+` (outside any `[...]` predicate); their
|
||||
values are concatenated in order. This is how a date and a time held in separate elements reach
|
||||
`DATETIME`: `G62[G6201=10]/G6202+G62[G6201=10]/G6204`. Inside a `[*]` loop path only the part after
|
||||
the anchor is split (`S5[*]/G6202+G6204`). A `condition` such as `present(G62[G6201=10]/G6202)` skips
|
||||
the rule when the elements are absent; a transform with no input otherwise fails the rule.
|
||||
|
||||
| Transform | Arguments / result |
|
||||
| --- | --- |
|
||||
| DATE | CCYYMMDD or YYMMDD; fixed 1950–2049 century window; QQQ date |
|
||||
| TIME | HHMM, HHMMSS, fractional seconds; normalized string |
|
||||
| DATETIME | Date and time values, or `time` argument; `timezone` defaults UTC |
|
||||
| DATEPART / TIMEPART | Outbound halves of a DATETIME: a QQQ timestamp (an instant, an offset timestamp, or a zone-less one read as UTC) rendered as CCYYMMDD or HHMMSS in the `timezone` argument (default UTC) |
|
||||
| DECIMAL | `scale` implied decimal places, default 0 |
|
||||
| QUALIFIER | Sibling predicate in `ediPath`, e.g. `N1[N101=ST]/N104` |
|
||||
| CODE | Map-local translation object, e.g. `{"EA":"each","CA":"case"}` |
|
||||
@@ -21,7 +28,8 @@ guide's loop tree; target paths support one association level. `condition`, `def
|
||||
| SUBSTRING | Zero-based `start` inclusive, `end` exclusive |
|
||||
| TRIM / UPPER | Whitespace trim / locale-independent uppercase |
|
||||
| LOOKUP | `table`, `key`, `result` (default id); exactly one permitted row |
|
||||
| SEQUENCE / HL | Outbound placeholders, reject inbound execution |
|
||||
| SEQUENCE | Outbound loop occurrence, starting at `start` (default 1) |
|
||||
| HL | Outbound HL01 counter, HL02 nearest parent, HL03 `code`, HL04 child indicator |
|
||||
|
||||
Supply the partner timezone explicitly for DATETIME. UNIT uses configured conversion factors/pack
|
||||
sizes; host-dependent field lookups and code-list translation can register named transforms through
|
||||
@@ -32,3 +40,52 @@ Samples reference encrypted PayloadStore objects. Preview JSON contains `values`
|
||||
`associations`; `expectedResultJson` uses the same shape. Comparisons show structured before/after
|
||||
JSON as Result lines. Preview never invokes target application. See the synthetic 850 resources
|
||||
under `src/main/resources/edi/maps` and [GUIDE-FORMAT.md](GUIDE-FORMAT.md).
|
||||
|
||||
## Outbound maps
|
||||
|
||||
OUTBOUND rules are evaluated in sequence order. EDI path prefixes create nested loop scopes;
|
||||
the directly mapped QQQ association drives each occurrence. Nested QQQ paths such as
|
||||
`cartons.items.sku` are supported and fetched recursively by the worker. Constants within a
|
||||
loop can carry an association field as `qqqPath` to identify their scope. See the 856 samples
|
||||
for Shipment/Order/Pack/Item loops and parent numbering.
|
||||
|
||||
DATE/TIME format QQQ values as X12 dates/times; DECIMAL multiplies by the implied `scale`.
|
||||
Other registered transforms operate on the source value. Conditions use the existing EDI rule
|
||||
language over segments already emitted. A missing required mapping input fails mapping;
|
||||
structurally invalid mapped output opens VALIDATION and is never sent. ST/SE or UNH/UNT are generated by
|
||||
the mapper; the worker allocates controls. Component-target paths use `-`, for example
|
||||
`LIN03-1` and `LIN03-2`, and retain components separately so the EDIFACT writer can escape data.
|
||||
|
||||
## Warehouse and logistics samples
|
||||
|
||||
Phase d adds baseline guides and sample maps for four documents; test sources define the synthetic
|
||||
`sample_wms_*`, `sample_shipment_status` and `sample_load*` tables. Maps with a second direction use
|
||||
an `-in` or `-out` file suffix.
|
||||
|
||||
| File | Direction | Target | Business key |
|
||||
| --- | --- | --- | --- |
|
||||
| `940-*-sample.json` | INBOUND | `sample_wms_order` + `lines` | W0502 (secondary W0503) |
|
||||
| `945-*-sample.json` / `945-in-*` | OUTBOUND / INBOUND | `sample_wms_shipment` + `lines` | W0602 |
|
||||
| `214-*-sample.json` | INBOUND | `sample_shipment_status` (flat, first LX event) | B1002 (secondary B1001) |
|
||||
| `204-*-sample.json` / `204-out-*` | INBOUND / OUTBOUND | `sample_load` + `stops` | B204 |
|
||||
|
||||
Order dates and appointments arrive as `DATETIME` from a qualified G62 (`G62[G6201=10]/...`) or from
|
||||
the stop's G62 and leave as `DATEPART` plus `TIMEPART`. The 204 stop address is read from the nested
|
||||
loop (`S5[*]/S5N1/N102`; the guide gives the stop-level N1 loop its own id, `S5N1`, so it does not
|
||||
collide with the header N1 loop) and the stop sequence is mapped from S501. A 214 can carry several LX
|
||||
events; the sample map records the first (`LX[1]/...`) and a host that wants all of them maps an
|
||||
association instead. Optional outbound qualifiers such as MAN01 and L1102 are constants, so the
|
||||
sample source rows must carry the value they qualify (the carton SSCC, the bill of lading).
|
||||
|
||||
## EDIFACT retail samples
|
||||
|
||||
Each of D96A and D01B includes `ORDERS-<version>-sample.json` inbound to `sample_order` and
|
||||
`lines`, and outbound `ORDRSP` from `sample_order`, `DESADV` from `sample_shipment` with
|
||||
`cartons.items`, and `INVOIC` from `sample_invoice` with `lines`. Test sources define these
|
||||
synthetic tables. ORDERS carries its business key in BGM02, date in qualified DTM01 component 2,
|
||||
buyer in qualified NAD02 component 1, and items in SG25. BGM's document codes are 220, 231, 351
|
||||
and 380 respectively. These subsets intentionally carry only the fields exercised by the samples.
|
||||
|
||||
Composite paths address values, not delimiter-joined strings. An identifier containing `+`, `:`
|
||||
or `'` remains a single value; the writer supplies release escapes. The outbound agreement owns
|
||||
UNA settings. Separate map resources per directory keep future version-specific changes isolated.
|
||||
@@ -3,6 +3,7 @@ CREATE TABLE edi_agreement (
|
||||
component_separator VARCHAR(255),
|
||||
control_number_scope VARCHAR(255),
|
||||
create_date TIMESTAMP,
|
||||
decimal_mark VARCHAR(255),
|
||||
duplicate_window_days INTEGER,
|
||||
element_separator VARCHAR(255),
|
||||
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
@@ -19,11 +20,13 @@ CREATE TABLE edi_agreement (
|
||||
outbound_transport_id INTEGER,
|
||||
partner_id INTEGER,
|
||||
partner_identity_id INTEGER,
|
||||
release_character VARCHAR(255),
|
||||
repetition_separator VARCHAR(255),
|
||||
request_ta1 BOOLEAN,
|
||||
segment_terminator VARCHAR(255),
|
||||
send_ta1 VARCHAR(255),
|
||||
standard VARCHAR(255),
|
||||
syntax_identifier VARCHAR(255),
|
||||
usage_indicator VARCHAR(255),
|
||||
UNIQUE (local_identity_id, partner_identity_id, standard, usage_indicator)
|
||||
);
|
||||
@@ -55,6 +58,31 @@ CREATE TABLE edi_agreement_document (
|
||||
UNIQUE (agreement_id, direction, transaction_set, version)
|
||||
);
|
||||
|
||||
CREATE TABLE edi_as2_mdn (
|
||||
create_date TIMESTAMP,
|
||||
delivered_at TIMESTAMP,
|
||||
delivery_attempts INTEGER,
|
||||
delivery_status VARCHAR(20),
|
||||
delivery_url VARCHAR(1000),
|
||||
direction VARCHAR(20),
|
||||
disposition VARCHAR(255),
|
||||
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
inbox_id INTEGER,
|
||||
message_id VARCHAR(255),
|
||||
mic_algorithm VARCHAR(50),
|
||||
mic_value VARCHAR(255),
|
||||
modify_date TIMESTAMP,
|
||||
next_attempt_at TIMESTAMP,
|
||||
original_message_id VARCHAR(255),
|
||||
outbound_message_id INTEGER,
|
||||
partner_id INTEGER,
|
||||
payload_ref VARCHAR(255),
|
||||
received_at TIMESTAMP,
|
||||
signature_valid BOOLEAN,
|
||||
signed BOOLEAN,
|
||||
transport_id INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE edi_certificate (
|
||||
alias VARCHAR(255),
|
||||
certificate_pem VARCHAR(10000),
|
||||
@@ -299,6 +327,7 @@ CREATE TABLE edi_interchange (
|
||||
interchange_date TIMESTAMP,
|
||||
interchange_version VARCHAR(255),
|
||||
modify_date TIMESTAMP,
|
||||
original_interchange_id INTEGER,
|
||||
outbound_message_id INTEGER,
|
||||
partner_id INTEGER,
|
||||
payload_ref VARCHAR(255),
|
||||
@@ -387,7 +416,8 @@ CREATE TABLE edi_outbound_message (
|
||||
size_bytes BIGINT,
|
||||
status VARCHAR(255),
|
||||
transport_id INTEGER,
|
||||
transport_message_id VARCHAR(255)
|
||||
transport_message_id VARCHAR(255),
|
||||
transport_mic VARCHAR(255)
|
||||
);
|
||||
|
||||
CREATE TABLE edi_outbound_request (
|
||||
@@ -402,7 +432,8 @@ CREATE TABLE edi_outbound_request (
|
||||
next_attempt_at TIMESTAMP,
|
||||
source_record_id VARCHAR(255),
|
||||
source_table_name VARCHAR(255),
|
||||
status VARCHAR(255)
|
||||
status VARCHAR(255),
|
||||
UNIQUE (agreement_document_id, event_id)
|
||||
);
|
||||
|
||||
CREATE TABLE edi_partner (
|
||||
@@ -534,5 +565,6 @@ CREATE TABLE edi_transport (
|
||||
sign_algorithm VARCHAR(255),
|
||||
type VARCHAR(255),
|
||||
van_name VARCHAR(255),
|
||||
weak_crypto BOOLEAN,
|
||||
UNIQUE (name)
|
||||
);
|
||||
@@ -68,6 +68,10 @@
|
||||
|
||||
|
||||
<dependencies>
|
||||
<dependency><groupId>com.kingsrook.qqq</groupId><artifactId>qqq-frontend-next</artifactId><version>1.0.0-RC.14</version><scope>test</scope></dependency>
|
||||
<dependency><groupId>org.bouncycastle</groupId><artifactId>bcpkix-jdk18on</artifactId><version>1.85</version></dependency>
|
||||
<dependency><groupId>com.helger.as2</groupId><artifactId>as2-lib</artifactId><version>5.1.8</version></dependency>
|
||||
<dependency><groupId>com.kingsrook.qqq</groupId><artifactId>qqq-backend-module-filesystem</artifactId></dependency>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-esb</artifactId>
|
||||
@@ -122,7 +126,36 @@
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<resources>
|
||||
<!-- help bundles carry the artifact version (QQQ help bundle provenance) -->
|
||||
<resource>
|
||||
<directory>src/main/resources</directory>
|
||||
<filtering>true</filtering>
|
||||
<includes>
|
||||
<include>help/*.yaml</include>
|
||||
<include>qbit-edi.properties</include>
|
||||
</includes>
|
||||
</resource>
|
||||
<resource>
|
||||
<directory>src/main/resources</directory>
|
||||
<filtering>false</filtering>
|
||||
<excludes>
|
||||
<exclude>help/*.yaml</exclude>
|
||||
<exclude>qbit-edi.properties</exclude>
|
||||
</excludes>
|
||||
</resource>
|
||||
</resources>
|
||||
<plugins>
|
||||
<!-- the artifact version, for the help bundle version test -->
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-surefire-plugin</artifactId>
|
||||
<configuration>
|
||||
<systemPropertyVariables combine.children="append">
|
||||
<qbit.artifactVersion>${project.version}</qbit.artifactVersion>
|
||||
</systemPropertyVariables>
|
||||
</configuration>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-checkstyle-plugin</artifactId>
|
||||
@@ -209,7 +242,7 @@
|
||||
<activeByDefault>true</activeByDefault>
|
||||
</activation>
|
||||
<properties>
|
||||
<qqq.snapshot.version>4.1.0-RC.5</qqq.snapshot.version>
|
||||
<qqq.snapshot.version>4.1.0-RC.15</qqq.snapshot.version>
|
||||
</properties>
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
|
||||
@@ -22,6 +22,7 @@ package com.kingsrook.qbits.edi;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitConfig;
|
||||
|
||||
@@ -35,6 +36,45 @@ public class EdiQBitConfig implements QBitConfig
|
||||
public static final String TABLE_NAME_PREFIX = "edi_";
|
||||
public static final String PERMISSION_BASE_NAME = "edi";
|
||||
|
||||
private final java.util.Set<String> sendAsEdiTables = new java.util.LinkedHashSet<>();
|
||||
|
||||
/*******************************************************************************
|
||||
** Opt a source table into the manual Send As EDI action.
|
||||
*******************************************************************************/
|
||||
public EdiQBitConfig withSendAsEdiTable(String tableName)
|
||||
{
|
||||
sendAsEdiTables.add(tableName);
|
||||
return (this);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Explicitly opted-in manual source tables.
|
||||
*******************************************************************************/
|
||||
public java.util.Set<String> getSendAsEdiTables()
|
||||
{
|
||||
return (java.util.Set.copyOf(sendAsEdiTables));
|
||||
}
|
||||
|
||||
private com.kingsrook.qbits.edi.guide.ExternalCodeSetProvider externalCodeSetProvider;
|
||||
|
||||
/*******************************************************************************
|
||||
** Optional host provider for licensed external code sets.
|
||||
*******************************************************************************/
|
||||
@JsonIgnore
|
||||
public com.kingsrook.qbits.edi.guide.ExternalCodeSetProvider getExternalCodeSetProvider()
|
||||
{
|
||||
return (externalCodeSetProvider);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Register a provider; null retains format-only validation.
|
||||
*******************************************************************************/
|
||||
public EdiQBitConfig withExternalCodeSetProvider(com.kingsrook.qbits.edi.guide.ExternalCodeSetProvider value)
|
||||
{
|
||||
externalCodeSetProvider = value;
|
||||
return (this);
|
||||
}
|
||||
|
||||
private String payloadBucketAlias = "edi";
|
||||
private java.util.function.Supplier<com.kingsrook.qqq.backend.core.model.session.QSession> serviceSessionSupplier = null;
|
||||
private java.time.Clock clock = java.time.Clock.systemUTC();
|
||||
@@ -48,6 +88,8 @@ public class EdiQBitConfig implements QBitConfig
|
||||
private String appName = "edi";
|
||||
private String appLabel = "EDI";
|
||||
private String parentAppName;
|
||||
private String publicBaseUrl;
|
||||
private Integer inboundDelayedAfterMinutes = 60;
|
||||
|
||||
|
||||
|
||||
@@ -73,6 +115,10 @@ public class EdiQBitConfig implements QBitConfig
|
||||
{
|
||||
errors.add("secretsResolver and partnerLocks are required");
|
||||
}
|
||||
if(inboundDelayedAfterMinutes == null || inboundDelayedAfterMinutes < 1)
|
||||
{
|
||||
errors.add("inboundDelayedAfterMinutes must be at least 1");
|
||||
}
|
||||
if(appName == null || appName.isBlank())
|
||||
{
|
||||
errors.add("appName is required");
|
||||
@@ -211,6 +257,7 @@ public class EdiQBitConfig implements QBitConfig
|
||||
/*******************************************************************************
|
||||
** Get payloadStore.
|
||||
*******************************************************************************/
|
||||
@JsonIgnore
|
||||
public String getPayloadStore()
|
||||
{
|
||||
return (payloadStore);
|
||||
@@ -228,6 +275,7 @@ public class EdiQBitConfig implements QBitConfig
|
||||
/*******************************************************************************
|
||||
** Get secretsResolver.
|
||||
*******************************************************************************/
|
||||
@JsonIgnore
|
||||
public com.kingsrook.qbits.edi.security.EdiSecrets getSecretsResolver()
|
||||
{
|
||||
return (secretsResolver);
|
||||
@@ -279,6 +327,7 @@ public class EdiQBitConfig implements QBitConfig
|
||||
/*******************************************************************************
|
||||
** Get serviceSessionSupplier.
|
||||
*******************************************************************************/
|
||||
@JsonIgnore
|
||||
public java.util.function.Supplier<com.kingsrook.qqq.backend.core.model.session.QSession> getServiceSessionSupplier()
|
||||
{
|
||||
return (serviceSessionSupplier);
|
||||
@@ -296,6 +345,7 @@ public class EdiQBitConfig implements QBitConfig
|
||||
/*******************************************************************************
|
||||
** Get clock.
|
||||
*******************************************************************************/
|
||||
@JsonIgnore
|
||||
public java.time.Clock getClock()
|
||||
{
|
||||
return (clock);
|
||||
@@ -326,4 +376,41 @@ public class EdiQBitConfig implements QBitConfig
|
||||
esbDestinationName = value;
|
||||
return (this);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Public base URL of this deployment (for example https://edi.example.com),
|
||||
** used to build the asynchronous AS2 receipt address
|
||||
** {base}/qqq/hooks/edi/as2/{transport}/mdn.
|
||||
*******************************************************************************/
|
||||
public String getPublicBaseUrl()
|
||||
{
|
||||
return (publicBaseUrl);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Set the public base URL.
|
||||
*******************************************************************************/
|
||||
public EdiQBitConfig withPublicBaseUrl(String value)
|
||||
{
|
||||
publicBaseUrl = value;
|
||||
return (this);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** How old (in minutes) the oldest unprocessed inbound document may get before
|
||||
** the EDI home says "Inbound documents are delayed". Default 60.
|
||||
*******************************************************************************/
|
||||
public Integer getInboundDelayedAfterMinutes()
|
||||
{
|
||||
return (inboundDelayedAfterMinutes);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Set the inbound delay threshold in minutes.
|
||||
*******************************************************************************/
|
||||
public EdiQBitConfig withInboundDelayedAfterMinutes(Integer value)
|
||||
{
|
||||
inboundDelayedAfterMinutes = value;
|
||||
return (this);
|
||||
}
|
||||
}
|
||||
@@ -21,9 +21,13 @@
|
||||
package com.kingsrook.qbits.edi;
|
||||
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.util.Properties;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerMultiOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.help.bundles.HelpBundles;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitMetaDataProducer;
|
||||
|
||||
@@ -36,13 +40,41 @@ public class EdiQBitProducer implements QBitMetaDataProducer<EdiQBitConfig>
|
||||
{
|
||||
public static final String GROUP_ID = "com.kingsrook.qbits";
|
||||
public static final String ARTIFACT_ID = "qbit-edi";
|
||||
public static final String VERSION = "0.1.0";
|
||||
public static final String VERSION = loadVersion();
|
||||
|
||||
private EdiQBitConfig qBitConfig;
|
||||
private String namespace;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Read the artifact version from the Maven-filtered resource.
|
||||
*******************************************************************************/
|
||||
private static String loadVersion()
|
||||
{
|
||||
try(InputStream inputStream = EdiQBitProducer.class.getClassLoader().getResourceAsStream("qbit-edi.properties"))
|
||||
{
|
||||
if(inputStream == null)
|
||||
{
|
||||
throw new IllegalStateException("Missing qbit-edi.properties; build with Maven to supply the artifact version");
|
||||
}
|
||||
Properties properties = new Properties();
|
||||
properties.load(inputStream);
|
||||
String version = properties.getProperty("version");
|
||||
if(version == null || version.isBlank() || version.contains("${"))
|
||||
{
|
||||
throw new IllegalStateException("Missing or unfiltered version in qbit-edi.properties; build with Maven");
|
||||
}
|
||||
return (version);
|
||||
}
|
||||
catch(IOException e)
|
||||
{
|
||||
throw new IllegalStateException("Could not read the artifact version from qbit-edi.properties", e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent configuration setter.
|
||||
*******************************************************************************/
|
||||
@@ -130,6 +162,33 @@ public class EdiQBitProducer implements QBitMetaDataProducer<EdiQBitConfig>
|
||||
MetaDataProducerMultiOutput output = QBitMetaDataProducer.super.produce(instance);
|
||||
output.addSelfToInstance(instance);
|
||||
EdiRuntime.install(instance, qBitConfig);
|
||||
// Registration is metadata-only; core startup sync skips hosts without helpContent.
|
||||
HelpBundles.register(instance, "help/qbit-edi.yaml");
|
||||
// The app name is configurable; its bundle key must follow the installed app.
|
||||
HelpBundles.of(instance).getBundles().getLast().getEntries().stream()
|
||||
.filter(entry -> "app:edi;slot:help".equals(entry.getKey()))
|
||||
.forEach(entry -> entry.setKey("app:" + qBitConfig.getAppName() + ";slot:help"));
|
||||
if(instance.getTable("helpContent") == null)
|
||||
{
|
||||
com.kingsrook.qbits.edi.metadata.EdiHelp.apply(instance, qBitConfig.getAppName());
|
||||
}
|
||||
instance.withSupplementalMetaData(new com.kingsrook.qbits.edi.metadata.EdiRecordLabels());
|
||||
instance.withRecordChangeListener(new com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference(com.kingsrook.qbits.edi.flow.OutboundEnqueue.class));
|
||||
instance.withSupplementalMetaData(new com.kingsrook.qbits.edi.transport.EdiTransportPluginsMetaData()
|
||||
.withPlugin(new com.kingsrook.qbits.edi.transport.gateway.GatewayTransport())
|
||||
.withPlugin(new com.kingsrook.qbits.edi.transport.HttpsTransport()).withPlugin(new com.kingsrook.qbits.edi.transport.SftpTransport())
|
||||
.withPlugin(new com.kingsrook.qbits.edi.as2.As2Transport()).withPlugin(new com.kingsrook.qbits.edi.transport.UnavailableTransport("EMAIL")));
|
||||
try
|
||||
{
|
||||
com.kingsrook.qbits.edi.api.EdiApi.register(instance);
|
||||
com.kingsrook.qqq.middleware.javalin.QJavalinMetaData.ofOrWithNew(instance).withAdditionalRouteProviderReference(
|
||||
new com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference(com.kingsrook.qbits.edi.transport.HttpsRouteProvider.class))
|
||||
.withAdditionalRouteProviderReference(new com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference(com.kingsrook.qbits.edi.as2.As2RouteProvider.class));
|
||||
}
|
||||
catch(NoClassDefFoundError optionalMiddlewareAbsent)
|
||||
{
|
||||
// Machine hooks are installed only when optional Javalin is present.
|
||||
}
|
||||
return (new MetaDataProducerMultiOutput());
|
||||
}
|
||||
}
|
||||
@@ -53,7 +53,7 @@ public final class EdiRuntime
|
||||
private static final Map<QInstance, EdiRuntime> INSTANCES = Collections.synchronizedMap(new WeakHashMap<>());
|
||||
private final X12EnvelopeReader reader;
|
||||
private final X12Writer writer = new X12Writer();
|
||||
private final GuideValidator validator = new GuideValidator();
|
||||
private final GuideValidator validator;
|
||||
private final BuiltInGuideLoader guides = new BuiltInGuideLoader();
|
||||
private final com.kingsrook.qbits.edi.storage.PayloadStore payloads;
|
||||
private final EdiQBitConfig config;
|
||||
@@ -64,6 +64,7 @@ public final class EdiRuntime
|
||||
private final Ack999Builder ack999 = new Ack999Builder();
|
||||
private final TA1Builder ta1 = new TA1Builder();
|
||||
private final InboundAckReader acknowledgements = new InboundAckReader();
|
||||
private final com.kingsrook.qbits.edi.flow.OutboundSourceTables outboundSources = new com.kingsrook.qbits.edi.flow.OutboundSourceTables();
|
||||
|
||||
/*******************************************************************************
|
||||
** Construct services without resolving keys until content is stored or opened.
|
||||
@@ -71,6 +72,7 @@ public final class EdiRuntime
|
||||
private EdiRuntime(QInstance instance, EdiQBitConfig config)
|
||||
{
|
||||
this.config = config;
|
||||
validator = new GuideValidator(config);
|
||||
reader = new X12EnvelopeReader(config.getMaxSegmentsPerTransaction());
|
||||
payloads = "OBJECT_STORAGE".equals(config.getPayloadStore())
|
||||
? new com.kingsrook.qbits.edi.storage.ObjectStoragePayloadStore(new PayloadCrypto(config.getSecretsResolver()), config.getPayloadBucketAlias(), config.getClock())
|
||||
@@ -94,6 +96,22 @@ public final class EdiRuntime
|
||||
return (Objects.requireNonNull(INSTANCES.get(QContext.getQInstance()), "EDI runtime has not been installed"));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Services for the current QQQ context, or null when none are installed.
|
||||
*******************************************************************************/
|
||||
public static EdiRuntime find()
|
||||
{
|
||||
return (QContext.getQInstance() == null ? null : INSTANCES.get(QContext.getQInstance()));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Cached source tables that have active RECORD_CHANGE outbound definitions.
|
||||
*******************************************************************************/
|
||||
public com.kingsrook.qbits.edi.flow.OutboundSourceTables outboundSources()
|
||||
{
|
||||
return (outboundSources);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Call at host startup after DDL, with the service session established.
|
||||
*******************************************************************************/
|
||||
@@ -103,7 +121,9 @@ public final class EdiRuntime
|
||||
{
|
||||
LOG.warn("EDI database payload storage is enabled; object storage is recommended for production");
|
||||
}
|
||||
return (guides.load());
|
||||
var ids = guides.load();
|
||||
com.kingsrook.qbits.edi.processes.OutboundProcesses.installSchedules();
|
||||
return (ids);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
|
||||
@@ -57,7 +57,7 @@ final class FunctionalAckBuilder
|
||||
for(X12TransactionSet transaction : group.transactions())
|
||||
{
|
||||
List<ValidationFinding> errors = findings.getOrDefault(transaction.st().value(2), List.of());
|
||||
boolean rejected = !transaction.findings().isEmpty() || errors.stream().anyMatch(f -> "ERROR".equals(f.severity()));
|
||||
boolean rejected = !transaction.findings().isEmpty() || errors.stream().anyMatch(f -> "ERROR".equals(f.severity()) && f.snipLevel() <= 2);
|
||||
String status = rejected ? "R" : errors.isEmpty() ? "A" : "E";
|
||||
if(!rejected)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.edi.api;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.fasterxml.jackson.databind.DeserializationFeature;
|
||||
import com.fasterxml.jackson.databind.MapperFeature;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.cfg.CoercionAction;
|
||||
import com.fasterxml.jackson.databind.cfg.CoercionInputShape;
|
||||
import com.fasterxml.jackson.databind.json.JsonMapper;
|
||||
import com.fasterxml.jackson.databind.type.LogicalType;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QBadRequestException;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.BasicOperation;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.BasicResponse;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.extensions.AbstractJsonEndpointSpec;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.extensions.MiddlewareSpecExtension;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.extensions.MiddlewareSpecExtensionsMetaData;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.v1.responses.BasicErrorResponseV1;
|
||||
import com.kingsrook.qqq.openapi.model.HttpMethod;
|
||||
import com.kingsrook.qqq.openapi.model.In;
|
||||
import com.kingsrook.qqq.openapi.model.Parameter;
|
||||
import com.kingsrook.qqq.openapi.model.Schema;
|
||||
import com.kingsrook.qqq.openapi.model.Type;
|
||||
import io.javalin.http.HttpStatus;
|
||||
|
||||
/*******************************************************************************
|
||||
** The EDI 1.1.0 UI API: four spec'd operations in the default qbit namespace.
|
||||
*******************************************************************************/
|
||||
public final class EdiApi
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Static registration only.
|
||||
*******************************************************************************/
|
||||
private EdiApi()
|
||||
{
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Install with optional Javalin from the qbit producer.
|
||||
*******************************************************************************/
|
||||
public static void register(QInstance instance)
|
||||
{
|
||||
MiddlewareSpecExtensionsMetaData.ofOrWithNew(instance).withExtension(extension());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Shared descriptor for route binding and the committed contract.
|
||||
*******************************************************************************/
|
||||
public static MiddlewareSpecExtension extension()
|
||||
{
|
||||
return (new MiddlewareSpecExtension().withName("edi").withSemanticVersion("1.1.0")
|
||||
.withDescription("EDI sandbox and mapper, using the signed-in user's permissions.")
|
||||
.withSpec(GuideTreeSpec.class).withSpec(MapTargetSpec.class).withSpec(PreviewSpec.class).withSpec(ValidateSpec.class));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Common typed JSON contract; core owns authentication and error mapping.
|
||||
*******************************************************************************/
|
||||
public abstract static class EdiSpec extends AbstractJsonEndpointSpec
|
||||
{
|
||||
private final String path;
|
||||
private final String response;
|
||||
private final String request;
|
||||
private final String summary;
|
||||
|
||||
/*******************************************************************************
|
||||
** Immutable operation metadata.
|
||||
*******************************************************************************/
|
||||
protected EdiSpec(String path, String response, String request, String summary)
|
||||
{
|
||||
this.path = path;
|
||||
this.response = response;
|
||||
this.request = request;
|
||||
this.summary = summary;
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Publish the method, path and permission expectations.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public BasicOperation defineBasicOperation()
|
||||
{
|
||||
return (new BasicOperation().withPath("/qbit/edi" + path).withHttpMethod(request == null ? HttpMethod.GET : HttpMethod.POST)
|
||||
.withShortSummary(summary).withLongDescription(summary + ". Uses the caller session and table read permissions. Preview also requires edi.map.preview. Rule writes use core table CRUD."));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Declare positive integer record identifiers.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<Parameter> defineRequestParameters()
|
||||
{
|
||||
String name = path.contains("{guideId}") ? "guideId" : path.contains("{mapId}") ? "mapId" : null;
|
||||
return (name == null ? List.of() : List.of(new Parameter().withName(name).withIn(In.PATH).withRequired(true)
|
||||
.withDescription("Positive record identifier.").withSchema(new Schema().withType(Type.INTEGER))));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** All schemas use the Edi prefix and V1 suffix.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Map<String, Schema> defineComponentSchemas()
|
||||
{
|
||||
return (path.startsWith("/guides/") ? EdiSchemas.all() : Map.of());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Response component name.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected String getResponseSchemaName()
|
||||
{
|
||||
return (response);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Optional request body contract.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected Schema defineRequestBodySchema()
|
||||
{
|
||||
return (request == null ? null : EdiSchemas.ref(request));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Core middleware errors are explicitly documented.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<BasicResponse> defineAdditionalBasicResponses()
|
||||
{
|
||||
return (List.of(new BasicResponse(HttpStatus.BAD_REQUEST, "Invalid request or sample exceeds 5 MiB", BasicErrorResponseV1.class.getSimpleName()),
|
||||
new BasicResponse(HttpStatus.UNAUTHORIZED, "Sign in required", BasicErrorResponseV1.class.getSimpleName()),
|
||||
new BasicResponse(HttpStatus.FORBIDDEN, "Permission denied", BasicErrorResponseV1.class.getSimpleName()),
|
||||
new BasicResponse(HttpStatus.NOT_FOUND, "Missing or invisible record", BasicErrorResponseV1.class.getSimpleName()),
|
||||
new BasicResponse(HttpStatus.CONTENT_TOO_LARGE, "Host HTTP request size limit exceeded", BasicErrorResponseV1.class.getSimpleName())));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Reject malformed or overflowing identifiers without echoing request content.
|
||||
*******************************************************************************/
|
||||
protected Integer id(JsonInput input, String name) throws QBadRequestException
|
||||
{
|
||||
try
|
||||
{
|
||||
int value = Integer.parseInt(input.getPathParam(name));
|
||||
if(value < 1)
|
||||
{
|
||||
throw (new NumberFormatException());
|
||||
}
|
||||
return (value);
|
||||
}
|
||||
catch(NumberFormatException e)
|
||||
{
|
||||
throw (new QBadRequestException("A positive integer identifier is required"));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Strict scalar typing prevents coercion of unsaved edits and sample identifiers.
|
||||
*******************************************************************************/
|
||||
protected <T> T body(JsonInput input, Class<T> type) throws QBadRequestException
|
||||
{
|
||||
ObjectMapper mapper = JsonMapper.builder().disable(MapperFeature.ALLOW_COERCION_OF_SCALARS)
|
||||
.disable(DeserializationFeature.ACCEPT_FLOAT_AS_INT).build();
|
||||
mapper.coercionConfigFor(LogicalType.Textual).setCoercion(CoercionInputShape.Integer, CoercionAction.Fail)
|
||||
.setCoercion(CoercionInputShape.Float, CoercionAction.Fail).setCoercion(CoercionInputShape.Boolean, CoercionAction.Fail);
|
||||
try
|
||||
{
|
||||
if(input.getBody() == null)
|
||||
{
|
||||
throw (new IllegalArgumentException("Object body required"));
|
||||
}
|
||||
return (mapper.convertValue(input.getBody(), type));
|
||||
}
|
||||
catch(IllegalArgumentException e)
|
||||
{
|
||||
throw (new QBadRequestException("Invalid body; check property names and types"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Read the guide tree.
|
||||
*******************************************************************************/
|
||||
public static class GuideTreeSpec extends EdiSpec
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Public constructor for core spec discovery.
|
||||
*******************************************************************************/
|
||||
public GuideTreeSpec()
|
||||
{
|
||||
super("/guides/{guideId}/tree", "EdiGuideTreeV1", null, "Read the guide tree");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Execute with the authenticated caller.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected Object handle(JsonInput input) throws Exception
|
||||
{
|
||||
return (new EdiApiService().tree(id(input, "guideId")));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Read target metadata and map rules.
|
||||
*******************************************************************************/
|
||||
public static class MapTargetSpec extends EdiSpec
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Public constructor for core spec discovery.
|
||||
*******************************************************************************/
|
||||
public MapTargetSpec()
|
||||
{
|
||||
super("/maps/{mapId}/target", "EdiTargetV1", null, "Read target metadata and map rules");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Execute with the authenticated caller.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected Object handle(JsonInput input) throws Exception
|
||||
{
|
||||
return (new EdiApiService().target(id(input, "mapId")));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Preview an encrypted sample with optional unsaved rules.
|
||||
*******************************************************************************/
|
||||
public static class PreviewSpec extends EdiSpec
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Public constructor for core spec discovery.
|
||||
*******************************************************************************/
|
||||
public PreviewSpec()
|
||||
{
|
||||
super("/sandbox/preview", "EdiPreviewV1", "EdiPreviewRequestV1", "Preview an encrypted sample with optional unsaved rules");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Execute with the authenticated caller.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected Object handle(JsonInput input) throws Exception
|
||||
{
|
||||
return (new EdiApiService().preview(body(input, EdiSchemas.EdiPreviewRequestV1.class)));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Validate unsaved map rules.
|
||||
*******************************************************************************/
|
||||
public static class ValidateSpec extends EdiSpec
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Public constructor for core spec discovery.
|
||||
*******************************************************************************/
|
||||
public ValidateSpec()
|
||||
{
|
||||
super("/maps/{mapId}/validate", "EdiValidationV1", "EdiValidateRequestV1", "Validate unsaved map rules");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Execute with the authenticated caller.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected Object handle(JsonInput input) throws Exception
|
||||
{
|
||||
return (new EdiApiService().validate(id(input, "mapId"), body(input, EdiSchemas.EdiValidateRequestV1.class)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.edi.api;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.Comparator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.kingsrook.qbits.edi.EdiRuntime;
|
||||
import com.kingsrook.qbits.edi.api.EdiSchemas.EdiAssociationV1;
|
||||
import com.kingsrook.qbits.edi.api.EdiSchemas.EdiElementV1;
|
||||
import com.kingsrook.qbits.edi.api.EdiSchemas.EdiFieldV1;
|
||||
import com.kingsrook.qbits.edi.api.EdiSchemas.EdiGuideTreeV1;
|
||||
import com.kingsrook.qbits.edi.api.EdiSchemas.EdiLoopV1;
|
||||
import com.kingsrook.qbits.edi.api.EdiSchemas.EdiPreviewRequestV1;
|
||||
import com.kingsrook.qbits.edi.api.EdiSchemas.EdiPreviewV1;
|
||||
import com.kingsrook.qbits.edi.api.EdiSchemas.EdiRuleErrorsV1;
|
||||
import com.kingsrook.qbits.edi.api.EdiSchemas.EdiRuleV1;
|
||||
import com.kingsrook.qbits.edi.api.EdiSchemas.EdiSegmentV1;
|
||||
import com.kingsrook.qbits.edi.api.EdiSchemas.EdiTargetV1;
|
||||
import com.kingsrook.qbits.edi.api.EdiSchemas.EdiTransformV1;
|
||||
import com.kingsrook.qbits.edi.api.EdiSchemas.EdiValidateRequestV1;
|
||||
import com.kingsrook.qbits.edi.api.EdiSchemas.EdiValidationV1;
|
||||
import com.kingsrook.qbits.edi.map.MapPreviewService;
|
||||
import com.kingsrook.qbits.edi.map.MapRuleValidator;
|
||||
import com.kingsrook.qbits.edi.storage.EdiRecords;
|
||||
import com.kingsrook.qbits.edi.storage.PayloadKind;
|
||||
import com.kingsrook.qbits.edi.support.SystemWrites;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionEnforcement;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.TablePermissionSubType;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QBadRequestException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QNotFoundException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
|
||||
/*******************************************************************************
|
||||
** Caller-authorized projections and sandbox orchestration, using normal QQQ actions.
|
||||
*******************************************************************************/
|
||||
public class EdiApiService
|
||||
{
|
||||
public static final int MAX_SAMPLE_BYTES = 5 * 1024 * 1024;
|
||||
private static final ObjectMapper JSON = new ObjectMapper().findAndRegisterModules();
|
||||
|
||||
/*******************************************************************************
|
||||
** Enforced reads preserve table permissions, field locks and partner record locks.
|
||||
*******************************************************************************/
|
||||
private static List<QRecord> query(String table, QQueryFilter filter) throws Exception
|
||||
{
|
||||
return (EdiRecords.queryEnforced(table, filter));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** A missing or invisible record has the same response.
|
||||
*******************************************************************************/
|
||||
private static QRecord row(String table, Integer id) throws Exception
|
||||
{
|
||||
if(id == null || id < 1)
|
||||
{
|
||||
throw (new QBadRequestException("A positive identifier is required"));
|
||||
}
|
||||
var records = query(table, EdiRecords.equal("id", id));
|
||||
if(records.isEmpty())
|
||||
{
|
||||
throw (new QNotFoundException("Not found"));
|
||||
}
|
||||
return (records.getFirst());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Read a guide as a nested tree, preserving duplicate loop ids in separate parents.
|
||||
*******************************************************************************/
|
||||
public EdiGuideTreeV1 tree(Integer id) throws Exception
|
||||
{
|
||||
row("edi_guide", id);
|
||||
List<EdiLoopV1> roots = new ArrayList<>();
|
||||
List<EdiSegmentV1> rootSegments = new ArrayList<>();
|
||||
Map<String, EdiLoopV1> loops = new LinkedHashMap<>();
|
||||
Map<String, String> latestPaths = new LinkedHashMap<>();
|
||||
var segments = query("edi_guide_segment", EdiRecords.equal("guideId", id));
|
||||
segments.sort(Comparator.comparing(r -> r.getValueInteger("sequence")));
|
||||
for(var segment : segments)
|
||||
{
|
||||
List<EdiElementV1> elements = new ArrayList<>();
|
||||
var rows = query("edi_guide_element", EdiRecords.equal("guideSegmentId", segment.getValue("id")));
|
||||
rows.sort(Comparator.comparing((QRecord r) -> r.getValueInteger("position")).thenComparing(r -> Objects.requireNonNullElse(r.getValueInteger("componentPosition"), 0)));
|
||||
for(var element : rows)
|
||||
{
|
||||
List<String> codes = List.of();
|
||||
Integer codeList = element.getValueInteger("codeListId");
|
||||
if(codeList != null)
|
||||
{
|
||||
row("edi_code_list", codeList);
|
||||
codes = query("edi_code_list_value", EdiRecords.equal("codeListId", codeList)
|
||||
.withCriteria("isActive", com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator.EQUALS, true))
|
||||
.stream().map(r -> r.getValueString("code")).sorted().limit(200).toList();
|
||||
}
|
||||
elements.add(new EdiElementV1(element.getValueInteger("position"), element.getValueInteger("componentPosition"),
|
||||
element.getValueString("elementId"), element.getValueString("name"), element.getValueString("dataType"),
|
||||
element.getValueInteger("minLength"), element.getValueInteger("maxLength"), element.getValueString("usage"), codeList, codes));
|
||||
}
|
||||
EdiSegmentV1 node = new EdiSegmentV1(segment.getValueString("segmentId"), segment.getValueString("position"), segment.getValueString("usage"),
|
||||
segment.getValueInteger("minUse"), segment.getValueInteger("maxUse"), segment.getValueString("name"), elements);
|
||||
String loopId = segment.getValueString("loopId");
|
||||
if(loopId == null || loopId.isBlank())
|
||||
{
|
||||
rootSegments.add(node);
|
||||
}
|
||||
else
|
||||
{
|
||||
String parent = latestPaths.get(segment.getValueString("parentLoopId"));
|
||||
String path = (parent == null ? "" : parent + "/") + loopId;
|
||||
EdiLoopV1 loop = loops.get(path);
|
||||
if(loop == null)
|
||||
{
|
||||
loop = new EdiLoopV1(loopId, segment.getValueInteger("loopMaxRepeat"), new ArrayList<>(), new ArrayList<>());
|
||||
loops.put(path, loop);
|
||||
(parent == null ? roots : loops.get(parent).loops()).add(loop);
|
||||
}
|
||||
latestPaths.put(loopId, path);
|
||||
loop.segments().add(node);
|
||||
}
|
||||
}
|
||||
return (new EdiGuideTreeV1(roots, rootSegments));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Return visible target metadata, rules and the same registry used by mapping.
|
||||
*******************************************************************************/
|
||||
public EdiTargetV1 target(Integer id) throws Exception
|
||||
{
|
||||
QRecord map = row("edi_map", id);
|
||||
QTableMetaData table = targetTable(map);
|
||||
List<EdiAssociationV1> associations = new ArrayList<>();
|
||||
if(table.getAssociations() != null)
|
||||
{
|
||||
for(var association : table.getAssociations())
|
||||
{
|
||||
QTableMetaData child = QContext.getQInstance().getTable(association.getAssociatedTableName());
|
||||
associations.add(new EdiAssociationV1(association.getName(), child.getName(), Objects.requireNonNullElse(child.getLabel(), association.getName()), fields(child)));
|
||||
}
|
||||
}
|
||||
List<EdiRuleV1> rules = new ArrayList<>();
|
||||
for(QRecord rule : query("edi_map_rule", EdiRecords.equal("mapId", id)))
|
||||
{
|
||||
rules.add(rule(rule));
|
||||
}
|
||||
rules.sort(Comparator.comparing(r -> Objects.requireNonNullElse(r.sequence(), 0)));
|
||||
return (new EdiTargetV1(table.getName(), fields(table), associations, rules,
|
||||
EdiRuntime.current().transforms().argumentSchemas().entrySet().stream().map(e -> new EdiTransformV1(e.getKey(), e.getValue())).toList()));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Resolve the configured target without accepting a client-supplied table.
|
||||
*******************************************************************************/
|
||||
private QTableMetaData targetTable(QRecord map) throws Exception
|
||||
{
|
||||
QTableMetaData table = QContext.getQInstance().getTable(map.getValueString("tableName"));
|
||||
if(table == null)
|
||||
{
|
||||
throw (new QBadRequestException("The map target table is unavailable"));
|
||||
}
|
||||
return (table);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Table metadata is only exposed with the table's read permission.
|
||||
*******************************************************************************/
|
||||
private List<EdiFieldV1> fields(QTableMetaData table) throws Exception
|
||||
{
|
||||
QueryInput input = new QueryInput(table.getName());
|
||||
input.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
|
||||
PermissionsHelper.checkTablePermissionThrowing(input, TablePermissionSubType.READ);
|
||||
return (table.getFields().values().stream().map(f -> new EdiFieldV1(f.getName(), f.getLabel(), f.getType().name(), f.getIsRequired(), f.getPossibleValueSourceName())).toList());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Copy only documented rule fields; do not leak persistence or diagnostic fields.
|
||||
*******************************************************************************/
|
||||
private EdiRuleV1 rule(QRecord r)
|
||||
{
|
||||
return (new EdiRuleV1(r.getValueInteger("id"), r.getValueInteger("sequence"), r.getValueString("ediPath"), r.getValueString("qqqPath"),
|
||||
r.getValueString("transform"), r.getValueString("transformArgs"), r.getValueBoolean("required"), r.getValueString("condition"), r.getValueString("defaultValue")));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Convert unsaved edits to detached records; mapId is never accepted from a rule.
|
||||
*******************************************************************************/
|
||||
private List<QRecord> records(List<EdiRuleV1> rules) throws Exception
|
||||
{
|
||||
if(rules == null || rules.size() > 10000 || rules.stream().anyMatch(Objects::isNull))
|
||||
{
|
||||
throw (new QBadRequestException("Supply an array of at most 10000 rules"));
|
||||
}
|
||||
List<QRecord> result = new ArrayList<>();
|
||||
for(EdiRuleV1 rule : rules)
|
||||
{
|
||||
result.add(new QRecord().withValue("id", rule.id()).withValue("sequence", rule.sequence()).withValue("ediPath", rule.ediPath())
|
||||
.withValue("qqqPath", rule.qqqPath()).withValue("transform", rule.transform()).withValue("transformArgs", rule.transformArgs())
|
||||
.withValue("required", rule.required()).withValue("condition", rule.condition()).withValue("defaultValue", rule.defaultValue()));
|
||||
}
|
||||
return (result);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Validate all submitted rules, with stable index correlation and no persistence.
|
||||
*******************************************************************************/
|
||||
public EdiValidationV1 validate(Integer id, EdiValidateRequestV1 request) throws Exception
|
||||
{
|
||||
QRecord map = row("edi_map", id);
|
||||
targetTable(map);
|
||||
var guide = MapPreviewService.guide(map, "ORIGINAL");
|
||||
List<QRecord> rules = records(request.rules());
|
||||
List<EdiRuleErrorsV1> results = new ArrayList<>();
|
||||
for(int i = 0; i < rules.size(); i++)
|
||||
{
|
||||
QRecord rule = rules.get(i);
|
||||
results.add(new EdiRuleErrorsV1(i, rule.getValueInteger("id"), rule.getValueInteger("sequence"),
|
||||
new MapRuleValidator().validate(rule, map, guide, EdiRuntime.current().transforms())));
|
||||
}
|
||||
return (new EdiValidationV1(results));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Authorize before decoding or saving an upload; previews never change map rules.
|
||||
*******************************************************************************/
|
||||
public EdiPreviewV1 preview(EdiPreviewRequestV1 request) throws Exception
|
||||
{
|
||||
QRecord map = row("edi_map", request.mapId());
|
||||
AbstractActionInput permission = new AbstractActionInput();
|
||||
permission.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
|
||||
PermissionsHelper.checkProcessPermissionThrowing(permission, "edi_map_preview");
|
||||
String revision = Objects.requireNonNullElse(request.guideRevision(), "CURRENT");
|
||||
if(!List.of("CURRENT", "ORIGINAL").contains(revision))
|
||||
{
|
||||
throw (new QBadRequestException("guideRevision must be CURRENT or ORIGINAL"));
|
||||
}
|
||||
if((request.sampleId() == null) == (request.sampleBase64() == null))
|
||||
{
|
||||
throw (new QBadRequestException("Supply exactly one of sampleId and sampleBase64"));
|
||||
}
|
||||
List<QRecord> overrides = request.ruleOverrides() == null ? null : records(request.ruleOverrides());
|
||||
QRecord sample;
|
||||
if(request.sampleId() != null)
|
||||
{
|
||||
sample = row("edi_map_sample", request.sampleId());
|
||||
if(!Objects.equals(sample.getValueInteger("mapId"), request.mapId()))
|
||||
{
|
||||
throw (new QNotFoundException("Not found"));
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
String encoded = request.sampleBase64();
|
||||
if(encoded.length() > ((MAX_SAMPLE_BYTES + 2) / 3) * 4)
|
||||
{
|
||||
throw (new QBadRequestException("Sample exceeds the 5 MiB limit"));
|
||||
}
|
||||
byte[] bytes;
|
||||
try
|
||||
{
|
||||
bytes = Base64.getDecoder().decode(encoded);
|
||||
}
|
||||
catch(IllegalArgumentException e)
|
||||
{
|
||||
throw (new QBadRequestException("sampleBase64 must be valid base64"));
|
||||
}
|
||||
if(bytes.length == 0 || bytes.length > MAX_SAMPLE_BYTES)
|
||||
{
|
||||
throw (new QBadRequestException("Sample must contain 1 byte through 5 MiB"));
|
||||
}
|
||||
if(request.sampleName() != null && request.sampleName().length() > 255)
|
||||
{
|
||||
throw (new QBadRequestException("sampleName must be at most 255 characters"));
|
||||
}
|
||||
// As in the record action, only encrypted sample archival uses the EDI writer.
|
||||
sample = SystemWrites.call(() ->
|
||||
{
|
||||
var ref = EdiRuntime.current().payloads().put(PayloadKind.SAMPLE, new ByteArrayInputStream(bytes));
|
||||
try
|
||||
{
|
||||
return (SystemWrites.insert("edi_map_sample", new QRecord().withValue("mapId", request.mapId())
|
||||
.withValue("name", Objects.requireNonNullElse(request.sampleName(), "Synthetic upload")).withValue("payloadRef", ref.value())));
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
EdiRuntime.current().payloads().delete(ref);
|
||||
throw (e);
|
||||
}
|
||||
});
|
||||
}
|
||||
return (JSON.convertValue(new MapPreviewService().preview(map, sample, overrides, revision), EdiPreviewV1.class));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,528 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.edi.api;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.TreeMap;
|
||||
import com.kingsrook.qqq.middleware.javalin.schemabuilder.ToSchema;
|
||||
import com.kingsrook.qqq.openapi.model.Schema;
|
||||
import com.kingsrook.qqq.openapi.model.Type;
|
||||
|
||||
/*******************************************************************************
|
||||
** Versioned wire types; schemas describe values without exposing QRecord internals.
|
||||
*******************************************************************************/
|
||||
public final class EdiSchemas
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Static schema catalog.
|
||||
*******************************************************************************/
|
||||
private EdiSchemas()
|
||||
{
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** A reference to another versioned wire type.
|
||||
*******************************************************************************/
|
||||
public static Schema ref(String name)
|
||||
{
|
||||
return (new Schema().withRefToSchema(name));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Array schema with an explicit item shape.
|
||||
*******************************************************************************/
|
||||
private static Schema array(Schema item)
|
||||
{
|
||||
return (new Schema().withType(Type.ARRAY).withItems(item));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Ordered guide tree. Root segments are outside loops; nested loops retain parent scope.
|
||||
*******************************************************************************/
|
||||
public record EdiGuideTreeV1(List<EdiLoopV1> loops, List<EdiSegmentV1> segments) implements ToSchema
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe this wire object for generated clients.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Schema toSchema()
|
||||
{
|
||||
return (new Schema().withType(Type.OBJECT).withDescription("Ordered guide tree. Root segments are outside loops; nested loops retain parent scope.")
|
||||
.withProperties(new TreeMap<>(Map.ofEntries(
|
||||
Map.entry("loops", array(ref("EdiLoopV1"))),
|
||||
Map.entry("segments", array(ref("EdiSegmentV1")))))));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Guide loop, nested within its parent; repeat is the maximum occurrence count.
|
||||
*******************************************************************************/
|
||||
public record EdiLoopV1(String id, Integer repeat, List<EdiLoopV1> loops, List<EdiSegmentV1> segments) implements ToSchema
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe this wire object for generated clients.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Schema toSchema()
|
||||
{
|
||||
return (new Schema().withType(Type.OBJECT).withDescription("Guide loop, nested within its parent; repeat is the maximum occurrence count.")
|
||||
.withProperties(new TreeMap<>(Map.ofEntries(
|
||||
Map.entry("id", new Schema().withType(Type.STRING).withNullable(true)),
|
||||
Map.entry("repeat", new Schema().withType(Type.INTEGER).withNullable(true)),
|
||||
Map.entry("loops", array(ref("EdiLoopV1"))),
|
||||
Map.entry("segments", array(ref("EdiSegmentV1")))))));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Segment definition in guide sequence order.
|
||||
*******************************************************************************/
|
||||
public record EdiSegmentV1(String id, String position, String usage, Integer min, Integer max, String name, List<EdiElementV1> elements) implements ToSchema
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe this wire object for generated clients.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Schema toSchema()
|
||||
{
|
||||
return (new Schema().withType(Type.OBJECT).withDescription("Segment definition in guide sequence order.")
|
||||
.withProperties(new TreeMap<>(Map.ofEntries(
|
||||
Map.entry("id", new Schema().withType(Type.STRING).withNullable(true)),
|
||||
Map.entry("position", new Schema().withType(Type.STRING).withNullable(true)),
|
||||
Map.entry("usage", new Schema().withType(Type.STRING).withNullable(true)),
|
||||
Map.entry("min", new Schema().withType(Type.INTEGER).withNullable(true)),
|
||||
Map.entry("max", new Schema().withType(Type.INTEGER).withNullable(true)),
|
||||
Map.entry("name", new Schema().withType(Type.STRING).withNullable(true)),
|
||||
Map.entry("elements", array(ref("EdiElementV1")))))));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Element or composite component; codes contain at most 200 active codes in lexical order.
|
||||
*******************************************************************************/
|
||||
public record EdiElementV1(Integer position, Integer component, String id, String name, String type, Integer minLength, Integer maxLength, String usage, Integer codeListId, List<String> codes) implements ToSchema
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe this wire object for generated clients.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Schema toSchema()
|
||||
{
|
||||
return (new Schema().withType(Type.OBJECT).withDescription("Element or composite component; codes contain at most 200 active codes in lexical order.")
|
||||
.withProperties(new TreeMap<>(Map.ofEntries(
|
||||
Map.entry("position", new Schema().withType(Type.INTEGER).withNullable(true)),
|
||||
Map.entry("component", new Schema().withType(Type.INTEGER).withNullable(true)),
|
||||
Map.entry("id", new Schema().withType(Type.STRING).withNullable(true)),
|
||||
Map.entry("name", new Schema().withType(Type.STRING).withNullable(true)),
|
||||
Map.entry("type", new Schema().withType(Type.STRING).withNullable(true)),
|
||||
Map.entry("minLength", new Schema().withType(Type.INTEGER).withNullable(true)),
|
||||
Map.entry("maxLength", new Schema().withType(Type.INTEGER).withNullable(true)),
|
||||
Map.entry("usage", new Schema().withType(Type.STRING).withNullable(true)),
|
||||
Map.entry("codeListId", new Schema().withType(Type.INTEGER).withNullable(true)),
|
||||
Map.entry("codes", array(new Schema().withType(Type.STRING).withNullable(true)))))));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Target metadata visible to the caller, current rules and registered transforms.
|
||||
*******************************************************************************/
|
||||
public record EdiTargetV1(String table, List<EdiFieldV1> fields, List<EdiAssociationV1> associations, List<EdiRuleV1> rules, List<EdiTransformV1> transforms) implements ToSchema
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe this wire object for generated clients.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Schema toSchema()
|
||||
{
|
||||
return (new Schema().withType(Type.OBJECT).withDescription("Target metadata visible to the caller, current rules and registered transforms.")
|
||||
.withProperties(new TreeMap<>(Map.ofEntries(
|
||||
Map.entry("table", new Schema().withType(Type.STRING).withNullable(true)),
|
||||
Map.entry("fields", array(ref("EdiFieldV1"))),
|
||||
Map.entry("associations", array(ref("EdiAssociationV1"))),
|
||||
Map.entry("rules", array(ref("EdiRuleV1"))),
|
||||
Map.entry("transforms", array(ref("EdiTransformV1")))))));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** QQQ target field metadata.
|
||||
*******************************************************************************/
|
||||
public record EdiFieldV1(String name, String label, String type, Boolean required, String possibleValueSourceName) implements ToSchema
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe this wire object for generated clients.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Schema toSchema()
|
||||
{
|
||||
return (new Schema().withType(Type.OBJECT).withDescription("QQQ target field metadata.")
|
||||
.withProperties(new TreeMap<>(Map.ofEntries(
|
||||
Map.entry("name", new Schema().withType(Type.STRING).withNullable(true)),
|
||||
Map.entry("label", new Schema().withType(Type.STRING).withNullable(true)),
|
||||
Map.entry("type", new Schema().withType(Type.STRING).withNullable(true)),
|
||||
Map.entry("required", new Schema().withType(Type.BOOLEAN)),
|
||||
Map.entry("possibleValueSourceName", new Schema().withType(Type.STRING).withNullable(true))))));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Direct QQQ association and its target fields.
|
||||
*******************************************************************************/
|
||||
public record EdiAssociationV1(String name, String table, String label, List<EdiFieldV1> fields) implements ToSchema
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe this wire object for generated clients.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Schema toSchema()
|
||||
{
|
||||
return (new Schema().withType(Type.OBJECT).withDescription("Direct QQQ association and its target fields.")
|
||||
.withProperties(new TreeMap<>(Map.ofEntries(
|
||||
Map.entry("name", new Schema().withType(Type.STRING).withNullable(true)),
|
||||
Map.entry("table", new Schema().withType(Type.STRING).withNullable(true)),
|
||||
Map.entry("label", new Schema().withType(Type.STRING).withNullable(true)),
|
||||
Map.entry("fields", array(ref("EdiFieldV1")))))));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Map rule. id is optional for unsaved rules. transformArgs is a JSON object encoded as a string, as in core table CRUD.
|
||||
*******************************************************************************/
|
||||
public record EdiRuleV1(Integer id, Integer sequence, String ediPath, String qqqPath, String transform, String transformArgs, Boolean required, String condition, String defaultValue) implements ToSchema
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe this wire object for generated clients.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Schema toSchema()
|
||||
{
|
||||
return (new Schema().withType(Type.OBJECT).withDescription("Map rule. id is optional for unsaved rules. transformArgs is a JSON object encoded as a string, as in core table CRUD.")
|
||||
.withProperties(new TreeMap<>(Map.ofEntries(
|
||||
Map.entry("id", new Schema().withType(Type.INTEGER).withNullable(true)),
|
||||
Map.entry("sequence", new Schema().withType(Type.INTEGER).withNullable(true)),
|
||||
Map.entry("ediPath", new Schema().withType(Type.STRING).withNullable(true)),
|
||||
Map.entry("qqqPath", new Schema().withType(Type.STRING).withNullable(true)),
|
||||
Map.entry("transform", new Schema().withType(Type.STRING).withNullable(true)),
|
||||
Map.entry("transformArgs", new Schema().withType(Type.STRING).withNullable(true)),
|
||||
Map.entry("required", new Schema().withType(Type.BOOLEAN).withNullable(true)),
|
||||
Map.entry("condition", new Schema().withType(Type.STRING).withNullable(true)),
|
||||
Map.entry("defaultValue", new Schema().withType(Type.STRING).withNullable(true))))));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Registered transform and JSON Schema describing its string-valued arguments.
|
||||
*******************************************************************************/
|
||||
public record EdiTransformV1(String name, Map<String, Object> argumentSchema) implements ToSchema
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe this wire object for generated clients.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Schema toSchema()
|
||||
{
|
||||
return (new Schema().withType(Type.OBJECT).withDescription("Registered transform and JSON Schema describing its string-valued arguments.")
|
||||
.withProperties(new TreeMap<>(Map.ofEntries(
|
||||
Map.entry("name", new Schema().withType(Type.STRING).withNullable(true)),
|
||||
Map.entry("argumentSchema", new Schema().withType(Type.OBJECT).withAdditionalProperties(true).withDescription("JSON Schema: type, properties, required and additionalProperties. All transform arguments are strings."))))));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** mapId is required. Supply exactly one of sampleId or sampleBase64. Uploads are at most 5 MiB decoded, stored encrypted. ruleOverrides replaces the whole rule set in memory. CURRENT (default) uses the newest active guide in the pinned guide family; ORIGINAL uses the pinned guide.
|
||||
*******************************************************************************/
|
||||
public record EdiPreviewRequestV1(Integer mapId, Integer sampleId, String sampleBase64, String sampleName, List<EdiRuleV1> ruleOverrides, String guideRevision) implements ToSchema
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe this wire object for generated clients.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Schema toSchema()
|
||||
{
|
||||
return (new Schema().withType(Type.OBJECT).withDescription("mapId is required. Supply exactly one of sampleId or sampleBase64. Uploads are at most 5 MiB decoded, stored encrypted. ruleOverrides replaces the whole rule set in memory. CURRENT (default) uses the newest active guide in the pinned guide family; ORIGINAL uses the pinned guide.")
|
||||
.withProperties(new TreeMap<>(Map.ofEntries(
|
||||
Map.entry("mapId", new Schema().withType(Type.INTEGER).withNullable(true)),
|
||||
Map.entry("sampleId", new Schema().withType(Type.INTEGER).withNullable(true)),
|
||||
Map.entry("sampleBase64", new Schema().withType(Type.STRING).withNullable(true).withFormat("byte").withMaxLength(6990508)),
|
||||
Map.entry("sampleName", new Schema().withType(Type.STRING).withNullable(true).withMaxLength(255)),
|
||||
Map.entry("ruleOverrides", array(ref("EdiRuleV1")).withNullable(true)),
|
||||
Map.entry("guideRevision", new Schema().withType(Type.STRING).withNullable(true).withEnumValues(List.of("CURRENT", "ORIGINAL")))))));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Parse, validate, acknowledge and map without target writes. PHI suppresses result, ack text, and both diffs.
|
||||
*******************************************************************************/
|
||||
public record EdiPreviewV1(EdiParseV1 parse, List<EdiFindingV1> findings, EdiAckV1 ack, EdiResultV1 result, EdiDiffV1 diff, Boolean phiSuppressed) implements ToSchema
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe this wire object for generated clients.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Schema toSchema()
|
||||
{
|
||||
return (new Schema().withType(Type.OBJECT).withDescription("Parse, validate, acknowledge and map without target writes. PHI suppresses result, ack text, and both diffs.")
|
||||
.withProperties(new TreeMap<>(Map.ofEntries(
|
||||
Map.entry("parse", ref("EdiParseV1")),
|
||||
Map.entry("findings", array(ref("EdiFindingV1"))),
|
||||
Map.entry("ack", ref("EdiAckV1")),
|
||||
Map.entry("result", ref("EdiResultV1").withNullable(true)),
|
||||
Map.entry("diff", ref("EdiDiffV1")),
|
||||
Map.entry("phiSuppressed", new Schema().withType(Type.BOOLEAN))))));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Counts of wire interchanges, groups and documents.
|
||||
*******************************************************************************/
|
||||
public record EdiParseV1(Integer interchanges, Integer groups, Integer documents) implements ToSchema
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe this wire object for generated clients.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Schema toSchema()
|
||||
{
|
||||
return (new Schema().withType(Type.OBJECT).withDescription("Counts of wire interchanges, groups and documents.")
|
||||
.withProperties(new TreeMap<>(Map.ofEntries(
|
||||
Map.entry("interchanges", new Schema().withType(Type.INTEGER).withNullable(true)),
|
||||
Map.entry("groups", new Schema().withType(Type.INTEGER).withNullable(true)),
|
||||
Map.entry("documents", new Schema().withType(Type.INTEGER).withNullable(true))))));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Value-free diagnostic. PHI messages are generic; only positions and codes are retained.
|
||||
*******************************************************************************/
|
||||
public record EdiFindingV1(Integer snipLevel, String severity, Integer segmentPosition, String segmentId, String loopId, Integer elementPosition, String code, String message) implements ToSchema
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe this wire object for generated clients.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Schema toSchema()
|
||||
{
|
||||
return (new Schema().withType(Type.OBJECT).withDescription("Value-free diagnostic. PHI messages are generic; only positions and codes are retained.")
|
||||
.withProperties(new TreeMap<>(Map.ofEntries(
|
||||
Map.entry("snipLevel", new Schema().withType(Type.INTEGER).withNullable(true)),
|
||||
Map.entry("severity", new Schema().withType(Type.STRING).withNullable(true)),
|
||||
Map.entry("segmentPosition", new Schema().withType(Type.INTEGER).withNullable(true)),
|
||||
Map.entry("segmentId", new Schema().withType(Type.STRING).withNullable(true)),
|
||||
Map.entry("loopId", new Schema().withType(Type.STRING).withNullable(true)),
|
||||
Map.entry("elementPosition", new Schema().withType(Type.INTEGER).withNullable(true)),
|
||||
Map.entry("code", new Schema().withType(Type.STRING).withNullable(true)),
|
||||
Map.entry("message", new Schema().withType(Type.STRING).withNullable(true))))));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Concatenated acknowledgement previews; type is 997, 999, TA1, CONTRL, NONE or MIXED. text is null for PHI.
|
||||
*******************************************************************************/
|
||||
public record EdiAckV1(String type, String text) implements ToSchema
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe this wire object for generated clients.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Schema toSchema()
|
||||
{
|
||||
return (new Schema().withType(Type.OBJECT).withDescription("Concatenated acknowledgement previews; type is 997, 999, TA1, CONTRL, NONE or MIXED. text is null for PHI.")
|
||||
.withProperties(new TreeMap<>(Map.ofEntries(
|
||||
Map.entry("type", new Schema().withType(Type.STRING).withNullable(true)),
|
||||
Map.entry("text", new Schema().withType(Type.STRING).withNullable(true))))));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Mapped records in document order; no target writes.
|
||||
*******************************************************************************/
|
||||
public record EdiResultV1(List<EdiRecordV1> records) implements ToSchema
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe this wire object for generated clients.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Schema toSchema()
|
||||
{
|
||||
return (new Schema().withType(Type.OBJECT).withDescription("Mapped records in document order; no target writes.")
|
||||
.withProperties(new TreeMap<>(Map.ofEntries(
|
||||
Map.entry("records", array(ref("EdiRecordV1")))))));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Field values and recursively associated records. Values use the target table field types.
|
||||
*******************************************************************************/
|
||||
public record EdiRecordV1(Map<String, Object> values, Map<String, List<EdiRecordV1>> associations) implements ToSchema
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe this wire object for generated clients.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Schema toSchema()
|
||||
{
|
||||
return (new Schema().withType(Type.OBJECT).withDescription("Field values and recursively associated records. Values use the target table field types.")
|
||||
.withProperties(new TreeMap<>(Map.ofEntries(
|
||||
Map.entry("values", new Schema().withType(Type.OBJECT).withAdditionalProperties(true)),
|
||||
Map.entry("associations", new Schema().withType(Type.OBJECT).withAdditionalProperties(array(ref("EdiRecordV1"))))))));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Only differing records are returned. Both lists are empty for PHI.
|
||||
*******************************************************************************/
|
||||
public record EdiDiffV1(List<EdiDifferenceV1> againstPreviousRevision, List<EdiDifferenceV1> againstExpected) implements ToSchema
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe this wire object for generated clients.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Schema toSchema()
|
||||
{
|
||||
return (new Schema().withType(Type.OBJECT).withDescription("Only differing records are returned. Both lists are empty for PHI.")
|
||||
.withProperties(new TreeMap<>(Map.ofEntries(
|
||||
Map.entry("againstPreviousRevision", array(ref("EdiDifferenceV1"))),
|
||||
Map.entry("againstExpected", array(ref("EdiDifferenceV1")))))));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** One document comparison; documentIndex is zero based.
|
||||
*******************************************************************************/
|
||||
public record EdiDifferenceV1(Integer documentIndex, Object before, Object after) implements ToSchema
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe this wire object for generated clients.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Schema toSchema()
|
||||
{
|
||||
return (new Schema().withType(Type.OBJECT).withDescription("One document comparison; documentIndex is zero based.")
|
||||
.withProperties(new TreeMap<>(Map.ofEntries(
|
||||
Map.entry("documentIndex", new Schema().withType(Type.INTEGER).withNullable(true)),
|
||||
Map.entry("before", new Schema().withNullable(true).withDescription("Expected JSON or a previous mapped record.")),
|
||||
Map.entry("after", new Schema().withType(Type.OBJECT).withAdditionalProperties(true).withNullable(true))))));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** rules is required and may be empty; rules are never persisted.
|
||||
*******************************************************************************/
|
||||
public record EdiValidateRequestV1(List<EdiRuleV1> rules) implements ToSchema
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe this wire object for generated clients.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Schema toSchema()
|
||||
{
|
||||
return (new Schema().withType(Type.OBJECT).withDescription("rules is required and may be empty; rules are never persisted.")
|
||||
.withProperties(new TreeMap<>(Map.ofEntries(
|
||||
Map.entry("rules", array(ref("EdiRuleV1")))))));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** One result per submitted rule, including rules with no errors.
|
||||
*******************************************************************************/
|
||||
public record EdiValidationV1(List<EdiRuleErrorsV1> rules) implements ToSchema
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe this wire object for generated clients.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Schema toSchema()
|
||||
{
|
||||
return (new Schema().withType(Type.OBJECT).withDescription("One result per submitted rule, including rules with no errors.")
|
||||
.withProperties(new TreeMap<>(Map.ofEntries(
|
||||
Map.entry("rules", array(ref("EdiRuleErrorsV1")))))));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Correlate errors using zero-based index, optional id and sequence.
|
||||
*******************************************************************************/
|
||||
public record EdiRuleErrorsV1(Integer index, Integer id, Integer sequence, List<String> errors) implements ToSchema
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe this wire object for generated clients.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Schema toSchema()
|
||||
{
|
||||
return (new Schema().withType(Type.OBJECT).withDescription("Correlate errors using zero-based index, optional id and sequence.")
|
||||
.withProperties(new TreeMap<>(Map.ofEntries(
|
||||
Map.entry("index", new Schema().withType(Type.INTEGER).withNullable(true)),
|
||||
Map.entry("id", new Schema().withType(Type.INTEGER).withNullable(true)),
|
||||
Map.entry("sequence", new Schema().withType(Type.INTEGER).withNullable(true)),
|
||||
Map.entry("errors", array(new Schema().withType(Type.STRING).withNullable(true)))))));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Core middleware error body.
|
||||
*******************************************************************************/
|
||||
public record EdiErrorV1(String error) implements ToSchema
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Describe this wire object for generated clients.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Schema toSchema()
|
||||
{
|
||||
return (new Schema().withType(Type.OBJECT).withDescription("Deprecated since 1.1.0: errors use BasicErrorResponseV1.")
|
||||
.withProperties(new TreeMap<>(Map.ofEntries(
|
||||
Map.entry("error", new Schema().withType(Type.STRING).withNullable(true))))));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Complete deterministic schema catalog for all four operations.
|
||||
*******************************************************************************/
|
||||
public static Map<String, Schema> all()
|
||||
{
|
||||
Map<String, Schema> result = new TreeMap<>();
|
||||
result.put("EdiGuideTreeV1", new EdiGuideTreeV1(null, null).toSchema());
|
||||
result.put("EdiLoopV1", new EdiLoopV1(null, null, null, null).toSchema());
|
||||
result.put("EdiSegmentV1", new EdiSegmentV1(null, null, null, null, null, null, null).toSchema());
|
||||
result.put("EdiElementV1", new EdiElementV1(null, null, null, null, null, null, null, null, null, null).toSchema());
|
||||
result.put("EdiTargetV1", new EdiTargetV1(null, null, null, null, null).toSchema());
|
||||
result.put("EdiFieldV1", new EdiFieldV1(null, null, null, null, null).toSchema());
|
||||
result.put("EdiAssociationV1", new EdiAssociationV1(null, null, null, null).toSchema());
|
||||
result.put("EdiRuleV1", new EdiRuleV1(null, null, null, null, null, null, null, null, null).toSchema());
|
||||
result.put("EdiTransformV1", new EdiTransformV1(null, null).toSchema());
|
||||
result.put("EdiPreviewRequestV1", new EdiPreviewRequestV1(null, null, null, null, null, null).toSchema());
|
||||
result.put("EdiPreviewV1", new EdiPreviewV1(null, null, null, null, null, null).toSchema());
|
||||
result.put("EdiParseV1", new EdiParseV1(null, null, null).toSchema());
|
||||
result.put("EdiFindingV1", new EdiFindingV1(null, null, null, null, null, null, null, null).toSchema());
|
||||
result.put("EdiAckV1", new EdiAckV1(null, null).toSchema());
|
||||
result.put("EdiResultV1", new EdiResultV1(null).toSchema());
|
||||
result.put("EdiRecordV1", new EdiRecordV1(null, null).toSchema());
|
||||
result.put("EdiDiffV1", new EdiDiffV1(null, null).toSchema());
|
||||
result.put("EdiDifferenceV1", new EdiDifferenceV1(null, null, null).toSchema());
|
||||
result.put("EdiValidateRequestV1", new EdiValidateRequestV1(null).toSchema());
|
||||
result.put("EdiValidationV1", new EdiValidationV1(null).toSchema());
|
||||
result.put("EdiRuleErrorsV1", new EdiRuleErrorsV1(null, null, null, null).toSchema());
|
||||
result.put("EdiErrorV1", new EdiErrorV1(null).toSchema());
|
||||
return (result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.edi.app;
|
||||
|
||||
|
||||
import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.ChildRecordListRenderer;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionEnforcement;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Explicitly enforce table and field permissions inside the widget's action scope.
|
||||
** RC.5 treats nested DEFAULT queries as trusted, so the generic renderer alone
|
||||
** does not remove fields denied by a FieldSecurityLock.
|
||||
*******************************************************************************/
|
||||
public class EdiChildRecordListRenderer extends ChildRecordListRenderer
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Use the standard typed child list with an enforced query at its public boundary.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected QueryOutput executeQuery(QueryInput input) throws QException
|
||||
{
|
||||
input.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
|
||||
return (super.executeQuery(input));
|
||||
}
|
||||
}
|
||||
@@ -27,9 +27,11 @@ import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.TreeMap;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.kingsrook.qbits.edi.EdiQBitConfig;
|
||||
import com.kingsrook.qbits.edi.EdiRuntime;
|
||||
@@ -38,6 +40,8 @@ import com.kingsrook.qqq.backend.core.actions.permissions.PermissionEnforcement;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.CountAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.count.CountInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
|
||||
@@ -45,6 +49,7 @@ import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.AlertData;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.StatisticsData;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.TableData;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.WidgetType;
|
||||
@@ -54,9 +59,11 @@ import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppSection;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.DenyBehavior;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
|
||||
|
||||
/*******************************************************************************
|
||||
** Typed EDI home. Every data query explicitly enforces the viewing user's access.
|
||||
@@ -65,27 +72,53 @@ public class EdiHome extends AbstractWidgetRenderer
|
||||
{
|
||||
public static final List<String> ACTIVITY = List.of("edi_transaction", "edi_interchange", "edi_exception");
|
||||
public static final List<String> SETUP = List.of("edi_partner", "edi_agreement", "edi_transport", "edi_certificate", "edi_guide", "edi_code_list", "edi_map");
|
||||
private static final Map<String, String> LABELS = new LinkedHashMap<>();
|
||||
|
||||
/*******************************************************************************
|
||||
** System-audience widgets (component and queue health, dead letters,
|
||||
** certificate expiry, setup and configuration). They are not on the EDI
|
||||
** business home: a host places them on its Admin home. Each is gated to
|
||||
** ADMIN_PERMISSION_BASE_NAME and hidden from everyone else.
|
||||
*******************************************************************************/
|
||||
public static final List<String> ADMIN_WIDGET_NAMES = List.of("edi_home_system", "edi_home_dead", "edi_home_certificates", "edi_home_setup");
|
||||
|
||||
/*******************************************************************************
|
||||
** Business widgets on the EDI home, in order. Each is gated to the EDI app
|
||||
** permission (edi.hasAccess) and hidden from everyone else.
|
||||
*******************************************************************************/
|
||||
public static final List<String> HOME_WIDGET_NAMES = List.of("edi_home_delayed", "edi_home_in_today", "edi_home_out_today", "edi_home_exceptions",
|
||||
"edi_home_overdue", "edi_home_queue", "edi_home_acks", "edi_home_errors", "edi_home_volume");
|
||||
|
||||
/*******************************************************************************
|
||||
** The EDI administrator permission (edi.admin.hasAccess), as on the admin processes.
|
||||
*******************************************************************************/
|
||||
public static final String ADMIN_PERMISSION_BASE_NAME = EdiQBitConfig.PERMISSION_BASE_NAME + ".admin";
|
||||
|
||||
private static final QLogger LOG = QLogger.getLogger(EdiHome.class);
|
||||
private static final Map<String, String> LABELS = new LinkedHashMap<>();
|
||||
private static final Map<String, String> DOCUMENT_NAMES = new HashMap<>();
|
||||
private static final List<String> TABLES = List.of("setup", "queue", "acks", "errors", "volume", "system");
|
||||
|
||||
static
|
||||
{
|
||||
DOCUMENT_NAMES.putAll(Map.ofEntries(Map.entry("810", "Invoice"), Map.entry("846", "Inventory Advice"), Map.entry("850", "Purchase Order"),
|
||||
Map.entry("855", "PO Acknowledgment"), Map.entry("856", "Ship Notice"), Map.entry("860", "PO Change"), Map.entry("940", "Warehouse Shipping Order"),
|
||||
Map.entry("945", "Warehouse Shipping Advice"), Map.entry("214", "Shipment Status"), Map.entry("204", "Load Tender"), Map.entry("837", "Claim"),
|
||||
Map.entry("835", "Remittance"), Map.entry("270", "Eligibility Inquiry"), Map.entry("271", "Eligibility Response"), Map.entry("276", "Claim Status Inquiry"),
|
||||
Map.entry("277", "Claim Status Response"), Map.entry("834", "Enrollment"), Map.entry("997", "Functional Ack"), Map.entry("999", "Implementation Ack"),
|
||||
Map.entry("TA1", "Interchange Ack")));
|
||||
LABELS.put("delayed", "Inbound documents delayed");
|
||||
LABELS.put("in_today", "Inbound today");
|
||||
LABELS.put("out_today", "Outbound today");
|
||||
LABELS.put("in_week", "Inbound over 7 days");
|
||||
LABELS.put("out_week", "Outbound over 7 days");
|
||||
LABELS.put("exceptions", "Open exceptions");
|
||||
LABELS.put("overdue", "Overdue acknowledgements");
|
||||
LABELS.put("overdue", "Overdue acks");
|
||||
LABELS.put("dead", "Dead letters");
|
||||
LABELS.put("certificates", "Certificates expiring within 30 days");
|
||||
LABELS.put("backlog", "Inbox backlog");
|
||||
LABELS.put("oldest", "Oldest pending age (seconds)");
|
||||
LABELS.put("storage", "Payload storage");
|
||||
LABELS.put("secured", "Secured-data integration");
|
||||
LABELS.put("external", "External code-set validation");
|
||||
LABELS.put("volume", "Volume by partner and document over 7 days");
|
||||
LABELS.put("acks", "Overdue acknowledgement queue");
|
||||
LABELS.put("errors", "Recent errors (24 hours)");
|
||||
LABELS.put("certificates", "Certificates expiring (30 days)");
|
||||
LABELS.put("setup", "Setup status");
|
||||
LABELS.put("queue", "Exception queue");
|
||||
LABELS.put("acks", "Overdue acknowledgements");
|
||||
LABELS.put("errors", "Recent errors (24 hours)");
|
||||
LABELS.put("volume", "Volume by partner and document (7 days)");
|
||||
LABELS.put("system", "System");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -94,7 +127,7 @@ public class EdiHome extends AbstractWidgetRenderer
|
||||
public static QAppMetaData app(EdiQBitConfig config, List<QTableMetaData> tables)
|
||||
{
|
||||
QAppMetaData app = new QAppMetaData().withName(config.getAppName()).withLabel(config.getAppLabel()).withIcon(new QIcon("swap_horiz"))
|
||||
.withWidgets(LABELS.keySet().stream().map(n -> "edi_home_" + n).toList())
|
||||
.withWidgets(HOME_WIDGET_NAMES)
|
||||
.withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.NOT_PROTECTED))
|
||||
.withSection(new QAppSection().withName("activity").withLabel("Activity").withTables(ACTIVITY))
|
||||
.withSection(new QAppSection().withName("setup").withLabel("Setup").withTables(SETUP));
|
||||
@@ -104,15 +137,23 @@ public class EdiHome extends AbstractWidgetRenderer
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** RC.5 typed statistics and tables; no raw markup in metadata or widget data.
|
||||
** RC.5 typed statistics, tables and one alert. Business widgets need the EDI
|
||||
** app permission; the ADMIN_WIDGET_NAMES need the EDI administrator
|
||||
** permission. Denied widgets are hidden, never shown as locked shells.
|
||||
*******************************************************************************/
|
||||
public static List<QWidgetMetaData> widgets()
|
||||
{
|
||||
List<QWidgetMetaData> widgets = new ArrayList<>();
|
||||
LABELS.forEach((name, label) -> widgets.add(new QWidgetMetaData().withName("edi_home_" + name).withLabel(label)
|
||||
.withType((name.equals("queue") ? WidgetType.CHILD_RECORD_LIST : List.of("volume", "acks", "errors").contains(name) ? WidgetType.TABLE : WidgetType.STATISTICS).getType())
|
||||
.withGridColumns(List.of("volume", "acks", "errors", "queue").contains(name) ? 12 : 3).withCodeReference(new QCodeReference(EdiHome.class))
|
||||
.withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.NOT_PROTECTED))));
|
||||
LABELS.forEach((name, label) ->
|
||||
{
|
||||
String widgetName = "edi_home_" + name;
|
||||
boolean admin = ADMIN_WIDGET_NAMES.contains(widgetName);
|
||||
String type = name.equals("delayed") ? WidgetType.ALERT.getType() : (TABLES.contains(name) ? WidgetType.TABLE : WidgetType.STATISTICS).getType();
|
||||
widgets.add(new QWidgetMetaData().withName(widgetName).withLabel(label).withType(type)
|
||||
.withGridColumns(TABLES.contains(name) || admin || name.equals("delayed") ? 12 : 2).withCodeReference(new QCodeReference(EdiHome.class))
|
||||
.withPermissionRules(new QPermissionRules().withPermissionBaseName(admin ? ADMIN_PERMISSION_BASE_NAME : EdiQBitConfig.PERMISSION_BASE_NAME)
|
||||
.withLevel(PermissionLevel.HAS_ACCESS_PERMISSION).withDenyBehavior(DenyBehavior.HIDDEN)));
|
||||
});
|
||||
return (widgets);
|
||||
}
|
||||
|
||||
@@ -122,134 +163,414 @@ public class EdiHome extends AbstractWidgetRenderer
|
||||
@Override
|
||||
public RenderWidgetOutput render(RenderWidgetInput input) throws QException
|
||||
{
|
||||
String name = input.getWidgetMetaData().getName().substring("edi_home_".length());
|
||||
Instant now = EdiRuntime.current().config().getClock().instant();
|
||||
String table = "edi_transaction";
|
||||
QQueryFilter filter = new QQueryFilter();
|
||||
if(name.startsWith("in_") || name.startsWith("out_"))
|
||||
String name = input.getWidgetMetaData().getName().substring("edi_home_".length());
|
||||
Instant now = EdiRuntime.current().config().getClock().instant();
|
||||
return (switch(name)
|
||||
{
|
||||
filter.withCriteria("direction", QCriteriaOperator.EQUALS, name.startsWith("in_") ? "INBOUND" : "OUTBOUND")
|
||||
.withCriteria("createDate", QCriteriaOperator.GREATER_THAN_OR_EQUALS, name.endsWith("today") ? now.truncatedTo(ChronoUnit.DAYS) : now.minus(7, ChronoUnit.DAYS));
|
||||
}
|
||||
else if(List.of("exceptions", "queue", "errors").contains(name))
|
||||
{
|
||||
table = "edi_exception";
|
||||
if(!name.equals("errors"))
|
||||
{
|
||||
filter.withCriteria("status", QCriteriaOperator.IN, "OPEN", "IN_PROGRESS");
|
||||
}
|
||||
if(name.equals("errors"))
|
||||
{
|
||||
filter.withCriteria("createDate", QCriteriaOperator.GREATER_THAN_OR_EQUALS, now.minus(1, ChronoUnit.DAYS));
|
||||
}
|
||||
}
|
||||
else if(List.of("overdue", "acks").contains(name))
|
||||
{
|
||||
table = "edi_functional_group";
|
||||
filter.withCriteria("ackStatus", QCriteriaOperator.EQUALS, "PENDING").withCriteria("ackDueAt", QCriteriaOperator.LESS_THAN, now);
|
||||
}
|
||||
else if(name.equals("certificates"))
|
||||
{
|
||||
table = "edi_certificate";
|
||||
filter.withCriteria("notAfter", QCriteriaOperator.LESS_THAN_OR_EQUALS, now.plus(30, ChronoUnit.DAYS)).withCriteria("status", QCriteriaOperator.EQUALS, "ACTIVE");
|
||||
}
|
||||
else if(List.of("dead", "backlog", "oldest").contains(name))
|
||||
{
|
||||
table = "edi_inbox";
|
||||
filter.withCriteria("status", QCriteriaOperator.IN, name.equals("dead") ? List.of("DEAD_LETTER") : List.of("PENDING", "PROCESSING", "RETRY_WAIT"));
|
||||
}
|
||||
if(name.equals("secured"))
|
||||
{
|
||||
boolean present = com.kingsrook.qqq.backend.core.context.QContext.getQInstance().getQBits().values().stream().anyMatch(q -> "qbit-secured-data".equals(q.getArtifactId()));
|
||||
return (new RenderWidgetOutput(new StatisticsData().withCount(present ? "Installed" : "Not installed").withCountContext("PHI field protection depends on host configuration")));
|
||||
}
|
||||
if(name.equals("external"))
|
||||
{
|
||||
return (new RenderWidgetOutput(new StatisticsData().withCount("Not configured").withCountContext("Baseline and configured guide code lists only")));
|
||||
}
|
||||
if(name.equals("storage"))
|
||||
{
|
||||
return (new RenderWidgetOutput(new StatisticsData().withCount(EdiRuntime.current().config().getPayloadStore()).withCountContext("Encrypted; database fallback is intended for demos and tests")));
|
||||
}
|
||||
if(name.equals("volume"))
|
||||
{
|
||||
filter.withCriteria("createDate", QCriteriaOperator.GREATER_THAN_OR_EQUALS, now.minus(7, ChronoUnit.DAYS));
|
||||
Map<String, Map<String, Object>> groups = new LinkedHashMap<>();
|
||||
for(QRecord row : query(table, filter))
|
||||
{
|
||||
String key = row.getValueString("partnerId") + "/" + row.getValueString("transactionSet");
|
||||
Map<String, Object> counts = groups.computeIfAbsent(key, k -> new LinkedHashMap<>(Map.of("partner", row.getValueString("partnerId") == null ? "Unrouted" : row.getValueString("partnerId"),
|
||||
"document", row.getValueString("transactionSet"), "in", 0, "out", 0, "rejected", 0)));
|
||||
String direction = "INBOUND".equals(row.getValueString("direction")) ? "in" : "out";
|
||||
counts.put(direction, (Integer) counts.get(direction) + 1);
|
||||
if(List.of("INVALID", "REJECTED").contains(row.getValueString("status")))
|
||||
{
|
||||
counts.put("rejected", (Integer) counts.get("rejected") + 1);
|
||||
}
|
||||
}
|
||||
return (new RenderWidgetOutput(tableData(List.of("partner", "document", "in", "out", "rejected"), new ArrayList<>(groups.values())).withLinkURL(link(table, filter)).withLinkText("View documents")));
|
||||
}
|
||||
if(name.equals("queue"))
|
||||
{
|
||||
CountInput count = new CountInput(table).withFilter(filter);
|
||||
count.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
|
||||
Integer total = new CountAction().execute(count).getCount();
|
||||
QueryInput query = new QueryInput(table).withFilter(filter.withOrderBy(new QFilterOrderBy("createDate")).withLimit(20));
|
||||
query.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
|
||||
return (new RenderWidgetOutput(new com.kingsrook.qqq.backend.core.model.dashboard.widgets.ChildRecordListData("Exception queue", new QueryAction().execute(query),
|
||||
com.kingsrook.qqq.backend.core.context.QContext.getQInstance().getTable(table), "/app/" + table, link(table, filter), total).withCanAddChildRecord(false)));
|
||||
}
|
||||
if(List.of("errors", "acks").contains(name))
|
||||
{
|
||||
List<String> columns = name.equals("acks") ? List.of("partner", "group", "sent", "due", "ageSeconds") : List.of("id", "type", "status", "createDate");
|
||||
List<Map<String, Object>> rows = new ArrayList<>();
|
||||
for(QRecord row : query(table, filter.withOrderBy(new QFilterOrderBy("createDate")).withLimit(20)))
|
||||
{
|
||||
Map<String, Object> values = new LinkedHashMap<>();
|
||||
if(name.equals("acks"))
|
||||
{
|
||||
values.put("group", row.getValue("controlNumber"));
|
||||
values.put("due", row.getValue("ackDueAt"));
|
||||
var interchanges = query("edi_interchange", new QQueryFilter().withCriteria("id", QCriteriaOperator.EQUALS, row.getValue("interchangeId")));
|
||||
if(!interchanges.isEmpty())
|
||||
{
|
||||
QRecord interchange = interchanges.getFirst();
|
||||
values.put("partner", interchange.getValue("partnerId"));
|
||||
if(interchange.getValue("outboundMessageId") != null)
|
||||
{
|
||||
var messages = query("edi_outbound_message", new QQueryFilter().withCriteria("id", QCriteriaOperator.EQUALS, interchange.getValue("outboundMessageId")));
|
||||
if(!messages.isEmpty())
|
||||
{
|
||||
Instant sent = messages.getFirst().getValueInstant("sentAt");
|
||||
values.put("sent", sent);
|
||||
values.put("ageSeconds", sent == null ? null : Math.max(0L, Duration.between(sent, now).getSeconds()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
columns.forEach(c -> values.put(c, row.getValue(c)));
|
||||
}
|
||||
rows.add(values);
|
||||
}
|
||||
return (new RenderWidgetOutput(tableData(columns, rows).withLinkURL(link(table, filter)).withLinkText("View records and actions")));
|
||||
}
|
||||
Serializable count;
|
||||
if(name.equals("oldest"))
|
||||
{
|
||||
var rows = query(table, filter.withOrderBy(new QFilterOrderBy("receivedAt")).withLimit(1));
|
||||
count = rows.isEmpty() ? 0L : Math.max(0L, Duration.between(rows.getFirst().getValueInstant("receivedAt"), now).getSeconds());
|
||||
}
|
||||
else
|
||||
{
|
||||
CountInput countInput = new CountInput(table).withFilter(filter);
|
||||
countInput.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
|
||||
count = new CountAction().execute(countInput).getCount();
|
||||
}
|
||||
return (new RenderWidgetOutput(new StatisticsData().withCount(count).withCountURL(link(table, filter))));
|
||||
case "in_today", "out_today" -> count("edi_transaction", new QQueryFilter()
|
||||
.withCriteria("direction", QCriteriaOperator.EQUALS, name.startsWith("in_") ? "INBOUND" : "OUTBOUND")
|
||||
.withCriteria("createDate", QCriteriaOperator.GREATER_THAN_OR_EQUALS, now.truncatedTo(ChronoUnit.DAYS)));
|
||||
case "exceptions" -> count("edi_exception", openExceptions());
|
||||
case "overdue" -> count("edi_functional_group", overdueAcks(now));
|
||||
case "dead" -> count("edi_exception", openExceptions().withCriteria("type", QCriteriaOperator.IN, "DEAD_LETTER", "TRANSPORT"));
|
||||
case "delayed" -> delayed(now);
|
||||
case "certificates" -> count("edi_certificate", new QQueryFilter().withCriteria("notAfter", QCriteriaOperator.LESS_THAN_OR_EQUALS, now.plus(30, ChronoUnit.DAYS))
|
||||
.withCriteria("status", QCriteriaOperator.EQUALS, "ACTIVE"));
|
||||
case "setup" -> setup();
|
||||
case "queue" -> queue(now);
|
||||
case "acks" -> acks(now);
|
||||
case "errors" -> errors(now);
|
||||
case "volume" -> volume(now);
|
||||
case "system" -> system(now);
|
||||
default -> throw new QException("Unknown EDI home widget " + name);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A counted statistic linking to the same filter.
|
||||
*******************************************************************************/
|
||||
private static RenderWidgetOutput count(String table, QQueryFilter filter) throws QException
|
||||
{
|
||||
return (new RenderWidgetOutput(new StatisticsData().withCount(countOf(table, filter)).withCountURL(link(table, filter))));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Count with enforcement.
|
||||
*******************************************************************************/
|
||||
private static Integer countOf(String table, QQueryFilter filter) throws QException
|
||||
{
|
||||
CountInput countInput = new CountInput(table).withFilter(filter);
|
||||
countInput.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
|
||||
return (new CountAction().execute(countInput).getCount());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Open and in-progress exceptions.
|
||||
*******************************************************************************/
|
||||
private static QQueryFilter openExceptions()
|
||||
{
|
||||
return (new QQueryFilter().withCriteria("status", QCriteriaOperator.IN, "OPEN", "IN_PROGRESS"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Outbound groups whose expected acknowledgement is past due.
|
||||
*******************************************************************************/
|
||||
private static QQueryFilter overdueAcks(Instant now)
|
||||
{
|
||||
return (new QQueryFilter().withCriteria("ackStatus", QCriteriaOperator.EQUALS, "PENDING").withCriteria("ackDueAt", QCriteriaOperator.LESS_THAN, now));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The setup steps in order, with their status and the next step. Counts are
|
||||
** taken as the viewer; a table the viewer cannot read shows as not visible.
|
||||
*******************************************************************************/
|
||||
private static RenderWidgetOutput setup() throws QException
|
||||
{
|
||||
QQueryFilter active = new QQueryFilter().withCriteria("isActive", QCriteriaOperator.EQUALS, true);
|
||||
EdiSetupStatus.Counts counts = new EdiSetupStatus.Counts(safeCount("edi_partner", new QQueryFilter()),
|
||||
safeCount("edi_identity", new QQueryFilter().withCriteria("isActive", QCriteriaOperator.EQUALS, true).withCriteria("partnerId", QCriteriaOperator.IS_BLANK)),
|
||||
safeCount("edi_identity", new QQueryFilter().withCriteria("isActive", QCriteriaOperator.EQUALS, true).withCriteria("partnerId", QCriteriaOperator.IS_NOT_BLANK)),
|
||||
safeCount("edi_agreement", new QQueryFilter()), safeCount("edi_agreement", active),
|
||||
safeCount("edi_agreement_document", new QQueryFilter()), safeCount("edi_agreement_document", active),
|
||||
safeCount("edi_transport", new QQueryFilter()), safeCount("edi_transport", active),
|
||||
safeCount("edi_certificate", new QQueryFilter()), safeCount("edi_guide", new QQueryFilter()), safeCount("edi_code_list", new QQueryFilter()),
|
||||
safeCount("edi_map", new QQueryFilter()), safeCount("edi_map", new QQueryFilter().withCriteria("status", QCriteriaOperator.EQUALS, "ACTIVE")));
|
||||
List<Map<String, Object>> rows = new ArrayList<>();
|
||||
for(EdiSetupStatus.Item item : EdiSetupStatus.items(counts))
|
||||
{
|
||||
rows.add(row("Step", item.step(), "Status", item.state().label() + (item.detail().isEmpty() ? "" : ": " + item.detail()), "Next step", item.nextStep().isEmpty() ? "Nothing to do." : item.nextStep()));
|
||||
}
|
||||
return (table(List.of("Step", "Status", "Next step"), rows, "edi_partner", new QQueryFilter(), "Open partners"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A count that may fail (for example a table the viewer cannot read): null,
|
||||
** shown as "Unavailable", and logged; never a made-up number.
|
||||
*******************************************************************************/
|
||||
static Integer safeCount(String table, QQueryFilter filter)
|
||||
{
|
||||
try
|
||||
{
|
||||
return (countOf(table, filter));
|
||||
}
|
||||
catch(QException | RuntimeException e)
|
||||
{
|
||||
LOG.warn("EDI setup count unavailable", e, logPair("table", table));
|
||||
return (null);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The business face of a stalled inbound flow: when the oldest unprocessed
|
||||
** inbound document is older than the configured threshold, a warning with its
|
||||
** age that links to those documents. Hidden when nothing is delayed, or when
|
||||
** the viewer cannot read the inbound documents.
|
||||
*******************************************************************************/
|
||||
private static RenderWidgetOutput delayed(Instant now) throws QException
|
||||
{
|
||||
Integer minutes = EdiRuntime.current().config().getInboundDelayedAfterMinutes();
|
||||
Instant cutoff = now.minus(minutes, ChronoUnit.MINUTES);
|
||||
List<QRecord> oldest = List.of();
|
||||
try
|
||||
{
|
||||
oldest = query("edi_inbox", delayedInbound(cutoff).withOrderBy(new QFilterOrderBy("receivedAt", true)).withLimit(1));
|
||||
}
|
||||
catch(QPermissionDeniedException e)
|
||||
{
|
||||
///////////////////////////////////////////////////////////
|
||||
// omitted, not locked: the viewer cannot open the inbox //
|
||||
///////////////////////////////////////////////////////////
|
||||
}
|
||||
if(oldest.isEmpty())
|
||||
{
|
||||
return (new RenderWidgetOutput(new AlertData().withHideWidget(true)));
|
||||
}
|
||||
int count = countOf("edi_inbox", delayedInbound(cutoff));
|
||||
String age = humanize(Duration.between(oldest.getFirst().getValueInstant("receivedAt"), now));
|
||||
String html = "<strong>Inbound documents are delayed.</strong> " + count + (count == 1 ? " document has" : " documents have")
|
||||
+ " waited more than " + humanize(Duration.ofMinutes(minutes)) + " to be processed; the oldest has waited " + age + ". "
|
||||
+ "<a href=\"" + link("edi_inbox", delayedInbound(cutoff)) + "\">View delayed documents</a>";
|
||||
return (new RenderWidgetOutput(new AlertData(AlertData.AlertType.WARNING, html)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Unprocessed inbound documents received before the cutoff.
|
||||
*******************************************************************************/
|
||||
static QQueryFilter delayedInbound(Instant cutoff)
|
||||
{
|
||||
return (inboxBacklog().withCriteria("receivedAt", QCriteriaOperator.LESS_THAN, cutoff));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Open exceptions, oldest first, in four narrow columns.
|
||||
*******************************************************************************/
|
||||
private static RenderWidgetOutput queue(Instant now) throws QException
|
||||
{
|
||||
Map<Integer, String> partners = partnerNames();
|
||||
List<Map<String, Object>> rows = new ArrayList<>();
|
||||
for(QRecord row : query("edi_exception", openExceptions().withOrderBy(new QFilterOrderBy("createDate", true)).withOrderBy(new QFilterOrderBy("id", true)).withLimit(20)))
|
||||
{
|
||||
rows.add(row("Exception", plain(row.getValueString("type")) + ": " + row.getValueString("summary"),
|
||||
"Partner", partnerName(partners, row.getValueInteger("partnerId")), "Age", humanize(Duration.between(row.getValueInstant("createDate"), now))));
|
||||
}
|
||||
return (table(List.of("Exception", "Partner", "Age"), rows, "edi_exception", openExceptions(), "View all exceptions"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Overdue acknowledgements with partner names and humanized ages.
|
||||
*******************************************************************************/
|
||||
private static RenderWidgetOutput acks(Instant now) throws QException
|
||||
{
|
||||
Map<Integer, String> partners = partnerNames();
|
||||
List<Map<String, Object>> rows = new ArrayList<>();
|
||||
for(QRecord group : query("edi_functional_group", overdueAcks(now).withOrderBy(new QFilterOrderBy("ackDueAt", true)).withLimit(20)))
|
||||
{
|
||||
Integer partnerId = null;
|
||||
Instant sent = null;
|
||||
List<QRecord> interchanges = query("edi_interchange", new QQueryFilter().withCriteria("id", QCriteriaOperator.EQUALS, group.getValue("interchangeId")));
|
||||
if(!interchanges.isEmpty())
|
||||
{
|
||||
partnerId = interchanges.getFirst().getValueInteger("partnerId");
|
||||
Serializable messageId = interchanges.getFirst().getValue("outboundMessageId");
|
||||
if(messageId != null)
|
||||
{
|
||||
List<QRecord> messages = query("edi_outbound_message", new QQueryFilter().withCriteria("id", QCriteriaOperator.EQUALS, messageId));
|
||||
sent = messages.isEmpty() ? null : messages.getFirst().getValueInstant("sentAt");
|
||||
}
|
||||
}
|
||||
Instant due = group.getValueInstant("ackDueAt");
|
||||
rows.add(row("Partner", partnerName(partners, partnerId), "Group", "GS " + group.getValueString("controlNumber"),
|
||||
"Sent", sent == null ? "Not sent" : humanize(Duration.between(sent, now)) + " ago", "Overdue by", due == null ? "" : humanize(Duration.between(due, now))));
|
||||
}
|
||||
return (table(List.of("Partner", "Group", "Sent", "Overdue by"), rows, "edi_functional_group", overdueAcks(now), "View functional groups"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Exceptions raised in the last day, newest first.
|
||||
*******************************************************************************/
|
||||
private static RenderWidgetOutput errors(Instant now) throws QException
|
||||
{
|
||||
Map<Integer, String> partners = partnerNames();
|
||||
List<Map<String, Object>> rows = new ArrayList<>();
|
||||
for(QRecord row : query("edi_exception", lastDay(now).withOrderBy(new QFilterOrderBy("createDate", false)).withOrderBy(new QFilterOrderBy("id", false)).withLimit(20)))
|
||||
{
|
||||
rows.add(row("Error", plain(row.getValueString("type")) + ": " + row.getValueString("summary"),
|
||||
"Partner", partnerName(partners, row.getValueInteger("partnerId")), "When", humanize(Duration.between(row.getValueInstant("createDate"), now)) + " ago"));
|
||||
}
|
||||
return (table(List.of("Error", "Partner", "When"), rows, "edi_exception", lastDay(now), "View exceptions"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Created within the last day.
|
||||
*******************************************************************************/
|
||||
private static QQueryFilter lastDay(Instant now)
|
||||
{
|
||||
return (new QQueryFilter().withCriteria("createDate", QCriteriaOperator.GREATER_THAN_OR_EQUALS, now.minus(1, ChronoUnit.DAYS)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Documents by partner and transaction set over seven days, with a total row.
|
||||
*******************************************************************************/
|
||||
private static RenderWidgetOutput volume(Instant now) throws QException
|
||||
{
|
||||
Map<Integer, String> partners = partnerNames();
|
||||
QQueryFilter filter = new QQueryFilter().withCriteria("createDate", QCriteriaOperator.GREATER_THAN_OR_EQUALS, now.minus(7, ChronoUnit.DAYS));
|
||||
Map<List<String>, int[]> counts = new TreeMap<>((a, b) -> a.get(0).equals(b.get(0)) ? a.get(1).compareTo(b.get(1)) : a.get(0).compareTo(b.get(0)));
|
||||
for(QRecord row : query("edi_transaction", filter))
|
||||
{
|
||||
int[] c = counts.computeIfAbsent(List.of(partnerName(partners, row.getValueInteger("partnerId")), documentName(row.getValueString("transactionSet"))), k -> new int[3]);
|
||||
c["INBOUND".equals(row.getValueString("direction")) ? 0 : 1]++;
|
||||
if(List.of("INVALID", "REJECTED").contains(row.getValueString("status")))
|
||||
{
|
||||
c[2]++;
|
||||
}
|
||||
}
|
||||
List<Map<String, Object>> rows = new ArrayList<>();
|
||||
int[] totals = new int[3];
|
||||
for(Map.Entry<List<String>, int[]> entry : counts.entrySet())
|
||||
{
|
||||
int[] c = entry.getValue();
|
||||
rows.add(row("Partner", entry.getKey().get(0), "Document", entry.getKey().get(1), "In / out / rejected", c[0] + " / " + c[1] + " / " + c[2]));
|
||||
for(int i = 0; i < 3; i++)
|
||||
{
|
||||
totals[i] += c[i];
|
||||
}
|
||||
}
|
||||
rows.add(row("Partner", "Total", "Document", "", "In / out / rejected", totals[0] + " / " + totals[1] + " / " + totals[2]));
|
||||
return (table(List.of("Partner", "Document", "In / out / rejected"), rows, "edi_transaction", filter, "View documents"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Integration and queue state as one compact two-column table.
|
||||
*******************************************************************************/
|
||||
private static RenderWidgetOutput system(Instant now) throws QException
|
||||
{
|
||||
boolean secured = com.kingsrook.qqq.backend.core.context.QContext.getQInstance().getQBits().values().stream().anyMatch(q -> "qbit-secured-data".equals(q.getArtifactId()));
|
||||
String store = EdiRuntime.current().config().getPayloadStore();
|
||||
List<QRecord> oldestIn = query("edi_inbox", inboxBacklog().withOrderBy(new QFilterOrderBy("receivedAt", true)).withLimit(1));
|
||||
List<QRecord> oldestOut = query("edi_outbound_message", outboundQueue().withOrderBy(new QFilterOrderBy("createDate", true)).withLimit(1));
|
||||
List<Map<String, Object>> rows = new ArrayList<>();
|
||||
rows.add(row("Item", "Payload storage", "Status", "DATABASE".equals(store) ? "Database, encrypted (demos and tests)" : "Object storage, encrypted"));
|
||||
rows.add(row("Item", "Secured data", "Status", secured ? "Installed" : "Not installed: PHI fields are not masked"));
|
||||
rows.add(row("Item", "External code sets", "Status", EdiRuntime.current().config().getExternalCodeSetProvider() == null ? "Format only: no provider configured" : "Provider configured"));
|
||||
rows.add(row("Item", "Inbound backlog", "Status", countOf("edi_inbox", inboxBacklog())
|
||||
+ (oldestIn.isEmpty() ? "" : ", oldest " + humanize(Duration.between(oldestIn.getFirst().getValueInstant("receivedAt"), now)))));
|
||||
rows.add(row("Item", "Outbound queue", "Status", countOf("edi_outbound_message", outboundQueue())
|
||||
+ (oldestOut.isEmpty() ? "" : ", oldest " + humanize(Duration.between(oldestOut.getFirst().getValueInstant("createDate"), now)))));
|
||||
return (table(List.of("Item", "Status"), rows, "edi_outbound_message", outboundQueue(), "View outbound queue"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Inbox rows not yet processed.
|
||||
*******************************************************************************/
|
||||
private static QQueryFilter inboxBacklog()
|
||||
{
|
||||
return (new QQueryFilter().withCriteria("status", QCriteriaOperator.IN, "PENDING", "PROCESSING", "RETRY_WAIT"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Outbound messages waiting to send.
|
||||
*******************************************************************************/
|
||||
private static QQueryFilter outboundQueue()
|
||||
{
|
||||
return (new QQueryFilter().withCriteria("status", QCriteriaOperator.IN, "QUEUED", "RETRY_WAIT"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Partner names readable by the viewing user.
|
||||
*******************************************************************************/
|
||||
private static Map<Integer, String> partnerNames() throws QException
|
||||
{
|
||||
Map<Integer, String> names = new HashMap<>();
|
||||
for(QRecord partner : query("edi_partner", new QQueryFilter()))
|
||||
{
|
||||
names.put(partner.getValueInteger("id"), partner.getValueString("name"));
|
||||
}
|
||||
return (names);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A partner's name, or a plain placeholder.
|
||||
*******************************************************************************/
|
||||
private static String partnerName(Map<Integer, String> names, Integer id)
|
||||
{
|
||||
return (id == null ? "Unrouted" : names.getOrDefault(id, "Partner " + id));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A transaction set with its plain name.
|
||||
*******************************************************************************/
|
||||
static String documentName(String transactionSet)
|
||||
{
|
||||
String name = DOCUMENT_NAMES.get(transactionSet);
|
||||
return (name == null ? String.valueOf(transactionSet) : transactionSet + " " + name);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** UPPER_SNAKE enum value as a sentence-case label.
|
||||
*******************************************************************************/
|
||||
static String plain(String value)
|
||||
{
|
||||
if(value == null || value.isEmpty())
|
||||
{
|
||||
return ("");
|
||||
}
|
||||
String lower = value.toLowerCase().replace('_', ' ');
|
||||
return (Character.toUpperCase(lower.charAt(0)) + lower.substring(1));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Compact human duration: 45 s, 12 m, 3 h 5 m, 2 d 4 h.
|
||||
*******************************************************************************/
|
||||
static String humanize(Duration duration)
|
||||
{
|
||||
long seconds = Math.max(0L, duration.getSeconds());
|
||||
if(seconds < 60)
|
||||
{
|
||||
return (seconds + " s");
|
||||
}
|
||||
long minutes = seconds / 60;
|
||||
if(minutes < 60)
|
||||
{
|
||||
return (minutes + " m");
|
||||
}
|
||||
long hours = minutes / 60;
|
||||
if(hours < 24)
|
||||
{
|
||||
return (hours + " h" + (minutes % 60 == 0 ? "" : " " + (minutes % 60) + " m"));
|
||||
}
|
||||
return (hours / 24 + " d" + (hours % 24 == 0 ? "" : " " + (hours % 24) + " h"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** An ordered row from alternating keys and values.
|
||||
*******************************************************************************/
|
||||
private static Map<String, Object> row(Object... keysAndValues)
|
||||
{
|
||||
Map<String, Object> row = new LinkedHashMap<>();
|
||||
for(int i = 0; i < keysAndValues.length; i += 2)
|
||||
{
|
||||
row.put(String.valueOf(keysAndValues[i]), keysAndValues[i + 1]);
|
||||
}
|
||||
return (row);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A typed table with a link to the filtered table.
|
||||
*******************************************************************************/
|
||||
private static RenderWidgetOutput table(List<String> columns, List<Map<String, Object>> rows, String linkTable, QQueryFilter linkFilter, String linkText) throws QException
|
||||
{
|
||||
return (new RenderWidgetOutput(tableData(columns, rows).withLinkURL(link(linkTable, linkFilter)).withLinkText(linkText)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Plain cells allow typed rendering and never contain HTML.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.edi.app;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/*******************************************************************************
|
||||
** The Setup status on the EDI home: each setup step in the order it has to be
|
||||
** done (partner, identity, agreement, document, transport), whether it is
|
||||
** done, and the next step. The counts use the same conditions the runtime
|
||||
** uses to route documents (active rows), so the card never reports "set up"
|
||||
** for rows the runtime ignores.
|
||||
*******************************************************************************/
|
||||
public final class EdiSetupStatus
|
||||
{
|
||||
/*******************************************************************************
|
||||
** DONE: working. TODO: the exchange needs it. BUILT_IN: provided by the
|
||||
** qbit. OPTIONAL: only some setups need it. UNKNOWN: its count failed (for
|
||||
** example the viewer cannot read the table), shown as "Unavailable".
|
||||
*******************************************************************************/
|
||||
public enum State
|
||||
{
|
||||
DONE("Set up"), TODO("Needs setup"), BUILT_IN("Built in"), OPTIONAL("Optional"), UNKNOWN("Unavailable");
|
||||
|
||||
private final String label;
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
State(String label)
|
||||
{
|
||||
this.label = label;
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** The label shown on the card.
|
||||
*******************************************************************************/
|
||||
public String label()
|
||||
{
|
||||
return (label);
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** One line of the card.
|
||||
*******************************************************************************/
|
||||
public record Item(String step, String tableName, State state, String detail, String nextStep)
|
||||
{
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** The row counts the card is built from; null means the count failed and the
|
||||
** step shows as unavailable. Active counts are rows with isActive true (what
|
||||
** the runtime uses).
|
||||
*******************************************************************************/
|
||||
public record Counts(Integer partners, Integer ownIdentities, Integer partnerIdentities, Integer agreements, Integer activeAgreements, Integer agreementDocuments,
|
||||
Integer activeAgreementDocuments, Integer transports, Integer activeTransports, Integer certificates, Integer guides, Integer codeLists,
|
||||
Integer maps, Integer activeMaps)
|
||||
{
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private EdiSetupStatus()
|
||||
{
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** The items, in setup order.
|
||||
*******************************************************************************/
|
||||
public static List<Item> items(Counts c)
|
||||
{
|
||||
List<Item> items = new ArrayList<>();
|
||||
items.add(item("1. Partner", "edi_partner", c.partners(), c.partners(), "partner", "Add the trading partner with New on Partners."));
|
||||
items.add(identities(c));
|
||||
items.add(item("3. Agreement", "edi_agreement", c.agreements(), c.activeAgreements(), "agreement",
|
||||
"Add an agreement on Agreements: your identity, the partner's identity, the standard and test or production. Make sure Active is on."));
|
||||
items.add(item("4. Agreement documents", "edi_agreement_document", c.agreementDocuments(), c.activeAgreementDocuments(), "agreement document",
|
||||
"Add one per transaction set and direction on Agreement Documents: pick the guide, and a map if it should become records. A document type with no row is not processed."));
|
||||
items.add(item("5. Transport", "edi_transport", c.transports(), c.activeTransports(), "transport",
|
||||
"Add the AS2, SFTP or HTTPS connection on Transports (secrets as variable names), link it on the agreement, then run Test Connection."));
|
||||
items.add(c.certificates() == null ? new Item("Certificates", "edi_certificate", State.UNKNOWN, "", "")
|
||||
: c.certificates() == 0
|
||||
? new Item("Certificates", "edi_certificate", State.OPTIONAL, "None.", "Needed only for AS2 signing and encryption: add yours and the partner's on Certificates.")
|
||||
: new Item("Certificates", "edi_certificate", State.DONE, c.certificates() + (c.certificates() == 1 ? " certificate." : " certificates."), ""));
|
||||
items.add(c.guides() == null || c.codeLists() == null ? new Item("Guides and code lists", "edi_guide", State.UNKNOWN, "", "")
|
||||
: c.guides() == 0 || c.codeLists() == 0
|
||||
? new Item("Guides and code lists", "edi_guide", State.TODO, "The built-in guides are not loaded.", "The host must call EdiRuntime.initialize() at startup, after the schema exists.")
|
||||
: new Item("Guides and code lists", "edi_guide", State.BUILT_IN, c.guides() + " guides and " + c.codeLists() + " code lists.", ""));
|
||||
items.add(c.maps() == null || c.activeMaps() == null ? new Item("Maps", "edi_map", State.UNKNOWN, "", "")
|
||||
: c.maps() == 0
|
||||
? new Item("Maps", "edi_map", State.OPTIONAL, "None.", "Needed to turn documents into records. Run Install sample maps on Maps to start from an example.")
|
||||
: c.activeMaps() == 0
|
||||
? new Item("Maps", "edi_map", State.TODO, c.maps() + " maps, none active.", "Point a map at your table, preview it, then run Activate Map.")
|
||||
: new Item("Maps", "edi_map", State.DONE, c.activeMaps() + " active of " + c.maps() + ".", ""));
|
||||
return (items);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Step 2: your own identity (no partner) and the partner's.
|
||||
*******************************************************************************/
|
||||
private static Item identities(Counts c)
|
||||
{
|
||||
String step = "2. Identities";
|
||||
if(c.ownIdentities() == null || c.partnerIdentities() == null)
|
||||
{
|
||||
return (new Item(step, "edi_identity", State.UNKNOWN, "", ""));
|
||||
}
|
||||
if(c.ownIdentities() == 0)
|
||||
{
|
||||
return (new Item(step, "edi_identity", State.TODO, "None of yours.", "Add your own ISA or UNB qualifier and ID on Identities, with no partner."));
|
||||
}
|
||||
if(c.partnerIdentities() == 0)
|
||||
{
|
||||
return (new Item(step, "edi_identity", State.TODO, "Yours is set; no partner has one.", "Add the partner's qualifier and ID on Identities, with the partner selected."));
|
||||
}
|
||||
return (new Item(step, "edi_identity", State.DONE, c.ownIdentities() + " of yours, " + c.partnerIdentities() + " for partners.", ""));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** A step that needs at least one active row.
|
||||
*******************************************************************************/
|
||||
private static Item item(String step, String table, Integer total, Integer active, String noun, String next)
|
||||
{
|
||||
if(total == null || active == null)
|
||||
{
|
||||
return (new Item(step, table, State.UNKNOWN, "", ""));
|
||||
}
|
||||
if(total == 0)
|
||||
{
|
||||
return (new Item(step, table, State.TODO, "None.", next));
|
||||
}
|
||||
if(active == 0)
|
||||
{
|
||||
return (new Item(step, table, State.TODO, total + " " + noun + (total == 1 ? "" : "s") + ", none active.", "Turn Active on for the one to use."));
|
||||
}
|
||||
return (new Item(step, table, State.DONE, active + " active.", ""));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,646 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.edi.as2;
|
||||
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.cert.X509Certificate;
|
||||
import java.util.Arrays;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import com.helger.as2lib.crypto.BCCryptoHelper;
|
||||
import com.helger.as2lib.crypto.ECryptoAlgorithmCrypt;
|
||||
import com.helger.as2lib.crypto.ECryptoAlgorithmSign;
|
||||
import com.helger.as2lib.crypto.MIC;
|
||||
import com.helger.mail.cte.EContentTransferEncoding;
|
||||
import com.kingsrook.qbits.edi.as2.CertificateService.LocalKey;
|
||||
import jakarta.activation.DataHandler;
|
||||
import jakarta.mail.internet.ContentType;
|
||||
import jakarta.mail.internet.MimeBodyPart;
|
||||
import jakarta.mail.internet.MimeMultipart;
|
||||
import jakarta.mail.internet.MimeUtility;
|
||||
import jakarta.mail.util.ByteArrayDataSource;
|
||||
import org.bouncycastle.cms.CMSEnvelopedData;
|
||||
import org.bouncycastle.cms.CMSEnvelopedDataGenerator;
|
||||
import org.bouncycastle.cms.CMSProcessableByteArray;
|
||||
import org.bouncycastle.cms.CMSSignerDigestMismatchException;
|
||||
import org.bouncycastle.cms.RecipientInformation;
|
||||
import org.bouncycastle.cms.SignerInformation;
|
||||
import org.bouncycastle.cms.jcajce.JcaSimpleSignerInfoVerifierBuilder;
|
||||
import org.bouncycastle.cms.jcajce.JceCMSContentEncryptorBuilder;
|
||||
import org.bouncycastle.cms.jcajce.JceKeyTransEnvelopedRecipient;
|
||||
import org.bouncycastle.cms.jcajce.JceKeyTransRecipientId;
|
||||
import org.bouncycastle.cms.jcajce.JceKeyTransRecipientInfoGenerator;
|
||||
import org.bouncycastle.cms.jcajce.ZlibCompressor;
|
||||
import org.bouncycastle.cms.jcajce.ZlibExpanderProvider;
|
||||
import org.bouncycastle.mail.smime.SMIMECompressed;
|
||||
import org.bouncycastle.mail.smime.SMIMECompressedGenerator;
|
||||
import org.bouncycastle.mail.smime.SMIMEEnveloped;
|
||||
import org.bouncycastle.mail.smime.SMIMESigned;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The AS2 cryptographic layer, entirely in memory. S/MIME signing and the
|
||||
** algorithm tables use as2-lib's BouncyCastle helper; encryption, decryption,
|
||||
** signature verification, compression and the MIC use BouncyCastle directly,
|
||||
** because as2-lib's decrypt and verify stage content in temporary files and this
|
||||
** qbit never writes local files. The MIC is computed over the exact bytes of the
|
||||
** signed (or, when unsigned, the transmitted) MIME entity, headers included, so
|
||||
** both ends agree whatever transfer encoding was used. Messages follow RFC 4130
|
||||
** (AS2), RFC 5751 (S/MIME) and RFC 5402 (compression).
|
||||
*******************************************************************************/
|
||||
public final class As2Crypto
|
||||
{
|
||||
private static final BCCryptoHelper HELPER = new BCCryptoHelper();
|
||||
private static final String PROVIDER = BCCryptoHelper.DEFAULT_SECURITY_PROVIDER_NAME;
|
||||
private static final byte[] HEADER_END = "\r\n\r\n".getBytes(StandardCharsets.US_ASCII);
|
||||
private static final byte[] LINE_END = "\r\n".getBytes(StandardCharsets.US_ASCII);
|
||||
|
||||
public static final long MAX_EXPANDED_BYTES = 256L * 1024 * 1024;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The result of unwrapping an inbound message: the EDI payload, what protection
|
||||
** was applied and the MIC of the signed (or received) content.
|
||||
*******************************************************************************/
|
||||
public record Decoded(byte[] payload, String fileName, Boolean encrypted, Boolean signed, Boolean compressed, MIC mic)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A MIME entity split for HTTP: the Content-Type, transfer encoding and
|
||||
** disposition go in HTTP headers, the body is the HTTP body.
|
||||
*******************************************************************************/
|
||||
public record Wire(String contentType, String transferEncoding, String contentDisposition, byte[] body)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private As2Crypto()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Map the signAlgorithm setting; null means unsigned.
|
||||
*******************************************************************************/
|
||||
public static ECryptoAlgorithmSign signAlgorithm(String setting)
|
||||
{
|
||||
if(setting == null || setting.isBlank())
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
return (switch(setting)
|
||||
{
|
||||
case "SHA256" -> ECryptoAlgorithmSign.DIGEST_SHA_256;
|
||||
case "SHA384" -> ECryptoAlgorithmSign.DIGEST_SHA_384;
|
||||
case "SHA512" -> ECryptoAlgorithmSign.DIGEST_SHA_512;
|
||||
case "SHA1" -> ECryptoAlgorithmSign.DIGEST_SHA_1;
|
||||
default -> throw new IllegalArgumentException("Unsupported signAlgorithm");
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Map the encryptAlgorithm setting; null means unencrypted.
|
||||
*******************************************************************************/
|
||||
public static ECryptoAlgorithmCrypt encryptAlgorithm(String setting)
|
||||
{
|
||||
if(setting == null || setting.isBlank())
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
return (switch(setting)
|
||||
{
|
||||
case "AES128_CBC" -> ECryptoAlgorithmCrypt.CRYPT_AES128_CBC;
|
||||
case "AES256_CBC" -> ECryptoAlgorithmCrypt.CRYPT_AES256_CBC;
|
||||
case "AES128_GCM" -> ECryptoAlgorithmCrypt.CRYPT_AES128_GCM;
|
||||
case "AES256_GCM" -> ECryptoAlgorithmCrypt.CRYPT_AES256_GCM;
|
||||
case "3DES" -> ECryptoAlgorithmCrypt.CRYPT_3DES;
|
||||
default -> throw new IllegalArgumentException("Unsupported encryptAlgorithm");
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** SHA1 signatures and 3DES encryption are accepted only when set explicitly,
|
||||
** and are flagged as weak.
|
||||
*******************************************************************************/
|
||||
public static boolean isWeak(String sign, String encrypt)
|
||||
{
|
||||
return ("SHA1".equals(sign) || "3DES".equals(encrypt));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Parse a complete MIME entity (headers, blank line, body) held in memory.
|
||||
*******************************************************************************/
|
||||
public static MimeBodyPart parse(byte[] entity) throws As2Exception
|
||||
{
|
||||
try
|
||||
{
|
||||
return (new MimeBodyPart(new ByteArrayInputStream(entity)));
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
throw (new As2Exception("The AS2 message is not a valid MIME entity", As2Exception.UNEXPECTED_PROCESSING));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Rebuild the MIME entity of an HTTP request or response from its Content-Type,
|
||||
** Content-Transfer-Encoding and Content-Disposition headers and its body.
|
||||
*******************************************************************************/
|
||||
public static MimeBodyPart fromHttp(String contentType, String transferEncoding, String contentDisposition, byte[] body) throws As2Exception
|
||||
{
|
||||
return (parse(entity(contentType, transferEncoding, contentDisposition, body)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The MIME entity bytes (headers, blank line, body) for HTTP-level parts. Header
|
||||
** values with line breaks are refused.
|
||||
*******************************************************************************/
|
||||
public static byte[] entity(String contentType, String transferEncoding, String contentDisposition, byte[] body) throws As2Exception
|
||||
{
|
||||
for(String value : Arrays.asList(contentType, transferEncoding, contentDisposition))
|
||||
{
|
||||
if(value != null && (value.contains("\r") || value.contains("\n")))
|
||||
{
|
||||
throw (new As2Exception("The AS2 message has an invalid header", As2Exception.UNEXPECTED_PROCESSING));
|
||||
}
|
||||
}
|
||||
if(contentType == null || contentType.isBlank())
|
||||
{
|
||||
throw (new As2Exception("The AS2 message has no valid Content-Type", As2Exception.UNEXPECTED_PROCESSING));
|
||||
}
|
||||
ByteArrayOutputStream entity = new ByteArrayOutputStream();
|
||||
String headers = "Content-Type: " + contentType + "\r\n" + (transferEncoding == null || transferEncoding.isBlank() ? "" : "Content-Transfer-Encoding: " + transferEncoding + "\r\n")
|
||||
+ (contentDisposition == null || contentDisposition.isBlank() ? "" : "Content-Disposition: " + contentDisposition + "\r\n") + "\r\n";
|
||||
entity.writeBytes(headers.getBytes(StandardCharsets.ISO_8859_1));
|
||||
entity.writeBytes(body);
|
||||
return (entity.toByteArray());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Serialize a MIME entity (headers and encoded body).
|
||||
*******************************************************************************/
|
||||
public static byte[] toBytes(MimeBodyPart part) throws As2Exception
|
||||
{
|
||||
try
|
||||
{
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||
part.writeTo(out);
|
||||
return (out.toByteArray());
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
throw (new As2Exception("The AS2 message could not be serialized", As2Exception.UNEXPECTED_PROCESSING));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Split a MIME entity into the HTTP headers that describe it and its body.
|
||||
*******************************************************************************/
|
||||
public static Wire toWire(MimeBodyPart part) throws As2Exception
|
||||
{
|
||||
return (splitRaw(toBytes(part)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Split already serialized MIME entity bytes into the Content-Type,
|
||||
** Content-Transfer-Encoding and Content-Disposition values and the body,
|
||||
** without re-serializing (which could invalidate a signature).
|
||||
*******************************************************************************/
|
||||
public static Wire splitRaw(byte[] bytes) throws As2Exception
|
||||
{
|
||||
int split = indexOf(bytes, HEADER_END, 0);
|
||||
if(split < 0)
|
||||
{
|
||||
throw (new As2Exception("The AS2 message has no body", As2Exception.UNEXPECTED_PROCESSING));
|
||||
}
|
||||
String contentType = null;
|
||||
String encoding = null;
|
||||
String disposition = null;
|
||||
String current = null;
|
||||
StringBuilder value = new StringBuilder();
|
||||
for(String line : (new String(bytes, 0, split, StandardCharsets.ISO_8859_1) + "\r\nX-End: end").split("\r\n"))
|
||||
{
|
||||
if((line.startsWith(" ") || line.startsWith("\t")) && current != null)
|
||||
{
|
||||
value.append(' ').append(line.trim());
|
||||
continue;
|
||||
}
|
||||
if(current != null)
|
||||
{
|
||||
contentType = "content-type".equals(current) ? value.toString() : contentType;
|
||||
encoding = "content-transfer-encoding".equals(current) ? value.toString() : encoding;
|
||||
disposition = "content-disposition".equals(current) ? value.toString() : disposition;
|
||||
}
|
||||
int colon = line.indexOf(':');
|
||||
current = colon < 0 ? null : line.substring(0, colon).trim().toLowerCase(java.util.Locale.ROOT);
|
||||
value = new StringBuilder(colon < 0 ? "" : line.substring(colon + 1).trim());
|
||||
}
|
||||
byte[] body = new byte[bytes.length - split - HEADER_END.length];
|
||||
System.arraycopy(bytes, split + HEADER_END.length, body, 0, body.length);
|
||||
return (new Wire(contentType, encoding, disposition, body));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The EDI document as a binary MIME part.
|
||||
*******************************************************************************/
|
||||
public static MimeBodyPart payloadPart(byte[] payload, String contentType, String fileName) throws As2Exception
|
||||
{
|
||||
try
|
||||
{
|
||||
MimeBodyPart part = new MimeBodyPart();
|
||||
part.setDataHandler(new DataHandler(new ByteArrayDataSource(payload, contentType)));
|
||||
part.setHeader("Content-Type", contentType);
|
||||
part.setHeader("Content-Transfer-Encoding", "binary");
|
||||
part.setHeader("Content-Disposition", "attachment; filename=\"" + fileName + "\"");
|
||||
return (part);
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
throw (new As2Exception("The EDI document could not be wrapped for AS2", As2Exception.UNEXPECTED_PROCESSING));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Compress a part (RFC 5402, zlib).
|
||||
*******************************************************************************/
|
||||
public static MimeBodyPart compress(MimeBodyPart part) throws As2Exception
|
||||
{
|
||||
try
|
||||
{
|
||||
SMIMECompressedGenerator generator = new SMIMECompressedGenerator();
|
||||
generator.setContentTransferEncoding("binary");
|
||||
return (generator.generate(part, new ZlibCompressor()));
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
throw (new As2Exception("The AS2 message could not be compressed", As2Exception.UNEXPECTED_PROCESSING));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The MIC of a MIME entity: the digest of its exact bytes, headers included
|
||||
** (RFC 4130 section 7.3.1).
|
||||
*******************************************************************************/
|
||||
public static MIC mic(byte[] entity, ECryptoAlgorithmSign algorithm) throws As2Exception
|
||||
{
|
||||
try
|
||||
{
|
||||
return (new MIC(MessageDigest.getInstance(algorithm.getID().toUpperCase(java.util.Locale.ROOT)).digest(entity), algorithm));
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
throw (new As2Exception("The message integrity check could not be computed", As2Exception.UNEXPECTED_PROCESSING));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The exact bytes of the first body part of a multipart entity: the signed
|
||||
** content of multipart/signed, without the delimiter lines around it.
|
||||
*******************************************************************************/
|
||||
public static byte[] firstPart(String contentType, byte[] body) throws As2Exception
|
||||
{
|
||||
try
|
||||
{
|
||||
String boundary = new ContentType(contentType).getParameter("boundary");
|
||||
int open = indexOf(body, ("--" + boundary).getBytes(StandardCharsets.ISO_8859_1), 0);
|
||||
int start = open < 0 ? -1 : indexOf(body, LINE_END, open) + LINE_END.length;
|
||||
int end = start < 0 ? -1 : indexOf(body, ("\r\n--" + boundary).getBytes(StandardCharsets.ISO_8859_1), start - LINE_END.length);
|
||||
if(end < 0)
|
||||
{
|
||||
throw (new IllegalArgumentException("no signed content"));
|
||||
}
|
||||
return (Arrays.copyOfRange(body, start, end));
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
throw (new As2Exception("The signed AS2 message is malformed", As2Exception.AUTHENTICATION_FAILED));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Sign a part into multipart/signed with the local key.
|
||||
*******************************************************************************/
|
||||
public static MimeBodyPart sign(MimeBodyPart part, LocalKey key, ECryptoAlgorithmSign algorithm) throws As2Exception
|
||||
{
|
||||
try
|
||||
{
|
||||
return (HELPER.sign(part, key.certificate(), key.key(), algorithm, true, algorithm == ECryptoAlgorithmSign.DIGEST_SHA_1, false, EContentTransferEncoding.BINARY));
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
throw (new As2Exception("The AS2 message could not be signed with certificate " + key.row().getValueString("alias") + ": " + e.getClass().getSimpleName(), As2Exception.UNEXPECTED_PROCESSING));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Encrypt the exact bytes of a MIME entity to the partner certificate (CMS
|
||||
** enveloped data), returning the application/pkcs7-mime wire message.
|
||||
*******************************************************************************/
|
||||
public static Wire encrypt(byte[] entity, X509Certificate certificate, ECryptoAlgorithmCrypt algorithm) throws As2Exception
|
||||
{
|
||||
try
|
||||
{
|
||||
CMSEnvelopedDataGenerator generator = new CMSEnvelopedDataGenerator();
|
||||
generator.addRecipientInfoGenerator(new JceKeyTransRecipientInfoGenerator(certificate).setProvider(PROVIDER));
|
||||
CMSEnvelopedData enveloped = generator.generate(new CMSProcessableByteArray(entity), new JceCMSContentEncryptorBuilder(algorithm.getOID()).setProvider(PROVIDER).build());
|
||||
return (new Wire("application/pkcs7-mime; smime-type=enveloped-data; name=smime.p7m", "binary", "attachment; filename=\"smime.p7m\"", enveloped.getEncoded()));
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
throw (new As2Exception("The AS2 message could not be encrypted: " + e.getClass().getSimpleName(), As2Exception.UNEXPECTED_PROCESSING));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Unwrap an inbound entity: decrypt with any of our keys, verify the signature
|
||||
** against the partner certificates, decompress, and compute the MIC over the
|
||||
** exact signed bytes (or, when unsigned, the entity after decryption). Layers
|
||||
** may nest in any order (RFC 5402 allows compression before or after signing).
|
||||
*******************************************************************************/
|
||||
public static Decoded decode(byte[] entity, List<LocalKey> keys, List<X509Certificate> partnerCertificates, ECryptoAlgorithmSign micAlgorithm, Date now) throws As2Exception
|
||||
{
|
||||
byte[] raw = entity;
|
||||
byte[] micBytes = raw;
|
||||
MimeBodyPart part = parse(raw);
|
||||
boolean encrypted = false;
|
||||
boolean signed = false;
|
||||
boolean compressed = false;
|
||||
for(int depth = 0; depth < 6; depth++)
|
||||
{
|
||||
if(isEncrypted(part))
|
||||
{
|
||||
raw = decrypt(part, keys);
|
||||
part = parse(raw);
|
||||
micBytes = signed ? micBytes : raw;
|
||||
encrypted = true;
|
||||
}
|
||||
else if(isSigned(part))
|
||||
{
|
||||
verify(part, partnerCertificates, now);
|
||||
Wire wire = splitRaw(raw);
|
||||
raw = firstPart(wire.contentType(), wire.body());
|
||||
micBytes = raw;
|
||||
part = parse(raw);
|
||||
signed = true;
|
||||
}
|
||||
else if(isCompressed(part))
|
||||
{
|
||||
raw = expand(part);
|
||||
part = parse(raw);
|
||||
compressed = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
try
|
||||
{
|
||||
MIC mic = micAlgorithm == null ? null : mic(micBytes, micAlgorithm);
|
||||
return (new Decoded(part.getInputStream().readAllBytes(), part.getFileName(), encrypted, signed, compressed, mic));
|
||||
}
|
||||
catch(As2Exception e)
|
||||
{
|
||||
throw (e);
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
throw (new As2Exception("The AS2 payload could not be read", As2Exception.UNEXPECTED_PROCESSING));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Verify a multipart/signed entity against the candidate certificates and
|
||||
** return the signed content. A digest that does not match the content is an
|
||||
** integrity failure; a signature no candidate certificate verifies is an
|
||||
** authentication failure. Expired certificates are not accepted.
|
||||
*******************************************************************************/
|
||||
public static MimeBodyPart verify(MimeBodyPart part, List<X509Certificate> certificates, Date now) throws As2Exception
|
||||
{
|
||||
SMIMESigned signed;
|
||||
try
|
||||
{
|
||||
signed = new SMIMESigned(new MimeMultipart(new ByteArrayDataSource(part.getRawInputStream().readAllBytes(), MimeUtility.unfold(part.getContentType()))));
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
throw (new As2Exception("The signed AS2 message is malformed", As2Exception.AUTHENTICATION_FAILED));
|
||||
}
|
||||
for(SignerInformation signer : signed.getSignerInfos().getSigners())
|
||||
{
|
||||
for(X509Certificate certificate : certificates)
|
||||
{
|
||||
if(certificate.getNotAfter().before(now))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
try
|
||||
{
|
||||
if(signer.verify(new JcaSimpleSignerInfoVerifierBuilder().setProvider(PROVIDER).build(certificate)))
|
||||
{
|
||||
return (signed.getContent());
|
||||
}
|
||||
}
|
||||
catch(CMSSignerDigestMismatchException e)
|
||||
{
|
||||
throw (new As2Exception("The message digest does not match the signed content", As2Exception.INTEGRITY_CHECK_FAILED));
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
// This certificate cannot verify the signature; try the next.
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
throw (new As2Exception("The signature could not be verified with the partner certificates", As2Exception.AUTHENTICATION_FAILED));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Decrypt with the first of our keys the message was encrypted to, returning
|
||||
** the clear MIME entity bytes.
|
||||
*******************************************************************************/
|
||||
private static byte[] decrypt(MimeBodyPart part, List<LocalKey> keys) throws As2Exception
|
||||
{
|
||||
SMIMEEnveloped enveloped;
|
||||
try
|
||||
{
|
||||
enveloped = new SMIMEEnveloped(part);
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
throw (new As2Exception("The encrypted AS2 message is malformed", As2Exception.DECRYPTION_FAILED));
|
||||
}
|
||||
for(LocalKey key : keys)
|
||||
{
|
||||
try
|
||||
{
|
||||
RecipientInformation recipient = enveloped.getRecipientInfos().get(new JceKeyTransRecipientId(key.certificate()));
|
||||
if(recipient != null)
|
||||
{
|
||||
return (recipient.getContent(new JceKeyTransEnvelopedRecipient(key.key()).setProvider(PROVIDER)));
|
||||
}
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
// The key matched the recipient but could not decrypt; try the next key.
|
||||
continue;
|
||||
}
|
||||
}
|
||||
throw (new As2Exception("The message was not encrypted to an accepted certificate", As2Exception.DECRYPTION_FAILED));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Inflate a compressed-data entity, bounded to prevent decompression bombs.
|
||||
*******************************************************************************/
|
||||
private static byte[] expand(MimeBodyPart part) throws As2Exception
|
||||
{
|
||||
try
|
||||
{
|
||||
return (new SMIMECompressed(part).getContent(new ZlibExpanderProvider(MAX_EXPANDED_BYTES)));
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
throw (new As2Exception("The compressed AS2 message could not be expanded", As2Exception.UNEXPECTED_PROCESSING));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** True for application/pkcs7-mime enveloped-data.
|
||||
*******************************************************************************/
|
||||
private static boolean isEncrypted(MimeBodyPart part) throws As2Exception
|
||||
{
|
||||
try
|
||||
{
|
||||
return (HELPER.isEncrypted(part));
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
throw (new As2Exception("The AS2 Content-Type is invalid", As2Exception.UNEXPECTED_PROCESSING));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** True for multipart/signed.
|
||||
*******************************************************************************/
|
||||
private static boolean isSigned(MimeBodyPart part) throws As2Exception
|
||||
{
|
||||
try
|
||||
{
|
||||
return (HELPER.isSigned(part));
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
throw (new As2Exception("The AS2 Content-Type is invalid", As2Exception.UNEXPECTED_PROCESSING));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** True for application/pkcs7-mime compressed-data.
|
||||
*******************************************************************************/
|
||||
private static boolean isCompressed(MimeBodyPart part) throws As2Exception
|
||||
{
|
||||
try
|
||||
{
|
||||
return (HELPER.isCompressed(part.getContentType()));
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
throw (new As2Exception("The AS2 Content-Type is invalid", As2Exception.UNEXPECTED_PROCESSING));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Index of a byte sequence at or after a position, or -1.
|
||||
*******************************************************************************/
|
||||
private static int indexOf(byte[] data, byte[] pattern, int from)
|
||||
{
|
||||
for(int i = Math.max(from, 0); i <= data.length - pattern.length; i++)
|
||||
{
|
||||
int j = 0;
|
||||
while(j < pattern.length && data[i + j] == pattern[j])
|
||||
{
|
||||
j++;
|
||||
}
|
||||
if(j == pattern.length)
|
||||
{
|
||||
return (i);
|
||||
}
|
||||
}
|
||||
return (-1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.edi.as2;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A plain, value-free AS2 failure. The message never carries key material,
|
||||
** secrets or payload content; the optional code is the RFC 4130 disposition
|
||||
** modifier a receiver reports to the sender (for example authentication-failed).
|
||||
*******************************************************************************/
|
||||
public class As2Exception extends Exception
|
||||
{
|
||||
public static final String DECRYPTION_FAILED = "decryption-failed";
|
||||
public static final String AUTHENTICATION_FAILED = "authentication-failed";
|
||||
public static final String INTEGRITY_CHECK_FAILED = "integrity-check-failed";
|
||||
public static final String INSUFFICIENT_SECURITY = "insufficient-message-security";
|
||||
public static final String UNEXPECTED_PROCESSING = "unexpected-processing-error";
|
||||
|
||||
private final String code;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Failure without a disposition code (configuration and local problems).
|
||||
*******************************************************************************/
|
||||
public As2Exception(String message)
|
||||
{
|
||||
this(message, null);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Failure carrying the RFC 4130 disposition modifier.
|
||||
*******************************************************************************/
|
||||
public As2Exception(String message, String code)
|
||||
{
|
||||
super(message);
|
||||
this.code = code;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The RFC 4130 disposition modifier, or null when the failure is local.
|
||||
*******************************************************************************/
|
||||
public String getCode()
|
||||
{
|
||||
return (code);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.edi.as2;
|
||||
|
||||
|
||||
import javax.net.ssl.SSLContext;
|
||||
import javax.net.ssl.TrustManager;
|
||||
import javax.net.ssl.X509TrustManager;
|
||||
import java.io.InputStream;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.SecureRandom;
|
||||
import java.security.cert.CertificateException;
|
||||
import java.security.cert.X509Certificate;
|
||||
import java.time.Duration;
|
||||
import java.util.Base64;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.TreeMap;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** HTTP client helpers for AS2: bounded timeouts, no redirects, optional pinning
|
||||
** of the partner TLS certificate (otherwise the JVM trust store applies), and
|
||||
** bounded response bodies.
|
||||
*******************************************************************************/
|
||||
public final class As2Http
|
||||
{
|
||||
public static final int MAX_RESPONSE_BYTES = 10 * 1024 * 1024;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Status, lower-case header names and the (bounded) body of a response.
|
||||
*******************************************************************************/
|
||||
public record Response(int status, Map<String, String> headers, byte[] body)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Accepts only the pinned certificate as the server leaf; the hostname is
|
||||
** still checked by the TLS stack.
|
||||
*******************************************************************************/
|
||||
private static final class PinnedTrustManager implements X509TrustManager
|
||||
{
|
||||
private final byte[] pinned;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Pin the SHA-256 of the encoded certificate.
|
||||
*******************************************************************************/
|
||||
private PinnedTrustManager(X509Certificate certificate) throws Exception
|
||||
{
|
||||
pinned = MessageDigest.getInstance("SHA-256").digest(certificate.getEncoded());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** This client never authenticates with a client certificate.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void checkClientTrusted(X509Certificate[] chain, String authType) throws CertificateException
|
||||
{
|
||||
throw (new CertificateException("Client certificates are not accepted"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Require the leaf to be exactly the pinned certificate.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void checkServerTrusted(X509Certificate[] chain, String authType) throws CertificateException
|
||||
{
|
||||
try
|
||||
{
|
||||
if(chain == null || chain.length == 0 || !MessageDigest.isEqual(pinned, MessageDigest.getInstance("SHA-256").digest(chain[0].getEncoded())))
|
||||
{
|
||||
throw (new CertificateException("The server certificate does not match the pinned partner TLS certificate"));
|
||||
}
|
||||
}
|
||||
catch(CertificateException e)
|
||||
{
|
||||
throw (e);
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
throw (new CertificateException("The server certificate could not be checked against the pin"));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** No trust anchors beyond the pin.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public X509Certificate[] getAcceptedIssuers()
|
||||
{
|
||||
return (new X509Certificate[0]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private As2Http()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A client using the JVM trust store, or only the pinned certificate.
|
||||
*******************************************************************************/
|
||||
public static HttpClient client(X509Certificate pinned) throws Exception
|
||||
{
|
||||
HttpClient.Builder builder = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(15)).followRedirects(HttpClient.Redirect.NEVER);
|
||||
if(pinned != null)
|
||||
{
|
||||
SSLContext context = SSLContext.getInstance("TLS");
|
||||
context.init(null, new TrustManager[] {new PinnedTrustManager(pinned)}, new SecureRandom());
|
||||
builder.sslContext(context);
|
||||
}
|
||||
return (builder.build());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Trust manager for pin checks in tests and diagnostics.
|
||||
*******************************************************************************/
|
||||
public static X509TrustManager pinnedTrustManager(X509Certificate pinned) throws Exception
|
||||
{
|
||||
return (new PinnedTrustManager(pinned));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** POST bytes with the given headers.
|
||||
*******************************************************************************/
|
||||
public static Response post(HttpClient client, URI uri, Map<String, String> headers, byte[] body, Duration timeout) throws Exception
|
||||
{
|
||||
HttpRequest.Builder request = HttpRequest.newBuilder(uri).timeout(timeout).POST(HttpRequest.BodyPublishers.ofByteArray(body));
|
||||
headers.forEach(request::header);
|
||||
return (read(client.send(request.build(), HttpResponse.BodyHandlers.ofInputStream())));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** OPTIONS probe: no body is sent.
|
||||
*******************************************************************************/
|
||||
public static Response options(HttpClient client, URI uri, Map<String, String> headers, Duration timeout) throws Exception
|
||||
{
|
||||
HttpRequest.Builder request = HttpRequest.newBuilder(uri).timeout(timeout).method("OPTIONS", HttpRequest.BodyPublishers.noBody());
|
||||
headers.forEach(request::header);
|
||||
return (read(client.send(request.build(), HttpResponse.BodyHandlers.ofInputStream())));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Read the bounded body and lower-case the header names.
|
||||
*******************************************************************************/
|
||||
private static Response read(HttpResponse<InputStream> response) throws Exception
|
||||
{
|
||||
try(InputStream stream = response.body())
|
||||
{
|
||||
Map<String, String> headers = new TreeMap<>();
|
||||
response.headers().map().forEach((name, values) -> headers.put(name.toLowerCase(Locale.ROOT), values.getFirst()));
|
||||
return (new Response(response.statusCode(), headers, stream.readNBytes(MAX_RESPONSE_BYTES)));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** An Authorization header value for HTTP Basic authentication.
|
||||
*******************************************************************************/
|
||||
public static String basic(String user, String password)
|
||||
{
|
||||
return ("Basic " + Base64.getEncoder().encodeToString((user + ":" + password).getBytes(StandardCharsets.UTF_8)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** TLS is required except on loopback, which the tests and local peers use.
|
||||
*******************************************************************************/
|
||||
public static boolean isAllowedUrl(String url)
|
||||
{
|
||||
try
|
||||
{
|
||||
URI uri = URI.create(url);
|
||||
return (uri.getHost() != null && uri.getUserInfo() == null
|
||||
&& ("https".equals(uri.getScheme()) || "http".equals(uri.getScheme()) && List.of("127.0.0.1", "localhost", "[::1]").contains(uri.getHost())));
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.edi.as2;
|
||||
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.cert.X509Certificate;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import com.helger.as2lib.crypto.ECryptoAlgorithmSign;
|
||||
import com.helger.as2lib.crypto.MIC;
|
||||
import com.kingsrook.qbits.edi.as2.CertificateService.LocalKey;
|
||||
import jakarta.mail.internet.InternetHeaders;
|
||||
import jakarta.mail.internet.MimeBodyPart;
|
||||
import jakarta.mail.internet.MimeMultipart;
|
||||
import jakarta.mail.internet.MimeUtility;
|
||||
import jakarta.mail.util.ByteArrayDataSource;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Builds and parses AS2 message disposition notifications (RFC 4130 section 7,
|
||||
** RFC 8098): multipart/report with a message/disposition-notification part,
|
||||
** optionally wrapped in multipart/signed. Memory only.
|
||||
*******************************************************************************/
|
||||
public final class As2Mdn
|
||||
{
|
||||
public static final String PROCESSED = "automatic-action/MDN-sent-automatically; processed";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A receipt ready to send or store: HTTP-level parts and the complete MIME
|
||||
** entity (headers and body) kept as the stored raw MDN.
|
||||
*******************************************************************************/
|
||||
public record Built(String messageId, As2Crypto.Wire wire, byte[] entity, Boolean signed)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A receipt read from a partner. Fields come from the disposition notification;
|
||||
** signed and signatureValid describe the signature, when there was one.
|
||||
*******************************************************************************/
|
||||
public record Parsed(String originalMessageId, String disposition, String micText, MIC mic, Boolean signed, Boolean signatureValid)
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Success means the partner processed the message (a warning still counts).
|
||||
*******************************************************************************/
|
||||
public boolean processed()
|
||||
{
|
||||
if(disposition == null)
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
int separator = disposition.indexOf(';');
|
||||
String status = (separator < 0 ? disposition : disposition.substring(separator + 1)).trim().toLowerCase(java.util.Locale.ROOT);
|
||||
return (status.equals("processed") || status.startsWith("processed/warning"));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private As2Mdn()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Disposition text for a failure, e.g. processed/error: authentication-failed.
|
||||
*******************************************************************************/
|
||||
public static String failure(String code)
|
||||
{
|
||||
return ("automatic-action/MDN-sent-automatically; processed/error: " + code);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Normalize an AS2 identifier or Message-ID: trim, drop angle brackets and
|
||||
** quotes.
|
||||
*******************************************************************************/
|
||||
public static String normalizeId(String value)
|
||||
{
|
||||
if(value == null)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
String id = value.trim();
|
||||
while(id.length() >= 2 && ((id.startsWith("<") && id.endsWith(">")) || (id.startsWith("\"") && id.endsWith("\""))))
|
||||
{
|
||||
id = id.substring(1, id.length() - 1).trim();
|
||||
}
|
||||
return (id);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Keep only characters that are safe to show and store from a remote string.
|
||||
*******************************************************************************/
|
||||
public static String sanitize(String value)
|
||||
{
|
||||
if(value == null)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
String clean = value.replaceAll("[^A-Za-z0-9 /;:._,=+@<>-]", "");
|
||||
return (clean.length() <= 200 ? clean : clean.substring(0, 200));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Build a receipt. The MIC is included for processed messages; a signing key
|
||||
** and algorithm produce a signed receipt.
|
||||
*******************************************************************************/
|
||||
public static Built build(String messageId, String ourId, String originalMessageId, String disposition, MIC mic, LocalKey key, ECryptoAlgorithmSign algorithm) throws As2Exception
|
||||
{
|
||||
try
|
||||
{
|
||||
String fields = "Reporting-UA: qbit-edi\r\nOriginal-Recipient: rfc822; " + ourId + "\r\nFinal-Recipient: rfc822; " + ourId
|
||||
+ "\r\nOriginal-Message-ID: <" + originalMessageId + ">\r\nDisposition: " + disposition + "\r\n"
|
||||
+ (mic == null ? "" : "Received-Content-MIC: " + mic.getAsAS2String() + "\r\n");
|
||||
MimeMultipart report = new MimeMultipart("report; report-type=disposition-notification");
|
||||
MimeBodyPart text = new MimeBodyPart();
|
||||
text.setText("The AS2 message " + originalMessageId + " was processed with disposition: " + disposition + "\r\n", "us-ascii");
|
||||
text.setHeader("Content-Transfer-Encoding", "7bit");
|
||||
report.addBodyPart(text);
|
||||
InternetHeaders headers = new InternetHeaders();
|
||||
headers.addHeader("Content-Type", "message/disposition-notification");
|
||||
headers.addHeader("Content-Transfer-Encoding", "7bit");
|
||||
report.addBodyPart(new MimeBodyPart(headers, fields.getBytes(StandardCharsets.US_ASCII)));
|
||||
MimeBodyPart outer = new MimeBodyPart();
|
||||
outer.setContent(report);
|
||||
outer.setHeader("Content-Type", report.getContentType());
|
||||
MimeBodyPart result = key == null ? outer : As2Crypto.sign(outer, key, algorithm);
|
||||
byte[] entity = As2Crypto.toBytes(result);
|
||||
return (new Built(messageId, As2Crypto.splitRaw(entity), entity, key != null));
|
||||
}
|
||||
catch(As2Exception e)
|
||||
{
|
||||
throw (e);
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
throw (new As2Exception("The MDN could not be built", As2Exception.UNEXPECTED_PROCESSING));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Parse a receipt. A signature is verified against the candidate certificates
|
||||
** and reported in signatureValid; an invalid one still yields the readable
|
||||
** content so the failure can be recorded, but callers must not trust it.
|
||||
*******************************************************************************/
|
||||
public static Parsed parse(String contentType, String transferEncoding, byte[] body, List<X509Certificate> certificates, Date now) throws As2Exception
|
||||
{
|
||||
MimeBodyPart part = As2Crypto.fromHttp(contentType, transferEncoding, null, body);
|
||||
boolean signed = false;
|
||||
Boolean valid = null;
|
||||
try
|
||||
{
|
||||
MimeBodyPart content = part;
|
||||
if(part.isMimeType("multipart/signed"))
|
||||
{
|
||||
signed = true;
|
||||
try
|
||||
{
|
||||
content = As2Crypto.verify(part, certificates, now);
|
||||
valid = true;
|
||||
}
|
||||
catch(As2Exception e)
|
||||
{
|
||||
valid = false;
|
||||
content = (MimeBodyPart) new MimeMultipart(new ByteArrayDataSource(part.getRawInputStream().readAllBytes(), MimeUtility.unfold(part.getContentType()))).getBodyPart(0);
|
||||
}
|
||||
}
|
||||
if(!content.isMimeType("multipart/report"))
|
||||
{
|
||||
throw (new As2Exception("The response is not a message disposition notification"));
|
||||
}
|
||||
MimeMultipart report = new MimeMultipart(new ByteArrayDataSource(content.getRawInputStream().readAllBytes(), MimeUtility.unfold(content.getContentType())));
|
||||
for(int i = 0; i < report.getCount(); i++)
|
||||
{
|
||||
if(report.getBodyPart(i).isMimeType("message/disposition-notification"))
|
||||
{
|
||||
InternetHeaders fields = new InternetHeaders(new ByteArrayInputStream(report.getBodyPart(i).getInputStream().readAllBytes()));
|
||||
return (fromFields(fields, signed, valid));
|
||||
}
|
||||
}
|
||||
throw (new As2Exception("The response has no disposition notification"));
|
||||
}
|
||||
catch(As2Exception e)
|
||||
{
|
||||
throw (e);
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
throw (new As2Exception("The response is not a readable message disposition notification"));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Read the notification fields.
|
||||
*******************************************************************************/
|
||||
private static Parsed fromFields(InternetHeaders fields, boolean signed, Boolean valid)
|
||||
{
|
||||
String original = normalizeId(first(fields, "Original-Message-ID"));
|
||||
String disposition = sanitize(first(fields, "Disposition"));
|
||||
String micText = sanitize(first(fields, "Received-Content-MIC"));
|
||||
MIC mic = null;
|
||||
try
|
||||
{
|
||||
mic = MIC.parse(micText);
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
// An unreadable MIC is reported as a mismatch by the caller.
|
||||
mic = null;
|
||||
}
|
||||
return (new Parsed(original == null ? null : sanitize(original), disposition, micText, mic, signed, valid));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** First value of a notification field, unfolded, or null.
|
||||
*******************************************************************************/
|
||||
private static String first(InternetHeaders fields, String name)
|
||||
{
|
||||
String[] values = fields.getHeader(name);
|
||||
return (values == null || values.length == 0 ? null : MimeUtility.unfold(values[0]).trim());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.edi.as2;
|
||||
|
||||
|
||||
import java.security.cert.X509Certificate;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.helger.as2lib.crypto.MIC;
|
||||
import com.kingsrook.qbits.edi.EdiRuntime;
|
||||
import com.kingsrook.qbits.edi.flow.EdiJournal;
|
||||
import com.kingsrook.qbits.edi.model.EdiTransport;
|
||||
import com.kingsrook.qbits.edi.storage.EdiRecords;
|
||||
import com.kingsrook.qbits.edi.storage.PayloadRef;
|
||||
import com.kingsrook.qbits.edi.support.SystemWrites;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertSeverity;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertSignal;
|
||||
import com.kingsrook.qqq.backend.core.alerts.QAlerts;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Receives asynchronous MDNs for messages we sent: parses the receipt, verifies
|
||||
** its signature against the partner certificates, matches Original-Message-ID to
|
||||
** an outbound message, compares the MIC, then marks the message DELIVERED or
|
||||
** FAILED, stores the raw MDN, writes a timeline event and resolves the overdue
|
||||
** alert. Callers run inside the system context.
|
||||
*******************************************************************************/
|
||||
public class As2MdnReceiver
|
||||
{
|
||||
private static final QLogger LOG = QLogger.getLogger(As2MdnReceiver.class);
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Handle one inbound MDN; header names are lower case. A receipt that cannot be
|
||||
** authenticated changes nothing.
|
||||
*******************************************************************************/
|
||||
public As2Receiver.Result receive(EdiTransport transport, Map<String, String> headers, byte[] body) throws Exception
|
||||
{
|
||||
String from = As2Mdn.normalizeId(headers.get("as2-from"));
|
||||
String to = As2Mdn.normalizeId(headers.get("as2-to"));
|
||||
if(from == null || to == null || !from.equalsIgnoreCase(transport.getAs2PartnerId()) || !to.equalsIgnoreCase(transport.getAs2LocalId()))
|
||||
{
|
||||
LOG.warn("AS2 MDN refused for transport " + transport.getName() + ": unknown AS2 identity");
|
||||
return (As2Receiver.Result.text(403, "Unknown AS2 identity"));
|
||||
}
|
||||
As2Mdn.Parsed mdn;
|
||||
try
|
||||
{
|
||||
List<X509Certificate> certificates = transport.getPartnerCertificateId() == null ? List.of() : new CertificateService().partnerVerificationCertificates(transport);
|
||||
mdn = As2Mdn.parse(headers.get("content-type"), headers.get("content-transfer-encoding"), body, certificates, Date.from(EdiRuntime.current().config().getClock().instant()));
|
||||
}
|
||||
catch(As2Exception e)
|
||||
{
|
||||
return (As2Receiver.Result.text(400, "The MDN could not be read"));
|
||||
}
|
||||
if((Boolean.TRUE.equals(mdn.signed()) && !Boolean.TRUE.equals(mdn.signatureValid())) || (Boolean.TRUE.equals(transport.getMdnSigned()) && !Boolean.TRUE.equals(mdn.signed())))
|
||||
{
|
||||
LOG.warn("AS2 MDN refused for transport " + transport.getName() + ": signature check failed");
|
||||
return (As2Receiver.Result.text(401, "The MDN signature could not be verified"));
|
||||
}
|
||||
List<QRecord> messages = mdn.originalMessageId() == null ? List.of() : EdiRecords.query("edi_outbound_message", EdiRecords.equal("transportMessageId", mdn.originalMessageId())
|
||||
.withCriteria("transportId", QCriteriaOperator.EQUALS, transport.getId()));
|
||||
if(messages.isEmpty())
|
||||
{
|
||||
return (As2Receiver.Result.text(404, "No message was sent with that Original-Message-ID"));
|
||||
}
|
||||
String mdnId = As2Mdn.sanitize(As2Mdn.normalizeId(headers.get("message-id")));
|
||||
if(mdnId != null && !EdiRecords.query(As2Store.TABLE, EdiRecords.equal("direction", "INBOUND").withCriteria("messageId", QCriteriaOperator.EQUALS, mdnId)
|
||||
.withCriteria("originalMessageId", QCriteriaOperator.EQUALS, mdn.originalMessageId())).isEmpty())
|
||||
{
|
||||
return (new As2Receiver.Result(200, Map.of(), new byte[0]));
|
||||
}
|
||||
settle(transport, messages.getFirst(), mdn, mdnId, headers, body);
|
||||
return (new As2Receiver.Result(200, Map.of(), new byte[0]));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Record the receipt and settle the message it answers.
|
||||
*******************************************************************************/
|
||||
private void settle(EdiTransport transport, QRecord message, As2Mdn.Parsed mdn, String mdnId, Map<String, String> headers, byte[] body) throws Exception
|
||||
{
|
||||
MIC ours = null;
|
||||
try
|
||||
{
|
||||
ours = MIC.parse(message.getValueString("transportMic"));
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
LOG.warn("The MIC stored for outbound message " + message.getValue("id") + " could not be read");
|
||||
}
|
||||
String failure = As2Transport.judge(transport, mdn, ours);
|
||||
byte[] raw = As2Crypto.entity(headers.get("content-type"), headers.get("content-transfer-encoding"), null, body);
|
||||
SystemWrites.transaction(() ->
|
||||
{
|
||||
PayloadRef ref = As2Store.storeRaw(raw);
|
||||
As2Store.insert(new QRecord().withValue("direction", "INBOUND").withValue("messageId", mdnId).withValue("originalMessageId", mdn.originalMessageId())
|
||||
.withValue("disposition", mdn.disposition()).withValue("micAlgorithm", mdn.mic() == null ? null : mdn.mic().getDigestAlgorithm().getID())
|
||||
.withValue("micValue", As2Store.micValue(mdn.mic())).withValue("signed", mdn.signed()).withValue("signatureValid", mdn.signatureValid()).withValue("payloadRef", ref.value())
|
||||
.withValue("transportId", transport.getId()).withValue("partnerId", message.getValue("partnerId")).withValue("outboundMessageId", message.getValue("id")).withValue("deliveryStatus", "NONE"));
|
||||
Integer interchangeId = message.getValueInteger("interchangeId");
|
||||
EdiJournal.event(null, interchangeId, null, "MDN");
|
||||
if(SystemWrites.guard("UPDATE edi_outbound_message SET id=id WHERE id=? AND status='SENT'", message.getValue("id")))
|
||||
{
|
||||
var now = EdiRuntime.current().config().getClock().instant();
|
||||
String status = failure == null ? "DELIVERED" : "FAILED";
|
||||
QRecord update = new QRecord().withValue("id", message.getValue("id")).withValue("status", status).withValue("lastError", failure == null ? null : truncate(failure));
|
||||
if(failure == null)
|
||||
{
|
||||
update.withValue("deliveredAt", now);
|
||||
}
|
||||
SystemWrites.update("edi_outbound_message", update);
|
||||
if(interchangeId != null)
|
||||
{
|
||||
SystemWrites.update("edi_interchange", new QRecord().withValue("id", interchangeId).withValue("status", status));
|
||||
}
|
||||
EdiJournal.event(null, interchangeId, null, status);
|
||||
if(failure != null)
|
||||
{
|
||||
EdiJournal.exception(null, interchangeId, null, message.getValueInteger("partnerId"), "TRANSPORT", "Outbound delivery failed");
|
||||
QAlerts.raise(new AlertSignal("edi.send.failed").withSeverity(AlertSeverity.ERROR).withDedupKey("edi_outbound_message:" + message.getValue("id"))
|
||||
.withTitle("EDI delivery failed").withSummary("Review the outbound message"));
|
||||
}
|
||||
}
|
||||
return (null);
|
||||
});
|
||||
QAlerts.resolve("edi.mdn.overdue", "edi_outbound_message:" + message.getValue("id"), "MDN received");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Keep an error within its column.
|
||||
*******************************************************************************/
|
||||
private static String truncate(String text)
|
||||
{
|
||||
return (text.length() <= 255 ? text : text.substring(0, 255));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.edi.as2;
|
||||
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import com.kingsrook.qbits.edi.EdiRuntime;
|
||||
import com.kingsrook.qbits.edi.flow.OutboundWorker;
|
||||
import com.kingsrook.qbits.edi.flow.SendWorker;
|
||||
import com.kingsrook.qbits.edi.model.EdiTransport;
|
||||
import com.kingsrook.qbits.edi.storage.EdiRecords;
|
||||
import com.kingsrook.qbits.edi.storage.PayloadRef;
|
||||
import com.kingsrook.qbits.edi.support.SystemWrites;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertSeverity;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertSignal;
|
||||
import com.kingsrook.qqq.backend.core.alerts.QAlerts;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Delivers the asynchronous MDNs we owe partners. The edi_as2_mdn row is the
|
||||
** queue entry (deliveryStatus PENDING): a scheduled step posts the stored MDN to
|
||||
** the address the partner gave in Receipt-Delivery-Option, retries with the send
|
||||
** backoff, and fails (with an alert) after the transport maxAttempts. Row locks
|
||||
** fence concurrent senders.
|
||||
*******************************************************************************/
|
||||
public class As2MdnSender
|
||||
{
|
||||
private static final QLogger LOG = QLogger.getLogger(As2MdnSender.class);
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Deliver pending MDNs that are due; returns how many were attempted.
|
||||
*******************************************************************************/
|
||||
public Integer runOnce() throws QException
|
||||
{
|
||||
return (SystemWrites.call(() ->
|
||||
{
|
||||
int count = 0;
|
||||
var due = new QQueryFilter().withBooleanOperator(QQueryFilter.BooleanOperator.OR).withCriteria("nextAttemptAt", QCriteriaOperator.IS_BLANK)
|
||||
.withCriteria("nextAttemptAt", QCriteriaOperator.LESS_THAN_OR_EQUALS, EdiRuntime.current().config().getClock().instant());
|
||||
for(QRecord row : EdiRecords.query(As2Store.TABLE, new QQueryFilter().withCriteria("deliveryStatus", QCriteriaOperator.EQUALS, "PENDING").withSubFilter(due).withLimit(20)))
|
||||
{
|
||||
count += deliver(row);
|
||||
}
|
||||
return (count);
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One delivery attempt, fenced by a row lock on the selected state.
|
||||
*******************************************************************************/
|
||||
private Integer deliver(QRecord selected) throws QException
|
||||
{
|
||||
return (SystemWrites.transaction(() ->
|
||||
{
|
||||
if(!SystemWrites.guard("UPDATE edi_as2_mdn SET id=id WHERE id=? AND delivery_status='PENDING' AND COALESCE(delivery_attempts,0)=?", selected.getValue("id"),
|
||||
Objects.requireNonNullElse(selected.getValueInteger("deliveryAttempts"), 0)))
|
||||
{
|
||||
return (0);
|
||||
}
|
||||
QRecord row = OutboundWorker.row(As2Store.TABLE, selected.getValue("id"));
|
||||
EdiTransport transport = OutboundWorker.row("edi_transport", row.getValue("transportId")).toEntity(EdiTransport.class);
|
||||
int attempts = Objects.requireNonNullElse(row.getValueInteger("deliveryAttempts"), 0) + 1;
|
||||
boolean delivered = post(row, transport);
|
||||
int maximum = Objects.requireNonNullElse(transport.getMaxAttempts(), 8);
|
||||
Instant now = EdiRuntime.current().config().getClock().instant();
|
||||
String status = delivered ? "SENT" : attempts >= maximum ? "FAILED" : "PENDING";
|
||||
QRecord update = new QRecord().withValue("id", row.getValue("id")).withValue("deliveryStatus", status).withValue("deliveryAttempts", attempts)
|
||||
.withValue("nextAttemptAt", "PENDING".equals(status) ? now.plusSeconds(SendWorker.BACKOFF.get(Math.min(attempts - 1, SendWorker.BACKOFF.size() - 1))) : null);
|
||||
if(delivered)
|
||||
{
|
||||
update.withValue("deliveredAt", now);
|
||||
}
|
||||
SystemWrites.update(As2Store.TABLE, update);
|
||||
if("FAILED".equals(status))
|
||||
{
|
||||
QAlerts.raise(new AlertSignal("edi.send.failed").withSeverity(AlertSeverity.ERROR).withDedupKey("edi_as2_mdn:" + row.getValue("id"))
|
||||
.withTitle("AS2 receipt delivery failed").withSummary("Review the AS2 receipt"));
|
||||
}
|
||||
return (1);
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** POST the stored MDN to the partner receipt address; true on any 2xx.
|
||||
*******************************************************************************/
|
||||
private boolean post(QRecord row, EdiTransport transport)
|
||||
{
|
||||
try
|
||||
{
|
||||
byte[] raw;
|
||||
try(InputStream stream = EdiRuntime.current().payloads().open(new PayloadRef(row.getValueString("payloadRef"), null, 0)))
|
||||
{
|
||||
raw = stream.readAllBytes();
|
||||
}
|
||||
As2Crypto.Wire wire = As2Crypto.splitRaw(raw);
|
||||
Map<String, String> headers = new LinkedHashMap<>();
|
||||
headers.put("AS2-Version", "1.2");
|
||||
headers.put("AS2-From", As2Transport.asId(transport.getAs2LocalId()));
|
||||
headers.put("AS2-To", As2Transport.asId(transport.getAs2PartnerId()));
|
||||
headers.put("Message-ID", "<" + row.getValueString("messageId") + ">");
|
||||
headers.put("MIME-Version", "1.0");
|
||||
headers.put("Content-Type", wire.contentType());
|
||||
if(wire.transferEncoding() != null)
|
||||
{
|
||||
headers.put("Content-Transfer-Encoding", wire.transferEncoding());
|
||||
}
|
||||
if(transport.getHttpAuthUserEnvVar() != null)
|
||||
{
|
||||
var secrets = EdiRuntime.current().config().getSecretsResolver();
|
||||
headers.put("Authorization", As2Http.basic(secrets.resolve(transport.getHttpAuthUserEnvVar()), secrets.resolve(transport.getHttpAuthPasswordEnvVar())));
|
||||
}
|
||||
HttpClient client = As2Http.client(null);
|
||||
int status = As2Http.post(client, URI.create(row.getValueString("deliveryUrl")), headers, wire.body(), Duration.ofSeconds(60)).status();
|
||||
return (status >= 200 && status < 300);
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
LOG.warn("AS2 receipt delivery attempt failed for receipt " + row.getValue("id"));
|
||||
return (false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.edi.as2;
|
||||
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.cert.X509Certificate;
|
||||
import java.time.Instant;
|
||||
import java.util.Date;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import com.helger.as2lib.crypto.ECryptoAlgorithmSign;
|
||||
import com.helger.as2lib.crypto.MIC;
|
||||
import com.helger.as2lib.disposition.DispositionOptions;
|
||||
import com.kingsrook.qbits.edi.EdiRuntime;
|
||||
import com.kingsrook.qbits.edi.as2.CertificateService.LocalKey;
|
||||
import com.kingsrook.qbits.edi.flow.EdiJournal;
|
||||
import com.kingsrook.qbits.edi.flow.InboundPayload;
|
||||
import com.kingsrook.qbits.edi.flow.InboundSink;
|
||||
import com.kingsrook.qbits.edi.model.EdiTransport;
|
||||
import com.kingsrook.qbits.edi.storage.EdiRecords;
|
||||
import com.kingsrook.qbits.edi.storage.PayloadRef;
|
||||
import com.kingsrook.qbits.edi.support.SystemWrites;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import jakarta.mail.internet.MimeBodyPart;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Receives AS2 messages for one transport: checks the AS2 identities, decrypts
|
||||
** with our ACTIVE or NEXT keys, verifies the signature against the partner
|
||||
** certificates, computes the MIC, hands the document to the inbox, and answers
|
||||
** with a synchronous MDN or queues an asynchronous one. A retransmitted
|
||||
** Message-ID gets the original MDN again and is not reprocessed. Callers run
|
||||
** inside the system context; failures are reported as RFC 4130 dispositions.
|
||||
*******************************************************************************/
|
||||
public class As2Receiver
|
||||
{
|
||||
private static final QLogger LOG = QLogger.getLogger(As2Receiver.class);
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** An HTTP answer: status, headers and body.
|
||||
*******************************************************************************/
|
||||
public record Result(int status, Map<String, String> headers, byte[] body)
|
||||
{
|
||||
/*******************************************************************************
|
||||
** A plain-text answer.
|
||||
*******************************************************************************/
|
||||
static Result text(int status, String text)
|
||||
{
|
||||
return (new Result(status, Map.of("Content-Type", "text/plain; charset=utf-8"), text.getBytes(StandardCharsets.UTF_8)));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** What was asked of us for this message.
|
||||
*******************************************************************************/
|
||||
private record Request(String messageId, boolean mdnRequested, String deliveryUrl, boolean signMdn, ECryptoAlgorithmSign micAlgorithm)
|
||||
{
|
||||
/*******************************************************************************
|
||||
** The same request, answered in the HTTP response only. Used before the sender
|
||||
** is authenticated, so an unauthenticated sender cannot aim a receipt at an
|
||||
** address of its choosing.
|
||||
*******************************************************************************/
|
||||
Request synchronous()
|
||||
{
|
||||
return (new Request(messageId, mdnRequested, null, signMdn, micAlgorithm));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Handle one inbound message; header names are lower case.
|
||||
*******************************************************************************/
|
||||
public Result receive(EdiTransport transport, Map<String, String> headers, byte[] body, String remoteAddress) throws Exception
|
||||
{
|
||||
String messageId = As2Mdn.normalizeId(headers.get("message-id"));
|
||||
String from = As2Mdn.normalizeId(headers.get("as2-from"));
|
||||
String to = As2Mdn.normalizeId(headers.get("as2-to"));
|
||||
if(messageId == null || messageId.isBlank() || messageId.length() > 250 || !messageId.matches("[\\x21-\\x7E]+") || from == null || to == null)
|
||||
{
|
||||
return (Result.text(400, "The AS2-From, AS2-To and Message-ID headers are required"));
|
||||
}
|
||||
if(!from.equalsIgnoreCase(transport.getAs2PartnerId()) || !to.equalsIgnoreCase(transport.getAs2LocalId()))
|
||||
{
|
||||
LOG.warn("AS2 message refused for transport " + transport.getName() + ": unknown AS2 identity");
|
||||
return (Result.text(403, "Unknown AS2 identity"));
|
||||
}
|
||||
Request request = request(transport, headers, messageId);
|
||||
if(request.deliveryUrl() != null && !deliveryUrlAllowed(transport, request.deliveryUrl()))
|
||||
{
|
||||
return (Result.text(400, "The Receipt-Delivery-Option is not acceptable"));
|
||||
}
|
||||
CertificateService certificates = new CertificateService();
|
||||
CertificateService.KeySet keys = certificates.localDecryptionKeys();
|
||||
As2Crypto.Decoded decoded;
|
||||
try
|
||||
{
|
||||
List<X509Certificate> partnerCertificates = transport.getPartnerCertificateId() == null ? List.of() : certificates.partnerVerificationCertificates(transport);
|
||||
byte[] top = As2Crypto.entity(headers.get("content-type"), headers.get("content-transfer-encoding"), headers.get("content-disposition"), body);
|
||||
decoded = As2Crypto.decode(top, keys.keys(), partnerCertificates, request.micAlgorithm(), Date.from(EdiRuntime.current().config().getClock().instant()));
|
||||
if((transport.getSignAlgorithm() != null && !decoded.signed()) || (transport.getEncryptAlgorithm() != null && !decoded.encrypted()))
|
||||
{
|
||||
throw (new As2Exception("The message does not meet the transport security requirements", As2Exception.INSUFFICIENT_SECURITY));
|
||||
}
|
||||
}
|
||||
catch(As2Exception e)
|
||||
{
|
||||
boolean ourFault = e.getCode() == null || (As2Exception.DECRYPTION_FAILED.equals(e.getCode()) && !keys.problems().isEmpty());
|
||||
return (reject(transport, request.synchronous(), ourFault ? As2Exception.UNEXPECTED_PROCESSING : e.getCode(), certificates));
|
||||
}
|
||||
String externalId = externalId(transport, messageId);
|
||||
QRecord replay = alreadyAnswered(transport, request, externalId);
|
||||
if(replay != null)
|
||||
{
|
||||
return (replay(transport, request, replay));
|
||||
}
|
||||
QRecord inbox;
|
||||
try
|
||||
{
|
||||
inbox = new InboundSink(EdiRuntime.current().payloads()).receive(new InboundPayload("AS2", externalId, decoded.fileName(), remoteAddress, new ByteArrayInputStream(decoded.payload())));
|
||||
if(inbox.getValue("transportId") == null)
|
||||
{
|
||||
SystemWrites.update("edi_inbox", new QRecord().withValue("id", inbox.getValue("id")).withValue("transportId", transport.getId()));
|
||||
}
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
LOG.warn("AS2 intake failed for transport " + transport.getName());
|
||||
return (reject(transport, request, As2Exception.UNEXPECTED_PROCESSING, certificates));
|
||||
}
|
||||
if(!request.mdnRequested())
|
||||
{
|
||||
return (new Result(200, Map.of(), new byte[0]));
|
||||
}
|
||||
try
|
||||
{
|
||||
return (answer(transport, request, As2Mdn.PROCESSED, decoded.mic(), inbox.getValueInteger("id"), certificates));
|
||||
}
|
||||
catch(As2Exception e)
|
||||
{
|
||||
LOG.warn("AS2 receipt could not be produced for transport " + transport.getName() + ": " + e.getMessage());
|
||||
return (Result.text(500, "The receipt could not be produced"));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Read what the message asks for: whether a receipt is wanted, where an
|
||||
** asynchronous one goes, and whether and how to sign it.
|
||||
*******************************************************************************/
|
||||
private Request request(EdiTransport transport, Map<String, String> headers, String messageId)
|
||||
{
|
||||
DispositionOptions options = new DispositionOptions();
|
||||
try
|
||||
{
|
||||
options = DispositionOptions.createFromString(headers.get("disposition-notification-options"));
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
LOG.warn("AS2 disposition options ignored for transport " + transport.getName());
|
||||
}
|
||||
ECryptoAlgorithmSign algorithm = options.getFirstMICAlg();
|
||||
if(algorithm == null)
|
||||
{
|
||||
algorithm = As2Crypto.signAlgorithm(transport.getSignAlgorithm()) == null ? ECryptoAlgorithmSign.DIGEST_SHA_256 : As2Crypto.signAlgorithm(transport.getSignAlgorithm());
|
||||
}
|
||||
String delivery = headers.get("receipt-delivery-option");
|
||||
return (new Request(messageId, headers.get("disposition-notification-to") != null, delivery == null || delivery.isBlank() ? null : delivery.trim(),
|
||||
options.getProtocol() != null || Boolean.TRUE.equals(transport.getMdnSigned()), algorithm));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** An asynchronous receipt address must use TLS (loopback excepted) and, unless
|
||||
** the transport requires signed messages, must be on the partner's own host,
|
||||
** so an unauthenticated sender cannot aim our receipts at third parties.
|
||||
*******************************************************************************/
|
||||
static boolean deliveryUrlAllowed(EdiTransport transport, String url)
|
||||
{
|
||||
if(!As2Http.isAllowedUrl(url))
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
if(transport.getSignAlgorithm() != null)
|
||||
{
|
||||
return (true);
|
||||
}
|
||||
try
|
||||
{
|
||||
return (transport.getAs2Url() != null && java.net.URI.create(url).getHost().equalsIgnoreCase(java.net.URI.create(transport.getAs2Url()).getHost()));
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The inbox dedupe key: the Message-ID. Message-IDs are unique per sender, so if
|
||||
** the same Message-ID was already received on a different transport (another
|
||||
** partner), the key is scoped by the transport id so neither message is lost.
|
||||
*******************************************************************************/
|
||||
private static String externalId(EdiTransport transport, String messageId) throws Exception
|
||||
{
|
||||
List<QRecord> rows = EdiRecords.query("edi_inbox", EdiRecords.equal("transportType", "AS2").withCriteria("externalId", QCriteriaOperator.EQUALS, messageId));
|
||||
boolean foreign = !rows.isEmpty() && rows.getFirst().getValue("transportId") != null && !rows.getFirst().getValue("transportId").equals(transport.getId());
|
||||
return (foreign ? transport.getId() + ":" + messageId : messageId);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The receipt row we already sent for this Message-ID (a retransmission), or
|
||||
** null when the original was never answered.
|
||||
*******************************************************************************/
|
||||
private QRecord alreadyAnswered(EdiTransport transport, Request request, String externalId) throws Exception
|
||||
{
|
||||
if(EdiRecords.query("edi_inbox", EdiRecords.equal("transportType", "AS2").withCriteria("externalId", QCriteriaOperator.EQUALS, externalId)).isEmpty())
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
List<QRecord> rows = EdiRecords.query(As2Store.TABLE, EdiRecords.equal("direction", "OUTBOUND").withCriteria("originalMessageId", QCriteriaOperator.EQUALS, request.messageId())
|
||||
.withCriteria("transportId", QCriteriaOperator.EQUALS, transport.getId()));
|
||||
return (rows.isEmpty() ? null : rows.getFirst());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Answer a retransmission with the original receipt, unchanged. An
|
||||
** asynchronous receipt is queued to be delivered again.
|
||||
*******************************************************************************/
|
||||
private Result replay(EdiTransport transport, Request request, QRecord original) throws Exception
|
||||
{
|
||||
if(!request.mdnRequested())
|
||||
{
|
||||
return (new Result(200, Map.of(), new byte[0]));
|
||||
}
|
||||
if(request.deliveryUrl() != null)
|
||||
{
|
||||
SystemWrites.update(As2Store.TABLE, new QRecord().withValue("id", original.getValue("id")).withValue("deliveryUrl", request.deliveryUrl()).withValue("deliveryStatus", "PENDING")
|
||||
.withValue("deliveryAttempts", 0).withValue("nextAttemptAt", null));
|
||||
return (new Result(200, Map.of(), new byte[0]));
|
||||
}
|
||||
byte[] raw;
|
||||
try(InputStream stream = EdiRuntime.current().payloads().open(new PayloadRef(original.getValueString("payloadRef"), null, 0)))
|
||||
{
|
||||
raw = stream.readAllBytes();
|
||||
}
|
||||
return (syncResult(transport, original.getValueString("messageId"), As2Crypto.splitRaw(raw)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Reject a message that could not be accepted. With a receipt requested the
|
||||
** failure is reported in an MDN so the partner can act or retry; otherwise an
|
||||
** HTTP error answers. No inbox row exists for a rejected message.
|
||||
*******************************************************************************/
|
||||
private Result reject(EdiTransport transport, Request request, String code, CertificateService certificates) throws Exception
|
||||
{
|
||||
LOG.warn("AS2 message rejected for transport " + transport.getName() + ": " + code);
|
||||
if(!request.mdnRequested())
|
||||
{
|
||||
return (Result.text(As2Exception.UNEXPECTED_PROCESSING.equals(code) ? 500 : 400, "AS2 message rejected: " + code));
|
||||
}
|
||||
try
|
||||
{
|
||||
return (answer(transport, request, As2Mdn.failure(code), null, null, certificates));
|
||||
}
|
||||
catch(As2Exception e)
|
||||
{
|
||||
LOG.warn("AS2 receipt could not be produced for transport " + transport.getName() + ": " + e.getMessage());
|
||||
return (Result.text(500, "The receipt could not be produced"));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Build, store and answer or queue the MDN.
|
||||
*******************************************************************************/
|
||||
private Result answer(EdiTransport transport, Request request, String disposition, MIC mic, Integer inboxId, CertificateService certificates) throws Exception
|
||||
{
|
||||
LocalKey key = request.signMdn() ? certificates.activeLocal(transport) : null;
|
||||
String mdnId = "edi-mdn-" + UUID.randomUUID() + "@" + transport.getAs2LocalId().replaceAll("[^A-Za-z0-9.-]", "-");
|
||||
As2Mdn.Built built = As2Mdn.build(mdnId, transport.getAs2LocalId(), request.messageId(), disposition, mic, key, request.micAlgorithm());
|
||||
PayloadRef raw = As2Store.storeRaw(built.entity());
|
||||
Instant now = EdiRuntime.current().config().getClock().instant();
|
||||
QRecord row = As2Store.insert(new QRecord().withValue("direction", "OUTBOUND").withValue("messageId", mdnId).withValue("originalMessageId", request.messageId())
|
||||
.withValue("disposition", disposition).withValue("micAlgorithm", mic == null ? null : mic.getDigestAlgorithm().getID()).withValue("micValue", As2Store.micValue(mic))
|
||||
.withValue("signed", built.signed()).withValue("payloadRef", raw.value()).withValue("receivedAt", now).withValue("transportId", transport.getId())
|
||||
.withValue("partnerId", transport.getPartnerId()).withValue("inboxId", inboxId).withValue("deliveryStatus", request.deliveryUrl() == null ? "NONE" : "PENDING")
|
||||
.withValue("deliveryUrl", request.deliveryUrl()).withValue("deliveryAttempts", 0).withValue("nextAttemptAt", null));
|
||||
if(inboxId != null)
|
||||
{
|
||||
EdiJournal.event(inboxId, null, null, "MDN");
|
||||
}
|
||||
LOG.info("AS2 receipt " + row.getValue("id") + " " + (request.deliveryUrl() == null ? "returned" : "queued") + " for transport " + transport.getName());
|
||||
return (request.deliveryUrl() != null ? new Result(200, Map.of(), new byte[0]) : syncResult(transport, mdnId, built.wire()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The synchronous MDN response with its AS2 headers.
|
||||
*******************************************************************************/
|
||||
private Result syncResult(EdiTransport transport, String mdnId, As2Crypto.Wire wire)
|
||||
{
|
||||
Map<String, String> headers = new LinkedHashMap<>();
|
||||
headers.put("AS2-Version", "1.2");
|
||||
headers.put("AS2-From", As2Transport.asId(transport.getAs2LocalId()));
|
||||
headers.put("AS2-To", As2Transport.asId(transport.getAs2PartnerId()));
|
||||
headers.put("Message-ID", "<" + mdnId + ">");
|
||||
headers.put("MIME-Version", "1.0");
|
||||
headers.put("Content-Type", wire.contentType());
|
||||
if(wire.transferEncoding() != null)
|
||||
{
|
||||
headers.put("Content-Transfer-Encoding", wire.transferEncoding());
|
||||
}
|
||||
return (new Result(200, headers, wire.body()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.edi.as2;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.TreeMap;
|
||||
import com.kingsrook.qbits.edi.model.EdiTransport;
|
||||
import com.kingsrook.qbits.edi.storage.EdiRecords;
|
||||
import com.kingsrook.qbits.edi.support.SystemWrites;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.middleware.javalin.QJavalinRouteProviderInterface;
|
||||
import io.javalin.config.JavalinConfig;
|
||||
import io.javalin.http.Context;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** AS2 machine endpoints outside the UI API, authenticated per transport by AS2
|
||||
** identity, S/MIME signature and certificates (never a user session):
|
||||
** POST /qqq/hooks/edi/as2/{transport} receives messages and answers with the
|
||||
** MDN, and POST /qqq/hooks/edi/as2/{transport}/mdn receives asynchronous MDNs.
|
||||
*******************************************************************************/
|
||||
public class As2RouteProvider implements QJavalinRouteProviderInterface
|
||||
{
|
||||
private static final QLogger LOG = QLogger.getLogger(As2RouteProvider.class);
|
||||
|
||||
public static final long DEFAULT_MAX_BODY_BYTES = 50L * 1024 * 1024;
|
||||
public static final long ABSOLUTE_MAX_BODY_BYTES = 200L * 1024 * 1024;
|
||||
|
||||
private QInstance instance;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Host instance supplied by Javalin.
|
||||
*******************************************************************************/
|
||||
public void setQInstance(QInstance instance)
|
||||
{
|
||||
this.instance = instance;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Register the non-UI hooks.
|
||||
*******************************************************************************/
|
||||
public void acceptJavalinConfig(JavalinConfig config)
|
||||
{
|
||||
config.routes.post("/qqq/hooks/edi/as2/{transport}", context -> handle(context, false));
|
||||
config.routes.post("/qqq/hooks/edi/as2/{transport}/mdn", context -> handle(context, true));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Find the active AS2 transport, read the bounded body and delegate.
|
||||
*******************************************************************************/
|
||||
private void handle(Context context, boolean mdn)
|
||||
{
|
||||
var previous = QContext.capture();
|
||||
try
|
||||
{
|
||||
QContext.init(instance, null);
|
||||
As2Receiver.Result result = SystemWrites.call(() -> process(context, mdn));
|
||||
context.status(result.status());
|
||||
result.headers().forEach(context::header);
|
||||
context.result(result.body());
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
LOG.warn("AS2 hook failed: " + e.getClass().getSimpleName());
|
||||
context.status(503);
|
||||
context.result("The AS2 request could not be processed".getBytes(java.nio.charset.StandardCharsets.UTF_8));
|
||||
}
|
||||
finally
|
||||
{
|
||||
QContext.init(previous);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The hook body, inside the system context.
|
||||
*******************************************************************************/
|
||||
private As2Receiver.Result process(Context context, boolean mdn) throws Exception
|
||||
{
|
||||
List<QRecord> rows = EdiRecords.query("edi_transport", EdiRecords.equal("name", context.pathParam("transport")).withCriteria("type", QCriteriaOperator.EQUALS, "AS2")
|
||||
.withCriteria("isActive", QCriteriaOperator.EQUALS, true));
|
||||
if(rows.size() != 1 || (mdn ? "INBOUND" : "OUTBOUND").equals(rows.getFirst().getValueString("direction")))
|
||||
{
|
||||
return (As2Receiver.Result.text(404, "Unknown AS2 transport"));
|
||||
}
|
||||
EdiTransport transport = rows.getFirst().toEntity(EdiTransport.class);
|
||||
long limit = mdn ? As2Http.MAX_RESPONSE_BYTES : maxBodyBytes(transport);
|
||||
if(context.req().getContentLengthLong() > limit)
|
||||
{
|
||||
return (As2Receiver.Result.text(413, "The message is too large"));
|
||||
}
|
||||
byte[] body = context.req().getInputStream().readNBytes((int) limit + 1);
|
||||
if(body.length > limit)
|
||||
{
|
||||
return (As2Receiver.Result.text(413, "The message is too large"));
|
||||
}
|
||||
Map<String, String> headers = new TreeMap<>();
|
||||
context.headerMap().forEach((name, value) -> headers.put(name.toLowerCase(Locale.ROOT), value));
|
||||
return (mdn ? new As2MdnReceiver().receive(transport, headers, body) : new As2Receiver().receive(transport, headers, body, context.ip()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The largest interchange any agreement on this transport allows, or 50 MB.
|
||||
*******************************************************************************/
|
||||
private static long maxBodyBytes(EdiTransport transport) throws Exception
|
||||
{
|
||||
long limit = 0;
|
||||
for(QRecord agreement : EdiRecords.query("edi_agreement", EdiRecords.equal("inboundTransportId", transport.getId())))
|
||||
{
|
||||
limit = Math.max(limit, agreement.getValueLong("maxInterchangeBytes") == null ? 0 : agreement.getValueLong("maxInterchangeBytes"));
|
||||
}
|
||||
return (limit <= 0 ? DEFAULT_MAX_BODY_BYTES : Math.min(limit + limit / 2 + 65536, ABSOLUTE_MAX_BODY_BYTES));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.edi.as2;
|
||||
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.time.Instant;
|
||||
import com.helger.as2lib.crypto.MIC;
|
||||
import com.kingsrook.qbits.edi.EdiRuntime;
|
||||
import com.kingsrook.qbits.edi.storage.PayloadKind;
|
||||
import com.kingsrook.qbits.edi.storage.PayloadRef;
|
||||
import com.kingsrook.qbits.edi.support.SystemWrites;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Persistence helpers for AS2 receipts: the raw MDN goes to the encrypted payload
|
||||
** store and a read-only edi_as2_mdn row describes it. Callers run inside the
|
||||
** system context.
|
||||
*******************************************************************************/
|
||||
public final class As2Store
|
||||
{
|
||||
public static final String TABLE = "edi_as2_mdn";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private As2Store()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Store the raw MDN entity (headers and body) encrypted.
|
||||
*******************************************************************************/
|
||||
public static PayloadRef storeRaw(byte[] entity) throws Exception
|
||||
{
|
||||
return (EdiRuntime.current().payloads().put(PayloadKind.MDN, new ByteArrayInputStream(entity)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The base64 value of a MIC without its algorithm.
|
||||
*******************************************************************************/
|
||||
public static String micValue(MIC mic)
|
||||
{
|
||||
return (mic == null ? null : java.util.Base64.getEncoder().encodeToString(mic.micBytes()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Insert a receipt row and return it.
|
||||
*******************************************************************************/
|
||||
public static QRecord insert(QRecord row) throws QException
|
||||
{
|
||||
if(row.getValue("receivedAt") == null)
|
||||
{
|
||||
row.setValue("receivedAt", Instant.now(EdiRuntime.current().config().getClock()));
|
||||
}
|
||||
return (SystemWrites.insert(TABLE, row));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,584 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.edi.as2;
|
||||
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.cert.X509Certificate;
|
||||
import java.time.Duration;
|
||||
import java.time.ZoneOffset;
|
||||
import java.time.ZonedDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.Date;
|
||||
import java.util.HexFormat;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.helger.as2lib.crypto.ECryptoAlgorithmCrypt;
|
||||
import com.helger.as2lib.crypto.ECryptoAlgorithmSign;
|
||||
import com.helger.as2lib.crypto.MIC;
|
||||
import com.helger.as2lib.disposition.DispositionOptions;
|
||||
import com.kingsrook.qbits.edi.EdiRuntime;
|
||||
import com.kingsrook.qbits.edi.as2.CertificateService.LocalKey;
|
||||
import com.kingsrook.qbits.edi.flow.EdiJournal;
|
||||
import com.kingsrook.qbits.edi.model.EdiOutboundMessage;
|
||||
import com.kingsrook.qbits.edi.model.EdiTransport;
|
||||
import com.kingsrook.qbits.edi.storage.EdiRecords;
|
||||
import com.kingsrook.qbits.edi.storage.PayloadRef;
|
||||
import com.kingsrook.qbits.edi.support.SystemWrites;
|
||||
import com.kingsrook.qbits.edi.transport.EdiSendResult;
|
||||
import com.kingsrook.qbits.edi.transport.EdiTestResult;
|
||||
import com.kingsrook.qbits.edi.transport.EdiTransportPlugin;
|
||||
import com.kingsrook.qqq.backend.core.alerts.QAlerts;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** AS2 (RFC 4130) transport. Sending builds the message (compress, sign, then
|
||||
** encrypt), POSTs it, and for a synchronous receipt verifies the MDN signature,
|
||||
** the received-content MIC and the disposition. An asynchronous receipt leaves
|
||||
** the message SENT until the MDN arrives at the receipt hook. No local files are
|
||||
** used; keys come from environment variables through the secrets resolver.
|
||||
*******************************************************************************/
|
||||
public class As2Transport implements EdiTransportPlugin
|
||||
{
|
||||
public static final String TYPE = "AS2";
|
||||
|
||||
private static final String ID_PATTERN = "[A-Za-z0-9 ._@:/-]{1,128}";
|
||||
private static final Duration TIMEOUT = Duration.ofSeconds(60);
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The prepared wire message and the MIC computed for it.
|
||||
*******************************************************************************/
|
||||
private record Prepared(As2Crypto.Wire wire, MIC mic)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Registry key.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getType()
|
||||
{
|
||||
return (TYPE);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Check identifiers, URL, algorithms, receipt mode and certificate ownership
|
||||
** without resolving any secret. SHA1 and 3DES are accepted only when set
|
||||
** explicitly; they set the weakCrypto flag on the record.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void validate(EdiTransport transport, List<String> errors)
|
||||
{
|
||||
boolean outbound = !"INBOUND".equals(transport.getDirection());
|
||||
if(transport.getAs2LocalId() == null || !transport.getAs2LocalId().matches(ID_PATTERN))
|
||||
{
|
||||
errors.add("as2LocalId is required (letters, digits, spaces and . _ @ : / - only)");
|
||||
}
|
||||
if(transport.getAs2PartnerId() == null || !transport.getAs2PartnerId().matches(ID_PATTERN))
|
||||
{
|
||||
errors.add("as2PartnerId is required (letters, digits, spaces and . _ @ : / - only)");
|
||||
}
|
||||
if(outbound && (transport.getAs2Url() == null || !As2Http.isAllowedUrl(transport.getAs2Url())))
|
||||
{
|
||||
errors.add("as2Url must use TLS (HTTP is allowed on loopback only) and carry no credentials");
|
||||
}
|
||||
try
|
||||
{
|
||||
As2Crypto.signAlgorithm(transport.getSignAlgorithm());
|
||||
}
|
||||
catch(IllegalArgumentException e)
|
||||
{
|
||||
errors.add("Unsupported signAlgorithm");
|
||||
}
|
||||
try
|
||||
{
|
||||
As2Crypto.encryptAlgorithm(transport.getEncryptAlgorithm());
|
||||
}
|
||||
catch(IllegalArgumentException e)
|
||||
{
|
||||
errors.add("Unsupported encryptAlgorithm");
|
||||
}
|
||||
if(transport.getMdnMode() != null && !List.of("SYNC", "ASYNC", "NONE").contains(transport.getMdnMode()))
|
||||
{
|
||||
errors.add("Unsupported mdnMode");
|
||||
}
|
||||
if(Boolean.TRUE.equals(transport.getCompress()) && transport.getSignAlgorithm() == null && transport.getEncryptAlgorithm() == null)
|
||||
{
|
||||
errors.add("compress requires signing or encryption");
|
||||
}
|
||||
if(transport.getPartnerId() == null && !Boolean.TRUE.equals(transport.getIsVan()))
|
||||
{
|
||||
errors.add("partnerId is required unless the transport is a VAN (isVan)");
|
||||
}
|
||||
if((transport.getHttpAuthUserEnvVar() == null) != (transport.getHttpAuthPasswordEnvVar() == null))
|
||||
{
|
||||
errors.add("httpAuthUserEnvVar and httpAuthPasswordEnvVar must be set together");
|
||||
}
|
||||
if(outbound && "ASYNC".equals(transport.getMdnMode()) && baseUrl() == null)
|
||||
{
|
||||
errors.add("ASYNC receipts need the qbit publicBaseUrl configuration");
|
||||
}
|
||||
try
|
||||
{
|
||||
validateCertificates(transport, errors);
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
errors.add("Certificates could not be checked");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Certificate references must exist and have the right owner: ours (no
|
||||
** partner, with a key variable) as the local certificate; the partner's own, or
|
||||
** for a VAN a certificate that is not ours, as the partner certificate; a TLS
|
||||
** certificate for pinning. Retired certificates are refused.
|
||||
*******************************************************************************/
|
||||
private static void validateCertificates(EdiTransport transport, List<String> errors) throws Exception
|
||||
{
|
||||
boolean secured = transport.getSignAlgorithm() != null || transport.getEncryptAlgorithm() != null || Boolean.TRUE.equals(transport.getMdnSigned());
|
||||
QRecord local = certificate(transport.getLocalCertificateId());
|
||||
QRecord partner = certificate(transport.getPartnerCertificateId());
|
||||
QRecord tls = certificate(transport.getPartnerTlsCertificateId());
|
||||
if(transport.getLocalCertificateId() == null ? secured : local == null)
|
||||
{
|
||||
errors.add(transport.getLocalCertificateId() == null ? "localCertificateId is required when signing or encrypting" : "localCertificateId does not exist");
|
||||
}
|
||||
else if(local != null && (local.getValue("partnerId") != null || local.getValueString("privateKeyEnvVar") == null || "RETIRED".equals(local.getValueString("status"))))
|
||||
{
|
||||
errors.add("localCertificateId must be one of our own certificates (no partner, with a private key variable) that is not retired");
|
||||
}
|
||||
if(transport.getPartnerCertificateId() == null ? secured : partner == null)
|
||||
{
|
||||
errors.add(transport.getPartnerCertificateId() == null ? "partnerCertificateId is required when signing or encrypting" : "partnerCertificateId does not exist");
|
||||
}
|
||||
else if(partner != null)
|
||||
{
|
||||
boolean ownedByPartner = transport.getPartnerId() == null || transport.getPartnerId().equals(partner.getValueInteger("partnerId"));
|
||||
if(!ownedByPartner || partner.getValueString("privateKeyEnvVar") != null || "RETIRED".equals(partner.getValueString("status")))
|
||||
{
|
||||
errors.add("partnerCertificateId must be a certificate of the partner (or, for a VAN, of the VAN) that is not retired and holds no private key");
|
||||
}
|
||||
}
|
||||
if(transport.getPartnerTlsCertificateId() != null && (tls == null || !"TLS".equals(tls.getValueString("usage"))))
|
||||
{
|
||||
errors.add("partnerTlsCertificateId must be a certificate with usage TLS");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Certificate row by id, or null.
|
||||
*******************************************************************************/
|
||||
private static QRecord certificate(Integer id) throws Exception
|
||||
{
|
||||
if(id == null)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
return (SystemWrites.call(() -> EdiRecords.query(CertificateService.TABLE, EdiRecords.equal("id", id)).stream().findFirst().orElse(null)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The deployment public base URL without a trailing slash, or null.
|
||||
*******************************************************************************/
|
||||
static String baseUrl()
|
||||
{
|
||||
try
|
||||
{
|
||||
String url = EdiRuntime.current().config().getPublicBaseUrl();
|
||||
return (url == null || url.isBlank() ? null : url.replaceAll("/+$", ""));
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Build, sign, encrypt and POST the message; settle it from the receipt.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public EdiSendResult send(EdiOutboundMessage message, PayloadRef payload, EdiTransport transport) throws Exception
|
||||
{
|
||||
return (SystemWrites.call(() -> deliver(message, payload, transport)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The send itself, inside the system context. Configuration problems (a
|
||||
** missing variable, an expired or missing certificate) are RETRY results that
|
||||
** name only the problem, so the retry policy and alerts apply once fixed.
|
||||
*******************************************************************************/
|
||||
private EdiSendResult deliver(EdiOutboundMessage message, PayloadRef payload, EdiTransport transport) throws Exception
|
||||
{
|
||||
byte[] edi;
|
||||
try(InputStream stream = EdiRuntime.current().payloads().open(payload))
|
||||
{
|
||||
edi = stream.readAllBytes();
|
||||
}
|
||||
String messageId = messageId(message.getId(), transport, edi);
|
||||
CertificateService certificates = new CertificateService();
|
||||
String mode = mode(transport);
|
||||
As2Http.Response response;
|
||||
Prepared prepared;
|
||||
try
|
||||
{
|
||||
prepared = prepare(message, transport, edi, messageId, certificates);
|
||||
HttpClient client = As2Http.client(certificates.tlsPin(transport));
|
||||
response = As2Http.post(client, URI.create(transport.getAs2Url()), headers(transport, messageId, prepared, mode), prepared.wire().body(), TIMEOUT);
|
||||
}
|
||||
catch(As2Exception e)
|
||||
{
|
||||
return (new EdiSendResult("RETRY", null, e.getMessage()));
|
||||
}
|
||||
catch(java.io.IOException e)
|
||||
{
|
||||
return (new EdiSendResult("RETRY", null, "The AS2 endpoint could not be reached"));
|
||||
}
|
||||
if(response.status() >= 500 || response.status() == 429)
|
||||
{
|
||||
return (new EdiSendResult("RETRY", null, "HTTP " + response.status()));
|
||||
}
|
||||
if(response.status() < 200 || response.status() >= 300)
|
||||
{
|
||||
return (new EdiSendResult("FAILED", null, "HTTP " + response.status()));
|
||||
}
|
||||
SystemWrites.update("edi_outbound_message", new QRecord().withValue("id", message.getId()).withValue("transportMic", prepared.mic().getAsAS2String()));
|
||||
if(!"SYNC".equals(mode))
|
||||
{
|
||||
return (new EdiSendResult("SENT", messageId, "ASYNC".equals(mode) ? "Awaiting the asynchronous MDN" : "No receipt requested"));
|
||||
}
|
||||
return (settleSync(message, transport, messageId, prepared, response, certificates));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Compress, sign and encrypt as configured, computing the MIC over the content
|
||||
** that is signed.
|
||||
*******************************************************************************/
|
||||
private Prepared prepare(EdiOutboundMessage message, EdiTransport transport, byte[] edi, String messageId, CertificateService certificates) throws Exception
|
||||
{
|
||||
ECryptoAlgorithmSign signAlgorithm = As2Crypto.signAlgorithm(transport.getSignAlgorithm());
|
||||
ECryptoAlgorithmCrypt encryptAlgorithm = As2Crypto.encryptAlgorithm(transport.getEncryptAlgorithm());
|
||||
LocalKey key = signAlgorithm == null ? null : certificates.activeLocal(transport);
|
||||
X509Certificate partner = encryptAlgorithm == null ? null : certificates.activePartnerCertificate(transport);
|
||||
checkValid("Local certificate", key == null ? null : key.certificate());
|
||||
checkValid("Partner certificate", partner);
|
||||
String contentType = edi.length >= 3 && edi[0] == 'U' && edi[1] == 'N' ? "application/edifact" : "application/edi-x12";
|
||||
var part = As2Crypto.payloadPart(edi, contentType, "edi-" + message.getId() + ".edi");
|
||||
if(Boolean.TRUE.equals(transport.getCompress()))
|
||||
{
|
||||
part = As2Crypto.compress(part);
|
||||
}
|
||||
ECryptoAlgorithmSign micAlgorithm = signAlgorithm == null ? ECryptoAlgorithmSign.DIGEST_SHA_256 : signAlgorithm;
|
||||
byte[] clear;
|
||||
MIC mic;
|
||||
if(signAlgorithm == null)
|
||||
{
|
||||
clear = As2Crypto.toBytes(part);
|
||||
mic = As2Crypto.mic(clear, micAlgorithm);
|
||||
}
|
||||
else
|
||||
{
|
||||
clear = As2Crypto.toBytes(As2Crypto.sign(part, key, signAlgorithm));
|
||||
As2Crypto.Wire signed = As2Crypto.splitRaw(clear);
|
||||
mic = As2Crypto.mic(As2Crypto.firstPart(signed.contentType(), signed.body()), micAlgorithm);
|
||||
}
|
||||
return (new Prepared(encryptAlgorithm == null ? As2Crypto.splitRaw(clear) : As2Crypto.encrypt(clear, partner, encryptAlgorithm), mic));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Certificates outside their validity period are not used.
|
||||
*******************************************************************************/
|
||||
private static void checkValid(String what, X509Certificate certificate) throws As2Exception
|
||||
{
|
||||
if(certificate != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
certificate.checkValidity();
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
throw (new As2Exception(what + " " + certificate.getSerialNumber().toString(16) + " is expired or not yet valid"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The AS2 request headers.
|
||||
*******************************************************************************/
|
||||
private Map<String, String> headers(EdiTransport transport, String messageId, Prepared prepared, String mode) throws Exception
|
||||
{
|
||||
Map<String, String> headers = new LinkedHashMap<>();
|
||||
headers.put("AS2-Version", "1.2");
|
||||
headers.put("AS2-From", asId(transport.getAs2LocalId()));
|
||||
headers.put("AS2-To", asId(transport.getAs2PartnerId()));
|
||||
headers.put("Message-ID", "<" + messageId + ">");
|
||||
headers.put("Subject", subject(transport));
|
||||
headers.put("Date", DateTimeFormatter.RFC_1123_DATE_TIME.format(ZonedDateTime.now(EdiRuntime.current().config().getClock().withZone(ZoneOffset.UTC))));
|
||||
headers.put("MIME-Version", "1.0");
|
||||
headers.put("User-Agent", "qbit-edi");
|
||||
headers.put("Content-Type", prepared.wire().contentType());
|
||||
if(prepared.wire().transferEncoding() != null)
|
||||
{
|
||||
headers.put("Content-Transfer-Encoding", prepared.wire().transferEncoding());
|
||||
}
|
||||
if(prepared.wire().contentDisposition() != null)
|
||||
{
|
||||
headers.put("Content-Disposition", prepared.wire().contentDisposition());
|
||||
}
|
||||
if(!"NONE".equals(mode))
|
||||
{
|
||||
headers.put("Disposition-Notification-To", transport.getAs2LocalId());
|
||||
if(Boolean.TRUE.equals(transport.getMdnSigned()))
|
||||
{
|
||||
ECryptoAlgorithmSign alg = As2Crypto.signAlgorithm(transport.getSignAlgorithm()) == null ? ECryptoAlgorithmSign.DIGEST_SHA_256 : As2Crypto.signAlgorithm(transport.getSignAlgorithm());
|
||||
headers.put("Disposition-Notification-Options", new DispositionOptions().setProtocolImportance(DispositionOptions.IMPORTANCE_REQUIRED)
|
||||
.setProtocol(DispositionOptions.PROTOCOL_PKCS7_SIGNATURE).setMICAlgImportance(DispositionOptions.IMPORTANCE_REQUIRED).setMICAlg(alg).getAsString());
|
||||
}
|
||||
}
|
||||
if("ASYNC".equals(mode))
|
||||
{
|
||||
headers.put("Receipt-Delivery-Option", baseUrl() + "/qqq/hooks/edi/as2/" + transport.getName() + "/mdn");
|
||||
}
|
||||
if(transport.getHttpAuthUserEnvVar() != null)
|
||||
{
|
||||
headers.put("Authorization", As2Http.basic(secret(transport.getHttpAuthUserEnvVar()), secret(transport.getHttpAuthPasswordEnvVar())));
|
||||
}
|
||||
return (headers);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Evaluate a synchronous receipt: signature, disposition and MIC.
|
||||
*******************************************************************************/
|
||||
private EdiSendResult settleSync(EdiOutboundMessage message, EdiTransport transport, String messageId, Prepared prepared, As2Http.Response response, CertificateService certificates) throws Exception
|
||||
{
|
||||
String contentType = response.headers().get("content-type");
|
||||
if(response.body().length == 0 || contentType == null)
|
||||
{
|
||||
return (new EdiSendResult("RETRY", messageId, "The partner returned no MDN"));
|
||||
}
|
||||
As2Mdn.Parsed mdn;
|
||||
try
|
||||
{
|
||||
mdn = As2Mdn.parse(contentType, response.headers().get("content-transfer-encoding"), response.body(), certificates.partnerVerificationCertificates(transport),
|
||||
Date.from(EdiRuntime.current().config().getClock().instant()));
|
||||
}
|
||||
catch(As2Exception e)
|
||||
{
|
||||
return (new EdiSendResult("RETRY", messageId, e.getMessage()));
|
||||
}
|
||||
String failure = judge(transport, mdn, prepared.mic());
|
||||
PayloadRef raw = As2Store.storeRaw(As2Crypto.entity(contentType, response.headers().get("content-transfer-encoding"), null, response.body()));
|
||||
As2Store.insert(new QRecord().withValue("direction", "INBOUND").withValue("messageId", As2Mdn.sanitize(As2Mdn.normalizeId(response.headers().get("message-id"))))
|
||||
.withValue("originalMessageId", messageId).withValue("disposition", mdn.disposition()).withValue("micAlgorithm", mdn.mic() == null ? null : mdn.mic().getDigestAlgorithm().getID())
|
||||
.withValue("micValue", As2Store.micValue(mdn.mic())).withValue("signed", mdn.signed()).withValue("signatureValid", mdn.signatureValid()).withValue("payloadRef", raw.value())
|
||||
.withValue("transportId", transport.getId()).withValue("partnerId", message.getPartnerId()).withValue("outboundMessageId", message.getId()).withValue("deliveryStatus", "NONE"));
|
||||
EdiJournal.event(null, message.getInterchangeId(), null, "MDN");
|
||||
if(failure != null)
|
||||
{
|
||||
return (new EdiSendResult("FAILED", messageId, failure));
|
||||
}
|
||||
QAlerts.resolve("edi.mdn.overdue", "edi_outbound_message:" + message.getId(), "MDN received");
|
||||
return (new EdiSendResult("DELIVERED", messageId, "MDN processed"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The reason a receipt does not confirm delivery, or null when it does.
|
||||
*******************************************************************************/
|
||||
static String judge(EdiTransport transport, As2Mdn.Parsed mdn, MIC ours)
|
||||
{
|
||||
if(Boolean.TRUE.equals(mdn.signed()) && !Boolean.TRUE.equals(mdn.signatureValid()))
|
||||
{
|
||||
return ("The MDN signature could not be verified");
|
||||
}
|
||||
if(Boolean.TRUE.equals(transport.getMdnSigned()) && !Boolean.TRUE.equals(mdn.signed()))
|
||||
{
|
||||
return ("The MDN is not signed");
|
||||
}
|
||||
if(!mdn.processed())
|
||||
{
|
||||
return ("Partner disposition: " + mdn.disposition());
|
||||
}
|
||||
if(ours != null && (mdn.mic() == null ? Boolean.TRUE.equals(transport.getMdnSigned()) || mdn.micText() != null : !mdn.mic().equals(ours)))
|
||||
{
|
||||
return ("The MDN Received-Content-MIC does not match the message sent");
|
||||
}
|
||||
return (null);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Probe configuration without sending any document: resolve keys and
|
||||
** certificates, then OPTIONS the endpoint.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public EdiTestResult test(EdiTransport transport)
|
||||
{
|
||||
try
|
||||
{
|
||||
return (SystemWrites.call(() -> probe(transport)));
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
return (new EdiTestResult(false, "AS2 test could not run; check the database and configuration"));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Resolve keys and certificates and reach the endpoint.
|
||||
*******************************************************************************/
|
||||
private EdiTestResult probe(EdiTransport transport) throws Exception
|
||||
{
|
||||
CertificateService certificates = new CertificateService();
|
||||
List<String> notes = new java.util.ArrayList<>();
|
||||
try
|
||||
{
|
||||
boolean secured = transport.getSignAlgorithm() != null || transport.getEncryptAlgorithm() != null || Boolean.TRUE.equals(transport.getMdnSigned());
|
||||
if(secured || transport.getLocalCertificateId() != null)
|
||||
{
|
||||
LocalKey key = certificates.activeLocal(transport);
|
||||
notes.add("Local key " + key.row().getValueString("alias") + " resolved (valid until " + key.certificate().getNotAfter().toInstant() + ")");
|
||||
}
|
||||
if(secured || transport.getPartnerCertificateId() != null)
|
||||
{
|
||||
certificates.activePartnerCertificate(transport);
|
||||
notes.add(certificates.partnerVerificationCertificates(transport).size() + " partner certificate(s) accepted for verification");
|
||||
}
|
||||
HttpClient client = As2Http.client(certificates.tlsPin(transport));
|
||||
if(!"INBOUND".equals(transport.getDirection()) && transport.getAs2Url() != null)
|
||||
{
|
||||
int status = As2Http.options(client, URI.create(transport.getAs2Url()), Map.of(), Duration.ofSeconds(15)).status();
|
||||
notes.add("Endpoint responded with HTTP " + status);
|
||||
if(status >= 500)
|
||||
{
|
||||
return (new EdiTestResult(false, String.join("; ", notes)));
|
||||
}
|
||||
}
|
||||
}
|
||||
catch(As2Exception e)
|
||||
{
|
||||
notes.add(e.getMessage());
|
||||
return (new EdiTestResult(false, String.join("; ", notes)));
|
||||
}
|
||||
catch(java.io.IOException e)
|
||||
{
|
||||
notes.add("The AS2 endpoint could not be reached");
|
||||
return (new EdiTestResult(false, String.join("; ", notes)));
|
||||
}
|
||||
if(As2Crypto.isWeak(transport.getSignAlgorithm(), transport.getEncryptAlgorithm()))
|
||||
{
|
||||
notes.add("Warning: weak crypto (SHA1 or 3DES) is configured");
|
||||
}
|
||||
return (new EdiTestResult(true, String.join("; ", notes)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Receipt mode, defaulting to SYNC.
|
||||
*******************************************************************************/
|
||||
static String mode(EdiTransport transport)
|
||||
{
|
||||
return (transport.getMdnMode() == null ? "SYNC" : transport.getMdnMode());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A stable Message-ID: retries of the same outbound message reuse it, so the
|
||||
** partner can recognize them.
|
||||
*******************************************************************************/
|
||||
static String messageId(Integer messageId, EdiTransport transport, byte[] edi) throws Exception
|
||||
{
|
||||
String digest = HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(edi)).substring(0, 12);
|
||||
return ("edi-" + messageId + "-" + digest + "@" + transport.getAs2LocalId().replaceAll("[^A-Za-z0-9.-]", "-"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** An AS2 identifier as a header value; identifiers with spaces are quoted.
|
||||
*******************************************************************************/
|
||||
static String asId(String id)
|
||||
{
|
||||
return (id.contains(" ") ? "\"" + id + "\"" : id);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Subject header without line breaks.
|
||||
*******************************************************************************/
|
||||
private static String subject(EdiTransport transport)
|
||||
{
|
||||
String subject = transport.getAs2Subject() == null || transport.getAs2Subject().isBlank() ? "EDI interchange" : transport.getAs2Subject();
|
||||
return (subject.replaceAll("[\\r\\n]+", " "));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Resolve a secret by environment variable name; failure names only the name.
|
||||
*******************************************************************************/
|
||||
private static String secret(String variable) throws As2Exception
|
||||
{
|
||||
String value = EdiRuntime.current().config().getSecretsResolver().resolve(variable);
|
||||
if(value == null)
|
||||
{
|
||||
throw (new As2Exception("environment variable " + variable + " is not set"));
|
||||
}
|
||||
return (value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.edi.as2;
|
||||
|
||||
|
||||
import java.security.cert.X509Certificate;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizerInterface;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.statusmessages.BadInputStatusMessage;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Pre-insert and pre-update customizer for edi_certificate. It parses the pasted
|
||||
** public certificate and fills subject, issuer, serial number, fingerprint and
|
||||
** validity dates, and refuses a private key pasted into certificatePem (the
|
||||
** private key belongs in the environment variable named by privateKeyEnvVar).
|
||||
*******************************************************************************/
|
||||
public class CertificateCustomizer implements TableCustomizerInterface
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Parse the certificate on save; reject private keys and key variables on
|
||||
** partner certificates without echoing any value.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<QRecord> preInsertOrUpdate(AbstractActionInput input, List<QRecord> records, boolean preview, Optional<List<QRecord>> oldRecords) throws QException
|
||||
{
|
||||
for(QRecord record : records)
|
||||
{
|
||||
QRecord merged = new QRecord();
|
||||
oldRecords.orElse(List.of()).stream().filter(old -> Objects.equals(old.getValue("id"), record.getValue("id")))
|
||||
.findFirst().ifPresent(old -> merged.getValues().putAll(old.getValues()));
|
||||
merged.getValues().putAll(record.getValues());
|
||||
boolean insert = oldRecords.isEmpty() || record.getValue("id") == null;
|
||||
if(insert || record.getValues().containsKey("certificatePem"))
|
||||
{
|
||||
try
|
||||
{
|
||||
X509Certificate certificate = CertificateService.parse(merged.getValueString("certificatePem"));
|
||||
record.setValue("subject", abbreviate(certificate.getSubjectX500Principal().getName()));
|
||||
record.setValue("issuer", abbreviate(certificate.getIssuerX500Principal().getName()));
|
||||
record.setValue("serialNumber", certificate.getSerialNumber().toString(16).toUpperCase());
|
||||
record.setValue("fingerprintSha256", CertificateService.fingerprint(certificate));
|
||||
record.setValue("notBefore", certificate.getNotBefore().toInstant());
|
||||
record.setValue("notAfter", certificate.getNotAfter().toInstant());
|
||||
merged.setValue("notAfter", certificate.getNotAfter().toInstant());
|
||||
}
|
||||
catch(As2Exception e)
|
||||
{
|
||||
record.addError(new BadInputStatusMessage(e.getMessage()));
|
||||
}
|
||||
}
|
||||
if(insert && merged.getValueString("status") == null)
|
||||
{
|
||||
record.setValue("status", "ACTIVE");
|
||||
}
|
||||
if(merged.getValueInteger("partnerId") != null && merged.getValueString("privateKeyEnvVar") != null && !merged.getValueString("privateKeyEnvVar").isBlank())
|
||||
{
|
||||
record.addError(new BadInputStatusMessage("privateKeyEnvVar is only for our own certificates; leave it blank on a partner certificate"));
|
||||
}
|
||||
Instant notAfter = merged.getValueInstant("notAfter");
|
||||
if(notAfter != null && merged.getValueInstant("notBefore") != null && !notAfter.isAfter(merged.getValueInstant("notBefore")))
|
||||
{
|
||||
record.addError(new BadInputStatusMessage("The certificate validity period is empty"));
|
||||
}
|
||||
}
|
||||
return (records);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Keep parsed names within the column length.
|
||||
*******************************************************************************/
|
||||
private static String abbreviate(String value)
|
||||
{
|
||||
return (value.length() <= 255 ? value : value.substring(0, 255));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,474 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.edi.as2;
|
||||
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.StringReader;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.KeyStore;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.PrivateKey;
|
||||
import java.security.Signature;
|
||||
import java.security.cert.CertificateFactory;
|
||||
import java.security.cert.X509Certificate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import com.kingsrook.qbits.edi.EdiRuntime;
|
||||
import com.kingsrook.qbits.edi.model.EdiTransport;
|
||||
import com.kingsrook.qbits.edi.storage.EdiRecords;
|
||||
import com.kingsrook.qbits.edi.support.SystemWrites;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import org.bouncycastle.asn1.pkcs.PrivateKeyInfo;
|
||||
import org.bouncycastle.openssl.PEMKeyPair;
|
||||
import org.bouncycastle.openssl.PEMParser;
|
||||
import org.bouncycastle.openssl.jcajce.JcaPEMKeyConverter;
|
||||
import org.bouncycastle.openssl.jcajce.JceOpenSSLPKCS8DecryptorProviderBuilder;
|
||||
import org.bouncycastle.pkcs.PKCS8EncryptedPrivateKeyInfo;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Resolves the certificates and keys an AS2 transport uses. Public certificates
|
||||
** come from edi_certificate rows; our private keys come only from environment
|
||||
** variables (a PEM PKCS#8 key or a base64 PKCS#12 store) through the replaceable
|
||||
** secrets resolver, are matched to the certificate, and are never cached,
|
||||
** logged, stored or returned. Callers run inside the system context.
|
||||
*******************************************************************************/
|
||||
public final class CertificateService
|
||||
{
|
||||
private static final QLogger LOG = QLogger.getLogger(CertificateService.class);
|
||||
|
||||
public static final String TABLE = "edi_certificate";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Our certificate with its matched private key; the key is excluded from any
|
||||
** string form of this object.
|
||||
*******************************************************************************/
|
||||
public record LocalKey(QRecord row, X509Certificate certificate, PrivateKey key)
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Never render key material.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String toString()
|
||||
{
|
||||
return ("LocalKey[" + row.getValueString("alias") + "]");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Local keys usable for decryption, plus the problems (naming environment
|
||||
** variables only) for keys that could not be loaded.
|
||||
*******************************************************************************/
|
||||
public record KeySet(List<LocalKey> keys, List<String> problems)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** True when the text contains any private key block.
|
||||
*******************************************************************************/
|
||||
public static boolean containsPrivateKey(String text)
|
||||
{
|
||||
return (text != null && text.toUpperCase().contains("PRIVATE KEY"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Parse the first X.509 certificate of a PEM text; private keys are refused
|
||||
** without echoing any content.
|
||||
*******************************************************************************/
|
||||
public static X509Certificate parse(String pem) throws As2Exception
|
||||
{
|
||||
if(pem == null || pem.isBlank())
|
||||
{
|
||||
throw (new As2Exception("certificatePem is required"));
|
||||
}
|
||||
if(containsPrivateKey(pem))
|
||||
{
|
||||
throw (new As2Exception("certificatePem must hold only the public certificate; keep the private key in the environment variable named by privateKeyEnvVar"));
|
||||
}
|
||||
if(!pem.contains("-----BEGIN CERTIFICATE-----"))
|
||||
{
|
||||
throw (new As2Exception("certificatePem must be a PEM encoded X.509 certificate"));
|
||||
}
|
||||
Collection<?> certificates;
|
||||
try
|
||||
{
|
||||
certificates = CertificateFactory.getInstance("X.509").generateCertificates(new ByteArrayInputStream(pem.getBytes(StandardCharsets.UTF_8)));
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
throw (new As2Exception("certificatePem could not be parsed as an X.509 certificate"));
|
||||
}
|
||||
if(certificates.isEmpty() || !(certificates.iterator().next() instanceof X509Certificate certificate))
|
||||
{
|
||||
throw (new As2Exception("certificatePem does not contain an X.509 certificate"));
|
||||
}
|
||||
return (certificate);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Upper-case colon separated SHA-256 fingerprint of the certificate.
|
||||
*******************************************************************************/
|
||||
public static String fingerprint(X509Certificate certificate) throws As2Exception
|
||||
{
|
||||
try
|
||||
{
|
||||
return (HexFormat.ofDelimiter(":").withUpperCase().formatHex(MessageDigest.getInstance("SHA-256").digest(certificate.getEncoded())));
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
throw (new As2Exception("certificate fingerprint could not be computed"));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Resolve the key used to sign messages and receipts for the transport: the
|
||||
** referenced certificate when it is ACTIVE, otherwise the ACTIVE certificate
|
||||
** of ours with the same usage (so a promoted rollover takes effect at once).
|
||||
*******************************************************************************/
|
||||
public LocalKey activeLocal(EdiTransport transport) throws As2Exception, QException
|
||||
{
|
||||
QRecord referenced = byId(transport.getLocalCertificateId());
|
||||
if(referenced == null)
|
||||
{
|
||||
throw (new As2Exception("The local certificate of transport " + transport.getName() + " is not configured"));
|
||||
}
|
||||
QRecord active = "ACTIVE".equals(referenced.getValueString("status")) ? referenced : findActive(null, referenced.getValueString("usage"), true);
|
||||
if(active == null)
|
||||
{
|
||||
throw (new As2Exception("No ACTIVE local certificate is available for transport " + transport.getName()));
|
||||
}
|
||||
return (load(active));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Every local key inbound decryption accepts: our ACTIVE and NEXT certificates
|
||||
** that can decrypt. A key that cannot be loaded is skipped and reported by
|
||||
** environment variable name only.
|
||||
*******************************************************************************/
|
||||
public KeySet localDecryptionKeys() throws QException
|
||||
{
|
||||
List<LocalKey> keys = new ArrayList<>();
|
||||
List<String> problems = new ArrayList<>();
|
||||
QQueryFilter filter = new QQueryFilter().withCriteria("status", QCriteriaOperator.IN, "ACTIVE", "NEXT")
|
||||
.withCriteria("partnerId", QCriteriaOperator.IS_BLANK).withCriteria("privateKeyEnvVar", QCriteriaOperator.IS_NOT_BLANK);
|
||||
for(QRecord row : EdiRecords.query(TABLE, filter))
|
||||
{
|
||||
if(!List.of("ENCRYPTION", "SIGNING_AND_ENCRYPTION").contains(row.getValueString("usage")))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
try
|
||||
{
|
||||
keys.add(load(row));
|
||||
}
|
||||
catch(As2Exception e)
|
||||
{
|
||||
problems.add(e.getMessage());
|
||||
LOG.warn("Local AS2 key skipped: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
return (new KeySet(keys, problems));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The partner certificate outbound messages are encrypted to: the referenced
|
||||
** certificate when ACTIVE, otherwise the partner ACTIVE certificate of the
|
||||
** same usage.
|
||||
*******************************************************************************/
|
||||
public X509Certificate activePartnerCertificate(EdiTransport transport) throws As2Exception, QException
|
||||
{
|
||||
QRecord referenced = byId(transport.getPartnerCertificateId());
|
||||
if(referenced == null)
|
||||
{
|
||||
throw (new As2Exception("The partner certificate of transport " + transport.getName() + " is not configured"));
|
||||
}
|
||||
QRecord active = "ACTIVE".equals(referenced.getValueString("status")) ? referenced : findActive(referenced.getValueInteger("partnerId"), referenced.getValueString("usage"), false);
|
||||
if(active == null)
|
||||
{
|
||||
throw (new As2Exception("No ACTIVE partner certificate is available for transport " + transport.getName()));
|
||||
}
|
||||
return (parse(active.getValueString("certificatePem")));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Partner certificates a signature may verify against: the referenced one and
|
||||
** the same partner other ACTIVE or NEXT signing certificates. A partner
|
||||
** certificate with no partner (a shared VAN) has no rollover candidates.
|
||||
*******************************************************************************/
|
||||
public List<X509Certificate> partnerVerificationCertificates(EdiTransport transport) throws As2Exception, QException
|
||||
{
|
||||
QRecord referenced = byId(transport.getPartnerCertificateId());
|
||||
if(referenced == null)
|
||||
{
|
||||
throw (new As2Exception("The partner certificate of transport " + transport.getName() + " is not configured"));
|
||||
}
|
||||
List<QRecord> rows = new ArrayList<>();
|
||||
if(List.of("ACTIVE", "NEXT").contains(referenced.getValueString("status")))
|
||||
{
|
||||
rows.add(referenced);
|
||||
}
|
||||
if(referenced.getValueInteger("partnerId") != null)
|
||||
{
|
||||
QQueryFilter filter = EdiRecords.equal("partnerId", referenced.getValueInteger("partnerId")).withCriteria("status", QCriteriaOperator.IN, "ACTIVE", "NEXT")
|
||||
.withCriteria("usage", QCriteriaOperator.IN, "SIGNING", "SIGNING_AND_ENCRYPTION").withCriteria("privateKeyEnvVar", QCriteriaOperator.IS_BLANK);
|
||||
for(QRecord row : EdiRecords.query(TABLE, filter))
|
||||
{
|
||||
if(rows.stream().noneMatch(existing -> Objects.equals(existing.getValue("id"), row.getValue("id"))))
|
||||
{
|
||||
rows.add(row);
|
||||
}
|
||||
}
|
||||
}
|
||||
List<X509Certificate> certificates = new ArrayList<>();
|
||||
for(QRecord row : rows)
|
||||
{
|
||||
certificates.add(parse(row.getValueString("certificatePem")));
|
||||
}
|
||||
return (certificates);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The pinned TLS certificate of the partner endpoint, or null when no pin is set.
|
||||
*******************************************************************************/
|
||||
public X509Certificate tlsPin(EdiTransport transport) throws As2Exception, QException
|
||||
{
|
||||
QRecord row = byId(transport.getPartnerTlsCertificateId());
|
||||
if(transport.getPartnerTlsCertificateId() != null && row == null)
|
||||
{
|
||||
throw (new As2Exception("The partner TLS certificate of transport " + transport.getName() + " does not exist"));
|
||||
}
|
||||
return (row == null ? null : parse(row.getValueString("certificatePem")));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Promote a NEXT certificate to ACTIVE and retire the previous ACTIVE one of
|
||||
** the same partner and usage. Returns false when the certificate is not NEXT.
|
||||
*******************************************************************************/
|
||||
public boolean promote(QRecord row) throws QException
|
||||
{
|
||||
if(!"NEXT".equals(row.getValueString("status")))
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
boolean local = row.getValueInteger("partnerId") == null && row.getValueString("privateKeyEnvVar") != null;
|
||||
QRecord previous = findActive(row.getValueInteger("partnerId"), row.getValueString("usage"), local);
|
||||
while(previous != null)
|
||||
{
|
||||
SystemWrites.update(TABLE, new QRecord().withValue("id", previous.getValue("id")).withValue("status", "RETIRED"));
|
||||
previous = findActive(row.getValueInteger("partnerId"), row.getValueString("usage"), local);
|
||||
}
|
||||
SystemWrites.update(TABLE, new QRecord().withValue("id", row.getValue("id")).withValue("status", "ACTIVE"));
|
||||
return (true);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Certificate row by id, or null.
|
||||
*******************************************************************************/
|
||||
private QRecord byId(Integer id) throws QException
|
||||
{
|
||||
if(id == null)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
List<QRecord> rows = EdiRecords.query(TABLE, EdiRecords.equal("id", id));
|
||||
return (rows.isEmpty() ? null : rows.getFirst());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The ACTIVE certificate of a partner (null partner means ours or a shared
|
||||
** one) and usage, local ones being those with a private key variable.
|
||||
*******************************************************************************/
|
||||
private QRecord findActive(Integer partnerId, String usage, boolean local) throws QException
|
||||
{
|
||||
QQueryFilter filter = EdiRecords.equal("status", "ACTIVE").withCriteria("partnerId", partnerId == null ? QCriteriaOperator.IS_BLANK : QCriteriaOperator.EQUALS, partnerId)
|
||||
.withCriteria("usage", QCriteriaOperator.EQUALS, usage).withCriteria("privateKeyEnvVar", local ? QCriteriaOperator.IS_NOT_BLANK : QCriteriaOperator.IS_BLANK);
|
||||
List<QRecord> rows = EdiRecords.query(TABLE, filter);
|
||||
return (rows.stream().min((a, b) -> Integer.compare(a.getValueInteger("id"), b.getValueInteger("id"))).orElse(null));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Parse the certificate, read its private key from the environment and prove
|
||||
** the two belong together by signing and verifying a probe.
|
||||
*******************************************************************************/
|
||||
private LocalKey load(QRecord row) throws As2Exception
|
||||
{
|
||||
X509Certificate certificate = parse(row.getValueString("certificatePem"));
|
||||
String variable = row.getValueString("privateKeyEnvVar");
|
||||
if(variable == null || variable.isBlank())
|
||||
{
|
||||
throw (new As2Exception("Certificate " + row.getValueString("alias") + " has no privateKeyEnvVar"));
|
||||
}
|
||||
PrivateKey key = readKey(variable, row.getValueString("privateKeyPasswordEnvVar"));
|
||||
if(!matches(key, certificate))
|
||||
{
|
||||
throw (new As2Exception("The private key in environment variable " + variable + " does not match certificate " + row.getValueString("alias")));
|
||||
}
|
||||
return (new LocalKey(row, certificate, key));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Read the key from the named environment variable; failures name only the
|
||||
** variable.
|
||||
*******************************************************************************/
|
||||
private static PrivateKey readKey(String variable, String passwordVariable) throws As2Exception
|
||||
{
|
||||
var secrets = EdiRuntime.current().config().getSecretsResolver();
|
||||
String value = secrets.resolve(variable);
|
||||
if(value == null || value.isBlank())
|
||||
{
|
||||
throw (new As2Exception("environment variable " + variable + " is not set"));
|
||||
}
|
||||
char[] password = new char[0];
|
||||
if(passwordVariable != null && !passwordVariable.isBlank())
|
||||
{
|
||||
String resolved = secrets.resolve(passwordVariable);
|
||||
if(resolved == null)
|
||||
{
|
||||
throw (new As2Exception("environment variable " + passwordVariable + " is not set"));
|
||||
}
|
||||
password = resolved.toCharArray();
|
||||
}
|
||||
try
|
||||
{
|
||||
return (parseKey(value, password));
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
throw (new As2Exception("The private key in environment variable " + variable + " could not be read as a PEM PKCS#8 key or a base64 PKCS#12 store"));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Parse a PEM key (PKCS#8, encrypted PKCS#8 or traditional) or a base64 PKCS#12
|
||||
** store, entirely in memory.
|
||||
*******************************************************************************/
|
||||
static PrivateKey parseKey(String raw, char[] password) throws Exception
|
||||
{
|
||||
String text = raw.trim();
|
||||
if(text.indexOf('\n') < 0 && text.contains("\\n"))
|
||||
{
|
||||
text = text.replace("\\n", "\n");
|
||||
}
|
||||
if(text.contains("-----BEGIN"))
|
||||
{
|
||||
JcaPEMKeyConverter converter = new JcaPEMKeyConverter();
|
||||
try(PEMParser parser = new PEMParser(new StringReader(text)))
|
||||
{
|
||||
Object object = parser.readObject();
|
||||
while(object != null)
|
||||
{
|
||||
if(object instanceof PrivateKeyInfo info)
|
||||
{
|
||||
return (converter.getPrivateKey(info));
|
||||
}
|
||||
if(object instanceof PEMKeyPair pair)
|
||||
{
|
||||
return (converter.getKeyPair(pair).getPrivate());
|
||||
}
|
||||
if(object instanceof PKCS8EncryptedPrivateKeyInfo encrypted)
|
||||
{
|
||||
return (converter.getPrivateKey(encrypted.decryptPrivateKeyInfo(new JceOpenSSLPKCS8DecryptorProviderBuilder().build(password))));
|
||||
}
|
||||
object = parser.readObject();
|
||||
}
|
||||
}
|
||||
throw (new IllegalArgumentException("no private key block"));
|
||||
}
|
||||
KeyStore store = KeyStore.getInstance("PKCS12");
|
||||
store.load(new ByteArrayInputStream(Base64.getMimeDecoder().decode(text)), password);
|
||||
for(String alias : Collections.list(store.aliases()))
|
||||
{
|
||||
if(store.isKeyEntry(alias))
|
||||
{
|
||||
return ((PrivateKey) store.getKey(alias, password));
|
||||
}
|
||||
}
|
||||
throw (new IllegalArgumentException("no key entry"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Sign a probe with the key and verify it with the certificate public key.
|
||||
*******************************************************************************/
|
||||
private static boolean matches(PrivateKey key, X509Certificate certificate)
|
||||
{
|
||||
try
|
||||
{
|
||||
String algorithm = "EC".equals(key.getAlgorithm()) ? "SHA256withECDSA" : "SHA256with" + key.getAlgorithm();
|
||||
byte[] probe = "qbit-edi-key-match".getBytes(StandardCharsets.UTF_8);
|
||||
Signature signer = Signature.getInstance(algorithm);
|
||||
signer.initSign(key);
|
||||
signer.update(probe);
|
||||
byte[] signature = signer.sign();
|
||||
Signature verifier = Signature.getInstance(algorithm);
|
||||
verifier.initVerify(certificate.getPublicKey());
|
||||
verifier.update(probe);
|
||||
return (verifier.verify(signature));
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.edi.document;
|
||||
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.edi.x12.EnvelopeFinding;
|
||||
import com.kingsrook.qbits.edi.x12.X12Segment;
|
||||
|
||||
/*******************************************************************************
|
||||
** Standard-neutral document view over the existing immutable segment value model.
|
||||
*******************************************************************************/
|
||||
public interface EdiDocument
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Return the actual message header, including its original composite values.
|
||||
*******************************************************************************/
|
||||
X12Segment header();
|
||||
|
||||
/*******************************************************************************
|
||||
** Return ordered segments including the message envelope.
|
||||
*******************************************************************************/
|
||||
List<X12Segment> segments();
|
||||
|
||||
/*******************************************************************************
|
||||
** Return envelope diagnostics without submitted values.
|
||||
*******************************************************************************/
|
||||
List<EnvelopeFinding> findings();
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.edi.document;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import com.kingsrook.qbits.edi.edifact.EdifactSeparators;
|
||||
|
||||
/*******************************************************************************
|
||||
** Frame concatenated X12 and EDIFACT envelopes without delimiter guessing in data.
|
||||
*******************************************************************************/
|
||||
public class EnvelopeReader
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Read one envelope, respecting UNA and released terminators; null means EOF.
|
||||
*******************************************************************************/
|
||||
public byte[] next(InputStream input) throws IOException
|
||||
{
|
||||
int first;
|
||||
do
|
||||
{
|
||||
first = input.read();
|
||||
}
|
||||
while(first != -1 && Character.isWhitespace(first));
|
||||
if(first == -1)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
|
||||
bytes.write(first);
|
||||
bytes.write(input.readNBytes(2));
|
||||
String tag = bytes.toString(StandardCharsets.US_ASCII);
|
||||
char terminator;
|
||||
char release = 0;
|
||||
String end;
|
||||
StringBuilder prefix = new StringBuilder();
|
||||
if("ISA".equals(tag))
|
||||
{
|
||||
bytes.write(input.readNBytes(103));
|
||||
if(bytes.size() != 106)
|
||||
{
|
||||
return (bytes.toByteArray());
|
||||
}
|
||||
terminator = (char) bytes.toByteArray()[105];
|
||||
end = "IEA";
|
||||
}
|
||||
else if("UNA".equals(tag) || "UNB".equals(tag))
|
||||
{
|
||||
EdifactSeparators separators = EdifactSeparators.DEFAULT;
|
||||
if("UNA".equals(tag))
|
||||
{
|
||||
bytes.write(input.readNBytes(6));
|
||||
separators = EdifactSeparators.fromUna(bytes.toString(StandardCharsets.US_ASCII));
|
||||
}
|
||||
else
|
||||
{
|
||||
prefix.append("UNB");
|
||||
}
|
||||
terminator = separators.segment();
|
||||
release = separators.release();
|
||||
end = "UNZ";
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new IOException("Unknown EDI envelope");
|
||||
}
|
||||
boolean escaped = false;
|
||||
int value;
|
||||
while((value = input.read()) != -1)
|
||||
{
|
||||
bytes.write(value);
|
||||
if(bytes.size() > 50 * 1024 * 1024)
|
||||
{
|
||||
throw new IOException("EDI envelope exceeds size limit");
|
||||
}
|
||||
if(escaped)
|
||||
{
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
if(value == release)
|
||||
{
|
||||
escaped = true;
|
||||
}
|
||||
else if(value == terminator)
|
||||
{
|
||||
if(prefix.toString().equals(end))
|
||||
{
|
||||
break;
|
||||
}
|
||||
prefix.setLength(0);
|
||||
}
|
||||
else if(prefix.length() < 3 && !(prefix.isEmpty() && Character.isWhitespace(value)))
|
||||
{
|
||||
prefix.append((char) value);
|
||||
}
|
||||
}
|
||||
return (bytes.toByteArray());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.edi.edifact;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.edi.ack.AckLevel;
|
||||
import com.kingsrook.qbits.edi.guide.ValidationFinding;
|
||||
import com.kingsrook.qbits.edi.x12.X12Segment;
|
||||
|
||||
/*******************************************************************************
|
||||
** Positional CONTRL syntax responses; business application failures are not syntax errors.
|
||||
*******************************************************************************/
|
||||
public class ContrlBuilder
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Build directory-specific acknowledgement details without echoing data values.
|
||||
*******************************************************************************/
|
||||
public EdifactMessage build(EdifactInterchange input, Map<String, List<ValidationFinding>> findings, AckLevel level, String reference, String version)
|
||||
{
|
||||
List<X12Segment> segments = new ArrayList<>();
|
||||
segments.add(EdifactWriter.segment("UNH", 1, List.of(reference), List.of("CONTRL", version.substring(0, 1), version.substring(1), "UN")));
|
||||
boolean rejected = !input.findings().isEmpty() || level == AckLevel.GROUP_ONLY && (input.groups().stream().anyMatch(g -> !g.findings().isEmpty())
|
||||
|| input.allMessages().stream().anyMatch(m -> rejected(m, findings)));
|
||||
var uci = new ArrayList<List<String>>();
|
||||
uci.add(List.of(input.unb().value(5)));
|
||||
uci.add(input.unb().elements().get(1).getFirst());
|
||||
uci.add(input.unb().elements().get(2).getFirst());
|
||||
uci.add(List.of(rejected ? "4" : "7"));
|
||||
if(!input.findings().isEmpty())
|
||||
{
|
||||
uci.add(List.of(input.findings().getFirst().code()));
|
||||
}
|
||||
segments.add(new X12Segment("UCI", 2, uci.stream().map(List::of).toList()));
|
||||
if(level != AckLevel.GROUP_ONLY && input.findings().isEmpty())
|
||||
{
|
||||
messages(input.messages(), findings, level, segments);
|
||||
for(var group : input.groups())
|
||||
{
|
||||
var response = EdifactWriter.segment("UCF", segments.size() + 1, List.of(group.ung().value(5)), group.ung().elements().get(1).getFirst(),
|
||||
group.ung().elements().get(2).getFirst(), List.of(group.findings().isEmpty() ? "7" : "4"));
|
||||
if(!group.findings().isEmpty())
|
||||
{
|
||||
var fields = new ArrayList<>(response.elements());
|
||||
fields.add(List.of(List.of(group.findings().getFirst().code())));
|
||||
response = new X12Segment("UCF", response.position(), fields);
|
||||
}
|
||||
segments.add(response);
|
||||
messages(group.messages(), findings, level, segments);
|
||||
}
|
||||
}
|
||||
segments.add(X12Segment.of("UNT", segments.size() + 1, String.valueOf(segments.size() + 1), reference));
|
||||
return (new EdifactMessage(segments.getFirst(), segments.getLast(), List.copyOf(segments), List.of()));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Suppress accepted message detail only under ERRORS_ONLY.
|
||||
*******************************************************************************/
|
||||
private void messages(List<EdifactMessage> messages, Map<String, List<ValidationFinding>> findings, AckLevel level, List<X12Segment> segments)
|
||||
{
|
||||
for(var message : messages)
|
||||
{
|
||||
if("CONTRL".equals(message.type()))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
boolean rejected = rejected(message, findings);
|
||||
if(level == AckLevel.ERRORS_ONLY && !rejected)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
var fields = new ArrayList<List<List<String>>>();
|
||||
fields.add(List.of(List.of(message.reference())));
|
||||
fields.add(message.header().elements().get(1));
|
||||
fields.add(List.of(List.of(rejected ? "4" : "7")));
|
||||
if(!message.findings().isEmpty())
|
||||
{
|
||||
fields.add(List.of(List.of(message.findings().getFirst().code())));
|
||||
}
|
||||
segments.add(new X12Segment("UCM", segments.size() + 1, fields));
|
||||
for(var finding : findings.getOrDefault(message.reference(), List.of()))
|
||||
{
|
||||
if(!"ERROR".equals(finding.severity()))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
String code = syntaxCode(finding);
|
||||
segments.add(finding.elementPosition() == null
|
||||
? X12Segment.of("UCS", segments.size() + 1, String.valueOf(finding.segmentPosition()), code)
|
||||
: X12Segment.of("UCS", segments.size() + 1, String.valueOf(finding.segmentPosition())));
|
||||
if(finding.elementPosition() != null)
|
||||
{
|
||||
List<String> position = finding.componentPosition() == null ? List.of(finding.elementPosition().toString())
|
||||
: List.of(finding.elementPosition().toString(), finding.componentPosition().toString());
|
||||
segments.add(EdifactWriter.segment("UCD", segments.size() + 1, List.of(code), position));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Envelope and structural errors reject the message, warnings do not.
|
||||
*******************************************************************************/
|
||||
private boolean rejected(EdifactMessage message, Map<String, List<ValidationFinding>> findings)
|
||||
{
|
||||
return (!message.findings().isEmpty() || findings.getOrDefault(message.reference(), List.of()).stream().anyMatch(f -> "ERROR".equals(f.severity())));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Translate shared validator diagnostics to service syntax codes (0085).
|
||||
*******************************************************************************/
|
||||
public static String syntaxCode(ValidationFinding finding)
|
||||
{
|
||||
String code = finding.elementPosition() == null ? finding.ak3Code() : finding.ak4Code();
|
||||
if(code == null)
|
||||
{
|
||||
return ("12");
|
||||
}
|
||||
return (finding.elementPosition() == null ? switch(code)
|
||||
{
|
||||
case "3" -> "13";
|
||||
case "4", "5" -> "35";
|
||||
case "2", "6", "7" -> "33";
|
||||
default -> "12";
|
||||
} : switch(code)
|
||||
{
|
||||
case "1", "2" -> "13";
|
||||
case "4" -> "14";
|
||||
case "5" -> "15";
|
||||
default -> "12";
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.edi.edifact;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.edi.EdiRuntime;
|
||||
import com.kingsrook.qbits.edi.flow.EdiJournal;
|
||||
import com.kingsrook.qbits.edi.storage.EdiRecords;
|
||||
import com.kingsrook.qbits.edi.support.SystemWrites;
|
||||
import com.kingsrook.qbits.edi.x12.X12Segment;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertSeverity;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertSignal;
|
||||
import com.kingsrook.qqq.backend.core.alerts.QAlerts;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
|
||||
/*******************************************************************************
|
||||
** Reconcile addressed CONTRL controls only within the routed outbound agreement.
|
||||
*******************************************************************************/
|
||||
public class ContrlReader
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Apply functional acceptance/rejection; receipt-only code 8 leaves deadlines open.
|
||||
*******************************************************************************/
|
||||
public void reconcile(EdifactMessage message, QRecord agreement, QRecord received) throws Exception
|
||||
{
|
||||
X12Segment uci = message.segments().stream().filter(s -> "UCI".equals(s.id())).findFirst().orElseThrow();
|
||||
if("8".equals(uci.value(4)))
|
||||
{
|
||||
return;
|
||||
}
|
||||
String overall = status(uci.value(4));
|
||||
var candidates = EdiRecords.query("edi_interchange", EdiRecords.equal("agreementId", agreement.getValue("id")).withCriteria("direction", QCriteriaOperator.EQUALS, "OUTBOUND")
|
||||
.withCriteria("standard", QCriteriaOperator.EQUALS, "EDIFACT").withCriteria("controlNumber", QCriteriaOperator.EQUALS, uci.value(1))
|
||||
.withCriteria("senderId", QCriteriaOperator.EQUALS, uci.value(2)).withCriteria("senderQualifier", QCriteriaOperator.EQUALS, uci.value(2, 1, 2))
|
||||
.withCriteria("receiverId", QCriteriaOperator.EQUALS, uci.value(3)).withCriteria("receiverQualifier", QCriteriaOperator.EQUALS, uci.value(3, 1, 2)));
|
||||
if(candidates.size() != 1)
|
||||
{
|
||||
throw new IllegalArgumentException("Acknowledgement reference is unknown or ambiguous");
|
||||
}
|
||||
QRecord interchange = candidates.getFirst();
|
||||
Map<String, X12Segment> details = new LinkedHashMap<>();
|
||||
Map<String, String> groupStatuses = new LinkedHashMap<>();
|
||||
String currentGroup = "";
|
||||
for(var segment : message.segments())
|
||||
{
|
||||
if("UCF".equals(segment.id()))
|
||||
{
|
||||
currentGroup = segment.value(1);
|
||||
groupStatuses.put(currentGroup, status(segment.value(4)));
|
||||
}
|
||||
if("UCM".equals(segment.id()))
|
||||
{
|
||||
String key = currentGroup + ":" + segment.value(1);
|
||||
if(details.put(key, segment) != null)
|
||||
{
|
||||
throw new IllegalArgumentException("Duplicate acknowledgement message reference");
|
||||
}
|
||||
status(segment.value(3));
|
||||
}
|
||||
}
|
||||
boolean rejected = "R".equals(overall);
|
||||
var groups = EdiRecords.query("edi_functional_group", EdiRecords.equal("interchangeId", interchange.getValue("id")));
|
||||
for(var group : groups)
|
||||
{
|
||||
String scope = interchange.getValueInteger("groupCount") == 0 ? "" : group.getValueString("controlNumber");
|
||||
String groupStatus = "R".equals(overall) ? "R" : groupStatuses.getOrDefault(scope, overall);
|
||||
boolean groupRejected = "R".equals(groupStatus);
|
||||
for(var tx : EdiRecords.query("edi_transaction", EdiRecords.equal("functionalGroupId", group.getValue("id"))))
|
||||
{
|
||||
X12Segment detail = details.remove(scope + ":" + tx.getValueString("controlNumber"));
|
||||
if(detail != null && (!detail.value(2).equals(tx.getValueString("transactionSet"))
|
||||
|| !(detail.value(2, 1, 2) + detail.value(2, 1, 3)).equals(tx.getValueString("version"))))
|
||||
{
|
||||
throw new IllegalArgumentException("Acknowledgement message identification mismatch");
|
||||
}
|
||||
String result = "R".equals(groupStatus) || detail != null && "R".equals(status(detail.value(3))) ? "R" : "A";
|
||||
groupRejected |= "R".equals(result);
|
||||
SystemWrites.update("edi_transaction", new QRecord().withValue("id", tx.getValue("id")).withValue("ackStatus", result)
|
||||
.withValue("status", "R".equals(result) ? "REJECTED" : "ACKNOWLEDGED"));
|
||||
}
|
||||
rejected |= groupRejected;
|
||||
SystemWrites.update("edi_functional_group", new QRecord().withValue("id", group.getValue("id")).withValue("ackStatus", groupRejected ? "R" : "A")
|
||||
.withValue("ackReceivedAt", EdiRuntime.current().config().getClock().instant()).withValue("ackTransactionId", received.getValue("id")));
|
||||
}
|
||||
if(!details.isEmpty() || groupStatuses.keySet().stream().anyMatch(c -> groups.stream().noneMatch(g -> c.equals(g.getValueString("controlNumber")))))
|
||||
{
|
||||
throw new IllegalArgumentException("Acknowledgement contains an unknown message or group reference");
|
||||
}
|
||||
SystemWrites.update("edi_interchange", new QRecord().withValue("id", interchange.getValue("id")).withValue("status", rejected ? "REJECTED" : "ACKNOWLEDGED"));
|
||||
SystemWrites.update("edi_transaction", new QRecord().withValue("id", received.getValue("id")).withValue("status", "ACKNOWLEDGED").withValue("ackStatus", rejected ? "R" : "A"));
|
||||
for(var group : groups)
|
||||
{
|
||||
QAlerts.resolve("edi.ack.overdue", "edi_functional_group:" + group.getValue("id"), "CONTRL received");
|
||||
}
|
||||
for(var exception : EdiRecords.query("edi_exception", EdiRecords.equal("interchangeId", interchange.getValue("id")).withCriteria("type", QCriteriaOperator.EQUALS, "OVERDUE_ACK")
|
||||
.withCriteria("status", QCriteriaOperator.IN, "OPEN", "IN_PROGRESS")))
|
||||
{
|
||||
SystemWrites.update("edi_exception", new QRecord().withValue("id", exception.getValue("id")).withValue("status", "RESOLVED")
|
||||
.withValue("resolvedAt", EdiRuntime.current().config().getClock().instant()).withValue("resolution", "CONTRL received"));
|
||||
}
|
||||
if(rejected)
|
||||
{
|
||||
EdiJournal.exception(null, interchange.getValueInteger("id"), null, agreement.getValueInteger("partnerId"), "REJECTED_ACK", "Partner rejected an outbound EDIFACT document");
|
||||
QAlerts.raise(new AlertSignal("edi.ack.rejected").withSeverity(AlertSeverity.ERROR).withDedupKey("edi_interchange:" + interchange.getValue("id"))
|
||||
.withTitle("EDI acknowledgement rejected").withSummary("Review the outbound interchange"));
|
||||
}
|
||||
EdiJournal.event(null, interchange.getValueInteger("id"), received.getValueInteger("id"), "ACK_RECEIVED");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Only functional acceptance and rejection can complete an expected response.
|
||||
*******************************************************************************/
|
||||
private String status(String action)
|
||||
{
|
||||
return (switch(action)
|
||||
{
|
||||
case "7" -> "A";
|
||||
case "4" -> "R";
|
||||
default -> throw new IllegalArgumentException("Unsupported CONTRL action");
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.edi.edifact;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.edi.document.EnvelopeReader;
|
||||
import com.kingsrook.qbits.edi.x12.EnvelopeFinding;
|
||||
import com.kingsrook.qbits.edi.x12.X12Segment;
|
||||
import io.xlate.edi.stream.EDIInputFactory;
|
||||
import io.xlate.edi.stream.EDIStreamEvent;
|
||||
import io.xlate.edi.stream.EDIStreamException;
|
||||
import io.xlate.edi.stream.EDIStreamReader;
|
||||
import io.xlate.edi.stream.EDIStreamValidationError;
|
||||
|
||||
/*******************************************************************************
|
||||
** StAEDI tokenizer with explicit UN envelope checks and bounded message retention.
|
||||
*******************************************************************************/
|
||||
public class EdifactEnvelopeReader
|
||||
{
|
||||
private final int maxSegments;
|
||||
|
||||
/*******************************************************************************
|
||||
** Bound retained message segments, including UNH and UNT.
|
||||
*******************************************************************************/
|
||||
public EdifactEnvelopeReader(int maxSegments)
|
||||
{
|
||||
if(maxSegments < 2)
|
||||
{
|
||||
throw new IllegalArgumentException("At least two segments are required");
|
||||
}
|
||||
this.maxSegments = maxSegments;
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Read concatenated EDIFACT interchanges without closing the caller stream.
|
||||
*******************************************************************************/
|
||||
public List<EdifactInterchange> read(InputStream input) throws IOException
|
||||
{
|
||||
List<EdifactInterchange> result = new ArrayList<>();
|
||||
EnvelopeReader framing = new EnvelopeReader();
|
||||
byte[] bytes;
|
||||
while((bytes = framing.next(input)) != null)
|
||||
{
|
||||
result.add(parse(bytes));
|
||||
}
|
||||
return (result);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Parse one framed interchange; diagnostics never include submitted values.
|
||||
*******************************************************************************/
|
||||
private EdifactInterchange parse(byte[] bytes) throws IOException
|
||||
{
|
||||
String prefix = new String(bytes, 0, Math.min(9, bytes.length), StandardCharsets.US_ASCII);
|
||||
EdifactSeparators separators = prefix.startsWith("UNA") ? EdifactSeparators.fromUna(prefix) : EdifactSeparators.DEFAULT;
|
||||
List<EnvelopeFinding> errors = new ArrayList<>();
|
||||
List<EnvelopeFinding> groupErrors = new ArrayList<>();
|
||||
List<EnvelopeFinding> messageErrors = new ArrayList<>();
|
||||
List<EdifactGroup> groups = new ArrayList<>();
|
||||
List<EdifactMessage> messages = new ArrayList<>();
|
||||
List<EdifactMessage> grouped = new ArrayList<>();
|
||||
List<X12Segment> segments = new ArrayList<>();
|
||||
var references = new HashSet<String>();
|
||||
X12Segment unb = null;
|
||||
X12Segment unz = null;
|
||||
X12Segment ung = null;
|
||||
X12Segment unh = null;
|
||||
String tag = "";
|
||||
int count = 0;
|
||||
List<List<List<String>>> values = new ArrayList<>();
|
||||
EDIInputFactory factory = EDIInputFactory.newFactory();
|
||||
factory.setProperty(EDIInputFactory.EDI_IGNORE_EXTRANEOUS_CHARACTERS, true);
|
||||
factory.setProperty(EDIInputFactory.EDI_VALIDATE_CONTROL_CODE_VALUES, false);
|
||||
try(EDIStreamReader reader = factory.createEDIStreamReader(new ByteArrayInputStream(bytes)))
|
||||
{
|
||||
while(reader.hasNext())
|
||||
{
|
||||
EDIStreamEvent event = reader.next();
|
||||
if(event == EDIStreamEvent.START_SEGMENT)
|
||||
{
|
||||
tag = reader.getText();
|
||||
values = new ArrayList<>();
|
||||
}
|
||||
else if(event == EDIStreamEvent.ELEMENT_DATA)
|
||||
{
|
||||
int e = reader.getLocation().getElementPosition();
|
||||
int r = Math.max(1, reader.getLocation().getElementOccurrence());
|
||||
int c = Math.max(1, reader.getLocation().getComponentPosition());
|
||||
while(values.size() < e)
|
||||
{
|
||||
values.add(new ArrayList<>());
|
||||
}
|
||||
while(values.get(e - 1).size() < r)
|
||||
{
|
||||
values.get(e - 1).add(new ArrayList<>());
|
||||
}
|
||||
var components = values.get(e - 1).get(r - 1);
|
||||
while(components.size() < c)
|
||||
{
|
||||
components.add("");
|
||||
}
|
||||
components.set(c - 1, reader.getText());
|
||||
}
|
||||
else if(event.isError() && reader.getErrorType() != EDIStreamValidationError.CONTROL_REFERENCE_MISMATCH
|
||||
&& reader.getErrorType() != EDIStreamValidationError.CONTROL_COUNT_DOES_NOT_MATCH_ACTUAL_COUNT)
|
||||
{
|
||||
if(unh != null)
|
||||
{
|
||||
messageErrors.add(error("UNH", Math.max(1, count), "12"));
|
||||
}
|
||||
else if(ung != null)
|
||||
{
|
||||
groupErrors.add(error("UNG", count, "12"));
|
||||
}
|
||||
else
|
||||
{
|
||||
errors.add(error("UNB", count, "12"));
|
||||
}
|
||||
}
|
||||
else if(event == EDIStreamEvent.END_SEGMENT)
|
||||
{
|
||||
if("UNA".equals(tag))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
count = "UNH".equals(tag) ? 1 : count + 1;
|
||||
X12Segment segment = new X12Segment(tag, count, values);
|
||||
switch(tag)
|
||||
{
|
||||
case "UNB" ->
|
||||
{
|
||||
unb = segment;
|
||||
if("4".equals(unb.value(1, 1, 2)) && !prefix.startsWith("UNA"))
|
||||
{
|
||||
separators = new EdifactSeparators(':', '+', '.', '?', '*', '\'');
|
||||
}
|
||||
if(!List.of("3", "4").contains(unb.value(1, 1, 2)) || !List.of("", "1").contains(unb.value(11))
|
||||
|| unb.value(5).isEmpty() || unb.value(5).length() > 14)
|
||||
{
|
||||
errors.add(error("UNB", 1, "12"));
|
||||
}
|
||||
}
|
||||
case "UNG" ->
|
||||
{
|
||||
ung = segment;
|
||||
grouped = new ArrayList<>();
|
||||
groupErrors = new ArrayList<>();
|
||||
}
|
||||
case "UNH" ->
|
||||
{
|
||||
unh = segment;
|
||||
segments = new ArrayList<>();
|
||||
segments.add(segment);
|
||||
messageErrors = new ArrayList<>();
|
||||
if(!references.add(unh.value(1)))
|
||||
{
|
||||
messageErrors.add(error("UNH", 1, "19"));
|
||||
}
|
||||
}
|
||||
case "UNT" ->
|
||||
{
|
||||
if(unh == null)
|
||||
{
|
||||
errors.add(error("UNB", count, "33"));
|
||||
break;
|
||||
}
|
||||
check(messageErrors, "UNH", segment, segment.value(1), String.valueOf(count), "29");
|
||||
check(messageErrors, "UNH", segment, segment.value(2), unh.value(1), "28");
|
||||
if(segments.size() < maxSegments)
|
||||
{
|
||||
segments.add(segment);
|
||||
}
|
||||
(ung == null ? messages : grouped).add(new EdifactMessage(unh, segment, List.copyOf(segments), List.copyOf(messageErrors)));
|
||||
unh = null;
|
||||
}
|
||||
case "UNE" ->
|
||||
{
|
||||
if(ung == null)
|
||||
{
|
||||
errors.add(error("UNB", count, "33"));
|
||||
break;
|
||||
}
|
||||
check(groupErrors, "UNG", segment, segment.value(1), String.valueOf(grouped.size()), "29");
|
||||
check(groupErrors, "UNG", segment, segment.value(2), ung.value(5), "28");
|
||||
groups.add(new EdifactGroup(ung, segment, List.copyOf(grouped), List.copyOf(groupErrors)));
|
||||
ung = null;
|
||||
}
|
||||
case "UNZ" ->
|
||||
{
|
||||
unz = segment;
|
||||
check(errors, "UNB", segment, segment.value(1), String.valueOf(groups.isEmpty() ? messages.size() : groups.size()), "29");
|
||||
check(errors, "UNB", segment, segment.value(2), unb == null ? "" : unb.value(5), "28");
|
||||
}
|
||||
default ->
|
||||
{
|
||||
if(unh == null)
|
||||
{
|
||||
errors.add(error("UNB", count, "33"));
|
||||
}
|
||||
else if(segments.size() < maxSegments - 1)
|
||||
{
|
||||
segments.add(segment);
|
||||
}
|
||||
else if(messageErrors.stream().noneMatch(f -> "35".equals(f.code())))
|
||||
{
|
||||
messageErrors.add(error("UNH", count, "35"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch(EDIStreamException e)
|
||||
{
|
||||
errors.add(error("UNB", 0, "12"));
|
||||
}
|
||||
if(unb == null || unz == null || ung != null || unh != null || !groups.isEmpty() && !messages.isEmpty())
|
||||
{
|
||||
errors.add(error("UNB", 0, "13"));
|
||||
}
|
||||
return (new EdifactInterchange(separators, unb, unz, List.copyOf(groups), List.copyOf(messages), List.copyOf(errors)));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Count fields accept zero padding; references retain their exact spelling.
|
||||
*******************************************************************************/
|
||||
private static void check(List<EnvelopeFinding> findings, String level, X12Segment segment, String actual, String expected, String code)
|
||||
{
|
||||
boolean equal = "29".equals(code) ? actual.matches("[0-9]+") && new java.math.BigInteger(actual).toString().equals(expected) : actual.equals(expected);
|
||||
if(!equal)
|
||||
{
|
||||
findings.add(error(level, segment.position(), code));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Service syntax error code 0085 with a value-free diagnostic.
|
||||
*******************************************************************************/
|
||||
private static EnvelopeFinding error(String level, int position, String code)
|
||||
{
|
||||
return (new EnvelopeFinding(level, position, code, "EDIFACT envelope failed validation"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.edi.edifact;
|
||||
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.edi.x12.EnvelopeFinding;
|
||||
import com.kingsrook.qbits.edi.x12.X12Segment;
|
||||
|
||||
/*******************************************************************************
|
||||
** Optional UNG/UNE functional group; ungrouped messages belong to the interchange.
|
||||
*******************************************************************************/
|
||||
public record EdifactGroup(X12Segment ung, X12Segment une, List<EdifactMessage> messages, List<EnvelopeFinding> findings)
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.edi.edifact;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import com.kingsrook.qbits.edi.EdiRuntime;
|
||||
import com.kingsrook.qbits.edi.ack.AckLevel;
|
||||
import com.kingsrook.qbits.edi.flow.EdiJournal;
|
||||
import com.kingsrook.qbits.edi.flow.InboundProcessor;
|
||||
import com.kingsrook.qbits.edi.flow.InboxWorker;
|
||||
import com.kingsrook.qbits.edi.flow.OutboundWorker;
|
||||
import com.kingsrook.qbits.edi.guide.GuideRepository;
|
||||
import com.kingsrook.qbits.edi.guide.ValidationFinding;
|
||||
import com.kingsrook.qbits.edi.storage.EdiRecords;
|
||||
import com.kingsrook.qbits.edi.storage.PayloadStore;
|
||||
import com.kingsrook.qbits.edi.support.SystemWrites;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
|
||||
/*******************************************************************************
|
||||
** Native UNB routing and CONTRL with the shared revision, validation and apply checkpoints.
|
||||
*******************************************************************************/
|
||||
public class EdifactInbound extends InboundProcessor
|
||||
{
|
||||
private final QRecord inbox;
|
||||
private final Instant lease;
|
||||
|
||||
/*******************************************************************************
|
||||
** Bind every write to the same inbox lease used by the X12 worker.
|
||||
*******************************************************************************/
|
||||
public EdifactInbound(QRecord inbox, Instant lease, PayloadStore store)
|
||||
{
|
||||
super(inbox, lease, store);
|
||||
this.inbox = inbox;
|
||||
this.lease = lease;
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Route UNB identities before validating, acknowledging or applying messages.
|
||||
*******************************************************************************/
|
||||
public void process(EdifactInterchange input, int position) throws Exception
|
||||
{
|
||||
if(input.unb() == null)
|
||||
{
|
||||
InboxWorker.fenced(inbox, lease, () ->
|
||||
{
|
||||
EdiJournal.exception(inbox.getValueInteger("id"), null, null, null, "ENVELOPE", "Invalid EDIFACT header");
|
||||
return (null);
|
||||
});
|
||||
return;
|
||||
}
|
||||
QRecord agreement = route(input);
|
||||
QRecord interchange = InboxWorker.fenced(inbox, lease, () -> interchange(input, agreement, position));
|
||||
if(agreement == null)
|
||||
{
|
||||
problem(interchange, "UNKNOWN_PARTNER");
|
||||
return;
|
||||
}
|
||||
if(inbox.getValueString("username") != null && EdiRecords.query("edi_partner", EdiRecords.equal("gatewayUsername", inbox.getValue("username"))).stream()
|
||||
.anyMatch(p -> !p.getValue("id").equals(agreement.getValue("partnerId"))))
|
||||
{
|
||||
problem(interchange, "PARTNER_MISMATCH");
|
||||
return;
|
||||
}
|
||||
if(inbox.getValueLong("sizeBytes") != null && inbox.getValueLong("sizeBytes") > Objects.requireNonNullElse(agreement.getValueLong("maxInterchangeBytes"), 52428800L))
|
||||
{
|
||||
problem(interchange, "ENVELOPE");
|
||||
return;
|
||||
}
|
||||
boolean duplicate = "DUPLICATE".equals(interchange.getValueString("status"));
|
||||
boolean ackInput = !input.allMessages().isEmpty() && input.allMessages().stream().allMatch(m -> "CONTRL".equals(m.type()));
|
||||
if(duplicate)
|
||||
{
|
||||
problem(interchange, "DUPLICATE");
|
||||
if(!"PROCESS".equals(agreement.getValueString("onDuplicate")))
|
||||
{
|
||||
if(!ackInput && "REJECT_TA1".equals(agreement.getValueString("onDuplicate")))
|
||||
{
|
||||
var rejected = new EdifactInterchange(input.separators(), input.unb(), input.unz(), input.groups(), input.messages(),
|
||||
List.of(new com.kingsrook.qbits.edi.x12.EnvelopeFinding("UNB", 1, "19", "Duplicate interchange")));
|
||||
acknowledge(rejected, agreement, interchange, Map.of(), AckLevel.GROUP_ONLY);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
if(!input.findings().isEmpty())
|
||||
{
|
||||
problem(interchange, "ENVELOPE");
|
||||
if(!ackInput)
|
||||
{
|
||||
acknowledge(input, agreement, interchange, Map.of(), AckLevel.GROUP_ONLY);
|
||||
}
|
||||
return;
|
||||
}
|
||||
Map<String, List<ValidationFinding>> findings = new LinkedHashMap<>();
|
||||
AckLevel level = AckLevel.ERRORS_ONLY;
|
||||
boolean sendAck = false;
|
||||
List<EdifactGroup> batches = input.groups().isEmpty() ? List.of(new EdifactGroup(null, null, input.messages(), List.of())) : input.groups();
|
||||
int groupPosition = 0;
|
||||
for(var batch : batches)
|
||||
{
|
||||
int gp = ++groupPosition;
|
||||
QRecord group = InboxWorker.fenced(inbox, lease, () ->
|
||||
{
|
||||
var prior = EdiRecords.query("edi_functional_group", EdiRecords.equal("interchangeId", interchange.getValue("id")).withCriteria("inboundPosition", QCriteriaOperator.EQUALS, gp));
|
||||
return (prior.isEmpty() ? SystemWrites.insert("edi_functional_group", new QRecord().withValue("interchangeId", interchange.getValue("id")).withValue("inboundPosition", gp)
|
||||
.withValue("senderId", input.unb().value(2)).withValue("controlNumber", batch.ung() == null ? input.unb().value(5) : batch.ung().value(5))
|
||||
.withValue("version", batch.messages().isEmpty() ? null : batch.messages().getFirst().version())
|
||||
.withValue("functionalIdCode", batch.ung() == null ? null : batch.ung().value(1)).withValue("transactionCount", batch.messages().size()).withValue("ackStatus", "NONE")) : prior.getFirst());
|
||||
});
|
||||
int messagePosition = 0;
|
||||
for(var message : batch.messages())
|
||||
{
|
||||
QRecord document = document(agreement, message);
|
||||
int mp = ++messagePosition;
|
||||
QRecord configured = document;
|
||||
QRecord tx = InboxWorker.fenced(inbox, lease, () ->
|
||||
{
|
||||
var prior = EdiRecords.query("edi_transaction", EdiRecords.equal("functionalGroupId", group.getValue("id")).withCriteria("inboundPosition", QCriteriaOperator.EQUALS, mp));
|
||||
return (prior.isEmpty() ? SystemWrites.insert("edi_transaction", new QRecord().withValue("interchangeId", interchange.getValue("id")).withValue("functionalGroupId", group.getValue("id"))
|
||||
.withValue("inboundPosition", mp).withValue("partnerId", agreement.getValue("partnerId")).withValue("direction", "INBOUND").withValue("transactionSet", message.type())
|
||||
.withValue("version", message.version()).withValue("controlNumber", message.reference()).withValue("segmentCount", message.segments().size()).withValue("status", "RECEIVED")
|
||||
.withValue("agreementDocumentId", configured == null ? null : configured.getValue("id"))
|
||||
.withValue("containsPhi", configured != null && Boolean.TRUE.equals(configured.getValueBoolean("containsPhi")))) : prior.getFirst());
|
||||
});
|
||||
document = revisions(document, tx);
|
||||
List<ValidationFinding> result = new ArrayList<>();
|
||||
if(document == null || document.getValue("guideId") == null)
|
||||
{
|
||||
result.add(finding("No implementation guide is configured"));
|
||||
problem(interchange, "NOT_CONFIGURED");
|
||||
}
|
||||
else
|
||||
{
|
||||
result.addAll(EdiRuntime.current().validator().validate(message, new GuideRepository().load(document.getValueInteger("guideId")), Boolean.TRUE.equals(document.getValueBoolean("containsPhi"))));
|
||||
}
|
||||
findings.put(message.reference(), List.copyOf(result));
|
||||
if(!batch.findings().isEmpty() || !message.findings().isEmpty())
|
||||
{
|
||||
result.add(finding("Envelope counts or references failed validation"));
|
||||
}
|
||||
QRecord effective = document;
|
||||
InboxWorker.fenced(inbox, lease, () ->
|
||||
{
|
||||
validation(tx, result, effective);
|
||||
return (null);
|
||||
});
|
||||
if("CONTRL".equals(message.type()))
|
||||
{
|
||||
if(result.isEmpty())
|
||||
{
|
||||
InboxWorker.fenced(inbox, lease, () ->
|
||||
{
|
||||
new ContrlReader().reconcile(message, agreement, tx);
|
||||
return (null);
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if(document != null && "CONTRL".equals(document.getValueString("ackExpectation")))
|
||||
{
|
||||
sendAck = true;
|
||||
AckLevel selected = AckLevel.valueOf(Objects.requireNonNullElse(document.getValueString("ackLevel"), "ERRORS_ONLY"));
|
||||
if(selected == AckLevel.ALL_TRANSACTIONS || level != AckLevel.ALL_TRANSACTIONS && selected == AckLevel.GROUP_ONLY)
|
||||
{
|
||||
level = selected;
|
||||
}
|
||||
}
|
||||
if(document != null && result.stream().noneMatch(f -> "ERROR".equals(f.severity()))
|
||||
&& (!"T".equals(input.usage()) || Boolean.TRUE.equals(agreement.getValueBoolean("applyTestDocuments"))))
|
||||
{
|
||||
apply(message, tx, document, agreement);
|
||||
}
|
||||
}
|
||||
}
|
||||
if(!ackInput && (sendAck || "1".equals(input.unb().value(9))))
|
||||
{
|
||||
acknowledge(input, agreement, interchange, findings, level);
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Match qualifier, identifier, standard, usage and identity ownership exactly.
|
||||
*******************************************************************************/
|
||||
private QRecord route(EdifactInterchange input) throws Exception
|
||||
{
|
||||
for(var agreement : EdiRecords.query("edi_agreement", EdiRecords.equal("isActive", true).withCriteria("standard", QCriteriaOperator.EQUALS, "EDIFACT")
|
||||
.withCriteria("usageIndicator", QCriteriaOperator.EQUALS, input.usage())))
|
||||
{
|
||||
QRecord sender = OutboundWorker.row("edi_identity", agreement.getValue("partnerIdentityId"));
|
||||
QRecord receiver = OutboundWorker.row("edi_identity", agreement.getValue("localIdentityId"));
|
||||
if(Boolean.TRUE.equals(sender.getValueBoolean("isActive")) && Boolean.TRUE.equals(receiver.getValueBoolean("isActive"))
|
||||
&& "EDIFACT".equals(sender.getValueString("standard")) && "EDIFACT".equals(receiver.getValueString("standard"))
|
||||
&& Objects.equals(sender.getValue("partnerId"), agreement.getValue("partnerId")) && receiver.getValue("partnerId") == null
|
||||
&& input.unb().value(2).equals(sender.getValueString("interchangeId")) && input.unb().value(3).equals(receiver.getValueString("interchangeId"))
|
||||
&& input.unb().value(2, 1, 2).equals(Objects.requireNonNullElse(sender.getValueString("interchangeQualifier"), ""))
|
||||
&& input.unb().value(3, 1, 2).equals(Objects.requireNonNullElse(receiver.getValueString("interchangeQualifier"), "")))
|
||||
{
|
||||
return (agreement);
|
||||
}
|
||||
}
|
||||
return (null);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Preserve retries and serialize reference duplicate checks on the agreement.
|
||||
*******************************************************************************/
|
||||
private QRecord interchange(EdifactInterchange input, QRecord agreement, int position) throws Exception
|
||||
{
|
||||
var prior = EdiRecords.query("edi_interchange", EdiRecords.equal("inboxId", inbox.getValue("id")).withCriteria("inboundPosition", QCriteriaOperator.EQUALS, position));
|
||||
if(!prior.isEmpty())
|
||||
{
|
||||
QRecord existing = prior.getFirst();
|
||||
if(agreement != null && existing.getValue("agreementId") == null)
|
||||
{
|
||||
existing.withValue("agreementId", agreement.getValue("id")).withValue("partnerId", agreement.getValue("partnerId"));
|
||||
SystemWrites.update("edi_interchange", existing);
|
||||
}
|
||||
return (existing);
|
||||
}
|
||||
boolean duplicate = false;
|
||||
if(agreement != null)
|
||||
{
|
||||
SystemWrites.guard("UPDATE edi_agreement SET id=id WHERE id=?", agreement.getValue("id"));
|
||||
duplicate = EdiRuntime.current().duplicates().edifact(agreement.getValueInteger("id"), input, Objects.requireNonNullElse(agreement.getValueInteger("duplicateWindowDays"), 365), EdiRuntime.current().config().getClock().instant());
|
||||
SystemWrites.update("edi_inbox", new QRecord().withValue("id", inbox.getValue("id")).withValue("partnerId", agreement.getValue("partnerId")));
|
||||
}
|
||||
return (SystemWrites.insert("edi_interchange", new QRecord().withValue("inboxId", inbox.getValue("id")).withValue("inboundPosition", position).withValue("direction", "INBOUND")
|
||||
.withValue("agreementId", agreement == null ? null : agreement.getValue("id")).withValue("partnerId", agreement == null ? null : agreement.getValue("partnerId"))
|
||||
.withValue("standard", "EDIFACT").withValue("senderId", input.unb().value(2)).withValue("senderQualifier", input.unb().value(2, 1, 2))
|
||||
.withValue("receiverId", input.unb().value(3)).withValue("receiverQualifier", input.unb().value(3, 1, 2)).withValue("controlNumber", input.unb().value(5))
|
||||
.withValue("interchangeVersion", input.unb().value(1, 1, 2)).withValue("usageIndicator", input.usage()).withValue("groupCount", input.groups().size())
|
||||
.withValue("payloadRef", inbox.getValue("payloadRef")).withValue("payloadSha256", inbox.getValue("payloadSha256")).withValue("sizeBytes", inbox.getValue("sizeBytes"))
|
||||
.withValue("status", duplicate ? "DUPLICATE" : input.findings().isEmpty() ? "ACCEPTED" : "REJECTED")));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Match message type and directory independently for every UNH.
|
||||
*******************************************************************************/
|
||||
private QRecord document(QRecord agreement, EdifactMessage message) throws Exception
|
||||
{
|
||||
var rows = EdiRecords.query("edi_agreement_document", EdiRecords.equal("agreementId", agreement.getValue("id")).withCriteria("direction", QCriteriaOperator.EQUALS, "INBOUND")
|
||||
.withCriteria("transactionSet", QCriteriaOperator.EQUALS, message.type()).withCriteria("version", QCriteriaOperator.EQUALS, message.version()).withCriteria("isActive", QCriteriaOperator.EQUALS, true));
|
||||
return (rows.isEmpty() ? null : rows.getFirst());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Queue at most one CONTRL per received interchange, never acknowledge CONTRL.
|
||||
*******************************************************************************/
|
||||
private void acknowledge(EdifactInterchange input, QRecord agreement, QRecord received, Map<String, List<ValidationFinding>> findings, AckLevel level) throws Exception
|
||||
{
|
||||
InboxWorker.fenced(inbox, lease, () ->
|
||||
{
|
||||
if(EdiRecords.query("edi_outbound_message", EdiRecords.equal("ackForInterchangeId", received.getValue("id"))).isEmpty())
|
||||
{
|
||||
String version = input.allMessages().isEmpty() ? "D96A" : input.allMessages().getFirst().version();
|
||||
var message = new ContrlBuilder().build(input, findings, level, EdifactOutbound.allocate(agreement, "UNH"), version);
|
||||
EdifactOutbound.envelope(agreement, null, null, List.of(), List.of(message), null, received.getValueInteger("id"));
|
||||
EdiJournal.event(inbox.getValueInteger("id"), received.getValueInteger("id"), null, "ACK_QUEUED");
|
||||
}
|
||||
return (null);
|
||||
});
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Record a safe positional structural failure.
|
||||
*******************************************************************************/
|
||||
private ValidationFinding finding(String text)
|
||||
{
|
||||
return (new ValidationFinding(1, "UNH", null, null, null, 1, "3", null, "3", null, "ERROR", text, null));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Journal failures only while holding the current inbox claim.
|
||||
*******************************************************************************/
|
||||
private void problem(QRecord interchange, String type) throws Exception
|
||||
{
|
||||
InboxWorker.fenced(inbox, lease, () ->
|
||||
{
|
||||
EdiJournal.exception(inbox.getValueInteger("id"), interchange.getValueInteger("id"), null, interchange.getValueInteger("partnerId"), type, "EDIFACT processing requires attention: " + type);
|
||||
return (null);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.edi.edifact;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.stream.Stream;
|
||||
import com.kingsrook.qbits.edi.x12.EnvelopeFinding;
|
||||
import com.kingsrook.qbits.edi.x12.X12Segment;
|
||||
|
||||
/*******************************************************************************
|
||||
** EDIFACT service envelope with optional groups and ungrouped messages.
|
||||
*******************************************************************************/
|
||||
public record EdifactInterchange(EdifactSeparators separators, X12Segment unb, X12Segment unz,
|
||||
List<EdifactGroup> groups, List<EdifactMessage> messages, List<EnvelopeFinding> findings)
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Visit every message without inventing a functional group on the wire.
|
||||
*******************************************************************************/
|
||||
public List<EdifactMessage> allMessages()
|
||||
{
|
||||
return (Stream.concat(messages.stream(), groups.stream().flatMap(g -> g.messages().stream())).toList());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Normalize the UNB test flag to the agreement's usage vocabulary.
|
||||
*******************************************************************************/
|
||||
public String usage()
|
||||
{
|
||||
return ("1".equals(unb.value(11)) ? "T" : "P");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.edi.edifact;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.edi.map.OutboundMapper;
|
||||
import com.kingsrook.qbits.edi.x12.X12Segment;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
|
||||
/*******************************************************************************
|
||||
** Reuse mapping transforms and associations with native UNH/UNT envelopes.
|
||||
*******************************************************************************/
|
||||
public class EdifactMapper extends OutboundMapper
|
||||
{
|
||||
private final char decimalMark;
|
||||
|
||||
/*******************************************************************************
|
||||
** Default numeric formatting follows the default UNA decimal mark.
|
||||
*******************************************************************************/
|
||||
public EdifactMapper()
|
||||
{
|
||||
this('.');
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Use the agreement's decimal service character for outbound numeric transforms.
|
||||
*******************************************************************************/
|
||||
public EdifactMapper(char decimalMark)
|
||||
{
|
||||
this.decimalMark = decimalMark;
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Render numeric values using the negotiated decimal mark.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected String decimal(String value)
|
||||
{
|
||||
return (value.replace('.', decimalMark));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Build one directory-specific message with an allocated UNH reference.
|
||||
*******************************************************************************/
|
||||
public EdifactMessage message(QRecord source, QRecord map, List<QRecord> rules, String reference) throws Exception
|
||||
{
|
||||
if(!"OUTBOUND".equals(map.getValueString("direction")))
|
||||
{
|
||||
throw new IllegalArgumentException("An outbound map is required");
|
||||
}
|
||||
String version = map.getValueString("version");
|
||||
List<X12Segment> segments = new ArrayList<>();
|
||||
segments.add(EdifactWriter.segment("UNH", 1, List.of(reference), List.of(map.getValueString("transactionSet"), version.substring(0, 1), version.substring(1), "UN")));
|
||||
emit(source, rules.stream().sorted(Comparator.comparing(r -> r.getValueInteger("sequence"))).toList(), "", "", 1, "", new int[] {0}, segments);
|
||||
segments.add(X12Segment.of("UNT", segments.size() + 1, String.valueOf(segments.size() + 1), reference));
|
||||
return (new EdifactMessage(segments.getFirst(), segments.getLast(), List.copyOf(segments), List.of()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.edi.edifact;
|
||||
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.edi.document.EdiDocument;
|
||||
import com.kingsrook.qbits.edi.x12.EnvelopeFinding;
|
||||
import com.kingsrook.qbits.edi.x12.X12Segment;
|
||||
|
||||
/*******************************************************************************
|
||||
** UNH/UNT message with its complete directory and association identification.
|
||||
*******************************************************************************/
|
||||
public record EdifactMessage(X12Segment header, X12Segment unt, List<X12Segment> segments, List<EnvelopeFinding> findings) implements EdiDocument
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Agreement document key, for example ORDERS.
|
||||
*******************************************************************************/
|
||||
public String type()
|
||||
{
|
||||
return (header.value(2));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Agreement directory key, for example D96A.
|
||||
*******************************************************************************/
|
||||
public String version()
|
||||
{
|
||||
return (header.value(2, 1, 2) + header.value(2, 1, 3));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Message reference, unique within its interchange.
|
||||
*******************************************************************************/
|
||||
public String reference()
|
||||
{
|
||||
return (header.value(1));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.edi.edifact;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import com.kingsrook.qbits.edi.EdiRuntime;
|
||||
import com.kingsrook.qbits.edi.flow.EdiJournal;
|
||||
import com.kingsrook.qbits.edi.flow.OutboundWorker;
|
||||
import com.kingsrook.qbits.edi.guide.RuleEvaluator;
|
||||
import com.kingsrook.qbits.edi.storage.PayloadKind;
|
||||
import com.kingsrook.qbits.edi.support.SystemWrites;
|
||||
import com.kingsrook.qbits.edi.x12.X12Segment;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
|
||||
/*******************************************************************************
|
||||
** EDIFACT outbound envelopes and durable tracking through the existing send queue.
|
||||
*******************************************************************************/
|
||||
public class EdifactOutbound
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Allocate independent UNB/UNH sequences within the configured control scope.
|
||||
*******************************************************************************/
|
||||
public static String allocate(QRecord agreement, String level) throws Exception
|
||||
{
|
||||
boolean local = "LOCAL_IDENTITY".equals(agreement.getValueString("controlNumberScope"));
|
||||
return (String.valueOf(EdiRuntime.current().controls().allocate(local ? null : agreement.getValueInteger("id"),
|
||||
local ? agreement.getValueInteger("localIdentityId") : null, level, 1, 99999999999999L, true)));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Build an ungrouped interchange; UNZ counts messages when UNG is absent.
|
||||
*******************************************************************************/
|
||||
public static EdifactInterchange wrap(QRecord agreement, QRecord sender, QRecord recipient, List<EdifactMessage> messages, String control, LocalDateTime at)
|
||||
{
|
||||
String syntax = Objects.requireNonNullElse(agreement.getValueString("interchangeVersion"), "3");
|
||||
if(!List.of("3", "4").contains(syntax))
|
||||
{
|
||||
throw new IllegalArgumentException("EDIFACT syntax version must be 3 or 4");
|
||||
}
|
||||
X12Segment unb = EdifactWriter.segment("UNB", 1, List.of(Objects.requireNonNullElse(agreement.getValueString("syntaxIdentifier"), "UNOC"), syntax),
|
||||
List.of(sender.getValueString("interchangeId"), Objects.requireNonNullElse(sender.getValueString("interchangeQualifier"), "")),
|
||||
List.of(recipient.getValueString("interchangeId"), Objects.requireNonNullElse(recipient.getValueString("interchangeQualifier"), "")),
|
||||
List.of(at.format(DateTimeFormatter.ofPattern("4".equals(syntax) ? "yyyyMMdd" : "yyMMdd")), at.format(DateTimeFormatter.ofPattern("HHmm"))),
|
||||
List.of(control), List.of(""), List.of(""), List.of(""), List.of(""), List.of(""), List.of("T".equals(agreement.getValueString("usageIndicator")) ? "1" : ""));
|
||||
return (new EdifactInterchange(EdifactSeparators.fromAgreement(agreement), unb, X12Segment.of("UNZ", 0, String.valueOf(messages.size()), control), List.of(), List.copyOf(messages), List.of()));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Store bytes and record checkpoints atomically with outbound requests or ack fencing.
|
||||
** A logical tracking group carries ack deadlines even when UNG is absent on the wire.
|
||||
*******************************************************************************/
|
||||
public static QRecord envelope(QRecord agreement, QRecord document, QRecord map, List<QRecord> requests, List<EdifactMessage> messages, Integer original, Integer ackFor) throws Exception
|
||||
{
|
||||
var runtime = EdiRuntime.current();
|
||||
var interchange = wrap(agreement, OutboundWorker.row("edi_identity", agreement.getValue("localIdentityId")), OutboundWorker.row("edi_identity", agreement.getValue("partnerIdentityId")),
|
||||
messages, allocate(agreement, "UNB"), LocalDateTime.ofInstant(runtime.config().getClock().instant(), ZoneOffset.UTC));
|
||||
String ending = switch(Objects.requireNonNullElse(agreement.getValueString("lineEnding"), "NONE"))
|
||||
{
|
||||
case "LF" -> "\n";
|
||||
case "CRLF" -> "\r\n";
|
||||
default -> "";
|
||||
};
|
||||
var payload = runtime.payloads().put(PayloadKind.OUTBOUND, new ByteArrayInputStream(new EdifactWriter().write(interchange, ending).getBytes(StandardCharsets.UTF_8)));
|
||||
QRecord persisted = SystemWrites.insert("edi_interchange", new QRecord().withValue("direction", "OUTBOUND").withValue("partnerId", agreement.getValue("partnerId"))
|
||||
.withValue("agreementId", agreement.getValue("id")).withValue("transportId", agreement.getValue("outboundTransportId")).withValue("standard", "EDIFACT")
|
||||
.withValue("senderQualifier", interchange.unb().value(2, 1, 2)).withValue("senderId", interchange.unb().value(2))
|
||||
.withValue("receiverQualifier", interchange.unb().value(3, 1, 2)).withValue("receiverId", interchange.unb().value(3))
|
||||
.withValue("controlNumber", interchange.unb().value(5)).withValue("interchangeVersion", interchange.unb().value(1, 1, 2)).withValue("usageIndicator", interchange.usage())
|
||||
.withValue("interchangeDate", runtime.config().getClock().instant()).withValue("groupCount", 0).withValue("payloadRef", payload.value()).withValue("payloadSha256", payload.sha256())
|
||||
.withValue("sizeBytes", payload.sizeBytes()).withValue("status", "ENVELOPED").withValue("originalInterchangeId", original)
|
||||
.withValue("containsPhi", document != null && Boolean.TRUE.equals(document.getValueBoolean("containsPhi"))));
|
||||
boolean expects = document != null && "CONTRL".equals(document.getValueString("ackExpectation"));
|
||||
QRecord group = SystemWrites.insert("edi_functional_group", new QRecord().withValue("interchangeId", persisted.getValue("id")).withValue("senderId", interchange.unb().value(2))
|
||||
.withValue("controlNumber", interchange.unb().value(5)).withValue("version", messages.getFirst().version()).withValue("transactionCount", messages.size()).withValue("ackStatus", expects ? "PENDING" : "NONE"));
|
||||
for(int i = 0; i < messages.size(); i++)
|
||||
{
|
||||
var message = messages.get(i);
|
||||
String key = map == null || map.getValueString("businessKeyPath") == null ? null : new RuleEvaluator(message.segments(), Map.of()).values(map.getValueString("businessKeyPath")).stream().findFirst().orElse(null);
|
||||
SystemWrites.insert("edi_transaction", new QRecord().withValue("interchangeId", persisted.getValue("id")).withValue("functionalGroupId", group.getValue("id"))
|
||||
.withValue("direction", "OUTBOUND").withValue("partnerId", agreement.getValue("partnerId")).withValue("agreementDocumentId", document == null ? null : document.getValue("id"))
|
||||
.withValue("transactionSet", message.type()).withValue("version", message.version()).withValue("controlNumber", message.reference()).withValue("status", "QUEUED")
|
||||
.withValue("guideId", document == null ? null : document.getValue("guideId"))
|
||||
.withValue("guideRevision", document == null || document.getValue("guideId") == null ? null : OutboundWorker.row("edi_guide", document.getValue("guideId")).getValue("revision")).withValue("mapId", map == null ? null : map.getValue("id"))
|
||||
.withValue("mapRevision", map == null ? null : map.getValue("revision"))
|
||||
.withValue(document != null && Boolean.TRUE.equals(document.getValueBoolean("containsPhi")) ? "phiBusinessKey" : "businessKey", key)
|
||||
.withValue("businessKeyType", map == null ? null : map.getValue("businessKeyType"))
|
||||
.withValue("containsPhi", document != null && Boolean.TRUE.equals(document.getValueBoolean("containsPhi"))).withValue("segmentCount", message.segments().size())
|
||||
.withValue("targetTableName", i < requests.size() ? requests.get(i).getValue("sourceTableName") : null).withValue("targetRecordId", i < requests.size() ? requests.get(i).getValue("sourceRecordId") : null));
|
||||
if(i < requests.size())
|
||||
{
|
||||
SystemWrites.update("edi_outbound_request", new QRecord().withValue("id", requests.get(i).getValue("id")).withValue("status", "ENVELOPED"));
|
||||
}
|
||||
}
|
||||
QRecord queued = SystemWrites.insert("edi_outbound_message", new QRecord().withValue("interchangeId", persisted.getValue("id")).withValue("agreementId", agreement.getValue("id"))
|
||||
.withValue("partnerId", agreement.getValue("partnerId")).withValue("transportId", agreement.getValue("outboundTransportId")).withValue("status", "QUEUED").withValue("attempts", 0)
|
||||
.withValue("ackForInterchangeId", ackFor).withValue("payloadRef", payload.value()).withValue("payloadSha256", payload.sha256()).withValue("sizeBytes", payload.sizeBytes()));
|
||||
SystemWrites.update("edi_interchange", new QRecord().withValue("id", persisted.getValue("id")).withValue("outboundMessageId", queued.getValue("id")));
|
||||
EdiJournal.event(null, persisted.getValueInteger("id"), null, "ENVELOPED");
|
||||
return (queued);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Re-envelope archived outbound messages without reading changed source records.
|
||||
*******************************************************************************/
|
||||
public static void resendAsNew(QRecord original) throws Exception
|
||||
{
|
||||
QRecord agreement = OutboundWorker.row("edi_agreement", original.getValue("agreementId"));
|
||||
QRecord oldInterchange = OutboundWorker.row("edi_interchange", original.getValue("interchangeId"));
|
||||
EdifactInterchange archive;
|
||||
try(var stream = EdiRuntime.current().payloads().open(new com.kingsrook.qbits.edi.storage.PayloadRef(original.getValueString("payloadRef"), null, 0)))
|
||||
{
|
||||
archive = new EdifactEnvelopeReader(EdiRuntime.current().config().getMaxSegmentsPerTransaction()).read(stream).getFirst();
|
||||
}
|
||||
List<EdifactMessage> messages = new java.util.ArrayList<>();
|
||||
for(var message : archive.allMessages())
|
||||
{
|
||||
String reference = allocate(agreement, "UNH");
|
||||
var segments = new java.util.ArrayList<>(message.segments());
|
||||
var elements = new java.util.ArrayList<>(message.header().elements());
|
||||
elements.set(0, List.of(List.of(reference)));
|
||||
segments.set(0, new X12Segment("UNH", 1, elements));
|
||||
segments.set(segments.size() - 1, X12Segment.of("UNT", segments.size(), String.valueOf(segments.size()), reference));
|
||||
messages.add(new EdifactMessage(segments.getFirst(), segments.getLast(), List.copyOf(segments), List.of()));
|
||||
}
|
||||
var oldTransactions = com.kingsrook.qbits.edi.storage.EdiRecords.query("edi_transaction", com.kingsrook.qbits.edi.storage.EdiRecords.equal("interchangeId", oldInterchange.getValue("id")));
|
||||
QRecord first = oldTransactions.getFirst();
|
||||
QRecord document = first.getValue("agreementDocumentId") == null ? null : OutboundWorker.row("edi_agreement_document", first.getValue("agreementDocumentId"));
|
||||
QRecord queued = envelope(agreement, document, null, List.of(), messages, oldInterchange.getValueInteger("id"), null);
|
||||
var newTransactions = com.kingsrook.qbits.edi.storage.EdiRecords.query("edi_transaction", com.kingsrook.qbits.edi.storage.EdiRecords.equal("interchangeId", queued.getValue("interchangeId")));
|
||||
for(int i = 0; i < archive.allMessages().size(); i++)
|
||||
{
|
||||
String reference = archive.allMessages().get(i).reference();
|
||||
QRecord old = oldTransactions.stream().filter(t -> reference.equals(t.getValueString("controlNumber"))).findFirst().orElseThrow();
|
||||
QRecord update = new QRecord().withValue("id", newTransactions.get(i).getValue("id"));
|
||||
for(String field : List.of("guideId", "guideRevision", "mapId", "mapRevision", "businessKey", "phiBusinessKey", "containsPhi", "businessKeyType", "secondaryKey", "targetTableName", "targetRecordId", "agreementDocumentId"))
|
||||
{
|
||||
update.withValue(field, old.getValue(field));
|
||||
}
|
||||
SystemWrites.update("edi_transaction", update);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.edi.edifact;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
|
||||
/*******************************************************************************
|
||||
** UNA service characters; the fifth character is reserved in syntax version 3.
|
||||
*******************************************************************************/
|
||||
public record EdifactSeparators(char component, char element, char decimal, char release, char repetition, char segment)
|
||||
{
|
||||
public static final EdifactSeparators DEFAULT = new EdifactSeparators(':', '+', '.', '?', ' ', '\'');
|
||||
|
||||
/*******************************************************************************
|
||||
** Ambiguous service characters cannot be safely parsed or escaped.
|
||||
*******************************************************************************/
|
||||
public EdifactSeparators
|
||||
{
|
||||
var values = List.of(component, element, decimal, release, repetition, segment);
|
||||
if(!List.of('.', ',').contains(decimal) || new HashSet<>(values).size() != 6 || values.stream().anyMatch(c -> c > 127 || Character.isISOControl(c)))
|
||||
{
|
||||
throw new IllegalArgumentException("Invalid EDIFACT service characters");
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Read exactly six UNA service characters.
|
||||
*******************************************************************************/
|
||||
public static EdifactSeparators fromUna(String una)
|
||||
{
|
||||
if(una.length() != 9 || !una.startsWith("UNA"))
|
||||
{
|
||||
throw new IllegalArgumentException("Invalid UNA");
|
||||
}
|
||||
return (new EdifactSeparators(una.charAt(3), una.charAt(4), una.charAt(5), una.charAt(6), una.charAt(7), una.charAt(8)));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Use the agreement's EDIFACT service characters.
|
||||
*******************************************************************************/
|
||||
public static EdifactSeparators fromAgreement(QRecord agreement)
|
||||
{
|
||||
return (new EdifactSeparators(character(agreement, "componentSeparator", ":"), character(agreement, "elementSeparator", "+"),
|
||||
character(agreement, "decimalMark", "."), character(agreement, "releaseCharacter", "?"),
|
||||
"4".equals(agreement.getValueString("interchangeVersion")) ? character(agreement, "repetitionSeparator", "*") : ' ', character(agreement, "segmentTerminator", "'")));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** A separator setting must contain exactly one character.
|
||||
*******************************************************************************/
|
||||
private static char character(QRecord agreement, String field, String fallback)
|
||||
{
|
||||
String value = Objects.requireNonNullElse(agreement.getValueString(field), fallback);
|
||||
if(value.length() != 1)
|
||||
{
|
||||
throw new IllegalArgumentException("Invalid service character setting");
|
||||
}
|
||||
return (value.charAt(0));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Render service string advice, including its segment terminator.
|
||||
*******************************************************************************/
|
||||
public String una()
|
||||
{
|
||||
return ("UNA" + component + element + decimal + release + repetition + segment);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.edi.edifact;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.edi.x12.X12Segment;
|
||||
|
||||
/*******************************************************************************
|
||||
** In-memory EDIFACT serialization; escape values, never composite structure.
|
||||
*******************************************************************************/
|
||||
public class EdifactWriter
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Render a full interchange with explicit UNA advice.
|
||||
*******************************************************************************/
|
||||
public String write(EdifactInterchange interchange)
|
||||
{
|
||||
return (write(interchange, ""));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Retain optional groups and caller-selected line endings.
|
||||
*******************************************************************************/
|
||||
public String write(EdifactInterchange interchange, String ending)
|
||||
{
|
||||
List<X12Segment> segments = new ArrayList<>();
|
||||
segments.add(interchange.unb());
|
||||
interchange.messages().forEach(m -> segments.addAll(m.segments()));
|
||||
for(var group : interchange.groups())
|
||||
{
|
||||
segments.add(group.ung());
|
||||
group.messages().forEach(m -> segments.addAll(m.segments()));
|
||||
segments.add(group.une());
|
||||
}
|
||||
segments.add(interchange.unz());
|
||||
return (interchange.separators().una() + ending + write(segments, interchange.separators(), ending));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Serialize component and repetition lists without losing released delimiters.
|
||||
*******************************************************************************/
|
||||
public String write(List<X12Segment> segments, EdifactSeparators separators, String ending)
|
||||
{
|
||||
if(!List.of("", "\n", "\r\n").contains(ending))
|
||||
{
|
||||
throw new IllegalArgumentException("Unsupported line ending");
|
||||
}
|
||||
StringBuilder out = new StringBuilder();
|
||||
for(var segment : segments)
|
||||
{
|
||||
out.append(segment.id());
|
||||
for(var element : segment.elements())
|
||||
{
|
||||
out.append(separators.element());
|
||||
for(int r = 0; r < element.size(); r++)
|
||||
{
|
||||
if(r > 0)
|
||||
{
|
||||
if(separators.repetition() == ' ')
|
||||
{
|
||||
throw new IllegalArgumentException("Repetition requires syntax version 4");
|
||||
}
|
||||
out.append(separators.repetition());
|
||||
}
|
||||
for(int c = 0; c < element.get(r).size(); c++)
|
||||
{
|
||||
if(c > 0)
|
||||
{
|
||||
out.append(separators.component());
|
||||
}
|
||||
for(char value : element.get(r).get(c).toCharArray())
|
||||
{
|
||||
if(value == separators.release() || value == separators.element() || value == separators.component()
|
||||
|| value == separators.segment() || separators.repetition() != ' ' && value == separators.repetition())
|
||||
{
|
||||
out.append(separators.release());
|
||||
}
|
||||
out.append(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out.append(separators.segment()).append(ending);
|
||||
}
|
||||
return (out.toString());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Construct a segment from explicit component lists, with no delimiter parsing.
|
||||
*******************************************************************************/
|
||||
@SafeVarargs
|
||||
public static X12Segment segment(String tag, int position, List<String>... elements)
|
||||
{
|
||||
return (new X12Segment(tag, position, java.util.Arrays.stream(elements).map(List::of).toList()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.edi.flow;
|
||||
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.edi.EdiRuntime;
|
||||
import com.kingsrook.qbits.edi.storage.EdiRecords;
|
||||
import com.kingsrook.qbits.edi.support.SystemWrites;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertSeverity;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertSignal;
|
||||
import com.kingsrook.qqq.backend.core.alerts.QAlerts;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
|
||||
/*******************************************************************************
|
||||
** Five-minute acknowledgement and public-certificate expiry monitor.
|
||||
*******************************************************************************/
|
||||
public class AckMonitor
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Deduplicate alerts and open exceptions without embedding document values.
|
||||
*******************************************************************************/
|
||||
public Integer runOnce() throws QException
|
||||
{
|
||||
return (SystemWrites.call(() ->
|
||||
{
|
||||
int count = 0;
|
||||
var now = EdiRuntime.current().config().getClock().instant();
|
||||
for(QRecord group : EdiRecords.query("edi_functional_group", EdiRecords.equal("ackStatus", "PENDING")
|
||||
.withCriteria("ackDueAt", QCriteriaOperator.LESS_THAN, now).withCriteria("ackReceivedAt", QCriteriaOperator.IS_BLANK)))
|
||||
{
|
||||
QRecord interchange = OutboundWorker.row("edi_interchange", group.getValue("interchangeId"));
|
||||
if(!"OUTBOUND".equals(interchange.getValueString("direction")))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
String key = "edi_functional_group:" + group.getValue("id");
|
||||
QAlerts.raise(new AlertSignal("edi.ack.overdue").withSeverity(AlertSeverity.WARNING).withDedupKey(key)
|
||||
.withRecordRef(new com.kingsrook.qqq.backend.core.alerts.AlertRecordRef("edi_functional_group", group.getValue("id")))
|
||||
.withTitle("EDI acknowledgement overdue").withSummary("Review the outbound functional group"));
|
||||
if(EdiRecords.query("edi_exception", EdiRecords.equal("interchangeId", interchange.getValue("id")).withCriteria("type", QCriteriaOperator.EQUALS, "OVERDUE_ACK")
|
||||
.withCriteria("status", QCriteriaOperator.IN, "OPEN", "IN_PROGRESS")).isEmpty())
|
||||
{
|
||||
EdiJournal.exception(null, interchange.getValueInteger("id"), null, interchange.getValueInteger("partnerId"), "OVERDUE_ACK", "Expected acknowledgement has not arrived");
|
||||
}
|
||||
count++;
|
||||
}
|
||||
for(QRecord certificate : EdiRecords.query("edi_certificate", new QQueryFilter().withCriteria("status", QCriteriaOperator.IN, "ACTIVE", "NEXT")))
|
||||
{
|
||||
if(certificate.getValueInstant("notAfter") == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
long days = ChronoUnit.DAYS.between(now, certificate.getValueInstant("notAfter"));
|
||||
if(!certificate.getValueInstant("notAfter").isAfter(now))
|
||||
{
|
||||
expiry(certificate, "edi.certificate.expired", "expired", AlertSeverity.ERROR);
|
||||
}
|
||||
else
|
||||
{
|
||||
for(int threshold : List.of(30, 14, 7))
|
||||
{
|
||||
if(days < threshold)
|
||||
{
|
||||
expiry(certificate, "edi.certificate.expiring", String.valueOf(threshold), AlertSeverity.WARNING);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return (count + overdueMdns(now));
|
||||
}));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** SENT messages on an asynchronous-MDN AS2 transport whose MDN has not arrived
|
||||
** within the agreement document ackDueMinutes (60 when none is set) raise a
|
||||
** warning, deduplicated per message and resolved when the MDN arrives.
|
||||
*******************************************************************************/
|
||||
private static int overdueMdns(java.time.Instant now) throws QException
|
||||
{
|
||||
List<java.io.Serializable> transports = new java.util.ArrayList<>();
|
||||
for(QRecord transport : EdiRecords.query("edi_transport", EdiRecords.equal("type", "AS2").withCriteria("mdnMode", QCriteriaOperator.EQUALS, "ASYNC")))
|
||||
{
|
||||
transports.add(transport.getValue("id"));
|
||||
}
|
||||
if(transports.isEmpty())
|
||||
{
|
||||
return (0);
|
||||
}
|
||||
int count = 0;
|
||||
for(QRecord message : EdiRecords.query("edi_outbound_message", EdiRecords.equal("status", "SENT").withCriteria("transportId", QCriteriaOperator.IN, transports)
|
||||
.withCriteria("sentAt", QCriteriaOperator.IS_NOT_BLANK)))
|
||||
{
|
||||
int minutes = 60;
|
||||
Integer smallest = null;
|
||||
for(QRecord document : message.getValue("agreementId") == null ? List.<QRecord>of()
|
||||
: EdiRecords.query("edi_agreement_document", EdiRecords.equal("agreementId", message.getValue("agreementId")).withCriteria("direction", QCriteriaOperator.EQUALS, "OUTBOUND")))
|
||||
{
|
||||
Integer due = document.getValueInteger("ackDueMinutes");
|
||||
smallest = due == null ? smallest : smallest == null ? due : Math.min(smallest, due);
|
||||
}
|
||||
minutes = smallest == null ? minutes : smallest;
|
||||
if(message.getValueInstant("sentAt").plus(minutes, ChronoUnit.MINUTES).isAfter(now))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
QAlerts.raise(new AlertSignal("edi.mdn.overdue").withSeverity(AlertSeverity.WARNING).withDedupKey("edi_outbound_message:" + message.getValue("id"))
|
||||
.withRecordRef(new com.kingsrook.qqq.backend.core.alerts.AlertRecordRef("edi_outbound_message", message.getValue("id")))
|
||||
.withTitle("AS2 receipt overdue").withSummary("The asynchronous MDN has not arrived; review the outbound message"));
|
||||
count++;
|
||||
}
|
||||
return (count);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Each certificate threshold has its own stable deduplication key.
|
||||
*******************************************************************************/
|
||||
private static void expiry(QRecord certificate, String type, String threshold, AlertSeverity severity)
|
||||
{
|
||||
QAlerts.raise(new AlertSignal(type).withSeverity(severity).withDedupKey("edi_certificate:" + certificate.getValue("id") + ":" + threshold)
|
||||
.withRecordRef(new com.kingsrook.qqq.backend.core.alerts.AlertRecordRef("edi_certificate", certificate.getValue("id")))
|
||||
.withTitle("EDI certificate expiry").withSummary("Review the certificate validity dates"));
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,9 @@ import java.util.List;
|
||||
import com.kingsrook.qbits.edi.EdiRuntime;
|
||||
import com.kingsrook.qbits.edi.storage.EdiRecords;
|
||||
import com.kingsrook.qbits.edi.support.SystemWrites;
|
||||
import com.kingsrook.qqq.backend.core.alerts.NotificationAudience;
|
||||
import com.kingsrook.qqq.backend.core.alerts.NotificationSignal;
|
||||
import com.kingsrook.qqq.backend.core.alerts.QAlerts;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
@@ -54,16 +57,33 @@ public final class EdiJournal
|
||||
filter.withCriteria(pair[0].toString(), pair[1] == null ? QCriteriaOperator.IS_BLANK : QCriteriaOperator.EQUALS, (Integer) pair[1]);
|
||||
}
|
||||
List<QRecord> matches = EdiRecords.query("edi_exception", filter);
|
||||
if(matches.isEmpty())
|
||||
QRecord exception;
|
||||
boolean opened = matches.isEmpty();
|
||||
if(opened)
|
||||
{
|
||||
SystemWrites.insert("edi_exception", new QRecord().withValue("inboxId", inbox).withValue("interchangeId", interchange).withValue("transactionId", transaction)
|
||||
exception = SystemWrites.insert("edi_exception", new QRecord().withValue("inboxId", inbox).withValue("interchangeId", interchange).withValue("transactionId", transaction)
|
||||
.withValue("partnerId", partner).withValue("type", type).withValue("summary", summary).withValue("status", "OPEN").withValue("occurrences", 1));
|
||||
}
|
||||
else
|
||||
{
|
||||
QRecord row = matches.getFirst();
|
||||
exception = row;
|
||||
opened = List.of("RESOLVED", "IGNORED").contains(row.getValueString("status"));
|
||||
SystemWrites.update("edi_exception", new QRecord().withValue("id", row.getValue("id")).withValue("occurrences", row.getValueInteger("occurrences") + 1).withValue("status", "OPEN"));
|
||||
}
|
||||
if(opened && partner != null && List.of("REJECTED_ACK", "DEAD_LETTER").contains(type))
|
||||
{
|
||||
String[] assignees = EdiRecords.query("edi_exception", EdiRecords.equal("partnerId", partner)
|
||||
.withCriteria("status", QCriteriaOperator.IN, "OPEN", "IN_PROGRESS")).stream()
|
||||
.map(row -> row.getValueString("assignedToUserId")).filter(user -> user != null && !user.isBlank()).distinct().toArray(String[]::new);
|
||||
if(assignees.length > 0)
|
||||
{
|
||||
QAlerts.notify(new NotificationSignal("EDI exception opened")
|
||||
.withBody("An acknowledgement rejection or dead-letter exception needs attention.")
|
||||
.withAudience(NotificationAudience.users(assignees))
|
||||
.withSourceName("edi").withSourceTableName("edi_exception").withSourceRecordId(exception.getValueString("id")));
|
||||
}
|
||||
}
|
||||
event(inbox, interchange, transaction, "EXCEPTION");
|
||||
}
|
||||
}
|
||||
@@ -185,7 +185,7 @@ public class InboundProcessor
|
||||
if(EdiRecords.query("edi_outbound_message", EdiRecords.equal("ackForGroupId", groupRow.getValue("id"))).isEmpty())
|
||||
{
|
||||
AckLevel level = AckLevel.valueOf(Objects.requireNonNullElse(config.getValueString("ackLevel"), "ERRORS_ONLY"));
|
||||
boolean hipaa = Boolean.TRUE.equals(config.getValueBoolean("containsPhi")) && group.gs().value(8).startsWith("005010X");
|
||||
boolean hipaa = group.gs().value(8).startsWith("005010X") || "999".equals(config.getValueString("ackExpectation"));
|
||||
var ack = hipaa ? runtime.ack999().build(group, findings, level, "0001") : runtime.ack997().build(group, findings, level, "0001");
|
||||
queue(AckEnvelope.wrap(x12, group, ack, control(agreement, "ISA"), control(agreement, "GS"), LocalDateTime.now(runtime.config().getClock())),
|
||||
agreement, id, groupRow.getValueInteger("id"), hipaa ? "999" : "997");
|
||||
@@ -220,7 +220,8 @@ public class InboundProcessor
|
||||
{
|
||||
QRecord sender = row("edi_identity", agreement.getValueInteger("partnerIdentityId"));
|
||||
QRecord receiver = row("edi_identity", agreement.getValueInteger("localIdentityId"));
|
||||
if(sender != null && receiver != null && Objects.equals(sender.getValueInteger("partnerId"), agreement.getValueInteger("partnerId"))
|
||||
if(sender != null && receiver != null && !Boolean.FALSE.equals(sender.getValueBoolean("isActive")) && !Boolean.FALSE.equals(receiver.getValueBoolean("isActive"))
|
||||
&& Objects.equals(sender.getValueInteger("partnerId"), agreement.getValueInteger("partnerId"))
|
||||
&& receiver.getValue("partnerId") == null && x12.isa().value(5).equals(sender.getValueString("interchangeQualifier"))
|
||||
&& x12.isa().value(6).trim().equals(sender.getValueString("interchangeId")) && x12.isa().value(7).equals(receiver.getValueString("interchangeQualifier"))
|
||||
&& x12.isa().value(8).trim().equals(receiver.getValueString("interchangeId")))
|
||||
@@ -261,6 +262,7 @@ public class InboundProcessor
|
||||
.withValue("receiverQualifier", x12.isa().value(7)).withValue("receiverId", x12.isa().value(8).trim()).withValue("controlNumber", x12.isa().value(13))
|
||||
.withValue("interchangeVersion", x12.isa().value(12)).withValue("usageIndicator", x12.isa().value(15)).withValue("groupCount", x12.groups().size())
|
||||
.withValue("payloadRef", inbox.getValue("payloadRef")).withValue("payloadSha256", inbox.getValue("payloadSha256")).withValue("sizeBytes", inbox.getValue("sizeBytes"))
|
||||
.withValue("containsPhi", x12.groups().stream().anyMatch(g -> g.gs().value(8).startsWith("005010X")))
|
||||
.withValue("status", duplicate ? "DUPLICATE" : x12.findings().isEmpty() ? "ACCEPTED" : "REJECTED"));
|
||||
EdiJournal.event(inbox.getValueInteger("id"), row.getValueInteger("id"), null, "PARSED");
|
||||
return (row);
|
||||
@@ -307,14 +309,14 @@ public class InboundProcessor
|
||||
return (SystemWrites.insert("edi_transaction", new QRecord().withValue("inboundPosition", position).withValue("interchangeId", interchange.getValue("id")).withValue("functionalGroupId", group.getValue("id"))
|
||||
.withValue("direction", "INBOUND").withValue("partnerId", interchange.getValue("partnerId")).withValue("transactionSet", tx.st().value(1))
|
||||
.withValue("version", group.getValue("version")).withValue("controlNumber", tx.st().value(2)).withValue("segmentCount", tx.segments().size())
|
||||
.withValue("agreementDocumentId", document == null ? null : document.getValue("id")).withValue("containsPhi", document != null && Boolean.TRUE.equals(document.getValueBoolean("containsPhi")))
|
||||
.withValue("agreementDocumentId", document == null ? null : document.getValue("id")).withValue("containsPhi", group.getValueString("version").startsWith("005010X") || document != null && Boolean.TRUE.equals(document.getValueBoolean("containsPhi")))
|
||||
.withValue("status", "RECEIVED")));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Replays select original persisted definitions or the currently active revision.
|
||||
*******************************************************************************/
|
||||
private QRecord revisions(QRecord document, QRecord transaction) throws Exception
|
||||
protected QRecord revisions(QRecord document, QRecord transaction) throws Exception
|
||||
{
|
||||
if(document == null)
|
||||
{
|
||||
@@ -352,7 +354,7 @@ public class InboundProcessor
|
||||
List<ValidationFinding> findings = new ArrayList<>();
|
||||
if(document != null && document.getValueInteger("guideId") != null)
|
||||
{
|
||||
findings.addAll(runtime.validator().validate(tx, new GuideRepository().load(document.getValueInteger("guideId")), Boolean.TRUE.equals(document.getValueBoolean("containsPhi"))));
|
||||
findings.addAll(runtime.validator().validate(tx, new GuideRepository().load(document.getValueInteger("guideId")), Boolean.TRUE.equals(document.getValueBoolean("containsPhi")) || group.gs().value(8).startsWith("005010X")));
|
||||
int snip = Objects.requireNonNullElse(document.getValueInteger("snipLevel"), 2);
|
||||
findings.removeIf(f -> f.snipLevel() > snip);
|
||||
}
|
||||
@@ -382,7 +384,7 @@ public class InboundProcessor
|
||||
/*******************************************************************************
|
||||
** Persist findings without repeatedly adding error rows on storage retries.
|
||||
*******************************************************************************/
|
||||
private void validation(QRecord tx, List<ValidationFinding> findings, QRecord document) throws Exception
|
||||
protected void validation(QRecord tx, List<ValidationFinding> findings, QRecord document) throws Exception
|
||||
{
|
||||
boolean rejected = findings.stream().anyMatch(f -> "ERROR".equals(f.severity()) && f.snipLevel() <= 2);
|
||||
if(!"APPLIED".equals(tx.getValueString("status")) || Boolean.TRUE.equals(inbox.getValueBoolean("reprocess")))
|
||||
@@ -420,7 +422,7 @@ public class InboundProcessor
|
||||
/*******************************************************************************
|
||||
** A failed target write rolls back only this set, then records an APPLY exception.
|
||||
*******************************************************************************/
|
||||
private void apply(X12TransactionSet x12, QRecord tx, QRecord document, QRecord agreement) throws Exception
|
||||
protected void apply(com.kingsrook.qbits.edi.document.EdiDocument x12, QRecord tx, QRecord document, QRecord agreement) throws Exception
|
||||
{
|
||||
if("APPLIED".equals(tx.getValueString("status")) && !Boolean.TRUE.equals(inbox.getValueBoolean("reprocess")))
|
||||
{
|
||||
@@ -432,7 +434,7 @@ public class InboundProcessor
|
||||
return;
|
||||
}
|
||||
String[] stage = {"MAPPING"};
|
||||
try
|
||||
try(var logging = com.kingsrook.qbits.edi.security.PhiLogScope.open(Boolean.TRUE.equals(tx.getValueBoolean("containsPhi"))))
|
||||
{
|
||||
InboxWorker.fenced(inbox, lease, () ->
|
||||
{
|
||||
@@ -447,7 +449,7 @@ public class InboundProcessor
|
||||
stage[0] = "APPLY";
|
||||
QRecord target = InboundApplier.asPartner(runtime.config().getServiceSessionSupplier(), agreement.getValueInteger("partnerId"), () -> new InboundApplier().apply(map, mapped.record()));
|
||||
String primaryKey = com.kingsrook.qqq.backend.core.context.QContext.getQInstance().getTable(map.getValueString("tableName")).getPrimaryKeyField();
|
||||
boolean phi = Boolean.TRUE.equals(document.getValueBoolean("containsPhi"));
|
||||
boolean phi = Boolean.TRUE.equals(tx.getValueBoolean("containsPhi"));
|
||||
SystemWrites.update("edi_transaction", new QRecord().withValue("id", tx.getValue("id")).withValue("status", "APPLIED")
|
||||
.withValue("mapId", map.getValue("id")).withValue("mapRevision", map.getValue("revision")).withValue("businessKeyType", map.getValue("businessKeyType"))
|
||||
.withValue(phi ? "phiBusinessKey" : "businessKey", mapped.businessKey()).withValue("secondaryKey", phi ? null : mapped.secondaryKey())
|
||||
@@ -526,6 +528,11 @@ public class InboundProcessor
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if(ta1)
|
||||
{
|
||||
SystemWrites.update("edi_interchange", new QRecord().withValue("id", interchange.getValue("id")).withValue("ta1Status", ack.status()));
|
||||
continue;
|
||||
}
|
||||
for(QRecord group : EdiRecords.query("edi_functional_group", EdiRecords.equal("interchangeId", interchange.getValue("id"))))
|
||||
{
|
||||
if(!ta1 && !ack.controlNumber().equals(group.getValueString("controlNumber")))
|
||||
@@ -539,7 +546,13 @@ public class InboundProcessor
|
||||
SystemWrites.update("edi_transaction", new QRecord().withValue("id", transaction.getValue("id")).withValue("ackStatus", status)
|
||||
.withValue("status", "R".equals(status) ? "REJECTED" : "ACKNOWLEDGED"));
|
||||
}
|
||||
QAlerts.resolve("edi.ack.overdue", group.getValueString("id"), "Acknowledgement received");
|
||||
QAlerts.resolve("edi.ack.overdue", "edi_functional_group:" + group.getValue("id"), "Acknowledgement received");
|
||||
for(QRecord exception : EdiRecords.query("edi_exception", EdiRecords.equal("interchangeId", interchange.getValue("id"))
|
||||
.withCriteria("type", QCriteriaOperator.EQUALS, "OVERDUE_ACK").withCriteria("status", QCriteriaOperator.IN, "OPEN", "IN_PROGRESS")))
|
||||
{
|
||||
SystemWrites.update("edi_exception", new QRecord().withValue("id", exception.getValue("id")).withValue("status", "RESOLVED")
|
||||
.withValue("resolvedAt", runtime.config().getClock().instant()).withValue("resolution", "Acknowledgement received"));
|
||||
}
|
||||
if(List.of("R", "P").contains(ack.status()))
|
||||
{
|
||||
EdiJournal.exception(inbox.getValueInteger("id"), received, null, agreement.getValueInteger("partnerId"), "REJECTED_ACK", "Partner rejected an outbound group");
|
||||
|
||||
@@ -88,11 +88,22 @@ public class InboxWorker
|
||||
}
|
||||
try(InputStream stream = store.open(new PayloadRef(row.getValueString("payloadRef"), row.getValueString("payloadSha256"), row.getValueLong("sizeBytes"))))
|
||||
{
|
||||
var interchanges = EdiRuntime.current().reader().read(stream);
|
||||
var framing = new com.kingsrook.qbits.edi.document.EnvelopeReader();
|
||||
byte[] envelope;
|
||||
int position = 0;
|
||||
for(var interchange : interchanges)
|
||||
while((envelope = framing.next(stream)) != null)
|
||||
{
|
||||
new InboundProcessor(row, lease, store).process(interchange, ++position);
|
||||
position++;
|
||||
if(envelope[0] == 'U')
|
||||
{
|
||||
var parsed = new com.kingsrook.qbits.edi.edifact.EdifactEnvelopeReader(EdiRuntime.current().config().getMaxSegmentsPerTransaction()).read(new java.io.ByteArrayInputStream(envelope)).getFirst();
|
||||
new com.kingsrook.qbits.edi.edifact.EdifactInbound(row, lease, store).process(parsed, position);
|
||||
}
|
||||
else
|
||||
{
|
||||
var parsed = EdiRuntime.current().reader().read(new java.io.ByteArrayInputStream(envelope)).getFirst();
|
||||
new InboundProcessor(row, lease, store).process(parsed, position);
|
||||
}
|
||||
}
|
||||
fenced(row, lease, () ->
|
||||
{
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.edi.flow;
|
||||
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.edi.EdiRuntime;
|
||||
import com.kingsrook.qbits.edi.storage.EdiRecords;
|
||||
import com.kingsrook.qbits.edi.storage.PayloadRef;
|
||||
import com.kingsrook.qbits.edi.support.SystemWrites;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
|
||||
/*******************************************************************************
|
||||
** Guarded resend/cancel operations used after the standard Review step.
|
||||
*******************************************************************************/
|
||||
public final class OutboundActions
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Call within SystemWrites.transaction so validation and mutation share a lock.
|
||||
*******************************************************************************/
|
||||
public static void execute(String table, QRecord selected, String operation) throws Exception
|
||||
{
|
||||
QRecord message = table.equals("edi_interchange") ? OutboundWorker.row("edi_outbound_message", selected.getValue("outboundMessageId")) : selected;
|
||||
if(!SystemWrites.guard("UPDATE edi_outbound_message SET id=id WHERE id=? AND status=?", message.getValue("id"), message.getValue("status")))
|
||||
{
|
||||
throw new IllegalStateException("Outbound message changed; refresh the record");
|
||||
}
|
||||
message = OutboundWorker.row("edi_outbound_message", message.getValue("id"));
|
||||
if(operation.equals("cancel"))
|
||||
{
|
||||
if(!List.of("QUEUED", "RETRY_WAIT").contains(message.getValueString("status")))
|
||||
{
|
||||
throw new IllegalStateException("Only queued or retrying messages can be cancelled");
|
||||
}
|
||||
SystemWrites.update("edi_outbound_message", new QRecord().withValue("id", message.getValue("id")).withValue("status", "CANCELLED").withValue("nextAttemptAt", null));
|
||||
}
|
||||
else if(operation.equals("resend"))
|
||||
{
|
||||
SystemWrites.update("edi_outbound_message", new QRecord().withValue("id", message.getValue("id")).withValue("status", "QUEUED")
|
||||
.withValue("attempts", 0).withValue("nextAttemptAt", null).withValue("lastError", null));
|
||||
}
|
||||
else if(operation.equals("new"))
|
||||
{
|
||||
reEnvelope(message);
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new IllegalArgumentException("Unknown outbound operation");
|
||||
}
|
||||
EdiJournal.event(null, message.getValueInteger("interchangeId"), null, operation.toUpperCase(java.util.Locale.ROOT));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Re-envelope the archived payload and tracking snapshots, including TA1/997/999.
|
||||
** Maps are not re-run: source edits require a new outbound request instead.
|
||||
*******************************************************************************/
|
||||
private static void reEnvelope(QRecord message) throws Exception
|
||||
{
|
||||
if("EDIFACT".equals(OutboundWorker.row("edi_agreement", message.getValue("agreementId")).getValueString("standard")))
|
||||
{
|
||||
com.kingsrook.qbits.edi.edifact.EdifactOutbound.resendAsNew(message);
|
||||
return;
|
||||
}
|
||||
var runtime = EdiRuntime.current();
|
||||
QRecord originalRow = OutboundWorker.row("edi_interchange", message.getValue("interchangeId"));
|
||||
QRecord agreement = OutboundWorker.row("edi_agreement", message.getValue("agreementId"));
|
||||
boolean local = "LOCAL_IDENTITY".equals(agreement.getValueString("controlNumberScope"));
|
||||
String control = String.format("%09d", runtime.controls().allocate(local ? null : agreement.getValueInteger("id"), local ? agreement.getValueInteger("localIdentityId") : null, "ISA", 1, 999999999, true));
|
||||
com.kingsrook.qbits.edi.x12.X12Interchange original;
|
||||
try(var stream = runtime.payloads().open(new PayloadRef(message.getValueString("payloadRef"), message.getValueString("payloadSha256"), message.getValueLong("sizeBytes"))))
|
||||
{
|
||||
original = runtime.reader().read(stream).getFirst();
|
||||
}
|
||||
var groups = new java.util.ArrayList<com.kingsrook.qbits.edi.x12.X12FunctionalGroup>();
|
||||
for(var group : original.groups())
|
||||
{
|
||||
String groupControl = String.valueOf(runtime.controls().allocate(local ? null : agreement.getValueInteger("id"), local ? agreement.getValueInteger("localIdentityId") : null, "GS", 1, 999999999, true));
|
||||
groups.add(new com.kingsrook.qbits.edi.x12.X12FunctionalGroup(replace(group.gs(), 6, groupControl), replace(group.ge(), 2, groupControl), group.transactions(), List.of()));
|
||||
}
|
||||
var rebuilt = new com.kingsrook.qbits.edi.x12.X12Interchange(original.separators(), replace(original.isa(), 13, control), replace(original.iea(), 2, control), groups, original.serviceSegments(), List.of());
|
||||
var payload = runtime.payloads().put(com.kingsrook.qbits.edi.storage.PayloadKind.OUTBOUND,
|
||||
new java.io.ByteArrayInputStream(runtime.writer().write(rebuilt).getBytes(java.nio.charset.StandardCharsets.UTF_8)));
|
||||
QRecord newInterchange = copy(originalRow).withValue("originalInterchangeId", originalRow.getValue("id")).withValue("controlNumber", control)
|
||||
.withValue("status", "ENVELOPED").withValue("ta1Status", null).withValue("outboundMessageId", null).withValue("payloadRef", payload.value()).withValue("payloadSha256", payload.sha256()).withValue("sizeBytes", payload.sizeBytes());
|
||||
newInterchange = SystemWrites.insert("edi_interchange", newInterchange);
|
||||
var groupIds = new java.util.HashMap<Integer, Integer>();
|
||||
for(QRecord oldGroup : EdiRecords.query("edi_functional_group", EdiRecords.equal("interchangeId", originalRow.getValue("id"))))
|
||||
{
|
||||
int index = -1;
|
||||
for(int i = 0; i < original.groups().size(); i++)
|
||||
{
|
||||
if(original.groups().get(i).gs().value(6).equals(oldGroup.getValueString("controlNumber")))
|
||||
{
|
||||
index = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if(index < 0)
|
||||
{
|
||||
throw new IllegalStateException("Stored group does not match archived envelope");
|
||||
}
|
||||
QRecord newGroup = SystemWrites.insert("edi_functional_group", copy(oldGroup).withValue("interchangeId", newInterchange.getValue("id"))
|
||||
.withValue("controlNumber", groups.get(index).gs().value(6)).withValue("ackDueAt", null).withValue("ackReceivedAt", null).withValue("ackTransactionId", null)
|
||||
.withValue("ackStatus", "NONE".equals(oldGroup.getValueString("ackStatus")) ? "NONE" : "PENDING"));
|
||||
groupIds.put(oldGroup.getValueInteger("id"), newGroup.getValueInteger("id"));
|
||||
}
|
||||
for(QRecord transaction : EdiRecords.query("edi_transaction", EdiRecords.equal("interchangeId", originalRow.getValue("id"))))
|
||||
{
|
||||
SystemWrites.insert("edi_transaction", copy(transaction).withValue("interchangeId", newInterchange.getValue("id"))
|
||||
.withValue("functionalGroupId", groupIds.get(transaction.getValueInteger("functionalGroupId"))).withValue("status", "QUEUED").withValue("ackStatus", null));
|
||||
}
|
||||
QRecord newMessage = SystemWrites.insert("edi_outbound_message", copy(message).withValue("interchangeId", newInterchange.getValue("id"))
|
||||
.withValue("payloadRef", payload.value()).withValue("payloadSha256", payload.sha256()).withValue("sizeBytes", payload.sizeBytes())
|
||||
.withValue("status", "QUEUED").withValue("attempts", 0).withValue("nextAttemptAt", null).withValue("sentAt", null).withValue("deliveredAt", null).withValue("claimedAt", null)
|
||||
.withValue("transportMessageId", null).withValue("lastError", null).withValue("ackForGroupId", null).withValue("ackForInterchangeId", null));
|
||||
SystemWrites.update("edi_interchange", new QRecord().withValue("id", newInterchange.getValue("id")).withValue("outboundMessageId", newMessage.getValue("id")));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Preserve components and repetitions while replacing one envelope element.
|
||||
*******************************************************************************/
|
||||
private static com.kingsrook.qbits.edi.x12.X12Segment replace(com.kingsrook.qbits.edi.x12.X12Segment segment, int position, String value)
|
||||
{
|
||||
var elements = new java.util.ArrayList<>(segment.elements());
|
||||
elements.set(position - 1, List.of(List.of(value)));
|
||||
return (new com.kingsrook.qbits.edi.x12.X12Segment(segment.id(), segment.position(), elements));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Clone business metadata without identity and audit timestamps.
|
||||
*******************************************************************************/
|
||||
private static QRecord copy(QRecord source)
|
||||
{
|
||||
QRecord copy = new QRecord();
|
||||
copy.getValues().putAll(source.getValues());
|
||||
for(String field : List.of("id", "createDate", "modifyDate"))
|
||||
{
|
||||
copy.getValues().remove(field);
|
||||
}
|
||||
return (copy);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.edi.flow;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.kingsrook.qbits.edi.EdiRuntime;
|
||||
import com.kingsrook.qbits.edi.storage.EdiRecords;
|
||||
import com.kingsrook.qbits.edi.support.SystemWrites;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.listeners.RecordChangeEvent;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.listeners.RecordChangeListenerInterface;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.listeners.RecordChangeType;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
|
||||
|
||||
/*******************************************************************************
|
||||
** Transactional source listener and explicit enqueue boundary for MANUAL/ESB.
|
||||
** The listener applies only to tables with an active RECORD_CHANGE definition
|
||||
** (cached per runtime, see {@link OutboundSourceTables}), and to the definition
|
||||
** tables themselves, whose changes invalidate that cache.
|
||||
*******************************************************************************/
|
||||
public class OutboundEnqueue implements RecordChangeListenerInterface
|
||||
{
|
||||
private static final QLogger LOG = QLogger.getLogger(OutboundEnqueue.class);
|
||||
|
||||
/*******************************************************************************
|
||||
** Definition changes (any type), and inserts/updates of configured sources.
|
||||
*******************************************************************************/
|
||||
public boolean appliesTo(String table, RecordChangeType type)
|
||||
{
|
||||
EdiRuntime runtime = EdiRuntime.find();
|
||||
if(runtime == null)
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
if(OutboundSourceTables.DEFINITION_TABLES.contains(table))
|
||||
{
|
||||
return (true);
|
||||
}
|
||||
if(table.startsWith("edi_") || type == RecordChangeType.DELETE)
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
try
|
||||
{
|
||||
return (runtime.outboundSources().contains(table, QContext.getQBackendTransaction()));
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
//////////////////////////////////////////////////////////////////////
|
||||
// if the definitions can't be read, apply - so the event runs its //
|
||||
// own queries and reports the failure, rather than silently //
|
||||
// dropping outbound work. //
|
||||
//////////////////////////////////////////////////////////////////////
|
||||
LOG.warn("Unable to read EDI outbound source tables", e, logPair("table", table));
|
||||
return (true);
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Use the event transaction, even when the caller did not put it in QContext.
|
||||
** Definitions are read once per event, not once per record.
|
||||
*******************************************************************************/
|
||||
public void onRecordsChanged(RecordChangeEvent event) throws QException
|
||||
{
|
||||
if(OutboundSourceTables.DEFINITION_TABLES.contains(event.getTableName()))
|
||||
{
|
||||
EdiRuntime.current().outboundSources().invalidate(event.getTransaction());
|
||||
return;
|
||||
}
|
||||
var previous = QContext.getQBackendTransaction();
|
||||
QContext.setTransaction(event.getTransaction());
|
||||
try
|
||||
{
|
||||
SystemWrites.call(() ->
|
||||
{
|
||||
List<QRecord> definitions = definitions(event.getTableName(), OutboundSourceTables.TRIGGER);
|
||||
for(QRecord record : event.getRecords())
|
||||
{
|
||||
enqueue(event.getTableName(), record, null, definitions);
|
||||
}
|
||||
return (null);
|
||||
});
|
||||
}
|
||||
finally
|
||||
{
|
||||
QContext.setTransaction(previous);
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Enqueue matching active outbound definitions using the writer's transaction.
|
||||
*******************************************************************************/
|
||||
public static Integer enqueue(String table, QRecord source, String trigger, String eventId) throws QException
|
||||
{
|
||||
return (SystemWrites.call(() -> enqueue(table, source, eventId, definitions(table, trigger))));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Active outbound documents for the table whose trigger matches (MANUAL
|
||||
** matches every trigger) and whose agreement is active. Called inside a
|
||||
** {@link SystemWrites#call}.
|
||||
*******************************************************************************/
|
||||
static List<QRecord> definitions(String table, String trigger) throws QException
|
||||
{
|
||||
List<QRecord> matching = new ArrayList<>();
|
||||
for(QRecord document : EdiRecords.query("edi_agreement_document", EdiRecords.equal("sourceTableName", table)
|
||||
.withCriteria("direction", QCriteriaOperator.EQUALS, "OUTBOUND").withCriteria("isActive", QCriteriaOperator.EQUALS, true)))
|
||||
{
|
||||
if("MANUAL".equals(trigger) || trigger.equals(document.getValueString("outboundTrigger")))
|
||||
{
|
||||
matching.add(document);
|
||||
}
|
||||
}
|
||||
Set<Integer> activeAgreements = OutboundSourceTables.activeAgreementIds(matching);
|
||||
matching.removeIf(document -> !activeAgreements.contains(document.getValueInteger("agreementId")));
|
||||
return (matching);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Enqueue one source record against already-resolved definitions.
|
||||
*******************************************************************************/
|
||||
private static int enqueue(String table, QRecord source, String eventId, List<QRecord> definitions) throws Exception
|
||||
{
|
||||
int count = 0;
|
||||
for(QRecord document : definitions)
|
||||
{
|
||||
var metadata = QContext.getQInstance().getTable(table);
|
||||
if(!metadata.getBackendName().equals(EdiRuntime.current().config().getBackendName()))
|
||||
{
|
||||
throw new QException("Outbound source and EDI must share the transactional backend");
|
||||
}
|
||||
String filter = document.getValueString("triggerFilter");
|
||||
if(filter != null && !filter.isBlank() && EdiRecords.query(table, EdiRecords.equal(metadata.getPrimaryKeyField(), source.getValue(metadata.getPrimaryKeyField()))
|
||||
.withSubFilter(new ObjectMapper().readValue(filter, QQueryFilter.class))).isEmpty())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if(eventId != null && !EdiRecords.query("edi_outbound_request", EdiRecords.equal("eventId", eventId)
|
||||
.withCriteria("agreementDocumentId", QCriteriaOperator.EQUALS, document.getValue("id"))).isEmpty())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
SystemWrites.insert("edi_outbound_request", new QRecord().withValue("agreementDocumentId", document.getValue("id"))
|
||||
.withValue("sourceTableName", table).withValue("sourceRecordId", source.getValueString(metadata.getPrimaryKeyField()))
|
||||
.withValue("eventId", eventId).withValue("status", "PENDING").withValue("attempts", 0)
|
||||
.withValue("nextAttemptAt", EdiRuntime.current().config().getClock().instant()));
|
||||
count++;
|
||||
}
|
||||
return (count);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.edi.flow;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.Collections;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.WeakHashMap;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
import com.kingsrook.qbits.edi.storage.EdiRecords;
|
||||
import com.kingsrook.qbits.edi.support.SystemWrites;
|
||||
import com.kingsrook.qqq.backend.core.actions.QBackendTransaction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
|
||||
/*******************************************************************************
|
||||
** Per-runtime cache of the source tables that have an active RECORD_CHANGE
|
||||
** outbound definition on an active agreement. The record-change listener
|
||||
** consults it so writes to every other table (audits, jobs, alerts...) cost
|
||||
** nothing. It is loaded from committed data only (never in a writer's
|
||||
** transaction). A change to an agreement or document invalidates it, and
|
||||
** again once that change commits (discarding any reload that raced it); until
|
||||
** then the changing transaction bypasses the cache, so it sees its own
|
||||
** definitions. A short TTL covers changes made by another process.
|
||||
*******************************************************************************/
|
||||
public final class OutboundSourceTables
|
||||
{
|
||||
static final String TRIGGER = "RECORD_CHANGE";
|
||||
static final Set<String> DEFINITION_TABLES = Set.of("edi_agreement", "edi_agreement_document");
|
||||
|
||||
private static final Duration TTL = Duration.ofSeconds(60);
|
||||
|
||||
private final AtomicLong generation = new AtomicLong();
|
||||
private final AtomicLong loads = new AtomicLong();
|
||||
private final Set<QBackendTransaction> changingTransactions = Collections.synchronizedSet(Collections.newSetFromMap(new WeakHashMap<>()));
|
||||
private volatile Snapshot snapshot;
|
||||
|
||||
/*******************************************************************************
|
||||
** Loaded table names, with the generation and time they were loaded at.
|
||||
*******************************************************************************/
|
||||
private record Snapshot(Set<String> tables, long generation, long loadedAtNanos)
|
||||
{
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether record changes to this table, written in this transaction (may be
|
||||
** null), can enqueue outbound work.
|
||||
*******************************************************************************/
|
||||
public boolean contains(String table, QBackendTransaction transaction) throws QException
|
||||
{
|
||||
if(transaction != null && changingTransactions.contains(transaction))
|
||||
{
|
||||
return (true);
|
||||
}
|
||||
Snapshot current = snapshot;
|
||||
if(current == null || current.generation() != generation.get() || System.nanoTime() - current.loadedAtNanos() > TTL.toNanos())
|
||||
{
|
||||
current = load();
|
||||
}
|
||||
return (current.tables().contains(table));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Drop the cache now, and again once the changing transaction commits.
|
||||
*******************************************************************************/
|
||||
public void invalidate(QBackendTransaction transaction)
|
||||
{
|
||||
generation.incrementAndGet();
|
||||
if(transaction != null)
|
||||
{
|
||||
changingTransactions.add(transaction);
|
||||
transaction.addAfterCommitCallback(() ->
|
||||
{
|
||||
changingTransactions.remove(transaction);
|
||||
generation.incrementAndGet();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** How many times the definitions have been loaded (for tests and diagnostics).
|
||||
*******************************************************************************/
|
||||
public long loadCount()
|
||||
{
|
||||
return (loads.get());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Read committed active definitions once. A load that raced an invalidation
|
||||
** is not kept as current, because it carries the older generation.
|
||||
*******************************************************************************/
|
||||
private Snapshot load() throws QException
|
||||
{
|
||||
long loadGeneration = generation.get();
|
||||
Set<String> tables = SystemWrites.call(() ->
|
||||
{
|
||||
List<QRecord> documents = committed("edi_agreement_document", new QQueryFilter()
|
||||
.withCriteria("direction", QCriteriaOperator.EQUALS, "OUTBOUND").withCriteria("isActive", QCriteriaOperator.EQUALS, true)
|
||||
.withCriteria("outboundTrigger", QCriteriaOperator.EQUALS, TRIGGER));
|
||||
Set<Integer> agreementIds = agreementIds(documents);
|
||||
Set<Integer> activeAgreements = new HashSet<>();
|
||||
if(!agreementIds.isEmpty())
|
||||
{
|
||||
committed("edi_agreement", new QQueryFilter().withCriteria("isActive", QCriteriaOperator.EQUALS, true)
|
||||
.withCriteria("id", QCriteriaOperator.IN, agreementIds)).forEach(agreement -> activeAgreements.add(agreement.getValueInteger("id")));
|
||||
}
|
||||
Set<String> result = new HashSet<>();
|
||||
for(QRecord document : documents)
|
||||
{
|
||||
if(document.getValueString("sourceTableName") != null && activeAgreements.contains(document.getValueInteger("agreementId")))
|
||||
{
|
||||
result.add(document.getValueString("sourceTableName"));
|
||||
}
|
||||
}
|
||||
return (Set.copyOf(result));
|
||||
});
|
||||
loads.incrementAndGet();
|
||||
Snapshot loaded = new Snapshot(tables, loadGeneration, System.nanoTime());
|
||||
snapshot = loaded;
|
||||
return (loaded);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Query committed rows - deliberately without the caller's transaction.
|
||||
*******************************************************************************/
|
||||
private static List<QRecord> committed(String table, QQueryFilter filter) throws QException
|
||||
{
|
||||
return (new QueryAction().execute(new QueryInput(table).withFilter(filter)).getRecords());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** The distinct non-null agreement ids the documents reference.
|
||||
*******************************************************************************/
|
||||
private static Set<Integer> agreementIds(List<QRecord> documents)
|
||||
{
|
||||
Set<Integer> ids = new HashSet<>();
|
||||
documents.forEach(document -> ids.add(document.getValueInteger("agreementId")));
|
||||
ids.remove(null);
|
||||
return (ids);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Active agreement ids among those the documents reference, in one query in
|
||||
** the current transaction.
|
||||
*******************************************************************************/
|
||||
static Set<Integer> activeAgreementIds(List<QRecord> documents) throws QException
|
||||
{
|
||||
Set<Integer> ids = agreementIds(documents);
|
||||
Set<Integer> active = new HashSet<>();
|
||||
if(!ids.isEmpty())
|
||||
{
|
||||
for(QRecord agreement : EdiRecords.query("edi_agreement", EdiRecords.equal("isActive", true).withCriteria("id", QCriteriaOperator.IN, ids)))
|
||||
{
|
||||
active.add(agreement.getValueInteger("id"));
|
||||
}
|
||||
}
|
||||
return (active);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.edi.flow;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.Serializable;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import com.kingsrook.qbits.edi.EdiRuntime;
|
||||
import com.kingsrook.qbits.edi.guide.GuideRepository;
|
||||
import com.kingsrook.qbits.edi.map.InboundApplier;
|
||||
import com.kingsrook.qbits.edi.map.OutboundMapper;
|
||||
import com.kingsrook.qbits.edi.storage.EdiRecords;
|
||||
import com.kingsrook.qbits.edi.storage.PayloadKind;
|
||||
import com.kingsrook.qbits.edi.support.SystemWrites;
|
||||
import com.kingsrook.qbits.edi.x12.X12FunctionalGroup;
|
||||
import com.kingsrook.qbits.edi.x12.X12Interchange;
|
||||
import com.kingsrook.qbits.edi.x12.X12Segment;
|
||||
import com.kingsrook.qbits.edi.x12.X12Separators;
|
||||
import com.kingsrook.qbits.edi.x12.X12TransactionSet;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionEnforcement;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
|
||||
/*******************************************************************************
|
||||
** Transactional map/validate/envelope worker. Row locks fence replicas; a crash
|
||||
** rolls the whole envelope back, leaving requests available for the next run.
|
||||
*******************************************************************************/
|
||||
public class OutboundWorker
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Process immediate definitions; batch processes call runDocument explicitly.
|
||||
*******************************************************************************/
|
||||
public Integer runOnce() throws QException
|
||||
{
|
||||
return (SystemWrites.call(() ->
|
||||
{
|
||||
int count = 0;
|
||||
for(QRecord document : EdiRecords.query("edi_agreement_document", EdiRecords.equal("direction", "OUTBOUND").withCriteria("isActive", QCriteriaOperator.EQUALS, true)))
|
||||
{
|
||||
if(!"BATCH".equals(document.getValueString("batchMode")))
|
||||
{
|
||||
count += runDocument(document.getValueInteger("id"));
|
||||
}
|
||||
}
|
||||
return (count);
|
||||
}));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** A scheduled batch invocation cuts one bounded batch for its definition.
|
||||
*******************************************************************************/
|
||||
public Integer runDocument(Integer id) throws QException
|
||||
{
|
||||
return (SystemWrites.call(() -> SystemWrites.transaction(() ->
|
||||
{
|
||||
QRecord document = row("edi_agreement_document", id);
|
||||
QRecord agreement = row("edi_agreement", document.getValueInteger("agreementId"));
|
||||
if(!Boolean.TRUE.equals(document.getValueBoolean("isActive")) || !Boolean.TRUE.equals(agreement.getValueBoolean("isActive")))
|
||||
{
|
||||
return (0);
|
||||
}
|
||||
QRecord localIdentity = row("edi_identity", agreement.getValueInteger("localIdentityId"));
|
||||
QRecord partnerIdentity = row("edi_identity", agreement.getValueInteger("partnerIdentityId"));
|
||||
if(localIdentity == null || partnerIdentity == null || Boolean.FALSE.equals(localIdentity.getValueBoolean("isActive")) || Boolean.FALSE.equals(partnerIdentity.getValueBoolean("isActive")))
|
||||
{
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
// an inactive identity stops sending, as it stops routing; requests wait //
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
return (0);
|
||||
}
|
||||
var requests = EdiRecords.query("edi_outbound_request", EdiRecords.equal("agreementDocumentId", id).withCriteria("status", QCriteriaOperator.EQUALS, "PENDING").withLimit(100));
|
||||
if(requests.isEmpty())
|
||||
{
|
||||
return (0);
|
||||
}
|
||||
List<QRecord> accepted = new ArrayList<>();
|
||||
List<com.kingsrook.qbits.edi.document.EdiDocument> sets = new ArrayList<>();
|
||||
QRecord map = row("edi_map", document.getValueInteger("mapId"));
|
||||
var rules = EdiRecords.query("edi_map_rule", EdiRecords.equal("mapId", map.getValue("id")));
|
||||
var associations = new java.util.LinkedHashSet<String>();
|
||||
for(QRecord rule : rules)
|
||||
{
|
||||
String path = rule.getValueString("qqqPath");
|
||||
if(path != null)
|
||||
{
|
||||
for(int dot = path.indexOf('.'); dot >= 0; dot = path.indexOf('.', dot + 1))
|
||||
{
|
||||
associations.add(path.substring(0, dot));
|
||||
}
|
||||
}
|
||||
}
|
||||
var guide = new GuideRepository().load(document.getValueInteger("guideId"));
|
||||
for(QRecord request : requests)
|
||||
{
|
||||
if(!SystemWrites.guard("UPDATE edi_outbound_request SET id=id WHERE id=? AND status='PENDING'", request.getValue("id")))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
try(var logging = com.kingsrook.qbits.edi.security.PhiLogScope.open(Boolean.TRUE.equals(document.getValueBoolean("containsPhi"))))
|
||||
{
|
||||
com.kingsrook.qbits.edi.document.EdiDocument set = InboundApplier.asPartner(EdiRuntime.current().config().getServiceSessionSupplier(), agreement.getValueInteger("partnerId"), () ->
|
||||
{
|
||||
String table = request.getValueString("sourceTableName");
|
||||
String primaryKey = QContext.getQInstance().getTable(table).getPrimaryKeyField();
|
||||
var query = new QueryInput(table).withFilter(EdiRecords.equal(primaryKey, request.getValue("sourceRecordId")))
|
||||
.withIncludeAssociations(true).withAssociationNamesToInclude(associations).withTransaction(QContext.getQBackendTransaction());
|
||||
query.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
|
||||
var records = new QueryAction().execute(query).getRecords();
|
||||
if(records.size() != 1 || (!"ACTIVE".equals(map.getValueString("status")) || !table.equals(map.getValueString("tableName"))))
|
||||
{
|
||||
throw new IllegalArgumentException("Source record or active map unavailable");
|
||||
}
|
||||
return ("EDIFACT".equals(agreement.getValueString("standard"))
|
||||
? new com.kingsrook.qbits.edi.edifact.EdifactMapper(com.kingsrook.qbits.edi.edifact.EdifactSeparators.fromAgreement(agreement).decimal()).message(records.getFirst(), map, rules, com.kingsrook.qbits.edi.edifact.EdifactOutbound.allocate(agreement, "UNH"))
|
||||
: new OutboundMapper().build(records.getFirst(), map, rules, String.format("%04d", sets.size() + 1)));
|
||||
});
|
||||
if(EdiRuntime.current().validator().validate(set, guide, Boolean.TRUE.equals(document.getValueBoolean("containsPhi"))).stream().anyMatch(f -> "ERROR".equals(f.severity())))
|
||||
{
|
||||
reject(request, agreement, "VALIDATION");
|
||||
continue;
|
||||
}
|
||||
accepted.add(request);
|
||||
sets.add(set);
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
reject(request, agreement, "MAPPING");
|
||||
}
|
||||
if(!"BATCH".equals(document.getValueString("batchMode")) && !sets.isEmpty())
|
||||
{
|
||||
envelope(agreement, document, map, accepted, sets, null);
|
||||
accepted.clear();
|
||||
sets.clear();
|
||||
}
|
||||
}
|
||||
if(!sets.isEmpty())
|
||||
{
|
||||
envelope(agreement, document, map, accepted, sets, null);
|
||||
}
|
||||
return (requests.size());
|
||||
})));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Invalid documents are terminal requests and never enter the send queue.
|
||||
*******************************************************************************/
|
||||
private void reject(QRecord request, QRecord agreement, String type) throws Exception
|
||||
{
|
||||
SystemWrites.update("edi_outbound_request", new QRecord().withValue("id", request.getValue("id")).withValue("status", "FAILED").withValue("lastError", type));
|
||||
EdiJournal.exception(null, null, null, agreement.getValueInteger("partnerId"), type, "Outbound request failed " + type.toLowerCase(java.util.Locale.ROOT));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Allocate controls once, store exact bytes, then persist all tracking records.
|
||||
*******************************************************************************/
|
||||
public static QRecord envelope(QRecord agreement, QRecord document, QRecord map, List<QRecord> requests, List<? extends com.kingsrook.qbits.edi.document.EdiDocument> sets, Integer original) throws Exception
|
||||
{
|
||||
if("EDIFACT".equals(agreement.getValueString("standard")))
|
||||
{
|
||||
return (com.kingsrook.qbits.edi.edifact.EdifactOutbound.envelope(agreement, document, map, requests,
|
||||
sets.stream().map(s -> (com.kingsrook.qbits.edi.edifact.EdifactMessage) s).toList(), original, null));
|
||||
}
|
||||
var runtime = EdiRuntime.current();
|
||||
QRecord local = row("edi_identity", agreement.getValueInteger("localIdentityId"));
|
||||
QRecord partner = row("edi_identity", agreement.getValueInteger("partnerIdentityId"));
|
||||
LocalDateTime at = LocalDateTime.ofInstant(runtime.config().getClock().instant(), ZoneOffset.UTC);
|
||||
String isaControl = String.format("%09d", allocate(agreement, "ISA"));
|
||||
String gsControl = String.valueOf(allocate(agreement, "GS"));
|
||||
String version = Objects.requireNonNullElse(agreement.getValueString("interchangeVersion"), "00401");
|
||||
String component = value(agreement, "componentSeparator", ":");
|
||||
String repetition = value(agreement, "repetitionSeparator", "^");
|
||||
X12Segment isa = X12Segment.of("ISA", 1, "00", "", "00", "", local.getValueString("interchangeQualifier"), local.getValueString("interchangeId"),
|
||||
partner.getValueString("interchangeQualifier"), partner.getValueString("interchangeId"), at.format(DateTimeFormatter.ofPattern("yyMMdd")), at.format(DateTimeFormatter.ofPattern("HHmm")),
|
||||
version.compareTo("00501") >= 0 ? repetition : "U", version, isaControl, Boolean.TRUE.equals(agreement.getValueBoolean("requestTa1")) ? "1" : "0", agreement.getValueString("usageIndicator"), component);
|
||||
X12Segment gs = X12Segment.of("GS", 2, document.getValueString("functionalIdCode"), local.getValueString("applicationCode"), partner.getValueString("applicationCode"), at.format(DateTimeFormatter.BASIC_ISO_DATE),
|
||||
at.format(DateTimeFormatter.ofPattern("HHmm")), gsControl, "X", document.getValueString("version"));
|
||||
var interchange = new X12Interchange(new X12Separators(value(agreement, "elementSeparator", "*").charAt(0), value(agreement, "segmentTerminator", "~").charAt(0), component.charAt(0), version.compareTo("00501") >= 0 ? repetition.charAt(0) : null),
|
||||
isa, X12Segment.of("IEA", 0, "1", isaControl), List.of(new X12FunctionalGroup(gs, X12Segment.of("GE", 0, String.valueOf(sets.size()), gsControl), sets.stream().map(s -> (X12TransactionSet) s).toList(), List.of())), List.of(), List.of());
|
||||
String ending = switch(value(agreement, "lineEnding", "NONE"))
|
||||
{
|
||||
case "LF" -> "\n";
|
||||
case "CRLF" -> "\r\n";
|
||||
default -> "";
|
||||
};
|
||||
var payload = runtime.payloads().put(PayloadKind.OUTBOUND, new ByteArrayInputStream(runtime.writer().write(interchange, ending).getBytes(StandardCharsets.UTF_8)));
|
||||
QRecord persisted = SystemWrites.insert("edi_interchange", new QRecord().withValue("direction", "OUTBOUND").withValue("partnerId", agreement.getValue("partnerId"))
|
||||
.withValue("agreementId", agreement.getValue("id")).withValue("transportId", agreement.getValue("outboundTransportId")).withValue("standard", "X12")
|
||||
.withValue("senderQualifier", isa.value(5)).withValue("senderId", isa.value(6)).withValue("receiverQualifier", isa.value(7)).withValue("receiverId", isa.value(8))
|
||||
.withValue("controlNumber", isaControl).withValue("interchangeVersion", version).withValue("usageIndicator", isa.value(15)).withValue("interchangeDate", runtime.config().getClock().instant())
|
||||
.withValue("groupCount", 1).withValue("payloadRef", payload.value()).withValue("payloadSha256", payload.sha256()).withValue("sizeBytes", payload.sizeBytes())
|
||||
.withValue("status", "ENVELOPED").withValue("containsPhi", document.getValue("containsPhi")).withValue("originalInterchangeId", original));
|
||||
boolean expectsAck = document.getValueString("ackExpectation") != null && !"NONE".equals(document.getValueString("ackExpectation"));
|
||||
QRecord group = SystemWrites.insert("edi_functional_group", new QRecord().withValue("interchangeId", persisted.getValue("id")).withValue("senderId", gs.value(2))
|
||||
.withValue("functionalIdCode", gs.value(1)).withValue("controlNumber", gsControl).withValue("version", gs.value(8)).withValue("transactionCount", sets.size())
|
||||
.withValue("ackStatus", expectsAck ? "PENDING" : "NONE"));
|
||||
for(int i = 0; i < sets.size(); i++)
|
||||
{
|
||||
var set = sets.get(i);
|
||||
String businessKey = map.getValueString("businessKeyPath") == null ? null : new com.kingsrook.qbits.edi.guide.RuleEvaluator(set.segments(), java.util.Map.of()).values(map.getValueString("businessKeyPath")).stream().findFirst().orElse(null);
|
||||
SystemWrites.insert("edi_transaction", new QRecord().withValue("interchangeId", persisted.getValue("id")).withValue("functionalGroupId", group.getValue("id"))
|
||||
.withValue("direction", "OUTBOUND").withValue("partnerId", agreement.getValue("partnerId")).withValue("agreementDocumentId", document.getValue("id"))
|
||||
.withValue("transactionSet", set.header().value(1)).withValue("version", gs.value(8)).withValue("controlNumber", set.header().value(2)).withValue("status", "QUEUED")
|
||||
.withValue("guideId", document.getValue("guideId")).withValue("guideRevision", document.getValue("guideId") == null ? null : row("edi_guide", document.getValue("guideId")).getValue("revision"))
|
||||
.withValue("targetTableName", i < requests.size() ? requests.get(i).getValue("sourceTableName") : null).withValue("targetRecordId", i < requests.size() ? requests.get(i).getValue("sourceRecordId") : null).withValue("mapId", map.getValue("id")).withValue("mapRevision", map.getValue("revision"))
|
||||
.withValue("businessKeyType", map.getValue("businessKeyType")).withValue(Boolean.TRUE.equals(document.getValueBoolean("containsPhi")) ? "phiBusinessKey" : "businessKey", businessKey)
|
||||
.withValue("containsPhi", document.getValue("containsPhi")).withValue("segmentCount", set.segments().size()));
|
||||
if(i < requests.size())
|
||||
{
|
||||
SystemWrites.update("edi_outbound_request", new QRecord().withValue("id", requests.get(i).getValue("id")).withValue("status", "ENVELOPED"));
|
||||
}
|
||||
}
|
||||
QRecord message = SystemWrites.insert("edi_outbound_message", new QRecord().withValue("interchangeId", persisted.getValue("id")).withValue("agreementId", agreement.getValue("id"))
|
||||
.withValue("partnerId", agreement.getValue("partnerId")).withValue("transportId", agreement.getValue("outboundTransportId")).withValue("status", "QUEUED").withValue("attempts", 0)
|
||||
.withValue("payloadRef", payload.value()).withValue("payloadSha256", payload.sha256()).withValue("sizeBytes", payload.sizeBytes()));
|
||||
SystemWrites.update("edi_interchange", new QRecord().withValue("id", persisted.getValue("id")).withValue("outboundMessageId", message.getValue("id")));
|
||||
EdiJournal.event(null, persisted.getValueInteger("id"), null, "ENVELOPED");
|
||||
return (message);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Allocate in the configured agreement or identity scope.
|
||||
*******************************************************************************/
|
||||
private static long allocate(QRecord agreement, String level) throws Exception
|
||||
{
|
||||
boolean local = "LOCAL_IDENTITY".equals(agreement.getValueString("controlNumberScope"));
|
||||
return (EdiRuntime.current().controls().allocate(local ? null : agreement.getValueInteger("id"), local ? agreement.getValueInteger("localIdentityId") : null, level, 1, 999999999, true));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Lookup required configuration without leaking submitted values.
|
||||
*******************************************************************************/
|
||||
public static QRecord row(String table, Serializable id) throws QException
|
||||
{
|
||||
if(id == null)
|
||||
{
|
||||
throw new QException("Required EDI record reference is absent");
|
||||
}
|
||||
return (EdiRecords.query(table, EdiRecords.equal("id", id)).getFirst());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Default optional envelope settings.
|
||||
*******************************************************************************/
|
||||
private static String value(QRecord record, String field, String fallback)
|
||||
{
|
||||
return (Objects.requireNonNullElse(record.getValueString(field), fallback));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.edi.flow;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import com.kingsrook.qbits.edi.EdiRuntime;
|
||||
import com.kingsrook.qbits.edi.model.EdiOutboundMessage;
|
||||
import com.kingsrook.qbits.edi.model.EdiTransport;
|
||||
import com.kingsrook.qbits.edi.storage.EdiRecords;
|
||||
import com.kingsrook.qbits.edi.storage.PayloadRef;
|
||||
import com.kingsrook.qbits.edi.support.SystemWrites;
|
||||
import com.kingsrook.qbits.edi.transport.EdiSendResult;
|
||||
import com.kingsrook.qbits.edi.transport.EdiTransportPluginsMetaData;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertSeverity;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertSignal;
|
||||
import com.kingsrook.qqq.backend.core.alerts.QAlerts;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
|
||||
/*******************************************************************************
|
||||
** Durable at-least-once sender. A database row lock fences concurrent senders;
|
||||
** transport timeouts bound each attempt. Stable bytes/ids make retries detectable.
|
||||
*******************************************************************************/
|
||||
public class SendWorker
|
||||
{
|
||||
public static final List<Long> BACKOFF = List.of(60L, 300L, 900L, 3600L, 10800L, 21600L, 43200L, 86400L);
|
||||
|
||||
/*******************************************************************************
|
||||
** Process only queued or due retry rows, including inbound-generated replies.
|
||||
*******************************************************************************/
|
||||
public Integer runOnce() throws QException
|
||||
{
|
||||
return (SystemWrites.call(() ->
|
||||
{
|
||||
int count = 0;
|
||||
var due = new QQueryFilter().withBooleanOperator(QQueryFilter.BooleanOperator.OR).withCriteria("nextAttemptAt", QCriteriaOperator.IS_BLANK)
|
||||
.withCriteria("nextAttemptAt", QCriteriaOperator.LESS_THAN_OR_EQUALS, now());
|
||||
for(QRecord message : EdiRecords.query("edi_outbound_message", new QQueryFilter().withCriteria("status", QCriteriaOperator.IN, "QUEUED", "RETRY_WAIT").withSubFilter(due).withLimit(20)))
|
||||
{
|
||||
count += send(message);
|
||||
}
|
||||
return (count);
|
||||
}));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Compare selected status and attempts under lock before performing remote I/O.
|
||||
*******************************************************************************/
|
||||
private Integer send(QRecord selected) throws QException
|
||||
{
|
||||
return (SystemWrites.transaction(() ->
|
||||
{
|
||||
if(!SystemWrites.guard("UPDATE edi_outbound_message SET id=id WHERE id=? AND status=? AND COALESCE(attempts,0)=?", selected.getValue("id"), selected.getValue("status"), Objects.requireNonNullElse(selected.getValueInteger("attempts"), 0)))
|
||||
{
|
||||
return (0);
|
||||
}
|
||||
QRecord message = OutboundWorker.row("edi_outbound_message", selected.getValue("id"));
|
||||
int attempts = Objects.requireNonNullElse(message.getValueInteger("attempts"), 0) + 1;
|
||||
int maximum = 8;
|
||||
EdiSendResult result;
|
||||
try
|
||||
{
|
||||
QRecord transport = OutboundWorker.row("edi_transport", message.getValue("transportId"));
|
||||
maximum = Objects.requireNonNullElse(transport.getValueInteger("maxAttempts"), 8);
|
||||
if(!Boolean.TRUE.equals(transport.getValueBoolean("isActive")) || "INBOUND".equals(transport.getValueString("direction")))
|
||||
{
|
||||
result = new EdiSendResult("FAILED", null, "Outbound transport is disabled");
|
||||
}
|
||||
else
|
||||
{
|
||||
result = EdiTransportPluginsMetaData.current().get(transport.getValueString("type")).send(message.toEntity(EdiOutboundMessage.class),
|
||||
new PayloadRef(message.getValueString("payloadRef"), message.getValueString("payloadSha256"), message.getValueLong("sizeBytes")), transport.toEntity(EdiTransport.class));
|
||||
}
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
result = new EdiSendResult("RETRY", null, "Transport attempt failed");
|
||||
}
|
||||
boolean sent = List.of("SENT", "DELIVERED").contains(result.status());
|
||||
String status = sent ? result.status() : "FAILED".equals(result.status()) ? "FAILED" : attempts >= maximum ? "DEAD_LETTER" : "RETRY_WAIT";
|
||||
QRecord update = new QRecord().withValue("id", message.getValue("id")).withValue("status", status).withValue("attempts", attempts)
|
||||
.withValue("claimedAt", null).withValue("lastError", sent ? null : failureText(result))
|
||||
.withValue("nextAttemptAt", "RETRY_WAIT".equals(status) ? now().plusSeconds(BACKOFF.get(Math.min(attempts - 1, BACKOFF.size() - 1))) : null);
|
||||
if(sent)
|
||||
{
|
||||
update.withValue("transportMessageId", result.messageId()).withValue("sentAt", now());
|
||||
if("DELIVERED".equals(status))
|
||||
{
|
||||
update.withValue("deliveredAt", now());
|
||||
}
|
||||
}
|
||||
SystemWrites.update("edi_outbound_message", update);
|
||||
Integer interchangeId = message.getValueInteger("interchangeId");
|
||||
if(interchangeId != null && sent)
|
||||
{
|
||||
SystemWrites.update("edi_interchange", new QRecord().withValue("id", interchangeId).withValue("status", status));
|
||||
for(QRecord group : EdiRecords.query("edi_functional_group", EdiRecords.equal("interchangeId", interchangeId)))
|
||||
{
|
||||
for(QRecord transaction : EdiRecords.query("edi_transaction", EdiRecords.equal("functionalGroupId", group.getValue("id"))))
|
||||
{
|
||||
if("QUEUED".equals(transaction.getValueString("status")))
|
||||
{
|
||||
SystemWrites.update("edi_transaction", new QRecord().withValue("id", transaction.getValue("id")).withValue("status", "SENT"));
|
||||
}
|
||||
if("PENDING".equals(group.getValueString("ackStatus")) && group.getValue("ackDueAt") == null)
|
||||
{
|
||||
QRecord document = OutboundWorker.row("edi_agreement_document", transaction.getValue("agreementDocumentId"));
|
||||
SystemWrites.update("edi_functional_group", new QRecord().withValue("id", group.getValue("id"))
|
||||
.withValue("ackDueAt", now().plusSeconds(60L * Objects.requireNonNullElse(document.getValueInteger("ackDueMinutes"), 1440))));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if(List.of("FAILED", "DEAD_LETTER").contains(status))
|
||||
{
|
||||
SystemWrites.insert("edi_exception", new QRecord().withValue("outboundMessageId", message.getValue("id")).withValue("interchangeId", interchangeId)
|
||||
.withValue("partnerId", message.getValue("partnerId")).withValue("type", "TRANSPORT").withValue("status", "OPEN").withValue("occurrences", 1).withValue("summary", "Outbound delivery failed"));
|
||||
QAlerts.raise(new AlertSignal("edi.send.failed").withSeverity(AlertSeverity.ERROR).withDedupKey("edi_outbound_message:" + message.getValue("id"))
|
||||
.withTitle("EDI delivery failed").withSummary("Review the outbound message"));
|
||||
}
|
||||
EdiJournal.event(null, interchangeId, null, status);
|
||||
return (1);
|
||||
}));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** The recorded failure: the transport reason (value-free by contract, for
|
||||
** example an HTTP status, an AS2 disposition or a missing variable name), or a
|
||||
** generic text.
|
||||
*******************************************************************************/
|
||||
private static String failureText(EdiSendResult result)
|
||||
{
|
||||
String reason = result.reason();
|
||||
return (reason == null || reason.isBlank() ? "Transport delivery failed" : reason.length() <= 255 ? reason : reason.substring(0, 255));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Clock controlled by the host and deterministic tests.
|
||||
*******************************************************************************/
|
||||
private static Instant now()
|
||||
{
|
||||
return (EdiRuntime.current().config().getClock().instant());
|
||||
}
|
||||
}
|
||||
@@ -37,7 +37,7 @@ import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
*******************************************************************************/
|
||||
public class BuiltInGuideLoader
|
||||
{
|
||||
public static final List<String> RESOURCES = List.of("850-004010", "850-005010", "997-004010", "997-005010", "999-005010X231A1");
|
||||
public static final List<String> RESOURCES = List.of("850-004010", "850-005010", "997-004010", "997-005010", "999-005010X231A1", "855-004010", "855-005010", "810-004010", "810-005010", "846-004010", "846-005010", "860-004010", "860-005010", "856-004010", "856-005010", "940-004010", "940-005010", "945-004010", "945-005010", "214-004010", "214-005010", "204-004010", "204-005010", "ORDERS-D96A", "ORDRSP-D96A", "DESADV-D96A", "INVOIC-D96A", "CONTRL-D96A", "ORDERS-D01B", "ORDRSP-D01B", "DESADV-D01B", "INVOIC-D01B", "CONTRL-D01B", "837P-005010X222A1", "837I-005010X223A2", "835-005010X221A1", "270-005010X279A1", "271-005010X279A1", "276-005010X212", "277-005010X212", "834-005010X220A1");
|
||||
|
||||
/*******************************************************************************
|
||||
** Read the documented JSON format in memory.
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.edi.guide;
|
||||
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Host-owned, licensed code sets. Implementations must not log submitted values.
|
||||
*******************************************************************************/
|
||||
@FunctionalInterface
|
||||
public interface ExternalCodeSetProvider
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Check membership on the document's effective service or payment date.
|
||||
*******************************************************************************/
|
||||
boolean isValid(String codeSet, String code, LocalDate asOf);
|
||||
}
|
||||
@@ -27,6 +27,13 @@ import java.util.List;
|
||||
/*******************************************************************************
|
||||
** An element or composite component constraint.
|
||||
*******************************************************************************/
|
||||
public record GuideElement(Integer position, Integer componentPosition, String elementId, String dataType, Integer minLength, Integer maxLength, String usage, List<String> codes, String fixedValue, Integer repeatMax, String condition)
|
||||
public record GuideElement(Integer position, Integer componentPosition, String elementId, String dataType, Integer minLength, Integer maxLength, String usage, List<String> codes, String fixedValue, Integer repeatMax, String condition, String source, String externalCodeSet)
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Preserve the original constructor for host-authored inline constraints.
|
||||
*******************************************************************************/
|
||||
public GuideElement(Integer position, Integer componentPosition, String elementId, String dataType, Integer minLength, Integer maxLength, String usage, List<String> codes, String fixedValue, Integer repeatMax, String condition)
|
||||
{
|
||||
this(position, componentPosition, elementId, dataType, minLength, maxLength, usage, codes, fixedValue, repeatMax, condition, null, null);
|
||||
}
|
||||
}
|
||||
@@ -47,22 +47,37 @@ public class GuideRepository
|
||||
*******************************************************************************/
|
||||
public Guide load(Integer id) throws Exception
|
||||
{
|
||||
QRecord guide = rows("edi_guide", "id", id).getFirst();
|
||||
List<QRecord> definitions = new ArrayList<>(rows("edi_guide_segment", "guideId", id));
|
||||
return (load(id, false));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** UI preview explicitly enforces every guide read, independently of host defaults.
|
||||
*******************************************************************************/
|
||||
public Guide load(Integer id, boolean enforcePermissions) throws Exception
|
||||
{
|
||||
var guideRows = rows("edi_guide", "id", id, enforcePermissions);
|
||||
if(guideRows.isEmpty())
|
||||
{
|
||||
throw (new com.kingsrook.qqq.backend.core.exceptions.QNotFoundException("Not found"));
|
||||
}
|
||||
QRecord guide = guideRows.getFirst();
|
||||
List<QRecord> definitions = new ArrayList<>(rows("edi_guide_segment", "guideId", id, enforcePermissions));
|
||||
definitions.sort(Comparator.comparing(r -> r.getValueInteger("sequence")));
|
||||
List<GuideSegment> segments = new ArrayList<>();
|
||||
List<QRecord> rules = rows("edi_guide_rule", "guideId", id);
|
||||
List<QRecord> rules = rows("edi_guide_rule", "guideId", id, enforcePermissions);
|
||||
for(QRecord definition : definitions)
|
||||
{
|
||||
Integer segmentId = definition.getValueInteger("id");
|
||||
List<GuideElement> elements = new ArrayList<>();
|
||||
for(QRecord element : rows("edi_guide_element", "guideSegmentId", segmentId))
|
||||
for(QRecord element : rows("edi_guide_element", "guideSegmentId", segmentId, enforcePermissions))
|
||||
{
|
||||
List<String> codes = null;
|
||||
QRecord codeList = null;
|
||||
if(element.getValueInteger("codeListId") != null)
|
||||
{
|
||||
codeList = rows("edi_code_list", "id", element.getValueInteger("codeListId"), enforcePermissions).getFirst();
|
||||
codes = new ArrayList<>();
|
||||
for(QRecord code : rows("edi_code_list_value", "codeListId", element.getValueInteger("codeListId")))
|
||||
for(QRecord code : rows("edi_code_list_value", "codeListId", element.getValueInteger("codeListId"), enforcePermissions))
|
||||
{
|
||||
if(Boolean.TRUE.equals(code.getValueBoolean("isActive")))
|
||||
{
|
||||
@@ -72,7 +87,7 @@ public class GuideRepository
|
||||
}
|
||||
elements.add(new GuideElement(element.getValueInteger("position"), element.getValueInteger("componentPosition"), element.getValueString("elementId"),
|
||||
element.getValueString("dataType"), element.getValueInteger("minLength"), element.getValueInteger("maxLength"), element.getValueString("usage"), codes,
|
||||
element.getValueString("fixedValue"), element.getValueInteger("repeatMax"), condition(rules, element.getValueInteger("situationalRuleId"))));
|
||||
element.getValueString("fixedValue"), element.getValueInteger("repeatMax"), condition(rules, element.getValueInteger("situationalRuleId")), codeList == null ? null : codeList.getValueString("source"), codeList == null ? null : codeList.getValueString("externalCodeSet")));
|
||||
}
|
||||
List<GuideRule> segmentRules = rules.stream().filter(r -> segmentId.equals(r.getValueInteger("guideSegmentId")) && !"condition".equals(r.getValueString("message"))).map(GuideRepository::rule).toList();
|
||||
segments.add(new GuideSegment(definition.getValueString("segmentId"), definition.getValueString("loopId"), definition.getValueString("parentLoopId"),
|
||||
@@ -107,11 +122,11 @@ public class GuideRepository
|
||||
for(GuideElement element : list(segment.elements()))
|
||||
{
|
||||
Integer codeListId = null;
|
||||
if(element.codes() != null)
|
||||
if(element.codes() != null || "EXTERNAL".equals(element.source()))
|
||||
{
|
||||
codeListId = insert("edi_code_list", new QRecord().withValue("name", guide.name() + "/" + revision + "/" + sequence + "/" + element.position())
|
||||
.withValue("elementId", element.elementId()).withValue("source", "BUILT_IN"), transaction).getValueInteger("id");
|
||||
for(String code : element.codes())
|
||||
.withValue("elementId", element.elementId()).withValue("source", element.source() == null ? "BUILT_IN" : element.source()).withValue("externalCodeSet", element.externalCodeSet()), transaction).getValueInteger("id");
|
||||
for(String code : list(element.codes()))
|
||||
{
|
||||
insert("edi_code_list_value", new QRecord().withValue("codeListId", codeListId).withValue("code", code).withValue("isActive", true), transaction);
|
||||
}
|
||||
@@ -204,9 +219,9 @@ public class GuideRepository
|
||||
/*******************************************************************************
|
||||
** Query normalized child records.
|
||||
*******************************************************************************/
|
||||
private static List<QRecord> rows(String table, String field, Integer id) throws Exception
|
||||
private static List<QRecord> rows(String table, String field, Integer id, boolean enforcePermissions) throws Exception
|
||||
{
|
||||
return (EdiRecords.query(table, EdiRecords.equal(field, id)));
|
||||
return (enforcePermissions ? EdiRecords.queryEnforced(table, EdiRecords.equal(field, id)) : EdiRecords.query(table, EdiRecords.equal(field, id)));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
|
||||
@@ -25,8 +25,8 @@ import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.edi.document.EdiDocument;
|
||||
import com.kingsrook.qbits.edi.x12.X12Segment;
|
||||
import com.kingsrook.qbits.edi.x12.X12TransactionSet;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -34,31 +34,31 @@ import com.kingsrook.qbits.edi.x12.X12TransactionSet;
|
||||
*******************************************************************************/
|
||||
public class GuideValidator
|
||||
{
|
||||
private final com.kingsrook.qbits.edi.EdiQBitConfig config;
|
||||
|
||||
/*******************************************************************************
|
||||
** Standalone validation uses built-in formats without a licensed provider.
|
||||
*******************************************************************************/
|
||||
public GuideValidator()
|
||||
{
|
||||
this(new com.kingsrook.qbits.edi.EdiQBitConfig());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Read the host provider at validation time, allowing runtime registration.
|
||||
*******************************************************************************/
|
||||
public GuideValidator(com.kingsrook.qbits.edi.EdiQBitConfig config)
|
||||
{
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Validate one transaction; diagnostic text never contains submitted values.
|
||||
*******************************************************************************/
|
||||
public List<ValidationFinding> validate(X12TransactionSet transaction, Guide guide, boolean containsPhi)
|
||||
public List<ValidationFinding> validate(EdiDocument transaction, Guide guide, boolean containsPhi)
|
||||
{
|
||||
State state = new State(transaction.segments(), containsPhi);
|
||||
List<Node> roots = new ArrayList<>();
|
||||
Map<String, Node> nodes = new LinkedHashMap<>();
|
||||
for(GuideSegment segment : guide.segments())
|
||||
{
|
||||
List<Node> destination = roots;
|
||||
if(segment.loopId() != null && !segment.loopId().isBlank())
|
||||
{
|
||||
Node loop = nodes.get(segment.loopId());
|
||||
if(loop == null)
|
||||
{
|
||||
loop = new Node(segment.loopId(), null);
|
||||
nodes.put(segment.loopId(), loop);
|
||||
Node parent = nodes.get(segment.parentLoopId());
|
||||
(parent == null ? roots : parent.children).add(loop);
|
||||
}
|
||||
destination = loop.children;
|
||||
}
|
||||
destination.add(new Node(null, segment));
|
||||
}
|
||||
State state = new State(transaction.segments(), containsPhi, "EDIFACT".equals(guide.standard()));
|
||||
List<Node> roots = tree(guide);
|
||||
consume(roots, state, false);
|
||||
while(state.index < state.segments.size())
|
||||
{
|
||||
@@ -86,48 +86,90 @@ public class GuideValidator
|
||||
}
|
||||
for(GuideRule rule : list(guide.rules()))
|
||||
{
|
||||
rule(rule, transaction.st(), null, evaluator, state);
|
||||
rule(rule, transaction.header(), null, evaluator, state);
|
||||
}
|
||||
for(Binding binding : state.bindings)
|
||||
{
|
||||
GuideSegment definition = binding.definition();
|
||||
X12Segment segment = binding.segment();
|
||||
elements(segment, definition, evaluator, state);
|
||||
external(segment, definition, state);
|
||||
for(GuideRule rule : list(definition.rules()))
|
||||
{
|
||||
rule(rule, segment, definition.loopId(), evaluator, state);
|
||||
rule(rule, segment, definition.loopId(), "SITUATIONAL".equals(rule.type()) && definition.loopId() != null ? scoped(binding, state) : evaluator, state);
|
||||
}
|
||||
}
|
||||
if(guide.version().startsWith("005010X"))
|
||||
{
|
||||
state.findings.addAll(new HealthcareValidation().validate(transaction, guide, state.loops));
|
||||
}
|
||||
return (List.copyOf(state.findings));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Resolve guide-defined loop occurrences for the mapper using the validator grammar.
|
||||
*******************************************************************************/
|
||||
public Map<String, List<List<X12Segment>>> loops(X12TransactionSet transaction, Guide guide)
|
||||
public Map<String, List<List<X12Segment>>> loops(EdiDocument transaction, Guide guide)
|
||||
{
|
||||
State state = new State(transaction.segments(), true, "EDIFACT".equals(guide.standard()));
|
||||
List<Node> roots = tree(guide);
|
||||
consume(roots, state, false);
|
||||
return (state.loops);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** The same loop name can occur under subscriber and dependent patient parents.
|
||||
*******************************************************************************/
|
||||
private static List<Node> tree(Guide guide)
|
||||
{
|
||||
State state = new State(transaction.segments(), true);
|
||||
List<Node> roots = new ArrayList<>();
|
||||
Map<String, Node> nodes = new LinkedHashMap<>();
|
||||
Map<String, Node> latest = new LinkedHashMap<>();
|
||||
for(GuideSegment segment : guide.segments())
|
||||
{
|
||||
List<Node> destination = roots;
|
||||
if(segment.loopId() != null && !segment.loopId().isBlank())
|
||||
{
|
||||
Node loop = nodes.get(segment.loopId());
|
||||
Node parent = latest.get(segment.parentLoopId());
|
||||
List<Node> siblings = parent == null ? roots : parent.children;
|
||||
Node loop = siblings.stream().filter(n -> segment.loopId().equals(n.loopId)).findFirst().orElse(null);
|
||||
if(loop == null)
|
||||
{
|
||||
loop = new Node(segment.loopId(), null);
|
||||
nodes.put(segment.loopId(), loop);
|
||||
Node parent = nodes.get(segment.parentLoopId());
|
||||
(parent == null ? roots : parent.children).add(loop);
|
||||
siblings.add(loop);
|
||||
}
|
||||
latest.put(segment.loopId(), loop);
|
||||
destination = loop.children;
|
||||
}
|
||||
destination.add(new Node(null, segment));
|
||||
}
|
||||
consume(roots, state, false);
|
||||
return (state.loops);
|
||||
return (roots);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Situational rules see their occurrence plus direct ancestor segments, never siblings.
|
||||
*******************************************************************************/
|
||||
private static RuleEvaluator scoped(Binding binding, State state)
|
||||
{
|
||||
List<X12Segment> scope = state.loops.getOrDefault(binding.definition().loopId(), List.of()).stream()
|
||||
.filter(o -> o.contains(binding.segment())).min(java.util.Comparator.comparingInt(List::size)).orElse(state.segments);
|
||||
List<X12Segment> visible = new ArrayList<>(scope);
|
||||
for(List<X12Segment> ancestor : state.loops.values().stream().flatMap(List::stream).toList())
|
||||
{
|
||||
if(ancestor.size() > scope.size() && ancestor.contains(scope.getFirst()))
|
||||
{
|
||||
for(X12Segment segment : ancestor)
|
||||
{
|
||||
boolean nested = state.loops.values().stream().flatMap(List::stream)
|
||||
.anyMatch(o -> o.size() < ancestor.size() && ancestor.contains(o.getFirst()) && o.contains(segment));
|
||||
if(!nested && !visible.contains(segment))
|
||||
{
|
||||
visible.add(segment);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
visible.sort(java.util.Comparator.comparingInt(X12Segment::position));
|
||||
return (new RuleEvaluator(visible, state.loops));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -141,7 +183,7 @@ public class GuideValidator
|
||||
{
|
||||
GuideSegment head = node.children.getFirst().definition;
|
||||
int count = 0;
|
||||
while(state.matches(head.segmentId()))
|
||||
while(state.matches(head))
|
||||
{
|
||||
int start = state.index;
|
||||
consume(node.children, state, true);
|
||||
@@ -165,7 +207,7 @@ public class GuideValidator
|
||||
GuideSegment definition = node.definition;
|
||||
int count = 0;
|
||||
int maximum = number(definition.maxUse(), 1);
|
||||
while(state.matches(definition.segmentId()) && (count == 0 || !loopOccurrence || node != nodes.getFirst()))
|
||||
while(state.matches(definition.segmentId()) && (node != nodes.getFirst() || !loopOccurrence || state.matches(definition)) && (count == 0 || !loopOccurrence || node != nodes.getFirst()))
|
||||
{
|
||||
X12Segment segment = state.segments.get(state.index++);
|
||||
state.bindings.add(new Binding(segment, definition));
|
||||
@@ -229,13 +271,13 @@ public class GuideValidator
|
||||
{
|
||||
state.add(segment, definition.loopId(), element, "8", "5", "Element too long", value);
|
||||
}
|
||||
if(!validType(element.dataType(), value))
|
||||
if(!validType(element.dataType(), state.edifact && "R".equals(element.dataType()) ? value.replace(',', '.') : value))
|
||||
{
|
||||
String code = "DT".equals(element.dataType()) ? "8" : "TM".equals(element.dataType()) ? "9" : "6";
|
||||
state.add(segment, definition.loopId(), element, "8", code, "Invalid element data type", value);
|
||||
}
|
||||
if(element.fixedValue() != null && !element.fixedValue().equals(value)
|
||||
|| "ID".equals(element.dataType()) && element.codes() != null && !element.codes().contains(value))
|
||||
|| !"EXTERNAL".equals(element.source()) && "ID".equals(element.dataType()) && element.codes() != null && !element.codes().contains(value))
|
||||
{
|
||||
state.add(segment, definition.loopId(), element, "8", "7", "Invalid code or fixed value", value);
|
||||
}
|
||||
@@ -243,6 +285,47 @@ public class GuideValidator
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** External membership is SNIP 5 and is optional; formats are always built in.
|
||||
*******************************************************************************/
|
||||
private void external(X12Segment segment, GuideSegment definition, State state)
|
||||
{
|
||||
if(config.getExternalCodeSetProvider() == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
for(GuideElement element : list(definition.elements()))
|
||||
{
|
||||
if(!"EXTERNAL".equals(element.source()))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
int repetitions = element.position() <= segment.elements().size() ? segment.elements().get(element.position() - 1).size() : 1;
|
||||
for(int repetition = 1; repetition <= repetitions; repetition++)
|
||||
{
|
||||
String value = segment.value(element.position(), repetition, number(element.componentPosition(), 1));
|
||||
if(!value.isEmpty())
|
||||
{
|
||||
boolean valid;
|
||||
try(var logging = com.kingsrook.qbits.edi.security.PhiLogScope.open(state.phi))
|
||||
{
|
||||
valid = config.getExternalCodeSetProvider().isValid(element.externalCodeSet(), value,
|
||||
HealthcareValidation.asOf(state.segments, config.getClock()));
|
||||
}
|
||||
catch(RuntimeException e)
|
||||
{
|
||||
valid = false;
|
||||
}
|
||||
if(!valid)
|
||||
{
|
||||
state.findings.add(new ValidationFinding(segment.position(), segment.id(), definition.loopId(), element.position(), element.componentPosition(),
|
||||
5, "8", "7", "8", "7", "ERROR", "External code validation failed", null));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Validate X12 lexical data types without coercing bad values.
|
||||
*******************************************************************************/
|
||||
@@ -370,6 +453,7 @@ public class GuideValidator
|
||||
{
|
||||
private final List<X12Segment> segments;
|
||||
private final boolean phi;
|
||||
private final boolean edifact;
|
||||
private final List<ValidationFinding> findings = new ArrayList<>();
|
||||
private final List<Binding> bindings = new ArrayList<>();
|
||||
private final Map<String, List<List<X12Segment>>> loops = new LinkedHashMap<>();
|
||||
@@ -378,10 +462,11 @@ public class GuideValidator
|
||||
/*******************************************************************************
|
||||
** Initialize a transaction validation.
|
||||
*******************************************************************************/
|
||||
private State(List<X12Segment> segments, boolean phi)
|
||||
private State(List<X12Segment> segments, boolean phi, boolean edifact)
|
||||
{
|
||||
this.segments = segments;
|
||||
this.phi = phi;
|
||||
this.edifact = edifact;
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -392,6 +477,15 @@ public class GuideValidator
|
||||
return (index < segments.size() && segments.get(index).id().equals(id));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Fixed qualifiers distinguish adjacent HL and NM1 loop anchors.
|
||||
*******************************************************************************/
|
||||
private boolean matches(GuideSegment definition)
|
||||
{
|
||||
return (matches(definition.segmentId()) && list(definition.elements()).stream().filter(e -> e.fixedValue() != null)
|
||||
.allMatch(e -> e.fixedValue().equals(segments.get(index).value(e.position(), 1, number(e.componentPosition(), 1)))));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Locate a missing segment relative to the buffered transaction.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.edi.guide;
|
||||
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Clock;
|
||||
import java.time.LocalDate;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.edi.document.EdiDocument;
|
||||
import com.kingsrook.qbits.edi.x12.X12Segment;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Original healthcare balancing and format checks, without licensed code content.
|
||||
*******************************************************************************/
|
||||
public class HealthcareValidation
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Exact decimal arithmetic; every diagnostic contains only positions and codes.
|
||||
*******************************************************************************/
|
||||
public List<ValidationFinding> validate(EdiDocument transaction, Guide guide, Map<String, List<List<X12Segment>>> loops)
|
||||
{
|
||||
List<ValidationFinding> findings = new ArrayList<>();
|
||||
List<X12Segment> segments = transaction.segments();
|
||||
if("837".equals(guide.transactionSet()))
|
||||
{
|
||||
for(List<X12Segment> claim : blocks(segments, "CLM"))
|
||||
{
|
||||
X12Segment head = claim.getFirst();
|
||||
try
|
||||
{
|
||||
BigDecimal lines = BigDecimal.ZERO;
|
||||
for(X12Segment line : claim)
|
||||
{
|
||||
if(line.id().equals("SV1") || line.id().equals("SV2"))
|
||||
{
|
||||
lines = lines.add(amount(line, line.id().equals("SV1") ? 2 : 3));
|
||||
}
|
||||
}
|
||||
if(amount(head, 2).compareTo(lines) != 0)
|
||||
{
|
||||
findings.add(finding(head, "2300", 2, null, 3, "Claim charges do not balance"));
|
||||
}
|
||||
}
|
||||
catch(NumberFormatException e)
|
||||
{
|
||||
findings.add(finding(head, "2300", 2, null, 3, "Claim balance cannot be calculated"));
|
||||
}
|
||||
}
|
||||
}
|
||||
if("835".equals(guide.transactionSet()))
|
||||
{
|
||||
remittance(segments, findings);
|
||||
}
|
||||
for(X12Segment segment : segments)
|
||||
{
|
||||
String loop = loops.entrySet().stream().filter(e -> e.getValue().stream().anyMatch(o -> o.contains(segment)))
|
||||
.min(java.util.Comparator.comparingInt(e -> e.getValue().stream().filter(o -> o.contains(segment)).mapToInt(List::size).min().orElse(Integer.MAX_VALUE)))
|
||||
.map(Map.Entry::getKey).orElse(null);
|
||||
if(segment.id().equals("NM1") && segment.value(8).equals("XX") && !validNpi(segment.value(9)))
|
||||
{
|
||||
findings.add(finding(segment, loop, 9, null, 5, "Invalid NPI format or check digit"));
|
||||
}
|
||||
if(segment.id().equals("N4") && !segment.value(3).isEmpty() && (segment.value(4).isEmpty() || segment.value(4).equals("US"))
|
||||
&& !segment.value(3).matches("[0-9]{5}([0-9]{4}|-[0-9]{4})?"))
|
||||
{
|
||||
findings.add(finding(segment, loop, 3, null, 5, "Invalid postal format"));
|
||||
}
|
||||
if(segment.id().equals("HI"))
|
||||
{
|
||||
for(int position = 1; position <= segment.elements().size(); position++)
|
||||
{
|
||||
for(int repetition = 1; repetition <= segment.elements().get(position - 1).size(); repetition++)
|
||||
{
|
||||
if(List.of("ABK", "ABF", "ABJ", "APR").contains(segment.value(position, repetition, 1))
|
||||
&& !segment.value(position, repetition, 2).matches("[A-Z][0-9][A-Z0-9](?:[A-Z0-9]{0,4}|\\.[A-Z0-9]{1,4})"))
|
||||
{
|
||||
findings.add(finding(segment, loop, position, 2, 5, "Invalid ICD-10 diagnosis format"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return (findings);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Claim and line CAS monetary positions are 3, 6, 9, 12, 15 and 18.
|
||||
*******************************************************************************/
|
||||
private static void remittance(List<X12Segment> segments, List<ValidationFinding> findings)
|
||||
{
|
||||
for(List<X12Segment> claim : blocks(segments, "CLP"))
|
||||
{
|
||||
X12Segment head = claim.getFirst();
|
||||
try
|
||||
{
|
||||
BigDecimal adjustments = BigDecimal.ZERO;
|
||||
for(X12Segment segment : claim)
|
||||
{
|
||||
if(segment.id().equals("CAS"))
|
||||
{
|
||||
for(int p = 3; p <= segment.elements().size(); p += 3)
|
||||
{
|
||||
adjustments = adjustments.add(amount(segment, p));
|
||||
}
|
||||
}
|
||||
}
|
||||
if(amount(head, 3).subtract(amount(head, 4)).compareTo(adjustments) != 0)
|
||||
{
|
||||
findings.add(finding(head, "2100", 3, null, 3, "Claim adjustments do not balance"));
|
||||
}
|
||||
}
|
||||
catch(NumberFormatException e)
|
||||
{
|
||||
findings.add(finding(head, "2100", 3, null, 3, "Claim balance cannot be calculated"));
|
||||
}
|
||||
}
|
||||
for(X12Segment payment : segments.stream().filter(s -> s.id().equals("BPR")).toList())
|
||||
{
|
||||
try
|
||||
{
|
||||
BigDecimal total = BigDecimal.ZERO;
|
||||
for(X12Segment segment : segments)
|
||||
{
|
||||
if(segment.id().equals("CLP"))
|
||||
{
|
||||
total = total.add(amount(segment, 4));
|
||||
}
|
||||
if(segment.id().equals("PLB"))
|
||||
{
|
||||
for(int p = 4; p <= segment.elements().size(); p += 2)
|
||||
{
|
||||
total = total.subtract(amount(segment, p));
|
||||
}
|
||||
}
|
||||
}
|
||||
if(amount(payment, 2).compareTo(total) != 0)
|
||||
{
|
||||
findings.add(finding(payment, null, 2, null, 3, "Payment does not balance"));
|
||||
}
|
||||
}
|
||||
catch(NumberFormatException e)
|
||||
{
|
||||
findings.add(finding(payment, null, 2, null, 3, "Payment balance cannot be calculated"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Isolate each claim so one claim cannot compensate for another's imbalance.
|
||||
*******************************************************************************/
|
||||
private static List<List<X12Segment>> blocks(List<X12Segment> segments, String anchor)
|
||||
{
|
||||
List<List<X12Segment>> blocks = new ArrayList<>();
|
||||
List<X12Segment> current = null;
|
||||
for(X12Segment segment : segments)
|
||||
{
|
||||
if(segment.id().equals(anchor))
|
||||
{
|
||||
current = new ArrayList<>();
|
||||
blocks.add(current);
|
||||
}
|
||||
if(current != null)
|
||||
{
|
||||
current.add(segment);
|
||||
}
|
||||
}
|
||||
return (blocks);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Missing monetary elements fail balancing rather than silently becoming zero.
|
||||
*******************************************************************************/
|
||||
private static BigDecimal amount(X12Segment segment, int position)
|
||||
{
|
||||
return (new BigDecimal(segment.value(position)));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Position-only finding with a positional trigger for the implementation ack.
|
||||
*******************************************************************************/
|
||||
private static ValidationFinding finding(X12Segment segment, String loop, Integer element, Integer component, int level, String message)
|
||||
{
|
||||
return (new ValidationFinding(segment.position(), segment.id(), loop, element, component, level,
|
||||
"8", "7", "8", "7", "ERROR", message, null)
|
||||
.withContext(level == 3 ? new AckContext(segment.id(), segment.position(), loop, element, component) : null));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** NPI uses the Luhn checksum over the 80840 prefix and all ten digits.
|
||||
*******************************************************************************/
|
||||
public static boolean validNpi(String value)
|
||||
{
|
||||
if(!value.matches("[12][0-9]{9}"))
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
String digits = "80840" + value;
|
||||
int sum = 0;
|
||||
for(int i = digits.length() - 1, n = 0; i >= 0; i--, n++)
|
||||
{
|
||||
int digit = digits.charAt(i) - '0';
|
||||
if(n % 2 == 1)
|
||||
{
|
||||
digit *= 2;
|
||||
}
|
||||
sum += digit > 9 ? digit - 9 : digit;
|
||||
}
|
||||
return (sum % 10 == 0);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Service date, then payment date, then the configured clock for undated documents.
|
||||
*******************************************************************************/
|
||||
public static LocalDate asOf(List<X12Segment> segments, Clock clock)
|
||||
{
|
||||
for(X12Segment segment : segments)
|
||||
{
|
||||
String date = segment.id().equals("DTP") && segment.value(1).equals("472") ? segment.value(3)
|
||||
: segment.id().equals("BPR") ? segment.value(16) : "";
|
||||
if(RuleEvaluator.validDate(date) && date.length() == 8)
|
||||
{
|
||||
return (LocalDate.parse(date, DateTimeFormatter.BASIC_ISO_DATE));
|
||||
}
|
||||
}
|
||||
return (LocalDate.now(clock));
|
||||
}
|
||||
}
|
||||
@@ -189,7 +189,7 @@ public class RuleEvaluator
|
||||
String name = selector.group(1);
|
||||
String qualifier = selector.group(2);
|
||||
Matcher element = Pattern.compile("([A-Z][A-Z0-9]{1,2}?)([0-9]{2})(?:-([0-9]+))?").matcher(name);
|
||||
if(element.matches())
|
||||
if(element.matches() && !loops.containsKey(name))
|
||||
{
|
||||
List<X12Segment> selected = select(scope, element.group(1), qualifier);
|
||||
int position = Integer.parseInt(element.group(2));
|
||||
|
||||
@@ -107,7 +107,7 @@ public class InboundApplier
|
||||
var targetTable = QContext.getQInstance().getTable(table);
|
||||
String backend = com.kingsrook.qbits.edi.EdiRuntime.current().config().getBackendName();
|
||||
if(targetTable == null || !backend.equals(targetTable.getBackendName())
|
||||
|| targetTable.getAssociations().stream().anyMatch(a -> !backend.equals(QContext.getQInstance().getTable(a.getAssociatedTableName()).getBackendName())))
|
||||
|| targetTable.getAssociations() != null && targetTable.getAssociations().stream().anyMatch(a -> !backend.equals(QContext.getQInstance().getTable(a.getAssociatedTableName()).getBackendName())))
|
||||
{
|
||||
throw new IllegalArgumentException("Inbound targets and associations must share the EDI transactional backend");
|
||||
}
|
||||
|
||||
@@ -25,12 +25,12 @@ import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.edi.document.EdiDocument;
|
||||
import com.kingsrook.qbits.edi.guide.Guide;
|
||||
import com.kingsrook.qbits.edi.guide.GuideValidator;
|
||||
import com.kingsrook.qbits.edi.guide.RuleEvaluator;
|
||||
import com.kingsrook.qbits.edi.storage.EdiRecords;
|
||||
import com.kingsrook.qbits.edi.x12.X12Segment;
|
||||
import com.kingsrook.qbits.edi.x12.X12TransactionSet;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -58,7 +58,7 @@ public class InboundMapper
|
||||
/*******************************************************************************
|
||||
** Load ordered rules for a persisted map.
|
||||
*******************************************************************************/
|
||||
public Result map(X12TransactionSet transaction, Guide guide, QRecord map) throws Exception
|
||||
public Result map(EdiDocument transaction, Guide guide, QRecord map) throws Exception
|
||||
{
|
||||
List<QRecord> rules = new ArrayList<>(EdiRecords.query("edi_map_rule", EdiRecords.equal("mapId", map.getValue("id"))));
|
||||
rules.sort(Comparator.comparing(r -> r.getValueInteger("sequence")));
|
||||
@@ -68,7 +68,7 @@ public class InboundMapper
|
||||
/*******************************************************************************
|
||||
** Map in memory; wildcard rules sharing an association use the same loop scope.
|
||||
*******************************************************************************/
|
||||
public Result map(X12TransactionSet transaction, Guide guide, QRecord map, List<QRecord> rules) throws Exception
|
||||
public Result map(EdiDocument transaction, Guide guide, QRecord map, List<QRecord> rules) throws Exception
|
||||
{
|
||||
Map<String, List<List<X12Segment>>> loops = new GuideValidator().loops(transaction, guide);
|
||||
RuleEvaluator evaluator = new RuleEvaluator(transaction.segments(), loops);
|
||||
@@ -86,7 +86,7 @@ public class InboundMapper
|
||||
}
|
||||
if(fields.length == 1)
|
||||
{
|
||||
apply(rule, evaluator, result, fields[0], path);
|
||||
apply(rule, evaluator, result, fields[0], path, "EDIFACT".equals(guide.standard()));
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -117,10 +117,14 @@ public class InboundMapper
|
||||
}
|
||||
for(int i = 0; i < occurrences.size(); i++)
|
||||
{
|
||||
apply(rule, new RuleEvaluator(occurrences.get(i), loops), children.get(i), fields[1], relative);
|
||||
apply(rule, new RuleEvaluator(occurrences.get(i), loops), children.get(i), fields[1], relative, "EDIFACT".equals(guide.standard()));
|
||||
}
|
||||
}
|
||||
}
|
||||
catch(com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException e)
|
||||
{
|
||||
throw (e);
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
throw new IllegalArgumentException("Mapping failed at rule " + rule.getValueInteger("sequence") + " (" + rule.getValueString("ediPath") + " -> " + rule.getValueString("qqqPath") + ")");
|
||||
@@ -132,18 +136,22 @@ public class InboundMapper
|
||||
/*******************************************************************************
|
||||
** Conditions and defaults are evaluated within the current loop occurrence.
|
||||
*******************************************************************************/
|
||||
private void apply(QRecord rule, RuleEvaluator evaluator, QRecord target, String field, String path) throws Exception
|
||||
private void apply(QRecord rule, RuleEvaluator evaluator, QRecord target, String field, String path, boolean edifact) throws Exception
|
||||
{
|
||||
String condition = rule.getValueString("condition");
|
||||
if(condition != null && !condition.isBlank() && !evaluator.test(condition))
|
||||
{
|
||||
return;
|
||||
}
|
||||
List<String> values = path == null || path.isBlank() ? List.of() : evaluator.values(path);
|
||||
List<String> values = path == null || path.isBlank() ? List.of() : values(evaluator, path);
|
||||
if(values.isEmpty() && rule.getValueString("defaultValue") != null)
|
||||
{
|
||||
values = List.of(rule.getValueString("defaultValue"));
|
||||
}
|
||||
if(edifact && List.of("DECIMAL", "UNIT").contains(java.util.Objects.requireNonNullElse(rule.getValueString("transform"), "")))
|
||||
{
|
||||
values = values.stream().map(v -> v.replace(',', '.')).toList();
|
||||
}
|
||||
Serializable value = transforms.apply(rule.getValueString("transform"), rule.getValueString("transformArgs"), values);
|
||||
if(Boolean.TRUE.equals(rule.getValueBoolean("required")) && (value == null || value.toString().isBlank()))
|
||||
{
|
||||
@@ -152,6 +160,20 @@ public class InboundMapper
|
||||
target.withValue(field, value);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** A path may join several element paths with a plus outside any predicate, in order, so
|
||||
** a transform such as DATETIME receives a date followed by a time from separate elements.
|
||||
*******************************************************************************/
|
||||
private static List<String> values(RuleEvaluator evaluator, String path)
|
||||
{
|
||||
List<String> values = new ArrayList<>();
|
||||
for(String part : path.split("\\+(?![^\\[]*\\])"))
|
||||
{
|
||||
values.addAll(evaluator.values(part.trim()));
|
||||
}
|
||||
return (values);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Missing optional business keys remain null.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -20,138 +20,47 @@
|
||||
|
||||
package com.kingsrook.qbits.edi.map;
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.kingsrook.qbits.edi.EdiRuntime;
|
||||
import com.kingsrook.qbits.edi.ack.AckLevel;
|
||||
import com.kingsrook.qbits.edi.guide.GuideRepository;
|
||||
import com.kingsrook.qbits.edi.storage.EdiRecords;
|
||||
import com.kingsrook.qbits.edi.storage.PayloadRef;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLine;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLineInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.Status;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
|
||||
/*******************************************************************************
|
||||
** No-op apply preview with ordinary Result lines; PHI never enters process output.
|
||||
** Standard record-action presentation of the shared sandbox preview.
|
||||
*******************************************************************************/
|
||||
public class MapPreview
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Parse, validate, acknowledge and map a stored synthetic sample without target writes.
|
||||
** Render the same pipeline as HTTP using ordinary Review/Result lines.
|
||||
*******************************************************************************/
|
||||
public ArrayList<ProcessSummaryLineInterface> preview(QRecord map, QRecord sample) throws Exception
|
||||
{
|
||||
EdiRuntime runtime = EdiRuntime.current();
|
||||
boolean phi = List.of("837", "835", "270", "271", "276", "277", "834").contains(map.getValueString("transactionSet"));
|
||||
phi = phi || com.kingsrook.qbits.edi.support.SystemWrites.call(() -> !EdiRecords.query("edi_agreement_document", EdiRecords.equal("mapId", map.getValue("id"))
|
||||
.withCriteria("containsPhi", com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator.EQUALS, true)).isEmpty());
|
||||
var guide = new GuideRepository().load(map.getValueInteger("guideId"));
|
||||
Map<String, Object> preview = new MapPreviewService().preview(map, sample, null, "ORIGINAL");
|
||||
var json = new ObjectMapper().findAndRegisterModules();
|
||||
ArrayList<ProcessSummaryLineInterface> lines = new ArrayList<>();
|
||||
try(InputStream stream = runtime.payloads().open(new PayloadRef(sample.getValueString("payloadRef"), null, 0)))
|
||||
lines.add(new ProcessSummaryLine(Status.INFO, "Parsed: " + json.writeValueAsString(preview.get("parse"))));
|
||||
lines.add(new ProcessSummaryLine(Status.INFO, "SNIP findings: " + json.writeValueAsString(preview.get("findings"))));
|
||||
if(Boolean.TRUE.equals(preview.get("phiSuppressed")))
|
||||
{
|
||||
var interchanges = runtime.reader().read(stream);
|
||||
lines.add(new ProcessSummaryLine(Status.INFO, interchanges.size(), "interchanges parsed"));
|
||||
for(var interchange : interchanges)
|
||||
lines.add(new ProcessSummaryLine(Status.INFO, "PHI map result suppressed; no target records written"));
|
||||
lines.add(new ProcessSummaryLine(Status.INFO, "Acknowledgement preview suppressed for PHI document"));
|
||||
}
|
||||
else
|
||||
{
|
||||
lines.add(new ProcessSummaryLine(Status.INFO, json.writeValueAsString(preview.get("result"))));
|
||||
lines.add(new ProcessSummaryLine(Status.INFO, json.writeValueAsString(preview.get("ack"))));
|
||||
Map<?, ?> diff = (Map<?, ?>) preview.get("diff");
|
||||
for(String key : List.of("againstExpected", "againstPreviousRevision"))
|
||||
{
|
||||
lines.add(new ProcessSummaryLine(Status.INFO, interchange.groups().size(), "functional groups parsed"));
|
||||
for(var group : interchange.groups())
|
||||
{
|
||||
lines.add(new ProcessSummaryLine(Status.INFO, group.transactions().size(), "transaction sets parsed"));
|
||||
Map<String, List<com.kingsrook.qbits.edi.guide.ValidationFinding>> all = new java.util.LinkedHashMap<>();
|
||||
for(var transaction : group.transactions())
|
||||
{
|
||||
var findings = runtime.validator().validate(transaction, guide, phi);
|
||||
all.put(transaction.st().value(2), findings);
|
||||
for(int snip = 1; snip <= 7; snip++)
|
||||
{
|
||||
int level = snip;
|
||||
lines.add(new ProcessSummaryLine(Status.INFO, (int) findings.stream().filter(f -> f.snipLevel() == level).count(), "findings at SNIP " + snip));
|
||||
}
|
||||
List<QRecord> rules = EdiRecords.query("edi_map_rule", EdiRecords.equal("mapId", map.getValue("id")));
|
||||
rules.sort(java.util.Comparator.comparing(r -> r.getValueInteger("sequence")));
|
||||
var mapped = InboundApplier.asService(runtime.config().getServiceSessionSupplier(), () -> new InboundMapper(runtime.transforms()).map(transaction, guide, map, rules));
|
||||
if(phi)
|
||||
{
|
||||
lines.add(new ProcessSummaryLine(Status.INFO, "PHI map result suppressed; no target records written"));
|
||||
}
|
||||
else
|
||||
{
|
||||
ObjectMapper json = new ObjectMapper().findAndRegisterModules();
|
||||
String actual = resultJson(mapped.record());
|
||||
lines.add(new ProcessSummaryLine(Status.INFO, actual));
|
||||
String expected = sample.getValueString("expectedResultJson");
|
||||
if(expected != null)
|
||||
{
|
||||
diff(lines, "Expected result", json.readTree(expected), json.readTree(actual));
|
||||
}
|
||||
Integer revision = map.getValueInteger("revision");
|
||||
var previous = EdiRecords.query("edi_map", EdiRecords.equal("name", map.getValue("name")));
|
||||
previous.removeIf(r -> !java.util.Objects.equals(r.getValue("partnerId"), map.getValue("partnerId"))
|
||||
|| revision == null || r.getValueInteger("revision") == null || r.getValueInteger("revision") >= revision);
|
||||
previous.sort(java.util.Comparator.comparing((QRecord r) -> r.getValueInteger("revision")).reversed());
|
||||
if(!previous.isEmpty())
|
||||
{
|
||||
QRecord prior = previous.getFirst();
|
||||
var priorGuide = new GuideRepository().load(prior.getValueInteger("guideId"));
|
||||
List<QRecord> priorRules = EdiRecords.query("edi_map_rule", EdiRecords.equal("mapId", prior.getValue("id")));
|
||||
priorRules.sort(java.util.Comparator.comparing(r -> r.getValueInteger("sequence")));
|
||||
var priorResult = InboundApplier.asService(runtime.config().getServiceSessionSupplier(),
|
||||
() -> new InboundMapper(runtime.transforms()).map(transaction, priorGuide, prior, priorRules));
|
||||
diff(lines, "Previous revision " + prior.getValueInteger("revision"), json.readTree(resultJson(priorResult.record())), json.readTree(actual));
|
||||
}
|
||||
}
|
||||
}
|
||||
var ack = phi && group.gs().value(8).startsWith("005010X") ? runtime.ack999().build(group, all, AckLevel.ALL_TRANSACTIONS, "0001")
|
||||
: runtime.ack997().build(group, all, AckLevel.ALL_TRANSACTIONS, "0001");
|
||||
lines.add(new ProcessSummaryLine(Status.INFO, phi ? "Acknowledgement preview suppressed for PHI document"
|
||||
: runtime.writer().write(com.kingsrook.qbits.edi.ack.AckEnvelope.wrap(interchange, group, ack, "1", "1", java.time.LocalDateTime.now(runtime.config().getClock())))));
|
||||
}
|
||||
String label = key.equals("againstExpected") ? "Expected result" : "Previous revision";
|
||||
List<?> differences = (List<?>) diff.get(key);
|
||||
lines.add(new ProcessSummaryLine(Status.INFO, label + (differences.isEmpty() ? " matches" : " differs: " + json.writeValueAsString(differences))));
|
||||
}
|
||||
}
|
||||
return (lines);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Stable record JSON excludes QRecord diagnostics and implementation metadata.
|
||||
*******************************************************************************/
|
||||
private static String resultJson(QRecord record) throws Exception
|
||||
{
|
||||
Map<String, Object> result = new java.util.LinkedHashMap<>();
|
||||
result.put("values", record.getValues());
|
||||
Map<String, Object> associations = new java.util.LinkedHashMap<>();
|
||||
if(record.getAssociatedRecords() != null)
|
||||
{
|
||||
for(var entry : record.getAssociatedRecords().entrySet())
|
||||
{
|
||||
List<Object> children = new ArrayList<>();
|
||||
for(QRecord child : entry.getValue())
|
||||
{
|
||||
children.add(new ObjectMapper().readTree(resultJson(child)));
|
||||
}
|
||||
associations.put(entry.getKey(), children);
|
||||
}
|
||||
}
|
||||
result.put("associations", associations);
|
||||
return (new ObjectMapper().findAndRegisterModules().writeValueAsString(result));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Show a structured before/after diff for synthetic samples only.
|
||||
*******************************************************************************/
|
||||
private static void diff(ArrayList<ProcessSummaryLineInterface> lines, String label, com.fasterxml.jackson.databind.JsonNode before, com.fasterxml.jackson.databind.JsonNode after) throws Exception
|
||||
{
|
||||
if(before.equals(after))
|
||||
{
|
||||
lines.add(new ProcessSummaryLine(Status.INFO, label + " matches"));
|
||||
}
|
||||
else
|
||||
{
|
||||
Map<String, Object> difference = Map.of("comparison", label, "before", before, "after", after);
|
||||
lines.add(new ProcessSummaryLine(Status.INFO, label + " differs: " + new ObjectMapper().writeValueAsString(difference)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.edi.map;
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.kingsrook.qbits.edi.EdiRuntime;
|
||||
import com.kingsrook.qbits.edi.ack.AckEnvelope;
|
||||
import com.kingsrook.qbits.edi.ack.AckLevel;
|
||||
import com.kingsrook.qbits.edi.document.EdiDocument;
|
||||
import com.kingsrook.qbits.edi.edifact.ContrlBuilder;
|
||||
import com.kingsrook.qbits.edi.edifact.EdifactEnvelopeReader;
|
||||
import com.kingsrook.qbits.edi.edifact.EdifactWriter;
|
||||
import com.kingsrook.qbits.edi.guide.Guide;
|
||||
import com.kingsrook.qbits.edi.guide.GuideRepository;
|
||||
import com.kingsrook.qbits.edi.guide.ValidationFinding;
|
||||
import com.kingsrook.qbits.edi.security.PhiLogScope;
|
||||
import com.kingsrook.qbits.edi.storage.EdiRecords;
|
||||
import com.kingsrook.qbits.edi.storage.PayloadRef;
|
||||
import com.kingsrook.qbits.edi.support.SystemWrites;
|
||||
import com.kingsrook.qbits.edi.x12.EnvelopeFinding;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QBadRequestException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
|
||||
/*******************************************************************************
|
||||
** Shared no-apply preview pipeline. All mapping, including LOOKUP, uses the caller.
|
||||
** This service has no optional middleware dependency.
|
||||
*******************************************************************************/
|
||||
public class MapPreviewService
|
||||
{
|
||||
private static final ObjectMapper JSON = new ObjectMapper().findAndRegisterModules();
|
||||
|
||||
/*******************************************************************************
|
||||
** Read ordered persisted rules, or order a copy of the unsaved replacement set.
|
||||
*******************************************************************************/
|
||||
public static List<QRecord> rules(QRecord map, List<QRecord> overrides) throws Exception
|
||||
{
|
||||
List<QRecord> result = new ArrayList<>(overrides == null ? EdiRecords.queryEnforced("edi_map_rule", EdiRecords.equal("mapId", map.getValue("id"))) : overrides);
|
||||
result.sort(Comparator.comparing(r -> Objects.requireNonNullElse(r.getValueInteger("sequence"), 0)));
|
||||
return (result);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** PHI classification cannot be weakened by the caller or by hidden agreements.
|
||||
*******************************************************************************/
|
||||
public static boolean containsPhi(QRecord map) throws Exception
|
||||
{
|
||||
return (healthcare(map.getValueString("transactionSet")) || SystemWrites.call(() -> !EdiRecords.query("edi_agreement_document",
|
||||
EdiRecords.equal("mapId", map.getValue("id")).withCriteria("containsPhi", QCriteriaOperator.EQUALS, true)).isEmpty()));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Healthcare document types always suppress values, even with a mismatched map.
|
||||
*******************************************************************************/
|
||||
private static boolean healthcare(String type)
|
||||
{
|
||||
return (type != null && List.of("837", "835", "270", "271", "276", "277", "834").contains(type));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** ORIGINAL is the pinned row; CURRENT follows only the same guide family.
|
||||
*******************************************************************************/
|
||||
public static Guide guide(QRecord map, String revision) throws Exception
|
||||
{
|
||||
Integer id = map.getValueInteger("guideId");
|
||||
if("CURRENT".equals(revision))
|
||||
{
|
||||
var pinnedRows = EdiRecords.queryEnforced("edi_guide", EdiRecords.equal("id", id));
|
||||
if(pinnedRows.isEmpty())
|
||||
{
|
||||
throw (new com.kingsrook.qqq.backend.core.exceptions.QNotFoundException("Not found"));
|
||||
}
|
||||
QRecord pinned = pinnedRows.getFirst();
|
||||
var candidates = EdiRecords.queryEnforced("edi_guide", EdiRecords.equal("name", pinned.getValue("name")).withCriteria("status", QCriteriaOperator.EQUALS, "ACTIVE"));
|
||||
candidates.removeIf(r -> !Objects.equals(r.getValue("partnerId"), pinned.getValue("partnerId"))
|
||||
|| !Objects.equals(r.getValue("standard"), pinned.getValue("standard"))
|
||||
|| !Objects.equals(r.getValue("version"), pinned.getValue("version"))
|
||||
|| !Objects.equals(r.getValue("transactionSet"), pinned.getValue("transactionSet")));
|
||||
candidates.sort(Comparator.comparing((QRecord r) -> Objects.requireNonNullElse(r.getValueInteger("revision"), 0)).reversed());
|
||||
if(!candidates.isEmpty())
|
||||
{
|
||||
id = candidates.getFirst().getValueInteger("id");
|
||||
}
|
||||
}
|
||||
return (new GuideRepository().load(id, true));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Parse and inspect the complete sample before exposing any values.
|
||||
*******************************************************************************/
|
||||
public Map<String, Object> preview(QRecord map, QRecord sample, List<QRecord> overrides, String revision) throws Exception
|
||||
{
|
||||
EdiRuntime runtime = EdiRuntime.current();
|
||||
boolean phi = containsPhi(map);
|
||||
Guide guide = guide(map, revision);
|
||||
List<QRecord> rules = rules(map, overrides);
|
||||
State state = new State();
|
||||
try(var logging = PhiLogScope.open(true); InputStream stream = SystemWrites.call(() -> runtime.payloads().open(new PayloadRef(sample.getValueString("payloadRef"), null, 0))))
|
||||
{
|
||||
if("EDIFACT".equals(map.getValueString("standard")))
|
||||
{
|
||||
var interchanges = new EdifactEnvelopeReader(runtime.config().getMaxSegmentsPerTransaction()).read(stream);
|
||||
state.interchanges = interchanges.size();
|
||||
for(var interchange : interchanges)
|
||||
{
|
||||
state.groups += interchange.groups().size();
|
||||
state.envelope(interchange.findings());
|
||||
interchange.groups().forEach(group -> state.envelope(group.findings()));
|
||||
Map<String, List<ValidationFinding>> all = new LinkedHashMap<>();
|
||||
for(var message : interchange.allMessages())
|
||||
{
|
||||
all.put(message.reference(), document(state, map, sample, rules, guide, message, phi));
|
||||
}
|
||||
if(interchange.unb() != null)
|
||||
{
|
||||
var ack = new ContrlBuilder().build(interchange, all, AckLevel.ALL_TRANSACTIONS, "1", map.getValueString("version"));
|
||||
state.ack("CONTRL", phi ? null : new EdifactWriter().write(ack.segments(), interchange.separators(), ""));
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var interchanges = runtime.reader().read(stream);
|
||||
phi = phi || interchanges.stream().flatMap(i -> i.groups().stream()).flatMap(g -> g.transactions().stream()).anyMatch(t -> healthcare(t.st().value(1)));
|
||||
state.interchanges = interchanges.size();
|
||||
for(var interchange : interchanges)
|
||||
{
|
||||
state.groups += interchange.groups().size();
|
||||
state.envelope(interchange.findings());
|
||||
if(!interchange.findings().isEmpty())
|
||||
{
|
||||
var ta1 = runtime.ta1().build(interchange.isa(), interchange.findings());
|
||||
var isa = AckEnvelope.replyIsa(interchange.isa(), "1", java.time.LocalDateTime.now(runtime.config().getClock()));
|
||||
var reply = new com.kingsrook.qbits.edi.x12.X12Interchange(interchange.separators(), isa,
|
||||
com.kingsrook.qbits.edi.x12.X12Segment.of("IEA", 3, "0", isa.value(13)), List.of(), List.of(ta1), List.of());
|
||||
state.ack("TA1", phi ? null : runtime.writer().write(reply));
|
||||
}
|
||||
for(var group : interchange.groups())
|
||||
{
|
||||
state.envelope(group.findings());
|
||||
Map<String, List<ValidationFinding>> all = new LinkedHashMap<>();
|
||||
for(var transaction : group.transactions())
|
||||
{
|
||||
all.put(transaction.st().value(2), document(state, map, sample, rules, guide, transaction, phi));
|
||||
}
|
||||
boolean use999 = phi && group.gs().value(8).startsWith("005010X");
|
||||
var ack = use999 ? runtime.ack999().build(group, all, AckLevel.ALL_TRANSACTIONS, "0001") : runtime.ack997().build(group, all, AckLevel.ALL_TRANSACTIONS, "0001");
|
||||
state.ack(use999 ? "999" : "997", phi ? null : runtime.writer().write(AckEnvelope.wrap(interchange, group, ack, "1", "1", java.time.LocalDateTime.now(runtime.config().getClock()))));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch(com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException e)
|
||||
{
|
||||
throw (e);
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
// Parser, custom transform and expected-JSON diagnostics may contain payload values.
|
||||
throw (new QBadRequestException("Sample could not be previewed; check the document, guide and rules."));
|
||||
}
|
||||
return (object("parse", Map.of("interchanges", state.interchanges, "groups", state.groups, "documents", state.documents),
|
||||
"findings", state.findings, "ack", object("type", state.ackType, "text", phi ? null : state.ackText.toString()),
|
||||
"result", phi ? null : Map.of("records", state.records), "diff", Map.of("againstPreviousRevision", state.previous, "againstExpected", state.expected), "phiSuppressed", phi));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Validate and map one document, collecting only value-free findings for PHI.
|
||||
*******************************************************************************/
|
||||
private List<ValidationFinding> document(State state, QRecord map, QRecord sample, List<QRecord> rules, Guide guide, EdiDocument document, boolean phi) throws Exception
|
||||
{
|
||||
int index = state.documents++;
|
||||
state.envelope(document.findings());
|
||||
var findings = EdiRuntime.current().validator().validate(document, guide, phi);
|
||||
for(var finding : findings)
|
||||
{
|
||||
state.findings.add(object("snipLevel", finding.snipLevel(), "severity", finding.severity(), "segmentPosition", finding.segmentPosition(),
|
||||
"segmentId", finding.segmentId(), "loopId", finding.loopId(), "elementPosition", finding.elementPosition(),
|
||||
"code", finding.elementPosition() == null ? finding.ak3Code() : finding.ak4Code(), "message", phi ? "Validation finding" : finding.message()));
|
||||
}
|
||||
QRecord mapped;
|
||||
try
|
||||
{
|
||||
mapped = new InboundMapper(EdiRuntime.current().transforms()).map(document, guide, map, rules).record();
|
||||
}
|
||||
catch(com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException e)
|
||||
{
|
||||
throw (e);
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
state.findings.add(object("snipLevel", 0, "severity", "ERROR", "segmentPosition", null, "segmentId", null, "loopId", null,
|
||||
"elementPosition", null, "code", "MAP", "message", "Mapping failed; check the rule paths and transform arguments"));
|
||||
return (findings);
|
||||
}
|
||||
if(!phi)
|
||||
{
|
||||
Map<String, Object> actual = record(mapped);
|
||||
state.records.add(actual);
|
||||
String expected = sample.getValueString("expectedResultJson");
|
||||
if(expected != null)
|
||||
{
|
||||
difference(state.expected, index, JSON.readValue(expected, Object.class), actual);
|
||||
}
|
||||
Integer revision = map.getValueInteger("revision");
|
||||
var previous = EdiRecords.queryEnforced("edi_map", EdiRecords.equal("name", map.getValue("name")));
|
||||
previous.removeIf(r -> !Objects.equals(r.getValue("partnerId"), map.getValue("partnerId"))
|
||||
|| !Objects.equals(r.getValue("standard"), map.getValue("standard")) || !Objects.equals(r.getValue("transactionSet"), map.getValue("transactionSet"))
|
||||
|| !Objects.equals(r.getValue("direction"), map.getValue("direction")) || !Objects.equals(r.getValue("version"), map.getValue("version"))
|
||||
|| !Objects.equals(r.getValue("tableName"), map.getValue("tableName"))
|
||||
|| revision == null || r.getValueInteger("revision") == null || r.getValueInteger("revision") >= revision);
|
||||
previous.sort(Comparator.comparing((QRecord r) -> r.getValueInteger("revision")).reversed());
|
||||
if(!previous.isEmpty() && !containsPhi(previous.getFirst()))
|
||||
{
|
||||
QRecord prior = previous.getFirst();
|
||||
QRecord priorResult = new InboundMapper(EdiRuntime.current().transforms()).map(document, guide(prior, "ORIGINAL"), prior, rules(prior, null)).record();
|
||||
difference(state.previous, index, record(priorResult), actual);
|
||||
}
|
||||
}
|
||||
return (findings);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Stable record values and recursive associations, without QQQ diagnostics.
|
||||
*******************************************************************************/
|
||||
private static Map<String, Object> record(QRecord record)
|
||||
{
|
||||
Map<String, Object> associations = new LinkedHashMap<>();
|
||||
if(record.getAssociatedRecords() != null)
|
||||
{
|
||||
record.getAssociatedRecords().forEach((name, children) -> associations.put(name, children.stream().map(MapPreviewService::record).toList()));
|
||||
}
|
||||
return (object("values", record.getValues(), "associations", associations));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Compare normalized JSON so temporal values and numeric types match wire output.
|
||||
*******************************************************************************/
|
||||
private static void difference(List<Map<String, Object>> result, int index, Object before, Object after)
|
||||
{
|
||||
if(!JSON.valueToTree(before).equals(JSON.valueToTree(after)))
|
||||
{
|
||||
result.add(object("documentIndex", index, "before", before, "after", after));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Ordered objects allowing explicit nulls on the wire.
|
||||
*******************************************************************************/
|
||||
private static Map<String, Object> object(Object... pairs)
|
||||
{
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
for(int i = 0; i < pairs.length; i += 2)
|
||||
{
|
||||
result.put((String) pairs[i], pairs[i + 1]);
|
||||
}
|
||||
return (result);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** One invocation's accumulation; never shared across requests.
|
||||
*******************************************************************************/
|
||||
private static class State
|
||||
{
|
||||
private int interchanges;
|
||||
private int groups;
|
||||
private int documents;
|
||||
private final List<Map<String, Object>> findings = new ArrayList<>();
|
||||
private final List<Map<String, Object>> records = new ArrayList<>();
|
||||
private final List<Map<String, Object>> previous = new ArrayList<>();
|
||||
private final List<Map<String, Object>> expected = new ArrayList<>();
|
||||
private String ackType = "NONE";
|
||||
private final StringBuilder ackText = new StringBuilder();
|
||||
|
||||
/*******************************************************************************
|
||||
** Envelope diagnostics carry no raw envelope identifiers.
|
||||
*******************************************************************************/
|
||||
private void envelope(List<EnvelopeFinding> values)
|
||||
{
|
||||
values.forEach(f -> findings.add(object("snipLevel", 1, "severity", "ERROR", "segmentPosition", f.segmentPosition(),
|
||||
"segmentId", null, "loopId", null, "elementPosition", null, "code", f.code(), "message", "Envelope validation finding")));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Aggregate acknowledgements in wire order.
|
||||
*******************************************************************************/
|
||||
private void ack(String type, String text)
|
||||
{
|
||||
ackType = "NONE".equals(ackType) || type.equals(ackType) ? type : "MIXED";
|
||||
if(text != null)
|
||||
{
|
||||
ackText.append(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.edi.map;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.regex.Pattern;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.kingsrook.qbits.edi.guide.Guide;
|
||||
import com.kingsrook.qbits.edi.guide.GuideSegment;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
|
||||
/*******************************************************************************
|
||||
** Static validation of unsaved rules; never runs transforms or writes records.
|
||||
*******************************************************************************/
|
||||
public class MapRuleValidator
|
||||
{
|
||||
private static final Pattern ELEMENT = Pattern.compile("([A-Z][A-Z0-9]{1,2}?)([0-9]{2})(?:-([0-9]+))?");
|
||||
private static final Pattern STEP = Pattern.compile("([^\\[]+)(?:\\[(\\*|[1-9][0-9]*|[^\\[\\]]+=[^\\[\\]]+)])?");
|
||||
|
||||
/*******************************************************************************
|
||||
** Report independent errors without echoing rule literals or default values.
|
||||
*******************************************************************************/
|
||||
public List<String> validate(QRecord rule, QRecord map, Guide guide, TransformRegistry registry)
|
||||
{
|
||||
List<String> errors = new ArrayList<>();
|
||||
if(rule.getValueInteger("sequence") == null || rule.getValueInteger("sequence") < 1)
|
||||
{
|
||||
errors.add("sequence must be a positive integer");
|
||||
}
|
||||
boolean outbound = "OUTBOUND".equals(map.getValueString("direction"));
|
||||
String ediPath = rule.getValueString("ediPath");
|
||||
String transform = Objects.requireNonNullElse(rule.getValueString("transform"), "").toUpperCase(Locale.ROOT);
|
||||
if(ediPath == null || ediPath.isBlank())
|
||||
{
|
||||
if(outbound || !transform.equals("CONSTANT") && rule.getValueString("defaultValue") == null)
|
||||
{
|
||||
errors.add("ediPath is required");
|
||||
}
|
||||
}
|
||||
else if(!resolves(ediPath, guide))
|
||||
{
|
||||
errors.add("ediPath does not resolve in the guide");
|
||||
}
|
||||
String target = rule.getValueString("qqqPath");
|
||||
QTableMetaData table = QContext.getQInstance().getTable(map.getValueString("tableName"));
|
||||
String[] fields = target == null ? new String[0] : target.split("\\.", -1);
|
||||
for(int i = 0; table != null && i < fields.length - 1; i++)
|
||||
{
|
||||
var associations = table.getAssociations();
|
||||
String name = fields[i];
|
||||
var association = associations == null ? null : associations.stream().filter(a -> a.getName().equals(name)).findFirst().orElse(null);
|
||||
table = association == null ? null : QContext.getQInstance().getTable(association.getAssociatedTableName());
|
||||
}
|
||||
boolean generated = outbound && (target == null || target.isBlank()) && List.of("CONSTANT", "SEQUENCE", "HL").contains(transform);
|
||||
if(!generated && (table == null || fields.length == 0 || !table.getFields().containsKey(fields[fields.length - 1])
|
||||
|| !outbound && fields.length > 2))
|
||||
{
|
||||
errors.add("qqqPath does not resolve in the target table");
|
||||
}
|
||||
if(fields.length > 1 && !"OUTBOUND".equals(map.getValueString("direction")) && (ediPath == null || !ediPath.contains("[*]/")))
|
||||
{
|
||||
errors.add("An inbound association requires a wildcard loop");
|
||||
}
|
||||
Map<String, Object> schema = registry.argumentSchemas().get(transform);
|
||||
if(!transform.isEmpty() && schema == null)
|
||||
{
|
||||
errors.add("Unknown transform");
|
||||
}
|
||||
try
|
||||
{
|
||||
String raw = rule.getValueString("transformArgs");
|
||||
var args = new ObjectMapper().readTree(raw == null || raw.isBlank() ? "{}" : raw);
|
||||
if(!args.isObject() || java.util.stream.StreamSupport.stream(java.util.Spliterators.spliteratorUnknownSize(args.elements(), 0), false).anyMatch(v -> !v.isTextual()))
|
||||
{
|
||||
errors.add("transformArgs must be a JSON object of strings");
|
||||
}
|
||||
else if(schema != null && schema.get("required") instanceof List<?> required)
|
||||
{
|
||||
for(Object key : required)
|
||||
{
|
||||
if(!args.hasNonNull(key.toString()))
|
||||
{
|
||||
errors.add("Missing required transform argument: " + key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
errors.add("transformArgs must be a JSON object of strings");
|
||||
}
|
||||
return (errors);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Resolve every joined element path, including selectors and composite positions.
|
||||
*******************************************************************************/
|
||||
private boolean resolves(String path, Guide guide)
|
||||
{
|
||||
for(String part : path.split("\\+(?![^\\[]*])", -1))
|
||||
{
|
||||
List<GuideSegment> scope = guide.segments();
|
||||
String[] steps = part.trim().split("/", -1);
|
||||
for(int index = 0; index < steps.length; index++)
|
||||
{
|
||||
var step = STEP.matcher(steps[index]);
|
||||
if(!step.matches())
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
String name = step.group(1);
|
||||
String qualifier = step.group(2);
|
||||
var element = ELEMENT.matcher(name);
|
||||
if(index == steps.length - 1)
|
||||
{
|
||||
if(!element.matches() || !element(scope, name))
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
scope = scope.stream().filter(s -> s.segmentId().equals(element.group(1))).toList();
|
||||
}
|
||||
else
|
||||
{
|
||||
List<GuideSegment> current = scope;
|
||||
scope = current.stream().filter(s -> s.segmentId().equals(name) || inLoop(s, name, guide, 0)).toList();
|
||||
if(scope.isEmpty())
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
}
|
||||
if(qualifier != null && qualifier.contains("=") && !element(scope, qualifier.substring(0, qualifier.indexOf('='))))
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
}
|
||||
}
|
||||
return (true);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Ancestor scope matters when different loops contain the same segment.
|
||||
*******************************************************************************/
|
||||
private boolean inLoop(GuideSegment segment, String loop, Guide guide, int depth)
|
||||
{
|
||||
if(loop.equals(segment.loopId()))
|
||||
{
|
||||
return (true);
|
||||
}
|
||||
if(segment.parentLoopId() == null || depth > guide.segments().size())
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
return (loop.equals(segment.parentLoopId()) || guide.segments().stream().filter(s -> segment.parentLoopId().equals(s.loopId()))
|
||||
.anyMatch(parent -> inLoop(parent, loop, guide, depth + 1)));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Match an element and its optional composite component.
|
||||
*******************************************************************************/
|
||||
private boolean element(List<GuideSegment> segments, String path)
|
||||
{
|
||||
var match = ELEMENT.matcher(path);
|
||||
if(!match.matches())
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
int position = Integer.parseInt(match.group(2));
|
||||
Integer component;
|
||||
try
|
||||
{
|
||||
component = match.group(3) == null ? null : Integer.valueOf(match.group(3));
|
||||
}
|
||||
catch(NumberFormatException e)
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
return (segments.stream().filter(s -> s.segmentId().equals(match.group(1))).flatMap(s -> s.elements().stream())
|
||||
.anyMatch(e -> e.position() == position && (component == null || component.equals(e.componentPosition()))));
|
||||
}
|
||||
}
|
||||
@@ -20,16 +20,198 @@
|
||||
|
||||
package com.kingsrook.qbits.edi.map;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.regex.Pattern;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.kingsrook.qbits.edi.EdiRuntime;
|
||||
import com.kingsrook.qbits.edi.guide.RuleEvaluator;
|
||||
import com.kingsrook.qbits.edi.storage.EdiRecords;
|
||||
import com.kingsrook.qbits.edi.x12.X12Segment;
|
||||
import com.kingsrook.qbits.edi.x12.X12TransactionSet;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
|
||||
/*******************************************************************************
|
||||
** Phase-b extension boundary for building outbound transaction sets.
|
||||
** Builds ordered segments from record associations. Loop paths define hierarchy;
|
||||
** rules within a loop share its association occurrence and sequence number.
|
||||
*******************************************************************************/
|
||||
public interface OutboundMapper
|
||||
public class OutboundMapper
|
||||
{
|
||||
private static final Pattern ELEMENT = Pattern.compile("([A-Z][A-Z0-9]{1,2})([0-9]{2})(?:-([0-9]+))?");
|
||||
|
||||
/*******************************************************************************
|
||||
** Build an outbound transaction using a source record and map definition.
|
||||
** Load a persisted outbound map's ordered rules.
|
||||
*******************************************************************************/
|
||||
X12TransactionSet build(QRecord source, QRecord map) throws Exception;
|
||||
public X12TransactionSet build(QRecord source, QRecord map) throws Exception
|
||||
{
|
||||
return (build(source, map, EdiRecords.query("edi_map_rule", EdiRecords.equal("mapId", map.getValue("id"))), "0001"));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Pure mapping entry point; the envelope owns transaction control allocation.
|
||||
*******************************************************************************/
|
||||
public X12TransactionSet build(QRecord source, QRecord map, List<QRecord> rules, String control) throws Exception
|
||||
{
|
||||
if(!"OUTBOUND".equals(map.getValueString("direction")))
|
||||
{
|
||||
throw new IllegalArgumentException("An outbound map is required");
|
||||
}
|
||||
List<X12Segment> segments = new ArrayList<>();
|
||||
String version = map.getValueString("version");
|
||||
segments.add(version != null && version.startsWith("005010X")
|
||||
? X12Segment.of("ST", 1, map.getValueString("transactionSet"), control, version)
|
||||
: X12Segment.of("ST", 1, map.getValueString("transactionSet"), control));
|
||||
List<QRecord> sorted = rules.stream().sorted(Comparator.comparing(r -> r.getValueInteger("sequence"))).toList();
|
||||
emit(source, sorted, "", "", 1, "", new int[] {0}, segments);
|
||||
segments.add(X12Segment.of("SE", segments.size() + 1, String.valueOf(segments.size() + 1), control));
|
||||
return (new X12TransactionSet(segments.getFirst(), segments.getLast(), List.copyOf(segments), List.of()));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Walk each loop once per associated record, retaining the nearest HL parent.
|
||||
*******************************************************************************/
|
||||
protected void emit(QRecord source, List<QRecord> rules, String prefix, String qPrefix, int sequence, String parent,
|
||||
int[] hierarchy, List<X12Segment> output) throws Exception
|
||||
{
|
||||
Map<String, List<QRecord>> nodes = new LinkedHashMap<>();
|
||||
for(QRecord rule : rules)
|
||||
{
|
||||
String path = rule.getValueString("ediPath").substring(prefix.length());
|
||||
String key = path.contains("/") ? path.substring(0, path.indexOf('/') + 1) : path.replaceAll("[0-9]{2}(-[0-9]+)?$", "");
|
||||
nodes.computeIfAbsent(key, ignored -> new ArrayList<>()).add(rule);
|
||||
}
|
||||
String currentParent = parent;
|
||||
for(var node : nodes.entrySet())
|
||||
{
|
||||
if(node.getKey().endsWith("/"))
|
||||
{
|
||||
String association = null;
|
||||
for(QRecord rule : node.getValue())
|
||||
{
|
||||
if(rule.getValueString("ediPath").substring((prefix + node.getKey()).length()).contains("/"))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
String path = rule.getValueString("qqqPath");
|
||||
if(path != null && path.startsWith(qPrefix) && path.substring(qPrefix.length()).contains("."))
|
||||
{
|
||||
association = path.substring(qPrefix.length()).split("\\.")[0];
|
||||
break;
|
||||
}
|
||||
}
|
||||
List<QRecord> children = association == null ? List.of(source) : Objects.requireNonNullElse(source.getAssociatedRecords() == null ? null : source.getAssociatedRecords().get(association), List.of());
|
||||
int index = 0;
|
||||
for(QRecord child : children)
|
||||
{
|
||||
emit(child, node.getValue(), prefix + node.getKey(), association == null ? qPrefix : qPrefix + association + ".", ++index, currentParent, hierarchy, output);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
List<List<String>> elements = new ArrayList<>();
|
||||
String segmentId = null;
|
||||
for(QRecord rule : node.getValue())
|
||||
{
|
||||
String path = rule.getValueString("ediPath");
|
||||
var match = ELEMENT.matcher(path.substring(path.lastIndexOf('/') + 1));
|
||||
if(!match.matches())
|
||||
{
|
||||
throw new IllegalArgumentException("Unsupported outbound element path");
|
||||
}
|
||||
segmentId = match.group(1);
|
||||
int position = Integer.parseInt(match.group(2));
|
||||
if(position < 1 || position > 99)
|
||||
{
|
||||
throw new IllegalArgumentException("Invalid outbound element position");
|
||||
}
|
||||
while(elements.size() < position)
|
||||
{
|
||||
elements.add(new ArrayList<>());
|
||||
}
|
||||
String condition = rule.getValueString("condition");
|
||||
if(condition != null && !condition.isBlank() && !new RuleEvaluator(output, Map.of()).test(condition))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
String qPath = rule.getValueString("qqqPath");
|
||||
Object raw = qPath == null ? null : source.getValue(qPath.startsWith(qPrefix) ? qPath.substring(qPrefix.length()) : qPath);
|
||||
String transform = Objects.requireNonNullElse(rule.getValueString("transform"), "");
|
||||
String argsJson = rule.getValueString("transformArgs");
|
||||
Map<String, String> args = argsJson == null ? Map.of() : new ObjectMapper().readValue(argsJson, new TypeReference<Map<String, String>>() {});
|
||||
String value;
|
||||
if(transform.equals("HL"))
|
||||
{
|
||||
value = switch(position)
|
||||
{
|
||||
case 1 -> String.valueOf(++hierarchy[0]);
|
||||
case 2 -> parent;
|
||||
case 3 -> args.get("code");
|
||||
case 4 -> source.getAssociatedRecords() != null && source.getAssociatedRecords().values().stream().anyMatch(v -> !v.isEmpty()) ? "1" : "0";
|
||||
default -> throw new IllegalArgumentException("HL supports positions 1 through 4");
|
||||
};
|
||||
}
|
||||
else if(transform.equals("SEQUENCE"))
|
||||
{
|
||||
value = String.valueOf(sequence + Integer.parseInt(args.getOrDefault("start", "1")) - 1);
|
||||
}
|
||||
else if(raw == null && !transform.equals("CONSTANT"))
|
||||
{
|
||||
value = rule.getValueString("defaultValue");
|
||||
}
|
||||
else if(transform.equals("DATE"))
|
||||
{
|
||||
value = java.time.LocalDate.parse(raw.toString()).format(java.time.format.DateTimeFormatter.BASIC_ISO_DATE);
|
||||
}
|
||||
else if(transform.equals("TIME"))
|
||||
{
|
||||
value = java.time.LocalTime.parse(raw.toString()).format(java.time.format.DateTimeFormatter.ofPattern("HHmmss"));
|
||||
}
|
||||
else if(transform.equals("DECIMAL"))
|
||||
{
|
||||
value = decimal(new java.math.BigDecimal(raw.toString()).movePointRight(Integer.parseInt(args.getOrDefault("scale", "0"))).stripTrailingZeros().toPlainString());
|
||||
}
|
||||
else
|
||||
{
|
||||
Object transformed = EdiRuntime.current().transforms().apply(transform, argsJson, raw == null ? List.of() : List.of(raw.toString()));
|
||||
value = transformed == null ? rule.getValueString("defaultValue") : transformed.toString();
|
||||
}
|
||||
if((value == null || value.isBlank()) && Boolean.TRUE.equals(rule.getValueBoolean("required")))
|
||||
{
|
||||
throw new IllegalArgumentException("Required outbound field is missing");
|
||||
}
|
||||
int component = match.group(3) == null ? 1 : Integer.parseInt(match.group(3));
|
||||
if(component < 1 || component > 99)
|
||||
{
|
||||
throw new IllegalArgumentException("Invalid outbound component position");
|
||||
}
|
||||
var components = elements.get(position - 1);
|
||||
while(components.size() < component)
|
||||
{
|
||||
components.add("");
|
||||
}
|
||||
components.set(component - 1, Objects.requireNonNullElse(value, ""));
|
||||
}
|
||||
if(elements.stream().flatMap(List::stream).anyMatch(v -> !v.isEmpty()))
|
||||
{
|
||||
output.add(new X12Segment(segmentId, output.size() + 1, elements.stream().map(e -> List.of(e.isEmpty() ? List.of("") : e)).toList()));
|
||||
if("HL".equals(segmentId))
|
||||
{
|
||||
currentParent = elements.getFirst().getFirst();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** X12 uses a period; EDIFACT can override the numeric service character.
|
||||
*******************************************************************************/
|
||||
protected String decimal(String value)
|
||||
{
|
||||
return (value);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.edi.map;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.kingsrook.qbits.edi.storage.EdiRecords;
|
||||
import com.kingsrook.qbits.edi.support.SystemWrites;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
|
||||
/*******************************************************************************
|
||||
** The bundled sample maps (synthetic, one per built-in document and version)
|
||||
** and their insert-only installation. Installed maps are drafts, bound to no
|
||||
** partner and to no agreement document, so installing one never changes how
|
||||
** any document is processed; the target table in each is an example name the
|
||||
** host replaces before activating.
|
||||
*******************************************************************************/
|
||||
public final class SampleMaps
|
||||
{
|
||||
public static final List<String> RESOURCES = List.of("204-004010", "204-005010", "204-out-004010", "204-out-005010", "214-004010", "214-005010",
|
||||
"810-004010", "810-005010", "846-004010", "846-005010", "850-004010", "850-005010", "855-004010", "855-005010", "856-004010", "856-005010",
|
||||
"860-004010", "860-005010", "940-004010", "940-005010", "945-004010", "945-005010", "945-in-004010", "945-in-005010",
|
||||
"DESADV-D01B", "DESADV-D96A", "INVOIC-D01B", "INVOIC-D96A", "ORDERS-D01B", "ORDERS-D96A", "ORDRSP-D01B", "ORDRSP-D96A");
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private SampleMaps()
|
||||
{
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** One bundled sample: the map's values and its rules.
|
||||
*******************************************************************************/
|
||||
public static JsonNode read(String resource) throws IOException
|
||||
{
|
||||
try(InputStream input = SampleMaps.class.getResourceAsStream("/edi/maps/" + resource + "-sample.json"))
|
||||
{
|
||||
if(input == null)
|
||||
{
|
||||
throw (new IOException("Unknown sample map " + resource));
|
||||
}
|
||||
return (new ObjectMapper().readTree(input));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** The bundled samples whose map name does not exist yet (any revision, any
|
||||
** partner), in bundle order. Reads the map table as the processing user.
|
||||
*******************************************************************************/
|
||||
public static List<String> missing() throws QException, IOException
|
||||
{
|
||||
Set<String> existing = new HashSet<>();
|
||||
for(QRecord map : SystemWrites.query("edi_map", new QQueryFilter()))
|
||||
{
|
||||
existing.add(map.getValueString("name"));
|
||||
}
|
||||
List<String> missing = new ArrayList<>();
|
||||
for(String resource : RESOURCES)
|
||||
{
|
||||
if(!existing.contains(read(resource).path("map").path("name").asText()))
|
||||
{
|
||||
missing.add(resource);
|
||||
}
|
||||
}
|
||||
return (missing);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** The map record a sample installs as: a draft, revision 0, no partner,
|
||||
** bound to the built-in guide for its document and version when present.
|
||||
*******************************************************************************/
|
||||
public static QRecord mapRecord(String resource) throws IOException, QException
|
||||
{
|
||||
JsonNode map = read(resource).path("map");
|
||||
QRecord record = new QRecord();
|
||||
map.fields().forEachRemaining(e ->
|
||||
{
|
||||
if(!e.getValue().isNull() && !List.of("status", "revision", "partnerId", "guideId", "parentMapId").contains(e.getKey()))
|
||||
{
|
||||
record.withValue(e.getKey(), e.getValue().asText());
|
||||
}
|
||||
});
|
||||
record.withValue("status", "DRAFT").withValue("revision", 0);
|
||||
Integer guideId = null;
|
||||
int revision = -1;
|
||||
for(QRecord guide : SystemWrites.query("edi_guide", new QQueryFilter().withCriteria("transactionSet", QCriteriaOperator.EQUALS, map.path("transactionSet").asText())
|
||||
.withCriteria("version", QCriteriaOperator.EQUALS, map.path("version").asText()).withCriteria("source", QCriteriaOperator.EQUALS, "BUILT_IN")
|
||||
.withCriteria("status", QCriteriaOperator.EQUALS, "ACTIVE")))
|
||||
{
|
||||
int candidate = guide.getValueInteger("revision") == null ? 0 : guide.getValueInteger("revision");
|
||||
if(candidate > revision)
|
||||
{
|
||||
revision = candidate;
|
||||
guideId = guide.getValueInteger("id");
|
||||
}
|
||||
}
|
||||
return (record.withValue("guideId", guideId));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Insert one sample and its ordered rules. Skips (returns null) a sample
|
||||
** whose map name now exists, so a repeated run adds nothing. It is an
|
||||
** administrator action; two people running it at the same moment is not
|
||||
** guarded against (edi_map has no unique name, since revisions share one).
|
||||
*******************************************************************************/
|
||||
public static Integer install(String resource) throws Exception
|
||||
{
|
||||
JsonNode sample = read(resource);
|
||||
String name = sample.path("map").path("name").asText();
|
||||
if(!EdiRecords.query("edi_map", EdiRecords.equal("name", name)).isEmpty())
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
QRecord map = SystemWrites.insert("edi_map", mapRecord(resource));
|
||||
for(JsonNode rule : sample.path("rules"))
|
||||
{
|
||||
QRecord row = new QRecord();
|
||||
rule.fields().forEachRemaining(e ->
|
||||
{
|
||||
if(!e.getValue().isNull())
|
||||
{
|
||||
row.withValue(e.getKey(), e.getValue().isInt() ? (java.io.Serializable) e.getValue().intValue() : e.getValue().isBoolean() ? (java.io.Serializable) e.getValue().booleanValue() : e.getValue().asText());
|
||||
}
|
||||
});
|
||||
SystemWrites.insert("edi_map_rule", row.withValue("mapId", map.getValue("id")));
|
||||
}
|
||||
return (map.getValueInteger("id"));
|
||||
}
|
||||
}
|
||||
@@ -23,9 +23,14 @@ package com.kingsrook.qbits.edi.map;
|
||||
import java.io.Serializable;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.LocalTime;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.time.ZoneOffset;
|
||||
import java.time.ZonedDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.time.format.DateTimeFormatterBuilder;
|
||||
import java.time.temporal.ChronoField;
|
||||
@@ -46,6 +51,7 @@ import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
public class TransformRegistry
|
||||
{
|
||||
private final Map<String, Transform> transforms = new ConcurrentHashMap<>();
|
||||
private final Map<String, Map<String, Object>> argumentSchemas = new ConcurrentHashMap<>();
|
||||
|
||||
/*******************************************************************************
|
||||
** Transform a selected path's values in the service session.
|
||||
@@ -71,6 +77,8 @@ public class TransformRegistry
|
||||
register("TIME", (v, a) -> time(first(v)).toString());
|
||||
register("DATETIME", (v, a) -> date(first(v)).atTime(time(v.size() > 1 ? v.get(1) : a.getOrDefault("time", "0000")))
|
||||
.atZone(ZoneId.of(a.getOrDefault("timezone", "UTC"))).toInstant());
|
||||
register("DATEPART", (v, a) -> zoned(first(v), a).format(DateTimeFormatter.ofPattern("yyyyMMdd")));
|
||||
register("TIMEPART", (v, a) -> zoned(first(v), a).format(DateTimeFormatter.ofPattern("HHmmss")));
|
||||
register("DECIMAL", (v, a) -> new BigDecimal(first(v)).movePointLeft(Integer.parseInt(a.getOrDefault("scale", "0"))));
|
||||
register("CONCAT", (v, a) -> String.join(a.getOrDefault("separator", ""), v));
|
||||
register("SPLIT", (v, a) -> first(v).split(java.util.regex.Pattern.quote(a.getOrDefault("separator", ",")), -1)[Integer.parseInt(a.getOrDefault("index", "0"))]);
|
||||
@@ -111,11 +119,59 @@ public class TransformRegistry
|
||||
** Register or replace a named host transform.
|
||||
*******************************************************************************/
|
||||
public TransformRegistry register(String name, Transform transform)
|
||||
{
|
||||
return (register(name, transform, schema(name)));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Host transforms publish their argument contract alongside executable behavior.
|
||||
*******************************************************************************/
|
||||
public TransformRegistry register(String name, Transform transform, Map<String, Object> argumentSchema)
|
||||
{
|
||||
transforms.put(name.toUpperCase(Locale.ROOT), transform);
|
||||
argumentSchemas.put(name.toUpperCase(Locale.ROOT), Map.copyOf(argumentSchema));
|
||||
return (this);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Sorted snapshot for mapper clients and static validation.
|
||||
*******************************************************************************/
|
||||
public Map<String, Map<String, Object>> argumentSchemas()
|
||||
{
|
||||
return (java.util.Collections.unmodifiableMap(new java.util.TreeMap<>(argumentSchemas)));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Built-ins accept strings, matching the persisted transformArgs representation.
|
||||
*******************************************************************************/
|
||||
private static Map<String, Object> schema(String name)
|
||||
{
|
||||
List<String> keys = switch(name.toUpperCase(Locale.ROOT))
|
||||
{
|
||||
case "CONSTANT" -> List.of("value");
|
||||
case "LOOKUP" -> List.of("table", "key", "result");
|
||||
case "DATETIME" -> List.of("time", "timezone");
|
||||
case "DATEPART", "TIMEPART" -> List.of("timezone");
|
||||
case "DECIMAL" -> List.of("scale");
|
||||
case "CONCAT" -> List.of("separator");
|
||||
case "SPLIT" -> List.of("separator", "index");
|
||||
case "SUBSTRING" -> List.of("start", "end");
|
||||
case "UNIT" -> List.of("factor", "divisor", "scale");
|
||||
case "SEQUENCE" -> List.of("start");
|
||||
case "HL" -> List.of("code");
|
||||
default -> List.of();
|
||||
};
|
||||
Map<String, Object> properties = new java.util.TreeMap<>();
|
||||
keys.forEach(key -> properties.put(key, Map.of("type", "string")));
|
||||
List<String> required = switch(name.toUpperCase(Locale.ROOT))
|
||||
{
|
||||
case "CONSTANT" -> List.of("value");
|
||||
case "LOOKUP" -> List.of("table", "key");
|
||||
default -> List.of();
|
||||
};
|
||||
return (Map.of("type", "object", "properties", properties, "required", required, "additionalProperties", Map.of("type", "string")));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Execute a closed registry entry; unknown names fail explicitly.
|
||||
*******************************************************************************/
|
||||
@@ -146,6 +202,15 @@ public class TransformRegistry
|
||||
return (values.getFirst());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Outbound DATETIME halves: an instant, or a zone-less timestamp read as UTC, in the partner zone.
|
||||
*******************************************************************************/
|
||||
private static ZonedDateTime zoned(String value, Map<String, String> args)
|
||||
{
|
||||
Instant instant = value.endsWith("Z") || value.matches(".*[+-][0-9]{2}:[0-9]{2}$") ? OffsetDateTime.parse(value).toInstant() : LocalDateTime.parse(value).toInstant(ZoneOffset.UTC);
|
||||
return (instant.atZone(ZoneId.of(args.getOrDefault("timezone", "UTC"))));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Six-digit dates use the fixed 1950-2049 century window.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -27,7 +27,7 @@ import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
|
||||
/*******************************************************************************
|
||||
** RC.5 form filters; no dependency on later dependency-behavior metadata.
|
||||
** RC.6 form filters; no dependency on later dependency-behavior metadata.
|
||||
*******************************************************************************/
|
||||
public final class EdiContingencies
|
||||
{
|
||||
@@ -68,9 +68,16 @@ public final class EdiContingencies
|
||||
filter = new QQueryFilter().withCriteria("transactionSet", QCriteriaOperator.EQUALS, "${input.transactionSet}")
|
||||
.withCriteria("version", QCriteriaOperator.EQUALS, "${input.version}");
|
||||
}
|
||||
if(table.getName().equals("edi_interchange") && field.getName().equals("originalInterchangeId"))
|
||||
{
|
||||
filter = new QQueryFilter().withCriteria("partnerId", QCriteriaOperator.EQUALS, "${input.partnerId}")
|
||||
.withCriteria("agreementId", QCriteriaOperator.EQUALS, "${input.agreementId}").withCriteria("direction", QCriteriaOperator.EQUALS, "OUTBOUND");
|
||||
}
|
||||
if(filter != null)
|
||||
{
|
||||
field.withPossibleValueSourceFilter(filter);
|
||||
// Core appends its search criteria to the root; keep them ANDed with partner scope.
|
||||
field.withPossibleValueSourceFilter(filter.getBooleanOperator() == QQueryFilter.BooleanOperator.OR
|
||||
? new QQueryFilter().withSubFilter(filter) : filter);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.edi.metadata;
|
||||
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.help.QHelpContent;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.help.bundles.HelpBundles;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Applies help/qbit-edi.yaml (the QQQ help-bundle format) to the Edi app
|
||||
** and its edi_* tables: what each table is for and how its rows get created,
|
||||
** shown on the table screen and when it is empty.
|
||||
** Fallback for hosts without the helpContent table. Hosts with that table use
|
||||
** the core bundle registry and startup sync, including HOST and USER overrides.
|
||||
*******************************************************************************/
|
||||
public final class EdiHelp
|
||||
{
|
||||
public static final String RESOURCE = "/help/qbit-edi.yaml";
|
||||
public static final String SLOT = "help";
|
||||
public static final String APP_KEY = "edi";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private EdiHelp()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The bundle's entries by key, validated (fails startup if malformed).
|
||||
*******************************************************************************/
|
||||
public static Map<String, JsonNode> entries() throws QException
|
||||
{
|
||||
Map<String, JsonNode> entries = new LinkedHashMap<>();
|
||||
ObjectMapper mapper = new ObjectMapper();
|
||||
for(var entry : HelpBundles.load("help/qbit-edi.yaml", EdiHelp.class.getClassLoader()).getEntries())
|
||||
{
|
||||
entries.put(entry.getKey(), mapper.valueToTree(entry));
|
||||
}
|
||||
return (entries);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Apply the help to the app (by its configured name) and every edi_* table. A
|
||||
** host's own help on a table is kept (the bundle only fills the slot).
|
||||
*******************************************************************************/
|
||||
public static void apply(QInstance qInstance, String appName) throws QException
|
||||
{
|
||||
Map<String, JsonNode> entries = entries();
|
||||
for(QTableMetaData table : qInstance.getTables().values())
|
||||
{
|
||||
JsonNode entry = entries.get("table:" + table.getName() + ";slot:" + SLOT);
|
||||
if(entry != null && (table.getHelpContent() == null || !table.getHelpContent().containsKey(SLOT)))
|
||||
{
|
||||
table.withHelpContent(SLOT, content(entry));
|
||||
}
|
||||
}
|
||||
QAppMetaData app = qInstance.getApp(appName);
|
||||
JsonNode appEntry = entries.get("app:" + APP_KEY + ";slot:" + SLOT);
|
||||
if(app != null && appEntry != null && (app.getHelpContent() == null || !app.getHelpContent().containsKey(SLOT)))
|
||||
{
|
||||
app.withHelpContent(SLOT, content(appEntry));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Preserve the tooltip summary separately from the Markdown long help.
|
||||
*******************************************************************************/
|
||||
static QHelpContent content(JsonNode entry)
|
||||
{
|
||||
return (new QHelpContent().withSummary(entry.path("summary").asText().strip()).withContentAsMarkdown(entry.path("content").asText().strip()));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -69,7 +69,7 @@ public class EdiMetaDataProducer extends QBitComponentMetaDataProducer<MetaDataP
|
||||
}
|
||||
output.add(com.kingsrook.qbits.edi.app.EdiHome.app(getQBitConfig(), tables));
|
||||
com.kingsrook.qbits.edi.app.EdiHome.widgets().forEach(output::add);
|
||||
for(String name : java.util.List.of("edi.inbox.dead-letter", "edi.ack.overdue", "edi.ack.rejected"))
|
||||
for(String name : java.util.List.of("edi.inbox.dead-letter", "edi.ack.overdue", "edi.ack.rejected", "edi.send.failed", "edi.certificate.expiring", "edi.certificate.expired", "edi.mdn.overdue"))
|
||||
{
|
||||
output.add(new com.kingsrook.qqq.backend.core.alerts.QAlertTypeMetaData().withName(name).withLabel(name)
|
||||
.withSourceName("edi").withDefaultSeverity(com.kingsrook.qqq.backend.core.alerts.AlertSeverity.ERROR)
|
||||
@@ -80,7 +80,7 @@ public class EdiMetaDataProducer extends QBitComponentMetaDataProducer<MetaDataP
|
||||
output.add(new com.kingsrook.qqq.backend.core.model.metadata.QBackendMetaData().withName("edi_preview_download")
|
||||
.withBackendType(com.kingsrook.qbits.edi.storage.PreviewDownloadBackend.class));
|
||||
output.add(new QTableMetaData().withName("edi_preview_download").withLabel("Preview Download").withBackendName("edi_preview_download")
|
||||
.withPrimaryKeyField("id").withField(new com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData("id", com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType.INTEGER))
|
||||
.withPrimaryKeyField("id").withField(new com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData("id", com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType.INTEGER).withLabel("ID"))
|
||||
.withPermissionRules(new com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules().withPermissionBaseName("edi.map.preview")
|
||||
.withLevel(com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel.HAS_ACCESS_PERMISSION))
|
||||
.withoutCapabilities(com.kingsrook.qqq.backend.core.model.metadata.tables.Capability.TABLE_INSERT,
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.edi.metadata;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizerInterface;
|
||||
import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizers;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.QueryOrGetInputInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QSupplementalInstanceMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.utils.StringUtils;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Stable operator labels, including a PHI-safe fallback for documents without keys.
|
||||
*******************************************************************************/
|
||||
public class EdiRecordLabels implements TableCustomizerInterface, QSupplementalInstanceMetaData
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Instance enrichment runs after core has supplied default table formats.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getName()
|
||||
{
|
||||
return ("edi.recordLabels");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Keep search/security label fields, while letting the query customizer supply
|
||||
** the document label. RC.5 otherwise generates a fixed format from those fields.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void enrich(QInstance instance)
|
||||
{
|
||||
instance.getTable("edi_transaction").setRecordLabelFormat(null);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Use native formats except where the label depends on available business keys.
|
||||
*******************************************************************************/
|
||||
public static void configure(QTableMetaData table)
|
||||
{
|
||||
switch(table.getName())
|
||||
{
|
||||
case "edi_identity" -> table.withRecordLabelFormatAndFields("%s %s", "interchangeId", "applicationCode");
|
||||
case "edi_agreement" -> table.withRecordLabelFormatAndFields("%s %s", "partnerId", "usageIndicator");
|
||||
case "edi_agreement_document" -> table.withRecordLabelFormatAndFields("%s %s %s", "transactionSet", "direction", "version");
|
||||
case "edi_control_number" -> table.withRecordLabelFormatAndFields("%s %s", "level", "lastValue");
|
||||
case "edi_guide", "edi_map" -> table.withRecordLabelFormatAndFields("%s r%s", "name", "revision");
|
||||
case "edi_guide_rule" -> table.withRecordLabelFormatAndFields("%s: %s", "type", "message");
|
||||
case "edi_map_rule" -> table.withRecordLabelFormatAndFields("%s: %s", "ediPath", "qqqPath");
|
||||
case "edi_interchange" -> table.withRecordLabelFormatAndFields("%s ISA %s", "direction", "controlNumber");
|
||||
case "edi_functional_group" -> table.withRecordLabelFormatAndFields("GS %s", "controlNumber");
|
||||
case "edi_exception" -> table.withRecordLabelFormatAndFields("%s: %s", "type", "summary");
|
||||
case "edi_inbox" -> table.withRecordLabelFormatAndFields("%s %s", "transportType", "externalId");
|
||||
case "edi_outbound_message" -> table.withRecordLabelFormatAndFields("%s %s", "transportId", "status");
|
||||
case "edi_as2_mdn" -> table.withRecordLabelFormatAndFields("%s MDN %s", "direction", "originalMessageId");
|
||||
case "edi_outbound_request" -> table.withRecordLabelFormatAndFields("%s %s %s", "sourceTableName", "sourceRecordId", "status");
|
||||
case "edi_transaction_error" -> table.withRecordLabelFormatAndFields("%s: %s", "severity", "message");
|
||||
case "edi_event" -> table.withRecordLabelFormatAndFields("%s %s", "type", "at");
|
||||
case "edi_transaction" -> table.withRecordLabelFormat(null)
|
||||
.withRecordLabelFields("transactionSet", "businessKeyType", "businessKey", "controlNumber", "containsPhi")
|
||||
.withCustomizer(TableCustomizers.POST_QUERY_RECORD, new QCodeReference(EdiRecordLabels.class));
|
||||
default ->
|
||||
{
|
||||
// Tables with a natural name retain their base record-label field.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Queries, record reads and table value sources all use this same fallback.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<QRecord> postQuery(QueryOrGetInputInterface input, List<QRecord> records)
|
||||
{
|
||||
for(QRecord record : records)
|
||||
{
|
||||
String key = record.getValueString("businessKey");
|
||||
String type = record.getValueString("businessKeyType");
|
||||
String suffix = !Boolean.TRUE.equals(record.getValueBoolean("containsPhi")) && StringUtils.hasContent(key) && StringUtils.hasContent(type)
|
||||
? type + " " + key : "ST " + record.getValueString("controlNumber");
|
||||
record.setRecordLabel(record.getValueString("transactionSet") + " " + suffix);
|
||||
}
|
||||
return (records);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.edi.metadata;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QFieldSection;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.Tier;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Operator vocabulary and scan order shared by lists, records and child widgets.
|
||||
*******************************************************************************/
|
||||
public final class EdiTablePresentation
|
||||
{
|
||||
private static final Set<String> ACRONYMS = Set.of("ID", "PHI", "PII", "URL", "ISA", "GS", "ST", "AS2", "SFTP", "HTTPS", "HTTP", "MDN", "TLS", "TA1", "EDI", "UPC", "NPI", "QQQ", "JSON", "PEM", "SHA256", "SNIP", "VAN", "AK3", "AK4", "IK3", "IK4", "MIC");
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Explicit singular names; unknown tables must declare their vocabulary.
|
||||
*******************************************************************************/
|
||||
public static String label(String table)
|
||||
{
|
||||
return (switch(table)
|
||||
{
|
||||
case "edi_partner" -> "Trading Partner";
|
||||
case "edi_identity" -> "EDI Identity";
|
||||
case "edi_agreement" -> "Agreement";
|
||||
case "edi_agreement_document" -> "Agreement Document";
|
||||
case "edi_control_number" -> "Control Number";
|
||||
case "edi_transport" -> "Transport";
|
||||
case "edi_certificate" -> "Certificate";
|
||||
case "edi_guide" -> "Implementation Guide";
|
||||
case "edi_guide_segment" -> "Guide Segment";
|
||||
case "edi_guide_element" -> "Guide Element";
|
||||
case "edi_guide_rule" -> "Guide Rule";
|
||||
case "edi_code_list" -> "Code List";
|
||||
case "edi_code_list_value" -> "Code";
|
||||
case "edi_map" -> "Map";
|
||||
case "edi_map_rule" -> "Map Rule";
|
||||
case "edi_map_sample" -> "Map Sample";
|
||||
case "edi_interchange" -> "Interchange";
|
||||
case "edi_functional_group" -> "Functional Group";
|
||||
case "edi_transaction" -> "Document";
|
||||
case "edi_transaction_error" -> "Validation Error";
|
||||
case "edi_event" -> "Event";
|
||||
case "edi_exception" -> "Exception";
|
||||
case "edi_inbox" -> "Inbound Message";
|
||||
case "edi_outbound_request" -> "Outbound Request";
|
||||
case "edi_outbound_message" -> "Outbound Message";
|
||||
case "edi_as2_mdn" -> "AS2 MDN";
|
||||
case "edi_payload" -> "Payload";
|
||||
default -> throw new IllegalArgumentException("Missing EDI table label: " + table);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Child sections use the plain plural, without database relationship syntax.
|
||||
*******************************************************************************/
|
||||
public static String pluralLabel(String table)
|
||||
{
|
||||
return (switch(table)
|
||||
{
|
||||
case "edi_identity" -> "Identities";
|
||||
case "edi_guide" -> "Guides";
|
||||
case "edi_guide_segment" -> "Segments";
|
||||
case "edi_guide_element" -> "Elements";
|
||||
case "edi_guide_rule" -> "Rules";
|
||||
case "edi_map_sample" -> "Samples";
|
||||
default -> label(table) + "s";
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Keep field insertion order and record sections aligned, with dates last.
|
||||
*******************************************************************************/
|
||||
public static void apply(QTableMetaData table)
|
||||
{
|
||||
table.setLabel(label(table.getName()));
|
||||
List<String> summary = leadingFields(table.getName());
|
||||
Map<String, QFieldMetaData> ordered = new LinkedHashMap<>();
|
||||
summary.forEach(name -> ordered.put(name, table.getField(name)));
|
||||
table.getFields().forEach(ordered::putIfAbsent);
|
||||
List<String> details = new ArrayList<>();
|
||||
List<String> dates = new ArrayList<>();
|
||||
for(QFieldMetaData field : ordered.values())
|
||||
{
|
||||
field.setLabel(fieldLabel(field));
|
||||
if(summary.contains(field.getName()) || Boolean.TRUE.equals(field.getIsHidden()))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
(field.getType() == QFieldType.DATE_TIME || field.getType() == QFieldType.DATE ? dates : details).add(field.getName());
|
||||
}
|
||||
Map<String, QFieldMetaData> fields = new LinkedHashMap<>();
|
||||
for(List<String> section : List.of(summary, details, dates))
|
||||
{
|
||||
section.forEach(name -> fields.put(name, ordered.get(name)));
|
||||
}
|
||||
ordered.forEach(fields::putIfAbsent);
|
||||
table.setFields(fields);
|
||||
table.setSections(new ArrayList<>(List.of(
|
||||
new QFieldSection("identity", new QIcon("badge"), Tier.T1, new ArrayList<>(summary)).withLabel("Overview"),
|
||||
new QFieldSection("details", new QIcon("description"), Tier.T2, details).withLabel("Details"),
|
||||
new QFieldSection("dates", new QIcon("schedule"), Tier.T3, dates).withLabel("Dates"))));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** References name the linked record; external identifiers retain the ID suffix.
|
||||
*******************************************************************************/
|
||||
private static String fieldLabel(QFieldMetaData field)
|
||||
{
|
||||
String name = switch(field.getName())
|
||||
{
|
||||
case "transactionId" -> "document";
|
||||
case "ackTransactionId" -> "ackDocument";
|
||||
case "inboxId" -> "inboundMessage";
|
||||
case "assignedToUserId" -> "assignedTo";
|
||||
default -> field.getName();
|
||||
};
|
||||
if(name.endsWith("Id") && field.getPossibleValueSourceName() != null)
|
||||
{
|
||||
name = name.substring(0, name.length() - 2);
|
||||
}
|
||||
List<String> words = new ArrayList<>();
|
||||
for(String word : name.replaceAll("([a-z0-9])([A-Z])", "$1 $2").split(" "))
|
||||
{
|
||||
String upper = word.toUpperCase(Locale.ROOT);
|
||||
words.add(ACRONYMS.contains(upper) ? upper : Character.toUpperCase(word.charAt(0)) + word.substring(1));
|
||||
}
|
||||
return (String.join(" ", words));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The first columns answer what happened, to whom, and what needs attention.
|
||||
*******************************************************************************/
|
||||
private static List<String> leadingFields(String table)
|
||||
{
|
||||
return (switch(table)
|
||||
{
|
||||
case "edi_partner" -> List.of("name", "code", "status", "contactName", "contactEmail", "contactPhone", "containsPhi");
|
||||
case "edi_identity" -> List.of("interchangeId", "interchangeQualifier", "applicationCode", "partnerId", "standard", "isActive");
|
||||
case "edi_agreement" -> List.of("partnerId", "localIdentityId", "partnerIdentityId", "standard", "usageIndicator", "isActive", "inboundTransportId", "outboundTransportId");
|
||||
case "edi_agreement_document" -> List.of("transactionSet", "direction", "version", "agreementId", "isActive", "guideId", "mapId", "ackExpectation");
|
||||
case "edi_control_number" -> List.of("level", "lastValue", "agreementId", "localIdentityId", "minValue", "maxValue", "wrapAllowed");
|
||||
case "edi_transport" -> List.of("name", "type", "direction", "partnerId", "isActive");
|
||||
case "edi_certificate" -> List.of("alias", "status", "usage", "partnerId", "notAfter", "subject", "issuer");
|
||||
case "edi_guide" -> List.of("name", "revision", "status", "transactionSet", "version", "standard", "partnerId", "source");
|
||||
case "edi_guide_segment" -> List.of("segmentId", "name", "position", "sequence", "usage", "loopId", "guideId");
|
||||
case "edi_guide_element" -> List.of("name", "elementId", "position", "componentPosition", "usage", "dataType", "guideSegmentId");
|
||||
case "edi_guide_rule" -> List.of("type", "message", "severity", "positions", "guideId", "guideSegmentId");
|
||||
case "edi_code_list" -> List.of("name", "elementId", "source", "externalCodeSet");
|
||||
case "edi_code_list_value" -> List.of("code", "description", "isActive", "codeListId");
|
||||
case "edi_map" -> List.of("name", "revision", "status", "direction", "transactionSet", "version", "partnerId", "guideId", "tableName");
|
||||
case "edi_map_rule" -> List.of("sequence", "ediPath", "qqqPath", "transform", "required", "mapId");
|
||||
case "edi_map_sample" -> List.of("name", "mapId", "lastPreviewStatus", "lastPreviewAt");
|
||||
case "edi_interchange" -> List.of("direction", "partnerId", "controlNumber", "status", "interchangeDate", "transportId", "groupCount");
|
||||
case "edi_functional_group" -> List.of("controlNumber", "functionalIdCode", "version", "transactionCount", "ackStatus", "interchangeId");
|
||||
case "edi_transaction" -> List.of("transactionSet", "businessKeyType", "businessKey", "partnerId", "direction", "status", "ackStatus", "version", "interchangeId");
|
||||
case "edi_transaction_error" -> List.of("severity", "message", "segmentId", "segmentPosition", "elementPosition", "transactionId");
|
||||
case "edi_event" -> List.of("type", "at", "detail", "transactionId", "interchangeId", "inboxId");
|
||||
case "edi_exception" -> List.of("type", "status", "summary", "partnerId", "transactionId", "interchangeId", "assignedToUserId", "occurrences");
|
||||
case "edi_inbox" -> List.of("transportType", "externalId", "status", "partnerId", "transportId", "receivedAt", "attempts");
|
||||
case "edi_outbound_request" -> List.of("status", "agreementDocumentId", "sourceTableName", "sourceRecordId", "attempts", "nextAttemptAt");
|
||||
case "edi_outbound_message" -> List.of("transportId", "status", "partnerId", "agreementId", "attempts", "sentAt", "deliveredAt");
|
||||
case "edi_as2_mdn" -> List.of("direction", "originalMessageId", "disposition", "signed", "signatureValid", "partnerId", "transportId", "receivedAt");
|
||||
case "edi_payload" -> List.of("kind", "sizeBytes", "sha256");
|
||||
default -> throw new IllegalArgumentException("Missing EDI field order: " + table);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@ import java.util.List;
|
||||
import com.kingsrook.qbits.edi.EdiQBitConfig;
|
||||
import com.kingsrook.qbits.edi.model.EdiAgreement;
|
||||
import com.kingsrook.qbits.edi.model.EdiAgreementDocument;
|
||||
import com.kingsrook.qbits.edi.model.EdiAs2Mdn;
|
||||
import com.kingsrook.qbits.edi.model.EdiCertificate;
|
||||
import com.kingsrook.qbits.edi.model.EdiCodeList;
|
||||
import com.kingsrook.qbits.edi.model.EdiCodeListValue;
|
||||
@@ -50,24 +51,23 @@ import com.kingsrook.qbits.edi.model.EdiPayload;
|
||||
import com.kingsrook.qbits.edi.model.EdiTransaction;
|
||||
import com.kingsrook.qbits.edi.model.EdiTransactionError;
|
||||
import com.kingsrook.qbits.edi.model.EdiTransport;
|
||||
import com.kingsrook.qbits.edi.security.EdiClassifications;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.AiExposure;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.AiFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.AiTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.DataClassification;
|
||||
import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizers;
|
||||
import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.ChildRecordListRenderer;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecordEntity;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.audits.AuditLevel;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.audits.QAuditRules;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.joins.JoinOn;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.joins.JoinType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.joins.QJoinMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValue;
|
||||
@@ -119,6 +119,8 @@ public final class EdiTables
|
||||
.withCustomizer(TableCustomizers.PRE_UPDATE_RECORD, new QCodeReference(TransportValidation.class));
|
||||
tables.add(transport);
|
||||
QTableMetaData certificate = base(config, EdiCertificate.TABLE_NAME, EdiCertificate.class, "alias", false);
|
||||
certificate.withCustomizer(TableCustomizers.PRE_INSERT_RECORD, new QCodeReference(com.kingsrook.qbits.edi.as2.CertificateCustomizer.class))
|
||||
.withCustomizer(TableCustomizers.PRE_UPDATE_RECORD, new QCodeReference(com.kingsrook.qbits.edi.as2.CertificateCustomizer.class));
|
||||
tables.add(certificate);
|
||||
QTableMetaData guide = base(config, EdiGuide.TABLE_NAME, EdiGuide.class, "name", false);
|
||||
guide.withUniqueKey(new UniqueKey("name", "revision"));
|
||||
@@ -155,8 +157,9 @@ public final class EdiTables
|
||||
tables.add(base(config, "edi_map", EdiMap.class, "name", false));
|
||||
tables.add(base(config, "edi_map_rule", EdiMapRule.class, "id", false));
|
||||
tables.add(base(config, "edi_map_sample", EdiMapSample.class, "name", false));
|
||||
tables.add(base(config, "edi_outbound_request", EdiOutboundRequest.class, "id", true));
|
||||
tables.add(base(config, "edi_outbound_request", EdiOutboundRequest.class, "id", true).withUniqueKey(new UniqueKey("agreementDocumentId", "eventId")));
|
||||
tables.add(base(config, "edi_outbound_message", EdiOutboundMessage.class, "id", true));
|
||||
tables.add(base(config, EdiAs2Mdn.TABLE_NAME, EdiAs2Mdn.class, "originalMessageId", true));
|
||||
for(QTableMetaData table : tables)
|
||||
{
|
||||
List<String> guidePath = switch(table.getName())
|
||||
@@ -229,20 +232,12 @@ public final class EdiTables
|
||||
QTableMetaData table = new QTableMetaData().withName(name).withBackendName(config.getBackendName())
|
||||
.withPrimaryKeyField("id").withFieldsFromEntity(entity).withRecordLabelFormatAndFields("%s", label)
|
||||
.withPermissionRules(new QPermissionRules().withPermissionBaseName("edi." + name.substring(4)).withLevel(PermissionLevel.READ_INSERT_EDIT_DELETE_PERMISSIONS))
|
||||
.withAuditRules(new QAuditRules().withAuditLevel(readOnly ? AuditLevel.RECORD : AuditLevel.FIELD));
|
||||
.withAuditRules(new QAuditRules().withAuditLevel(readOnly || name.equals("edi_map_sample") ? AuditLevel.RECORD : AuditLevel.FIELD));
|
||||
QRecordEntity.registerTable(entity, table);
|
||||
table.setLabel(switch(name)
|
||||
{
|
||||
case "edi_partner" -> "Trading Partner";
|
||||
case "edi_transaction" -> "Document";
|
||||
case "edi_guide" -> "Implementation Guide";
|
||||
case "edi_transaction_error" -> "Validation Error";
|
||||
case "edi_inbox" -> "Inbound Message";
|
||||
default -> name.substring(4).replace('_', ' ');
|
||||
});
|
||||
AiTableMetaData.ofOrWithNew(table).withClassification(DataClassification.CONFIDENTIAL);
|
||||
if(List.of("edi_payload", "edi_certificate", "edi_inbox").contains(name))
|
||||
{
|
||||
table.withSupplementalMetaData(new AiTableMetaData().withExposure(AiExposure.NONE));
|
||||
AiTableMetaData.ofOrWithNew(table).withExposure(AiExposure.NONE);
|
||||
}
|
||||
if(name.equals("edi_exception"))
|
||||
{
|
||||
@@ -257,30 +252,19 @@ public final class EdiTables
|
||||
{
|
||||
table.withRecordSecurityLock(new RecordSecurityLock().withFieldName("partnerId").withSecurityKeyType("edi_partner"));
|
||||
}
|
||||
List<String> identity = new ArrayList<>(List.of("id"));
|
||||
List<String> details = new ArrayList<>();
|
||||
List<String> dates = new ArrayList<>();
|
||||
for(QFieldMetaData field : table.getFields().values())
|
||||
{
|
||||
String classification = List.of("phiBusinessKey", "fileName", "ciphertext", "expectedResultJson", "valueExcerpt").contains(field.getName()) ? "PHI"
|
||||
: List.of("contactName", "contactEmail", "contactPhone").contains(field.getName()) ? "PII" : "CONFIDENTIAL";
|
||||
field.withSupplementalMetaData(new EdiClassifications(classification));
|
||||
if(classification.equals("PHI"))
|
||||
DataClassification classification = List.of("phiBusinessKey", "fileName", "ciphertext", "expectedResultJson", "valueExcerpt").contains(field.getName()) ? DataClassification.PHI
|
||||
: List.of("contactName", "contactEmail", "contactPhone").contains(field.getName()) ? DataClassification.PII : DataClassification.CONFIDENTIAL;
|
||||
AiFieldMetaData metadata = new AiFieldMetaData().withClassification(classification);
|
||||
if(classification == DataClassification.PHI)
|
||||
{
|
||||
field.withSupplementalMetaData(new AiFieldMetaData().withExposure(AiExposure.NONE));
|
||||
}
|
||||
if(field.getType() == QFieldType.DATE_TIME)
|
||||
{
|
||||
dates.add(field.getName());
|
||||
}
|
||||
else if(!field.getName().equals("id"))
|
||||
{
|
||||
(field.getName().equals(label) || field.getName().endsWith("Id") ? identity : details).add(field.getName());
|
||||
metadata.withExposure(AiExposure.NONE);
|
||||
}
|
||||
field.withSupplementalMetaData(metadata);
|
||||
}
|
||||
table.withSection(new QFieldSection("identity", new QIcon("badge"), Tier.T1, identity))
|
||||
.withSection(new QFieldSection("details", new QIcon("description"), Tier.T2, details))
|
||||
.withSection(new QFieldSection("dates", new QIcon("schedule"), Tier.T3, dates));
|
||||
EdiTablePresentation.apply(table);
|
||||
EdiRecordLabels.configure(table);
|
||||
return (table);
|
||||
}
|
||||
|
||||
@@ -299,11 +283,11 @@ public final class EdiTables
|
||||
sources.add(enumSource("edi_on_duplicate", "REJECT_TA1", "IGNORE", "PROCESS"));
|
||||
sources.add(enumSource("edi_direction", "INBOUND", "OUTBOUND"));
|
||||
sources.add(enumSource("edi_transport_direction", "INBOUND", "OUTBOUND", "BOTH"));
|
||||
sources.add(enumSource("edi_ack_expectation", "NONE", "997", "999"));
|
||||
sources.add(enumSource("edi_ack_expectation", "NONE", "997", "999", "CONTRL"));
|
||||
sources.add(enumSource("edi_ack_level", "GROUP_ONLY", "ALL_TRANSACTIONS", "ERRORS_ONLY"));
|
||||
sources.add(enumSource("edi_batch_mode", "IMMEDIATE", "BATCH"));
|
||||
sources.add(enumSource("edi_outbound_trigger", "ESB_EVENT", "RECORD_CHANGE", "MANUAL"));
|
||||
sources.add(enumSource("edi_level", "ISA", "GS"));
|
||||
sources.add(enumSource("edi_level", "ISA", "GS", "UNB", "UNG", "UNH"));
|
||||
sources.add(enumSource("edi_transport_type", "AS2", "SFTP", "GATEWAY", "HTTPS", "EMAIL"));
|
||||
sources.add(enumSource("edi_sign_algorithm", "SHA256", "SHA384", "SHA512", "SHA1"));
|
||||
sources.add(enumSource("edi_encrypt_algorithm", "AES128_CBC", "AES256_CBC", "AES128_GCM", "AES256_GCM", "3DES"));
|
||||
@@ -323,10 +307,11 @@ public final class EdiTables
|
||||
sources.add(enumSource("edi_interchange_status", "RECEIVED", "ACCEPTED", "ACCEPTED_WITH_ERRORS", "REJECTED", "DUPLICATE", "ENVELOPED", "SENT", "DELIVERED", "FAILED"));
|
||||
sources.add(enumSource("edi_ack_status", "A", "E", "P", "R", "NONE", "PENDING"));
|
||||
sources.add(enumSource("edi_transaction_status", "RECEIVED", "VALID", "INVALID", "MAPPED", "APPLIED", "EXCEPTION", "QUEUED", "SENT", "ACKNOWLEDGED", "REJECTED", "IGNORED"));
|
||||
sources.add(enumSource("edi_event_type", "RECEIVED", "STORED", "PARSED", "VALIDATED", "ACK_SENT", "ACK_QUEUED", "MAPPED", "APPLIED", "ENVELOPED", "SENT", "MDN", "DELIVERED", "ACK_RECEIVED", "EXCEPTION", "REPROCESSED", "RESENT"));
|
||||
sources.add(enumSource("edi_event_type", "RECEIVED", "STORED", "PARSED", "VALIDATED", "ACK_SENT", "ACK_QUEUED", "MAPPED", "APPLIED", "ENVELOPED", "SENT", "MDN", "DELIVERED", "ACK_RECEIVED", "EXCEPTION", "REPROCESSED", "RESENT", "RAW_DOWNLOADED"));
|
||||
sources.add(enumSource("edi_exception_type", "UNKNOWN_PARTNER", "PARTNER_MISMATCH", "NOT_CONFIGURED", "DUPLICATE", "ENVELOPE", "VALIDATION", "MAPPING", "APPLY", "TRANSPORT", "REJECTED_ACK", "OVERDUE_ACK", "DEAD_LETTER"));
|
||||
sources.add(enumSource("edi_exception_status", "OPEN", "IN_PROGRESS", "RESOLVED", "IGNORED"));
|
||||
sources.add(enumSource("edi_inbox_status", "PENDING", "PROCESSING", "PROCESSED", "RETRY_WAIT", "DEAD_LETTER", "DUPLICATE", "IGNORED"));
|
||||
sources.add(enumSource("edi_as2_mdn_delivery_status", "NONE", "PENDING", "SENT", "FAILED"));
|
||||
sources.add(enumSource("edi_payload_kind", "INBOUND", "OUTBOUND", "AS2", "MDN", "SAMPLE"));
|
||||
sources.add(QPossibleValueSource.newForTable("edi_partner"));
|
||||
sources.add(QPossibleValueSource.newForTable("edi_identity"));
|
||||
@@ -355,6 +340,7 @@ public final class EdiTables
|
||||
sources.add(QPossibleValueSource.newForTable("edi_map_sample"));
|
||||
sources.add(QPossibleValueSource.newForTable("edi_outbound_request"));
|
||||
sources.add(QPossibleValueSource.newForTable("edi_outbound_message"));
|
||||
sources.add(QPossibleValueSource.newForTable("edi_as2_mdn"));
|
||||
return (sources);
|
||||
}
|
||||
|
||||
@@ -425,6 +411,8 @@ public final class EdiTables
|
||||
joins.add(new QJoinMetaData().withName("edi_agreement_document_agreement_document_id_edi_outbound_request").withType(JoinType.ONE_TO_MANY).withLeftTable("edi_agreement_document").withRightTable("edi_outbound_request").withJoinOn(new JoinOn("id", "agreementDocumentId")));
|
||||
joins.add(new QJoinMetaData().withName("edi_agreement_agreement_id_edi_outbound_message").withType(JoinType.ONE_TO_MANY).withLeftTable("edi_agreement").withRightTable("edi_outbound_message").withJoinOn(new JoinOn("id", "agreementId")));
|
||||
joins.add(new QJoinMetaData().withName("edi_outbound_message_outbound_message_id_edi_interchange").withType(JoinType.ONE_TO_MANY).withLeftTable("edi_outbound_message").withRightTable("edi_interchange").withJoinOn(new JoinOn("id", "outboundMessageId")));
|
||||
joins.add(new QJoinMetaData().withName("edi_outbound_message_outbound_message_id_edi_as2_mdn").withType(JoinType.ONE_TO_MANY).withLeftTable("edi_outbound_message").withRightTable("edi_as2_mdn").withJoinOn(new JoinOn("id", "outboundMessageId")));
|
||||
joins.add(new QJoinMetaData().withName("edi_inbox_inbox_id_edi_as2_mdn").withType(JoinType.ONE_TO_MANY).withLeftTable("edi_inbox").withRightTable("edi_as2_mdn").withJoinOn(new JoinOn("id", "inboxId")));
|
||||
joins.add(new QJoinMetaData().withName("edi_inbox_inbox_id_edi_event").withType(JoinType.ONE_TO_MANY).withLeftTable("edi_inbox").withRightTable("edi_event").withJoinOn(new JoinOn("id", "inboxId")));
|
||||
return (joins);
|
||||
}
|
||||
@@ -435,7 +423,24 @@ public final class EdiTables
|
||||
public static List<QWidgetMetaData> childWidgets()
|
||||
{
|
||||
return (joins().stream().map(join -> ChildRecordListRenderer.widgetMetaDataBuilder(join).withName(join.getName())
|
||||
.withLabel(joinLabel(join)).withCanAddChildRecord(false).getWidgetMetaData().withGridColumns(12)).toList());
|
||||
.withLabel(joinLabel(join)).withCanAddChildRecord(false)
|
||||
.withOrderBys(childOrder(join.getRightTable())).getWidgetMetaData()
|
||||
.withCodeReference(new QCodeReference(com.kingsrook.qbits.edi.app.EdiChildRecordListRenderer.class)).withGridColumns(12)).toList());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Activity is newest first; configuration children retain their logical order.
|
||||
*******************************************************************************/
|
||||
private static List<QFilterOrderBy> childOrder(String table)
|
||||
{
|
||||
return (switch(table)
|
||||
{
|
||||
case "edi_transaction", "edi_interchange", "edi_exception", "edi_inbox", "edi_event", "edi_outbound_request", "edi_outbound_message", "edi_as2_mdn" ->
|
||||
List.of(new QFilterOrderBy("createDate", false), new QFilterOrderBy("id", false));
|
||||
case "edi_guide_segment", "edi_map_rule" -> List.of(new QFilterOrderBy("sequence"), new QFilterOrderBy("id"));
|
||||
case "edi_guide_element" -> List.of(new QFilterOrderBy("position"), new QFilterOrderBy("componentPosition"), new QFilterOrderBy("id"));
|
||||
default -> List.of(new QFilterOrderBy("id"));
|
||||
});
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -443,7 +448,16 @@ public final class EdiTables
|
||||
*******************************************************************************/
|
||||
private static String joinLabel(QJoinMetaData join)
|
||||
{
|
||||
String role = join.getJoinOns().getFirst().getRightField().replaceAll("([a-z])([A-Z])", "$1 $2").replace(" Id", "");
|
||||
return (join.getRightTable().substring(4).replace('_', ' ') + " (" + role.toLowerCase(java.util.Locale.ROOT) + ")");
|
||||
return (switch(join.getJoinOns().getFirst().getRightField())
|
||||
{
|
||||
case "localIdentityId" -> join.getRightTable().equals("edi_agreement") ? "Local Agreements" : "Control Numbers";
|
||||
case "partnerIdentityId" -> "Partner Agreements";
|
||||
case "inboundTransportId" -> "Inbound Agreements";
|
||||
case "outboundTransportId" -> "Outbound Agreements";
|
||||
case "localCertificateId" -> "Local Certificate Transports";
|
||||
case "partnerCertificateId" -> "Partner Certificate Transports";
|
||||
case "partnerTlsCertificateId" -> "Partner TLS Transports";
|
||||
default -> EdiTablePresentation.pluralLabel(join.getRightTable());
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -58,6 +58,29 @@ public class TransportValidation implements TableCustomizerInterface
|
||||
record.addError(new BadInputStatusMessage("type is required"));
|
||||
}
|
||||
}
|
||||
for(QRecord record : records)
|
||||
{
|
||||
QRecord merged = new QRecord();
|
||||
oldRecords.orElse(List.of()).stream().filter(old -> Objects.equals(old.getValue("id"), record.getValue("id")))
|
||||
.findFirst().ifPresent(old -> merged.getValues().putAll(old.getValues()));
|
||||
merged.getValues().putAll(record.getValues());
|
||||
try
|
||||
{
|
||||
List<String> errors = new java.util.ArrayList<>();
|
||||
com.kingsrook.qbits.edi.transport.EdiTransportPluginsMetaData.current().get(merged.getValueString("type"))
|
||||
.validate(merged.toEntity(com.kingsrook.qbits.edi.model.EdiTransport.class), errors);
|
||||
if(merged.getValueInteger("maxAttempts") != null && merged.getValueInteger("maxAttempts") < 1)
|
||||
{
|
||||
errors.add("maxAttempts must be positive");
|
||||
}
|
||||
errors.forEach(error -> record.addError(new BadInputStatusMessage(error)));
|
||||
record.setValue("weakCrypto", "AS2".equals(merged.getValueString("type")) && com.kingsrook.qbits.edi.as2.As2Crypto.isWeak(merged.getValueString("signAlgorithm"), merged.getValueString("encryptAlgorithm")));
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
record.addError(new BadInputStatusMessage("Invalid transport configuration"));
|
||||
}
|
||||
}
|
||||
return (records);
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,15 @@ public class EdiAgreement extends QRecordEntity
|
||||
{
|
||||
public static final String TABLE_NAME = "edi_agreement";
|
||||
|
||||
@QField(backendName = "release_character", maxLength = 255, defaultValue = "?")
|
||||
private String releaseCharacter;
|
||||
|
||||
@QField(backendName = "decimal_mark", maxLength = 255, defaultValue = ".")
|
||||
private String decimalMark;
|
||||
|
||||
@QField(backendName = "syntax_identifier", maxLength = 255, defaultValue = "UNOC")
|
||||
private String syntaxIdentifier;
|
||||
|
||||
@QField(backendName = "id", isPrimaryKey = true, isEditable = false)
|
||||
private Integer id;
|
||||
|
||||
@@ -839,4 +848,79 @@ public class EdiAgreement extends QRecordEntity
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Get releaseCharacter.
|
||||
*******************************************************************************/
|
||||
public String getReleaseCharacter()
|
||||
{
|
||||
return (releaseCharacter);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Set releaseCharacter.
|
||||
*******************************************************************************/
|
||||
public void setReleaseCharacter(String releaseCharacter)
|
||||
{
|
||||
this.releaseCharacter = releaseCharacter;
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Set releaseCharacter fluently.
|
||||
*******************************************************************************/
|
||||
public EdiAgreement withReleaseCharacter(String releaseCharacter)
|
||||
{
|
||||
this.releaseCharacter = releaseCharacter;
|
||||
return (this);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Get decimalMark.
|
||||
*******************************************************************************/
|
||||
public String getDecimalMark()
|
||||
{
|
||||
return (decimalMark);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Set decimalMark.
|
||||
*******************************************************************************/
|
||||
public void setDecimalMark(String decimalMark)
|
||||
{
|
||||
this.decimalMark = decimalMark;
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Set decimalMark fluently.
|
||||
*******************************************************************************/
|
||||
public EdiAgreement withDecimalMark(String decimalMark)
|
||||
{
|
||||
this.decimalMark = decimalMark;
|
||||
return (this);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Get syntaxIdentifier.
|
||||
*******************************************************************************/
|
||||
public String getSyntaxIdentifier()
|
||||
{
|
||||
return (syntaxIdentifier);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Set syntaxIdentifier.
|
||||
*******************************************************************************/
|
||||
public void setSyntaxIdentifier(String syntaxIdentifier)
|
||||
{
|
||||
this.syntaxIdentifier = syntaxIdentifier;
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Set syntaxIdentifier fluently.
|
||||
*******************************************************************************/
|
||||
public EdiAgreement withSyntaxIdentifier(String syntaxIdentifier)
|
||||
{
|
||||
this.syntaxIdentifier = syntaxIdentifier;
|
||||
return (this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,718 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.edi.model;
|
||||
|
||||
|
||||
import java.time.Instant;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QField;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecordEntity;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Persistent edi_as2_mdn record: an AS2 receipt we sent or received. Rows are
|
||||
** written by the qbit only; the delivery columns queue asynchronous receipts.
|
||||
*******************************************************************************/
|
||||
public class EdiAs2Mdn extends QRecordEntity
|
||||
{
|
||||
public static final String TABLE_NAME = "edi_as2_mdn";
|
||||
|
||||
@QField(backendName = "id", isPrimaryKey = true, isEditable = false)
|
||||
private Integer id;
|
||||
|
||||
@QField(backendName = "direction", maxLength = 20, possibleValueSourceName = "edi_direction")
|
||||
private String direction;
|
||||
|
||||
@QField(backendName = "message_id", maxLength = 255)
|
||||
private String messageId;
|
||||
|
||||
@QField(backendName = "original_message_id", maxLength = 255)
|
||||
private String originalMessageId;
|
||||
|
||||
@QField(backendName = "disposition", maxLength = 255)
|
||||
private String disposition;
|
||||
|
||||
@QField(backendName = "mic_algorithm", maxLength = 50)
|
||||
private String micAlgorithm;
|
||||
|
||||
@QField(backendName = "mic_value", maxLength = 255)
|
||||
private String micValue;
|
||||
|
||||
@QField(backendName = "signed")
|
||||
private Boolean signed;
|
||||
|
||||
@QField(backendName = "signature_valid")
|
||||
private Boolean signatureValid;
|
||||
|
||||
@QField(backendName = "payload_ref", maxLength = 255)
|
||||
private String payloadRef;
|
||||
|
||||
@QField(backendName = "received_at")
|
||||
private Instant receivedAt;
|
||||
|
||||
@QField(backendName = "transport_id", possibleValueSourceName = "edi_transport")
|
||||
private Integer transportId;
|
||||
|
||||
@QField(backendName = "partner_id", possibleValueSourceName = "edi_partner")
|
||||
private Integer partnerId;
|
||||
|
||||
@QField(backendName = "outbound_message_id", possibleValueSourceName = "edi_outbound_message")
|
||||
private Integer outboundMessageId;
|
||||
|
||||
@QField(backendName = "inbox_id", possibleValueSourceName = "edi_inbox")
|
||||
private Integer inboxId;
|
||||
|
||||
@QField(backendName = "delivery_url", maxLength = 1000)
|
||||
private String deliveryUrl;
|
||||
|
||||
@QField(backendName = "delivery_status", maxLength = 20, possibleValueSourceName = "edi_as2_mdn_delivery_status")
|
||||
private String deliveryStatus;
|
||||
|
||||
@QField(backendName = "delivery_attempts")
|
||||
private Integer deliveryAttempts;
|
||||
|
||||
@QField(backendName = "next_attempt_at")
|
||||
private Instant nextAttemptAt;
|
||||
|
||||
@QField(backendName = "delivered_at")
|
||||
private Instant deliveredAt;
|
||||
|
||||
@QField(backendName = "create_date", isEditable = false)
|
||||
private Instant createDate;
|
||||
|
||||
@QField(backendName = "modify_date", isEditable = false)
|
||||
private Instant modifyDate;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Get id.
|
||||
*******************************************************************************/
|
||||
public Integer getId()
|
||||
{
|
||||
return (id);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Set id.
|
||||
*******************************************************************************/
|
||||
public void setId(Integer id)
|
||||
{
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Set id fluently.
|
||||
*******************************************************************************/
|
||||
public EdiAs2Mdn withId(Integer id)
|
||||
{
|
||||
this.id = id;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Get direction.
|
||||
*******************************************************************************/
|
||||
public String getDirection()
|
||||
{
|
||||
return (direction);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Set direction.
|
||||
*******************************************************************************/
|
||||
public void setDirection(String direction)
|
||||
{
|
||||
this.direction = direction;
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Set direction fluently.
|
||||
*******************************************************************************/
|
||||
public EdiAs2Mdn withDirection(String direction)
|
||||
{
|
||||
this.direction = direction;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Get messageId.
|
||||
*******************************************************************************/
|
||||
public String getMessageId()
|
||||
{
|
||||
return (messageId);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Set messageId.
|
||||
*******************************************************************************/
|
||||
public void setMessageId(String messageId)
|
||||
{
|
||||
this.messageId = messageId;
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Set messageId fluently.
|
||||
*******************************************************************************/
|
||||
public EdiAs2Mdn withMessageId(String messageId)
|
||||
{
|
||||
this.messageId = messageId;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Get originalMessageId.
|
||||
*******************************************************************************/
|
||||
public String getOriginalMessageId()
|
||||
{
|
||||
return (originalMessageId);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Set originalMessageId.
|
||||
*******************************************************************************/
|
||||
public void setOriginalMessageId(String originalMessageId)
|
||||
{
|
||||
this.originalMessageId = originalMessageId;
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Set originalMessageId fluently.
|
||||
*******************************************************************************/
|
||||
public EdiAs2Mdn withOriginalMessageId(String originalMessageId)
|
||||
{
|
||||
this.originalMessageId = originalMessageId;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Get disposition.
|
||||
*******************************************************************************/
|
||||
public String getDisposition()
|
||||
{
|
||||
return (disposition);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Set disposition.
|
||||
*******************************************************************************/
|
||||
public void setDisposition(String disposition)
|
||||
{
|
||||
this.disposition = disposition;
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Set disposition fluently.
|
||||
*******************************************************************************/
|
||||
public EdiAs2Mdn withDisposition(String disposition)
|
||||
{
|
||||
this.disposition = disposition;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Get micAlgorithm.
|
||||
*******************************************************************************/
|
||||
public String getMicAlgorithm()
|
||||
{
|
||||
return (micAlgorithm);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Set micAlgorithm.
|
||||
*******************************************************************************/
|
||||
public void setMicAlgorithm(String micAlgorithm)
|
||||
{
|
||||
this.micAlgorithm = micAlgorithm;
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Set micAlgorithm fluently.
|
||||
*******************************************************************************/
|
||||
public EdiAs2Mdn withMicAlgorithm(String micAlgorithm)
|
||||
{
|
||||
this.micAlgorithm = micAlgorithm;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Get micValue.
|
||||
*******************************************************************************/
|
||||
public String getMicValue()
|
||||
{
|
||||
return (micValue);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Set micValue.
|
||||
*******************************************************************************/
|
||||
public void setMicValue(String micValue)
|
||||
{
|
||||
this.micValue = micValue;
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Set micValue fluently.
|
||||
*******************************************************************************/
|
||||
public EdiAs2Mdn withMicValue(String micValue)
|
||||
{
|
||||
this.micValue = micValue;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Get signed.
|
||||
*******************************************************************************/
|
||||
public Boolean getSigned()
|
||||
{
|
||||
return (signed);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Set signed.
|
||||
*******************************************************************************/
|
||||
public void setSigned(Boolean signed)
|
||||
{
|
||||
this.signed = signed;
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Set signed fluently.
|
||||
*******************************************************************************/
|
||||
public EdiAs2Mdn withSigned(Boolean signed)
|
||||
{
|
||||
this.signed = signed;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Get signatureValid.
|
||||
*******************************************************************************/
|
||||
public Boolean getSignatureValid()
|
||||
{
|
||||
return (signatureValid);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Set signatureValid.
|
||||
*******************************************************************************/
|
||||
public void setSignatureValid(Boolean signatureValid)
|
||||
{
|
||||
this.signatureValid = signatureValid;
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Set signatureValid fluently.
|
||||
*******************************************************************************/
|
||||
public EdiAs2Mdn withSignatureValid(Boolean signatureValid)
|
||||
{
|
||||
this.signatureValid = signatureValid;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Get payloadRef.
|
||||
*******************************************************************************/
|
||||
public String getPayloadRef()
|
||||
{
|
||||
return (payloadRef);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Set payloadRef.
|
||||
*******************************************************************************/
|
||||
public void setPayloadRef(String payloadRef)
|
||||
{
|
||||
this.payloadRef = payloadRef;
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Set payloadRef fluently.
|
||||
*******************************************************************************/
|
||||
public EdiAs2Mdn withPayloadRef(String payloadRef)
|
||||
{
|
||||
this.payloadRef = payloadRef;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Get receivedAt.
|
||||
*******************************************************************************/
|
||||
public Instant getReceivedAt()
|
||||
{
|
||||
return (receivedAt);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Set receivedAt.
|
||||
*******************************************************************************/
|
||||
public void setReceivedAt(Instant receivedAt)
|
||||
{
|
||||
this.receivedAt = receivedAt;
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Set receivedAt fluently.
|
||||
*******************************************************************************/
|
||||
public EdiAs2Mdn withReceivedAt(Instant receivedAt)
|
||||
{
|
||||
this.receivedAt = receivedAt;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Get transportId.
|
||||
*******************************************************************************/
|
||||
public Integer getTransportId()
|
||||
{
|
||||
return (transportId);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Set transportId.
|
||||
*******************************************************************************/
|
||||
public void setTransportId(Integer transportId)
|
||||
{
|
||||
this.transportId = transportId;
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Set transportId fluently.
|
||||
*******************************************************************************/
|
||||
public EdiAs2Mdn withTransportId(Integer transportId)
|
||||
{
|
||||
this.transportId = transportId;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Get partnerId.
|
||||
*******************************************************************************/
|
||||
public Integer getPartnerId()
|
||||
{
|
||||
return (partnerId);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Set partnerId.
|
||||
*******************************************************************************/
|
||||
public void setPartnerId(Integer partnerId)
|
||||
{
|
||||
this.partnerId = partnerId;
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Set partnerId fluently.
|
||||
*******************************************************************************/
|
||||
public EdiAs2Mdn withPartnerId(Integer partnerId)
|
||||
{
|
||||
this.partnerId = partnerId;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Get outboundMessageId.
|
||||
*******************************************************************************/
|
||||
public Integer getOutboundMessageId()
|
||||
{
|
||||
return (outboundMessageId);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Set outboundMessageId.
|
||||
*******************************************************************************/
|
||||
public void setOutboundMessageId(Integer outboundMessageId)
|
||||
{
|
||||
this.outboundMessageId = outboundMessageId;
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Set outboundMessageId fluently.
|
||||
*******************************************************************************/
|
||||
public EdiAs2Mdn withOutboundMessageId(Integer outboundMessageId)
|
||||
{
|
||||
this.outboundMessageId = outboundMessageId;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Get inboxId.
|
||||
*******************************************************************************/
|
||||
public Integer getInboxId()
|
||||
{
|
||||
return (inboxId);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Set inboxId.
|
||||
*******************************************************************************/
|
||||
public void setInboxId(Integer inboxId)
|
||||
{
|
||||
this.inboxId = inboxId;
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Set inboxId fluently.
|
||||
*******************************************************************************/
|
||||
public EdiAs2Mdn withInboxId(Integer inboxId)
|
||||
{
|
||||
this.inboxId = inboxId;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Get deliveryUrl.
|
||||
*******************************************************************************/
|
||||
public String getDeliveryUrl()
|
||||
{
|
||||
return (deliveryUrl);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Set deliveryUrl.
|
||||
*******************************************************************************/
|
||||
public void setDeliveryUrl(String deliveryUrl)
|
||||
{
|
||||
this.deliveryUrl = deliveryUrl;
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Set deliveryUrl fluently.
|
||||
*******************************************************************************/
|
||||
public EdiAs2Mdn withDeliveryUrl(String deliveryUrl)
|
||||
{
|
||||
this.deliveryUrl = deliveryUrl;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Get deliveryStatus.
|
||||
*******************************************************************************/
|
||||
public String getDeliveryStatus()
|
||||
{
|
||||
return (deliveryStatus);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Set deliveryStatus.
|
||||
*******************************************************************************/
|
||||
public void setDeliveryStatus(String deliveryStatus)
|
||||
{
|
||||
this.deliveryStatus = deliveryStatus;
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Set deliveryStatus fluently.
|
||||
*******************************************************************************/
|
||||
public EdiAs2Mdn withDeliveryStatus(String deliveryStatus)
|
||||
{
|
||||
this.deliveryStatus = deliveryStatus;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Get deliveryAttempts.
|
||||
*******************************************************************************/
|
||||
public Integer getDeliveryAttempts()
|
||||
{
|
||||
return (deliveryAttempts);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Set deliveryAttempts.
|
||||
*******************************************************************************/
|
||||
public void setDeliveryAttempts(Integer deliveryAttempts)
|
||||
{
|
||||
this.deliveryAttempts = deliveryAttempts;
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Set deliveryAttempts fluently.
|
||||
*******************************************************************************/
|
||||
public EdiAs2Mdn withDeliveryAttempts(Integer deliveryAttempts)
|
||||
{
|
||||
this.deliveryAttempts = deliveryAttempts;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Get nextAttemptAt.
|
||||
*******************************************************************************/
|
||||
public Instant getNextAttemptAt()
|
||||
{
|
||||
return (nextAttemptAt);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Set nextAttemptAt.
|
||||
*******************************************************************************/
|
||||
public void setNextAttemptAt(Instant nextAttemptAt)
|
||||
{
|
||||
this.nextAttemptAt = nextAttemptAt;
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Set nextAttemptAt fluently.
|
||||
*******************************************************************************/
|
||||
public EdiAs2Mdn withNextAttemptAt(Instant nextAttemptAt)
|
||||
{
|
||||
this.nextAttemptAt = nextAttemptAt;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Get deliveredAt.
|
||||
*******************************************************************************/
|
||||
public Instant getDeliveredAt()
|
||||
{
|
||||
return (deliveredAt);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Set deliveredAt.
|
||||
*******************************************************************************/
|
||||
public void setDeliveredAt(Instant deliveredAt)
|
||||
{
|
||||
this.deliveredAt = deliveredAt;
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Set deliveredAt fluently.
|
||||
*******************************************************************************/
|
||||
public EdiAs2Mdn withDeliveredAt(Instant deliveredAt)
|
||||
{
|
||||
this.deliveredAt = deliveredAt;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Get createDate.
|
||||
*******************************************************************************/
|
||||
public Instant getCreateDate()
|
||||
{
|
||||
return (createDate);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Set createDate.
|
||||
*******************************************************************************/
|
||||
public void setCreateDate(Instant createDate)
|
||||
{
|
||||
this.createDate = createDate;
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Set createDate fluently.
|
||||
*******************************************************************************/
|
||||
public EdiAs2Mdn withCreateDate(Instant createDate)
|
||||
{
|
||||
this.createDate = createDate;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Get modifyDate.
|
||||
*******************************************************************************/
|
||||
public Instant getModifyDate()
|
||||
{
|
||||
return (modifyDate);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Set modifyDate.
|
||||
*******************************************************************************/
|
||||
public void setModifyDate(Instant modifyDate)
|
||||
{
|
||||
this.modifyDate = modifyDate;
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Set modifyDate fluently.
|
||||
*******************************************************************************/
|
||||
public EdiAs2Mdn withModifyDate(Instant modifyDate)
|
||||
{
|
||||
this.modifyDate = modifyDate;
|
||||
return (this);
|
||||
}
|
||||
}
|
||||
@@ -54,7 +54,7 @@ public class EdiException extends QRecordEntity
|
||||
@QField(backendName = "transaction_id", possibleValueSourceName = "edi_transaction")
|
||||
private Integer transactionId;
|
||||
|
||||
@QField(backendName = "outbound_message_id")
|
||||
@QField(backendName = "outbound_message_id", possibleValueSourceName = "edi_outbound_message")
|
||||
private Integer outboundMessageId;
|
||||
|
||||
@QField(backendName = "summary", maxLength = 255)
|
||||
|
||||
@@ -31,6 +31,34 @@ import com.kingsrook.qqq.backend.core.model.data.QRecordEntity;
|
||||
*******************************************************************************/
|
||||
public class EdiInterchange extends QRecordEntity
|
||||
{
|
||||
@QField(backendName = "original_interchange_id", possibleValueSourceName = "edi_interchange")
|
||||
private Integer originalInterchangeId;
|
||||
|
||||
/*******************************************************************************
|
||||
** Original interchange for re-enveloped resends.
|
||||
*******************************************************************************/
|
||||
public Integer getOriginalInterchangeId()
|
||||
{
|
||||
return (originalInterchangeId);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Set the original interchange reference.
|
||||
*******************************************************************************/
|
||||
public void setOriginalInterchangeId(Integer value)
|
||||
{
|
||||
originalInterchangeId = value;
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent original interchange reference.
|
||||
*******************************************************************************/
|
||||
public EdiInterchange withOriginalInterchangeId(Integer value)
|
||||
{
|
||||
originalInterchangeId = value;
|
||||
return (this);
|
||||
}
|
||||
|
||||
public static final String TABLE_NAME = "edi_interchange";
|
||||
|
||||
@QField(backendName = "inbound_position")
|
||||
@@ -54,7 +82,7 @@ public class EdiInterchange extends QRecordEntity
|
||||
@QField(backendName = "inbox_id", possibleValueSourceName = "edi_inbox")
|
||||
private Integer inboxId;
|
||||
|
||||
@QField(backendName = "outbound_message_id")
|
||||
@QField(backendName = "outbound_message_id", possibleValueSourceName = "edi_outbound_message")
|
||||
private Integer outboundMessageId;
|
||||
|
||||
@QField(backendName = "standard", maxLength = 255, possibleValueSourceName = "edi_standard")
|
||||
|
||||
@@ -39,6 +39,9 @@ public class EdiOutboundMessage extends QRecordEntity
|
||||
@QField(backendName = "transport_message_id", maxLength = 255)
|
||||
private String transportMessageId;
|
||||
|
||||
@QField(backendName = "transport_mic", maxLength = 255)
|
||||
private String transportMic;
|
||||
|
||||
@QField(backendName = "id", isPrimaryKey = true, isEditable = false)
|
||||
private Integer id;
|
||||
|
||||
@@ -97,6 +100,31 @@ public class EdiOutboundMessage extends QRecordEntity
|
||||
private Instant modifyDate;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Get transportMic.
|
||||
*******************************************************************************/
|
||||
public String getTransportMic()
|
||||
{
|
||||
return (transportMic);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Set transportMic.
|
||||
*******************************************************************************/
|
||||
public void setTransportMic(String value)
|
||||
{
|
||||
transportMic = value;
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Configure transportMic.
|
||||
*******************************************************************************/
|
||||
public EdiOutboundMessage withTransportMic(String value)
|
||||
{
|
||||
transportMic = value;
|
||||
return (this);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Get id.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -93,7 +93,7 @@ public class EdiTransaction extends QRecordEntity
|
||||
@QField(backendName = "guide_revision")
|
||||
private Integer guideRevision;
|
||||
|
||||
@QField(backendName = "map_id")
|
||||
@QField(backendName = "map_id", possibleValueSourceName = "edi_map")
|
||||
private Integer mapId;
|
||||
|
||||
@QField(backendName = "map_revision")
|
||||
|
||||
@@ -81,6 +81,9 @@ public class EdiTransport extends QRecordEntity
|
||||
@QField(backendName = "mdn_signed")
|
||||
private Boolean mdnSigned;
|
||||
|
||||
@QField(backendName = "weak_crypto", isEditable = false)
|
||||
private Boolean weakCrypto;
|
||||
|
||||
@QField(backendName = "local_certificate_id", possibleValueSourceName = "edi_certificate")
|
||||
private Integer localCertificateId;
|
||||
|
||||
@@ -1583,4 +1586,29 @@ public class EdiTransport extends QRecordEntity
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Get weakCrypto: true when SHA1 or 3DES is configured. Set by the qbit on save.
|
||||
*******************************************************************************/
|
||||
public Boolean getWeakCrypto()
|
||||
{
|
||||
return (weakCrypto);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Set weakCrypto.
|
||||
*******************************************************************************/
|
||||
public void setWeakCrypto(Boolean weakCrypto)
|
||||
{
|
||||
this.weakCrypto = weakCrypto;
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Set weakCrypto fluently.
|
||||
*******************************************************************************/
|
||||
public EdiTransport withWeakCrypto(Boolean weakCrypto)
|
||||
{
|
||||
this.weakCrypto = weakCrypto;
|
||||
return (this);
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,9 @@ import com.kingsrook.qbits.edi.map.MapPreview;
|
||||
import com.kingsrook.qbits.edi.storage.EdiRecords;
|
||||
import com.kingsrook.qbits.edi.support.SystemWrites;
|
||||
import com.kingsrook.qqq.backend.core.actions.processes.BackendStep;
|
||||
import com.kingsrook.qqq.backend.core.alerts.NotificationAudience;
|
||||
import com.kingsrook.qqq.backend.core.alerts.NotificationSignal;
|
||||
import com.kingsrook.qqq.backend.core.alerts.QAlerts;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLine;
|
||||
@@ -75,12 +78,17 @@ public final class EdiProcesses
|
||||
{
|
||||
result.add(action(table, "preview", "Preview Map", "edi.map.preview"));
|
||||
}
|
||||
for(String table : List.of("edi_inbox", "edi_interchange"))
|
||||
{
|
||||
result.add(action(table, "download", "Download Raw Document", "edi.raw.download").withMaxInputRecords(1));
|
||||
}
|
||||
result.add(action("edi_exception", "assign", "Assign", "edi.exception.assign"));
|
||||
result.add(action("edi_exception", "ignore", "Ignore", "edi.exception.ignore"));
|
||||
result.add(action("edi_exception", "resolve", "Mark Resolved", "edi.exception.resolve"));
|
||||
result.add(action("edi_guide", "override", "Create Partner Override", "edi.guide.override"));
|
||||
result.add(action("edi_guide", "activate", "Activate Guide", "edi.guide.activate"));
|
||||
result.add(action("edi_map", "activate", "Activate Map", "edi.map.activate"));
|
||||
result.add(InstallSampleMapsProcess.process());
|
||||
QProcessMetaData scheduled = new QProcessMetaData().withName("edi_inbox_worker").withLabel("Process Inbox").withIsHidden(true)
|
||||
.withPermissionRules(permission("edi.admin")).withStep(new QBackendStepMetaData().withName("run").withCode(new QCodeReference(WorkerStep.class)));
|
||||
if(config.getSchedulerName() != null)
|
||||
@@ -88,6 +96,23 @@ public final class EdiProcesses
|
||||
scheduled.withSchedule(new QScheduleMetaData().withSchedulerName(config.getSchedulerName()).withRepeatSeconds(15).withInitialDelaySeconds(15));
|
||||
}
|
||||
result.add(scheduled);
|
||||
for(String table : List.of("edi_outbound_message", "edi_interchange"))
|
||||
{
|
||||
result.add(action(table, "resend", "Resend", "edi.resend"));
|
||||
result.add(action(table, "new", "Resend As New", "edi.resend"));
|
||||
result.add(action(table, "cancel", "Cancel", "edi.cancel"));
|
||||
}
|
||||
result.add(action("edi_transport", "test", "Test Connection", "edi.transport.test"));
|
||||
result.add(action("edi_certificate", "promote", "Promote Certificate", "edi.certificate.promote"));
|
||||
result.add(action("edi_certificate", "export", "Export Public Certificate", "edi.certificate.read"));
|
||||
for(String table : config.getSendAsEdiTables())
|
||||
{
|
||||
result.add(action(table, "send", "Send As EDI", "edi.send"));
|
||||
}
|
||||
for(String name : List.of("edi_outbound_worker", "edi_send_worker", "edi_as2_mdn_sender", "edi_ack_monitor", "edi_outbound_enqueue"))
|
||||
{
|
||||
result.add(OutboundProcesses.process(name, config, name.equals("edi_outbound_enqueue") ? null : name.equals("edi_ack_monitor") ? 300 : 15, null));
|
||||
}
|
||||
return (result);
|
||||
}
|
||||
|
||||
@@ -113,6 +138,8 @@ public final class EdiProcesses
|
||||
.withName(table + "_" + operation).withLabel(label).withTableName(table).withIsHidden(true).withMinInputRecords(1).withPermissionRules(permission(permission));
|
||||
if(operation.equals("preview"))
|
||||
{
|
||||
process.getPermissionRules().withCustomPermissionChecker(
|
||||
com.kingsrook.qqq.backend.core.actions.permissions.UseOtherPermissionNameCustomPermissionChecker.build("edi.map.preview"));
|
||||
if(table.equals("edi_map"))
|
||||
{
|
||||
process.getFrontendStep("review").withFormField(new QFieldMetaData("sampleId", QFieldType.INTEGER)
|
||||
@@ -120,6 +147,19 @@ public final class EdiProcesses
|
||||
}
|
||||
process.getFrontendStep("review").withFormField(new QFieldMetaData("sampleUpload", QFieldType.BLOB).withLabel("Upload synthetic sample (optional)")
|
||||
.withFieldAdornment(com.kingsrook.qqq.backend.core.model.metadata.fields.AdornmentType.FileUploadAdornment.newFieldAdornment()));
|
||||
}
|
||||
if(operation.equals("preview") || operation.equals("export"))
|
||||
{
|
||||
process.getFrontendStep("result").withComponent(new com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendComponentMetaData()
|
||||
.withType(com.kingsrook.qqq.backend.core.model.metadata.processes.QComponentType.DOWNLOAD_FORM));
|
||||
for(String field : List.of("downloadFileName", "storageTableName", "storageReference"))
|
||||
{
|
||||
process.getBackendStep("execute").getOutputMetaData().withField(new QFieldMetaData(field, QFieldType.STRING));
|
||||
process.getFrontendStep("result").withViewField(new QFieldMetaData(field, QFieldType.STRING));
|
||||
}
|
||||
}
|
||||
if(operation.equals("download"))
|
||||
{
|
||||
process.getFrontendStep("result").withComponent(new com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendComponentMetaData()
|
||||
.withType(com.kingsrook.qqq.backend.core.model.metadata.processes.QComponentType.DOWNLOAD_FORM));
|
||||
for(String field : List.of("downloadFileName", "storageTableName", "storageReference"))
|
||||
@@ -153,7 +193,7 @@ public final class EdiProcesses
|
||||
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output)
|
||||
{
|
||||
count += input.getRecords().size();
|
||||
output.getRecords().addAll(input.getRecords());
|
||||
input.getRecords().forEach(record -> output.getRecords().add(new QRecord().withValue("id", record.getValue("id"))));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -189,7 +229,30 @@ public final class EdiProcesses
|
||||
var query = new com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput(table).withFilter(EdiRecords.equal("id", selected.getValue("id"))).withShouldFetchHeavyFields(true);
|
||||
query.setPermissionEnforcement(com.kingsrook.qqq.backend.core.actions.permissions.PermissionEnforcement.ENFORCE);
|
||||
QRecord row = new com.kingsrook.qqq.backend.core.actions.tables.QueryAction().execute(query).getRecords().getFirst();
|
||||
if(operation.equals("preview"))
|
||||
if(operation.equals("promote") && !"NEXT".equals(row.getValueString("status")))
|
||||
{
|
||||
lines.add(new ProcessSummaryLine(Status.WARNING, 1, "only a NEXT certificate can be promoted"));
|
||||
continue;
|
||||
}
|
||||
if(operation.equals("export"))
|
||||
{
|
||||
lines.add(new ProcessSummaryLine(Status.OK, 1, "public certificate exported"));
|
||||
}
|
||||
else if(operation.equals("download"))
|
||||
{
|
||||
if(!QContext.getQSession().hasPermission("edi.raw.download"))
|
||||
{
|
||||
throw new QException("Raw document download is not authorized");
|
||||
}
|
||||
var storage = new com.kingsrook.qqq.backend.core.model.actions.tables.storage.StorageInput(com.kingsrook.qbits.edi.storage.PreviewDownloadBackend.TABLE)
|
||||
.withReference("raw:" + table + ":" + row.getValueInteger("id"));
|
||||
com.kingsrook.qqq.backend.core.actions.processes.ProcessFileDownload.registerStorage(storage);
|
||||
output.addValue("downloadFileName", "edi-document.edi");
|
||||
output.addValue("storageTableName", storage.getTableName());
|
||||
output.addValue("storageReference", storage.getReference());
|
||||
lines.add(new ProcessSummaryLine(Status.OK, 1, "Document ready for authorized download"));
|
||||
}
|
||||
else if(operation.equals("preview"))
|
||||
{
|
||||
QRecord map = table.equals("edi_map") ? row : EdiRecords.query("edi_map", EdiRecords.equal("id", row.getValue("mapId"))).getFirst();
|
||||
List<QRecord> samples = table.equals("edi_map_sample") ? List.of(row) : EdiRecords.query("edi_map_sample", EdiRecords.equal("mapId", row.getValue("id")));
|
||||
@@ -236,18 +299,48 @@ public final class EdiProcesses
|
||||
{
|
||||
reprocess(table, row, Boolean.TRUE.equals(input.getValueBoolean("useCurrentRevisions")));
|
||||
}
|
||||
else if(List.of("resend", "new", "cancel").contains(operation))
|
||||
{
|
||||
com.kingsrook.qbits.edi.flow.OutboundActions.execute(table, row, operation);
|
||||
}
|
||||
else if(operation.equals("send"))
|
||||
{
|
||||
com.kingsrook.qbits.edi.flow.OutboundEnqueue.enqueue(table, row, "MANUAL", null);
|
||||
}
|
||||
else if(operation.equals("test"))
|
||||
{
|
||||
var test = com.kingsrook.qbits.edi.transport.EdiTransportPluginsMetaData.current().get(row.getValueString("type"))
|
||||
.test(row.toEntity(com.kingsrook.qbits.edi.model.EdiTransport.class));
|
||||
lines.add(new ProcessSummaryLine(Boolean.TRUE.equals(test.success()) ? Status.OK : Status.ERROR, 1, test.summary()));
|
||||
}
|
||||
else if(operation.equals("promote"))
|
||||
{
|
||||
new com.kingsrook.qbits.edi.as2.CertificateService().promote(row);
|
||||
}
|
||||
else if(List.of("assign", "ignore", "resolve").contains(operation))
|
||||
{
|
||||
QRecord update = new QRecord().withValue("id", row.getValue("id")).withValue("status", operation.equals("assign") ? "IN_PROGRESS" : operation.equals("ignore") ? "IGNORED" : "RESOLVED");
|
||||
if(operation.equals("assign"))
|
||||
{
|
||||
update.withValue("assignedToUserId", input.getValue("assignedToUserId"));
|
||||
String assignee = input.getValueString("assignedToUserId");
|
||||
if(assignee == null || assignee.isBlank())
|
||||
{
|
||||
throw new IllegalArgumentException("An assignee is required");
|
||||
}
|
||||
update.withValue("assignedToUserId", assignee);
|
||||
}
|
||||
else
|
||||
{
|
||||
update.withValue("resolvedBy", user).withValue("resolvedAt", EdiRuntime.current().config().getClock().instant());
|
||||
}
|
||||
SystemWrites.update(table, update);
|
||||
if(operation.equals("assign"))
|
||||
{
|
||||
QAlerts.notify(new NotificationSignal("EDI exception assigned")
|
||||
.withBody("An EDI exception has been assigned to you.")
|
||||
.withAudience(NotificationAudience.user(update.getValueString("assignedToUserId")))
|
||||
.withSourceName("edi").withSourceTableName(table).withSourceRecordId(row.getValueString("id")));
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -257,18 +350,19 @@ public final class EdiProcesses
|
||||
}));
|
||||
lines.add(new ProcessSummaryLine(Status.OK, 1, "record processed"));
|
||||
}
|
||||
if(operation.equals("preview"))
|
||||
if(operation.equals("preview") || operation.equals("export"))
|
||||
{
|
||||
String json = new com.fasterxml.jackson.databind.ObjectMapper().findAndRegisterModules().writeValueAsString(lines);
|
||||
boolean export = operation.equals("export");
|
||||
String content = export ? row.getValueString("certificatePem") : new com.fasterxml.jackson.databind.ObjectMapper().findAndRegisterModules().writeValueAsString(lines);
|
||||
var ref = SystemWrites.call(() -> EdiRuntime.current().payloads().put(com.kingsrook.qbits.edi.storage.PayloadKind.SAMPLE,
|
||||
new java.io.ByteArrayInputStream(json.getBytes(java.nio.charset.StandardCharsets.UTF_8))));
|
||||
new java.io.ByteArrayInputStream(content.getBytes(java.nio.charset.StandardCharsets.UTF_8))));
|
||||
var storage = new com.kingsrook.qqq.backend.core.model.actions.tables.storage.StorageInput(com.kingsrook.qbits.edi.storage.PreviewDownloadBackend.TABLE).withReference(ref.value());
|
||||
com.kingsrook.qqq.backend.core.actions.processes.ProcessFileDownload.registerStorage(storage);
|
||||
output.addValue("downloadFileName", "edi-map-preview.json");
|
||||
output.addValue("downloadFileName", export ? row.getValueString("alias").replaceAll("[^A-Za-z0-9._-]", "-") + ".pem" : "edi-map-preview.json");
|
||||
output.addValue("storageTableName", storage.getTableName());
|
||||
output.addValue("storageReference", ref.value());
|
||||
}
|
||||
output.getRecords().add(selected);
|
||||
output.getRecords().add(new QRecord().withValue("id", selected.getValue("id")));
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.edi.processes;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.edi.map.SampleMaps;
|
||||
import com.kingsrook.qbits.edi.support.SystemWrites;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLine;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLineInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.Status;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractExtractStep;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractLoadStep;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractTransformStep;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.ProcessSummaryProviderInterface;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.StreamedETLWithFrontendProcess;
|
||||
|
||||
/*******************************************************************************
|
||||
** "Install sample maps": a table action on edi_map that adds the bundled
|
||||
** synthetic sample maps whose name does not exist yet. Standard streamed ETL:
|
||||
** Review lists what will be added, Result what was added. Insert-only by map
|
||||
** name, so existing maps and edits are never touched. Installed maps are
|
||||
** drafts with no partner and no agreement document, so nothing changes how a
|
||||
** document is processed until an administrator activates and binds one.
|
||||
*******************************************************************************/
|
||||
public final class InstallSampleMapsProcess
|
||||
{
|
||||
public static final String NAME = "edi_map_installSamples";
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private InstallSampleMapsProcess()
|
||||
{
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** The process: a table-level action (no records need to be selected).
|
||||
*******************************************************************************/
|
||||
public static QProcessMetaData process()
|
||||
{
|
||||
Map<String, Serializable> values = new LinkedHashMap<>();
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_SOURCE_TABLE, "edi_map");
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_DESTINATION_TABLE, "edi_map");
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_SUPPORTS_FULL_VALIDATION, false);
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_PREVIEW_MESSAGE,
|
||||
"These sample maps will be added as drafts, with no partner and no agreement document. Maps that already exist are not changed.");
|
||||
QProcessMetaData process = StreamedETLWithFrontendProcess.defineProcessMetaData(Extract.class, Transform.class, Load.class, values);
|
||||
process.setName(NAME);
|
||||
process.setLabel("Install sample maps");
|
||||
process.setIcon(new QIcon("playlist_add"));
|
||||
process.setTableName("edi_map");
|
||||
process.setIsHidden(true);
|
||||
process.setMinInputRecords(0);
|
||||
process.setPermissionRules(new QPermissionRules().withPermissionBaseName("edi.admin").withLevel(PermissionLevel.HAS_ACCESS_PERMISSION));
|
||||
return (process);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** The samples still to install, as the processing user.
|
||||
*******************************************************************************/
|
||||
static List<String> missing() throws QException
|
||||
{
|
||||
return (SystemWrites.call(SampleMaps::missing));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** A summary line with the standard four wordings.
|
||||
*******************************************************************************/
|
||||
private static ProcessSummaryLine line(Status status, String future, String futurePlural, String past, String pastPlural)
|
||||
{
|
||||
ProcessSummaryLine line = new ProcessSummaryLine(status, 0, null);
|
||||
line.setSingularFutureMessage(future);
|
||||
line.setPluralFutureMessage(futurePlural);
|
||||
line.setSingularPastMessage(past);
|
||||
line.setPluralPastMessage(pastPlural);
|
||||
return (line);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Extract: the missing samples, computed (no query of selected records).
|
||||
*******************************************************************************/
|
||||
public static class Extract extends AbstractExtractStep
|
||||
{
|
||||
/*******************************************************************************
|
||||
** How many samples will be added.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Integer doCount(RunBackendStepInput input) throws QException
|
||||
{
|
||||
return (missing().size());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** One record per missing sample.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
for(String resource : missing())
|
||||
{
|
||||
QRecord map = SystemWrites.call(() -> SampleMaps.mapRecord(resource));
|
||||
getRecordPipe().addRecord(map.withValue("sample", resource));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Transform: forward the records and count them by standard and direction.
|
||||
*******************************************************************************/
|
||||
public static class Transform extends AbstractTransformStep implements ProcessSummaryProviderInterface
|
||||
{
|
||||
private final Map<String, ProcessSummaryLine> byKind = new LinkedHashMap<>();
|
||||
|
||||
/*******************************************************************************
|
||||
** Count by standard and direction.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
for(QRecord record : input.getRecords())
|
||||
{
|
||||
String kind = record.getValueString("standard") + " " + record.getValueString("direction").toLowerCase();
|
||||
byKind.computeIfAbsent(kind, k -> line(Status.OK, k + " sample map will be added as a draft", k + " sample maps will be added as drafts",
|
||||
k + " sample map was added as a draft", k + " sample maps were added as drafts")).incrementCount();
|
||||
output.getRecords().add(record);
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Standard Review summary.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
|
||||
{
|
||||
ArrayList<ProcessSummaryLineInterface> lines = new ArrayList<>();
|
||||
byKind.values().forEach(line -> line.addSelfToListIfAnyCount(lines));
|
||||
if(lines.isEmpty())
|
||||
{
|
||||
lines.add(new ProcessSummaryLine(Status.INFO, null, "Every sample map is already installed."));
|
||||
}
|
||||
return (lines);
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Load: insert each map and its rules as the processing user.
|
||||
*******************************************************************************/
|
||||
public static class Load extends AbstractLoadStep implements ProcessSummaryProviderInterface
|
||||
{
|
||||
private final ProcessSummaryLine added = line(Status.OK, "map will be added", "maps will be added", "map was added as a draft", "maps were added as drafts");
|
||||
|
||||
/*******************************************************************************
|
||||
** One transaction per map, so a map never lands without its rules.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
for(QRecord record : input.getRecords())
|
||||
{
|
||||
Integer id = SystemWrites.call(() -> SystemWrites.transaction(() -> SampleMaps.install(record.getValueString("sample"))));
|
||||
if(id != null)
|
||||
{
|
||||
added.incrementCountAndAddPrimaryKey(id);
|
||||
output.getRecords().add(new QRecord().withValue("id", id));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Standard Result summary.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
|
||||
{
|
||||
ArrayList<ProcessSummaryLineInterface> lines = new ArrayList<>();
|
||||
added.addSelfToListIfAnyCount(lines);
|
||||
return (lines);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.edi.processes;
|
||||
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.edi.EdiQBitConfig;
|
||||
import com.kingsrook.qbits.edi.EdiRuntime;
|
||||
import com.kingsrook.qbits.edi.flow.AckMonitor;
|
||||
import com.kingsrook.qbits.edi.flow.InboundSink;
|
||||
import com.kingsrook.qbits.edi.flow.OutboundEnqueue;
|
||||
import com.kingsrook.qbits.edi.flow.OutboundWorker;
|
||||
import com.kingsrook.qbits.edi.flow.SendWorker;
|
||||
import com.kingsrook.qbits.edi.model.EdiTransport;
|
||||
import com.kingsrook.qbits.edi.storage.EdiRecords;
|
||||
import com.kingsrook.qbits.edi.support.SystemWrites;
|
||||
import com.kingsrook.qbits.edi.transport.EdiTransportPluginsMetaData;
|
||||
import com.kingsrook.qqq.backend.core.actions.processes.BackendStep;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.scheduleing.QScheduleMetaData;
|
||||
|
||||
/*******************************************************************************
|
||||
** Scheduled workers plus an explicit process boundary for broker-owned ESB wiring.
|
||||
*******************************************************************************/
|
||||
public class OutboundProcesses implements BackendStep
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Common worker metadata is hidden from application navigation.
|
||||
*******************************************************************************/
|
||||
public static QProcessMetaData process(String name, EdiQBitConfig config, Integer seconds, String cron)
|
||||
{
|
||||
QProcessMetaData process = new QProcessMetaData().withName(name).withLabel(name).withIsHidden(true)
|
||||
.withPermissionRules(new QPermissionRules().withPermissionBaseName("edi.admin").withLevel(PermissionLevel.HAS_ACCESS_PERMISSION))
|
||||
.withStep(new QBackendStepMetaData().withName("execute").withCode(new QCodeReference(OutboundProcesses.class)));
|
||||
if(config.getSchedulerName() != null && (seconds != null || cron != null))
|
||||
{
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
// core validation refuses a cron time zone on a repeat schedule //
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
process.withSchedule(new QScheduleMetaData().withSchedulerName(config.getSchedulerName()).withRepeatSeconds(seconds).withCronExpression(cron)
|
||||
.withCronTimeZoneId(cron == null ? null : "UTC"));
|
||||
}
|
||||
return (process);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Install data-driven schedules after the database is seeded and before startup.
|
||||
*******************************************************************************/
|
||||
public static void installSchedules() throws QException
|
||||
{
|
||||
SystemWrites.call(() ->
|
||||
{
|
||||
var config = EdiRuntime.current().config();
|
||||
for(QRecord document : EdiRecords.query("edi_agreement_document", EdiRecords.equal("direction", "OUTBOUND")
|
||||
.withCriteria("isActive", QCriteriaOperator.EQUALS, true).withCriteria("batchMode", QCriteriaOperator.EQUALS, "BATCH")))
|
||||
{
|
||||
if(document.getValueString("batchSchedule") == null)
|
||||
{
|
||||
throw new QException("BATCH requires batchSchedule");
|
||||
}
|
||||
install(process("edi_batch_" + document.getValue("id"), config, null, document.getValueString("batchSchedule")));
|
||||
}
|
||||
for(QRecord transport : EdiRecords.query("edi_transport", EdiRecords.equal("type", "SFTP").withCriteria("isActive", QCriteriaOperator.EQUALS, true)
|
||||
.withCriteria("sftpPollCron", QCriteriaOperator.IS_NOT_BLANK)))
|
||||
{
|
||||
install(process("edi_poll_" + transport.getValue("id"), config, null, transport.getValueString("sftpPollCron")));
|
||||
}
|
||||
return (null);
|
||||
});
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** ESB passes sourceTableName/sourceRecordId/eventId; hosts own broker bindings.
|
||||
*******************************************************************************/
|
||||
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
String name = input.getProcessName();
|
||||
if(name.equals("edi_outbound_worker"))
|
||||
{
|
||||
new OutboundWorker().runOnce();
|
||||
}
|
||||
else if(name.equals("edi_send_worker"))
|
||||
{
|
||||
new SendWorker().runOnce();
|
||||
}
|
||||
else if(name.equals("edi_as2_mdn_sender"))
|
||||
{
|
||||
new com.kingsrook.qbits.edi.as2.As2MdnSender().runOnce();
|
||||
}
|
||||
else if(name.equals("edi_ack_monitor"))
|
||||
{
|
||||
new AckMonitor().runOnce();
|
||||
}
|
||||
else if(name.startsWith("edi_batch_"))
|
||||
{
|
||||
new OutboundWorker().runDocument(Integer.valueOf(name.substring("edi_batch_".length())));
|
||||
}
|
||||
else if(name.startsWith("edi_poll_"))
|
||||
{
|
||||
SystemWrites.call(() ->
|
||||
{
|
||||
EdiTransport transport = OutboundWorker.row("edi_transport", Integer.valueOf(name.substring("edi_poll_".length()))).toEntity(EdiTransport.class);
|
||||
if(Boolean.TRUE.equals(transport.getIsActive()))
|
||||
{
|
||||
EdiTransportPluginsMetaData.current().get(transport.getType()).poll(transport, new InboundSink(EdiRuntime.current().payloads()));
|
||||
}
|
||||
return (null);
|
||||
});
|
||||
}
|
||||
else if(name.equals("edi_outbound_enqueue"))
|
||||
{
|
||||
String table = input.getValueString("sourceTableName");
|
||||
String key = QContext.getQInstance().getTable(table).getPrimaryKeyField();
|
||||
OutboundEnqueue.enqueue(table, new QRecord().withValue(key, input.getValue("sourceRecordId")), "ESB_EVENT", input.getValueString("eventId"));
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Repeated startup initialization updates existing data-driven schedule metadata.
|
||||
*******************************************************************************/
|
||||
private static void install(QProcessMetaData process)
|
||||
{
|
||||
if(QContext.getQInstance().getProcesses().containsKey(process.getName()))
|
||||
{
|
||||
QContext.getQInstance().getProcess(process.getName()).setSchedule(process.getSchedule());
|
||||
}
|
||||
else
|
||||
{
|
||||
QContext.getQInstance().addProcess(process);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -96,9 +96,19 @@ public class RevisionActions
|
||||
}
|
||||
for(QRecord rule : EdiRecords.query("edi_map_rule", EdiRecords.equal("mapId", row.getValue("id"))))
|
||||
{
|
||||
String[] path = rule.getValueString("qqqPath").split("\\.");
|
||||
var destination = path.length == 1 ? target : QContext.getQInstance().getTable(target.getAssociationByName(path[0]).orElseThrow().getAssociatedTableName());
|
||||
if(path.length > 2 || destination.getField(path[path.length - 1]) == null)
|
||||
String qqqPath = rule.getValueString("qqqPath");
|
||||
if((qqqPath == null || qqqPath.isBlank()) && "OUTBOUND".equals(row.getValueString("direction"))
|
||||
&& List.of("CONSTANT", "SEQUENCE", "HL").contains(rule.getValueString("transform")))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
String[] path = Objects.requireNonNull(qqqPath, "Map field path is required").split("\\.");
|
||||
var destination = target;
|
||||
for(int i = 0; i < path.length - 1; i++)
|
||||
{
|
||||
destination = QContext.getQInstance().getTable(destination.getAssociationByName(path[i]).orElseThrow().getAssociatedTableName());
|
||||
}
|
||||
if(!"OUTBOUND".equals(row.getValueString("direction")) && path.length > 2 || destination.getField(path[path.length - 1]) == null)
|
||||
{
|
||||
throw new IllegalArgumentException("Invalid map target path");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.edi.security;
|
||||
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.WeakHashMap;
|
||||
import org.apache.logging.log4j.Level;
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Marker;
|
||||
import org.apache.logging.log4j.core.LogEvent;
|
||||
import org.apache.logging.log4j.core.Logger;
|
||||
import org.apache.logging.log4j.core.LoggerContext;
|
||||
import org.apache.logging.log4j.core.config.Configuration;
|
||||
import org.apache.logging.log4j.core.filter.AbstractFilter;
|
||||
import org.apache.logging.log4j.message.Message;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** STAND-IN(core=QRun-IO/qqq#1147, removeBy=4.1.0-RC.7).
|
||||
** RC.6 logs query criteria verbatim. Suppress synchronous diagnostic logging only
|
||||
** while PHI mapping/application runs; value-free EDI events remain in the database.
|
||||
** This does not replace host masking or protection of host code outside this scope.
|
||||
*******************************************************************************/
|
||||
public final class PhiLogScope implements AutoCloseable
|
||||
{
|
||||
private static final ThreadLocal<Integer> DEPTH = ThreadLocal.withInitial(() -> 0);
|
||||
private static final Map<Configuration, Boolean> INSTALLED = new WeakHashMap<>();
|
||||
private final int previous;
|
||||
|
||||
/*******************************************************************************
|
||||
** Protect nested PHI work without changing logger levels or unrelated threads.
|
||||
*******************************************************************************/
|
||||
private PhiLogScope(boolean enabled)
|
||||
{
|
||||
previous = DEPTH.get();
|
||||
if(enabled)
|
||||
{
|
||||
install();
|
||||
DEPTH.set(previous + 1);
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Open a scope around code that may pass clinical values to RC.6 core actions.
|
||||
*******************************************************************************/
|
||||
public static PhiLogScope open(boolean enabled)
|
||||
{
|
||||
return (new PhiLogScope(enabled));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Install once per logger configuration, including after a host reconfiguration.
|
||||
*******************************************************************************/
|
||||
private static synchronized void install()
|
||||
{
|
||||
if(!(LogManager.getContext(false) instanceof LoggerContext context))
|
||||
{
|
||||
throw new IllegalStateException("PHI processing requires the RC.6 Log4j protection bridge");
|
||||
}
|
||||
Configuration configuration = context.getConfiguration();
|
||||
if(!INSTALLED.containsKey(configuration))
|
||||
{
|
||||
ScopeFilter filter = new ScopeFilter();
|
||||
filter.start();
|
||||
configuration.addFilter(filter);
|
||||
INSTALLED.put(configuration, true);
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Always restore nesting, including failed mapping or target transactions.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void close()
|
||||
{
|
||||
if(previous == 0)
|
||||
{
|
||||
DEPTH.remove();
|
||||
}
|
||||
else
|
||||
{
|
||||
DEPTH.set(previous);
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Pre-event filter prevents sensitive diagnostics from reaching any appender.
|
||||
*******************************************************************************/
|
||||
private static class ScopeFilter extends AbstractFilter
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Decide before Log4j creates or formats a data-bearing event.
|
||||
*******************************************************************************/
|
||||
private Result decision()
|
||||
{
|
||||
return (DEPTH.get() > 0 ? Result.DENY : Result.NEUTRAL);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** QLogger uses structured Log4j messages.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Result filter(Logger logger, Level level, Marker marker, Message message, Throwable error)
|
||||
{
|
||||
return (decision());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Object diagnostics from third-party mapping or target customizers.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Result filter(Logger logger, Level level, Marker marker, Object message, Throwable error)
|
||||
{
|
||||
return (decision());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Parameterized diagnostics from registered host extensions.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Result filter(Logger logger, Level level, Marker marker, String message, Object... parameters)
|
||||
{
|
||||
return (decision());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Synchronous event filtering for configurations invoking this filter later.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Result filter(LogEvent event)
|
||||
{
|
||||
return (decision());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -60,6 +60,17 @@ public final class EdiRecords
|
||||
return (new QueryAction().execute(new QueryInput(table).withFilter(filter).withShouldFetchHeavyFields(true).withTransaction(com.kingsrook.qqq.backend.core.context.QContext.getQBackendTransaction())).getRecords());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** UI-facing reads enforce permissions even when the host disables default enforcement.
|
||||
*******************************************************************************/
|
||||
public static List<QRecord> queryEnforced(String table, QQueryFilter filter) throws QException
|
||||
{
|
||||
QueryInput input = new QueryInput(table).withFilter(filter).withShouldFetchHeavyFields(true)
|
||||
.withTransaction(com.kingsrook.qqq.backend.core.context.QContext.getQBackendTransaction());
|
||||
input.setPermissionEnforcement(com.kingsrook.qqq.backend.core.actions.permissions.PermissionEnforcement.ENFORCE);
|
||||
return (new QueryAction().execute(input).getRecords());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Match one field by exact value.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -71,6 +71,10 @@ public class PreviewDownloadBackend implements QBackendModuleInterface, QStorage
|
||||
{
|
||||
throw new QException("Preview download is not authorized for this session");
|
||||
}
|
||||
if(input.getReference().startsWith("raw:"))
|
||||
{
|
||||
return (RawDocumentDownload.open(input.getReference()));
|
||||
}
|
||||
return (SystemWrites.call(() -> EdiRuntime.current().payloads().open(new PayloadRef(input.getReference(), null, 0))));
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.edi.storage;
|
||||
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.edi.EdiRuntime;
|
||||
import com.kingsrook.qbits.edi.support.SystemWrites;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionEnforcement;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Stream an authorized archived document, with a value-free access event per download.
|
||||
*******************************************************************************/
|
||||
public final class RawDocumentDownload
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Recheck permission and partner locks on every access, including registered URLs.
|
||||
*******************************************************************************/
|
||||
public static InputStream open(String reference) throws QException
|
||||
{
|
||||
String[] parts = reference.split(":");
|
||||
if(parts.length != 3 || !parts[0].equals("raw") || !List.of("edi_inbox", "edi_interchange").contains(parts[1])
|
||||
|| QContext.getQSession() == null || !QContext.getQSession().hasPermission("edi.raw.download"))
|
||||
{
|
||||
throw new QException("Raw document download is not authorized");
|
||||
}
|
||||
Integer id;
|
||||
try
|
||||
{
|
||||
id = Integer.valueOf(parts[2]);
|
||||
}
|
||||
catch(NumberFormatException e)
|
||||
{
|
||||
throw new QException("Invalid raw document reference");
|
||||
}
|
||||
QueryInput query = new QueryInput(parts[1]).withFilter(EdiRecords.equal("id", id));
|
||||
query.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
|
||||
List<QRecord> rows = new QueryAction().execute(query).getRecords();
|
||||
if(rows.size() != 1 || rows.getFirst().getValueString("payloadRef") == null)
|
||||
{
|
||||
throw new QException("Raw document is unavailable");
|
||||
}
|
||||
QRecord row = rows.getFirst();
|
||||
String user = QContext.getQSession().getUser() == null ? "system" : java.util.Objects.toString(QContext.getQSession().getUser().getIdReference(), "system");
|
||||
return (SystemWrites.call(() ->
|
||||
{
|
||||
SystemWrites.insert("edi_event", new QRecord().withValue("type", "RAW_DOWNLOADED").withValue("userName", user)
|
||||
.withValue("inboxId", parts[1].equals("edi_inbox") ? id : row.getValueInteger("inboxId"))
|
||||
.withValue("interchangeId", parts[1].equals("edi_interchange") ? id : null)
|
||||
.withValue("at", EdiRuntime.current().config().getClock().instant()).withValue("detail", "Raw document download authorized"));
|
||||
return (EdiRuntime.current().payloads().open(new PayloadRef(row.getValueString("payloadRef"), null, 0)));
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.edi.transport;
|
||||
|
||||
/*******************************************************************************
|
||||
** Value-free transport outcome; remote response bodies must never be persisted.
|
||||
*******************************************************************************/
|
||||
public record EdiSendResult(String status, String messageId, String reason)
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.edi.transport;
|
||||
|
||||
/*******************************************************************************
|
||||
** Connection test result without credentials or remote response bodies.
|
||||
*******************************************************************************/
|
||||
public record EdiTestResult(Boolean success, String summary)
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.edi.transport;
|
||||
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.edi.flow.InboundSink;
|
||||
import com.kingsrook.qbits.edi.model.EdiOutboundMessage;
|
||||
import com.kingsrook.qbits.edi.model.EdiTransport;
|
||||
import com.kingsrook.qbits.edi.storage.PayloadRef;
|
||||
|
||||
/*******************************************************************************
|
||||
** Streaming transport boundary, registered in the host instance's metadata.
|
||||
*******************************************************************************/
|
||||
public interface EdiTransportPlugin
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Stable transport type.
|
||||
*******************************************************************************/
|
||||
String getType();
|
||||
|
||||
/*******************************************************************************
|
||||
** Validate settings without resolving or exposing secrets.
|
||||
*******************************************************************************/
|
||||
void validate(EdiTransport transport, List<String> errors);
|
||||
|
||||
/*******************************************************************************
|
||||
** Send the already encrypted, durably queued payload.
|
||||
*******************************************************************************/
|
||||
EdiSendResult send(EdiOutboundMessage message, PayloadRef payload, EdiTransport transport) throws Exception;
|
||||
|
||||
/*******************************************************************************
|
||||
** Pull transports acknowledge remote pickup only after intake commits.
|
||||
*******************************************************************************/
|
||||
default void poll(EdiTransport transport, InboundSink sink) throws Exception
|
||||
{
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Unsupported tests never claim a successful connection.
|
||||
*******************************************************************************/
|
||||
default EdiTestResult test(EdiTransport transport)
|
||||
{
|
||||
return (new EdiTestResult(false, "Connection test is unavailable for this transport"));
|
||||
}
|
||||
}
|
||||
+29
-38
@@ -18,65 +18,56 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.edi.security;
|
||||
|
||||
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QSupplementalFieldMetaData;
|
||||
package com.kingsrook.qbits.edi.transport;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QSupplementalInstanceMetaData;
|
||||
|
||||
/*******************************************************************************
|
||||
** STAND-IN(core=QRun-IO/qqq#1142, removeBy=4.1.0-RC.6). Field classifications on RC.5.
|
||||
** Per-instance plugin registry; hosts may replace built-ins using withPlugin.
|
||||
*******************************************************************************/
|
||||
public class EdiClassifications extends QSupplementalFieldMetaData
|
||||
public class EdiTransportPluginsMetaData implements QSupplementalInstanceMetaData
|
||||
{
|
||||
private final String classification;
|
||||
private final Map<String, EdiTransportPlugin> plugins = new LinkedHashMap<>();
|
||||
|
||||
/*******************************************************************************
|
||||
** Retain classification as metadata until the core enum is available.
|
||||
*******************************************************************************/
|
||||
public EdiClassifications(String classification)
|
||||
{
|
||||
this.classification = classification;
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Stable supplemental metadata key.
|
||||
** Metadata key.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getType()
|
||||
public String getName()
|
||||
{
|
||||
return ("edi.classification");
|
||||
return ("edi.transport.plugins");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Read the classification without requiring an RC.6 class.
|
||||
** Register one implementation.
|
||||
*******************************************************************************/
|
||||
public String getClassification()
|
||||
public EdiTransportPluginsMetaData withPlugin(EdiTransportPlugin plugin)
|
||||
{
|
||||
return (classification);
|
||||
plugins.put(plugin.getType(), plugin);
|
||||
return (this);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Bridge when a host supplies the newer field classification API.
|
||||
** Resolve a known type, rejecting unknown configuration.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void enrich(QInstance instance, QFieldMetaData field)
|
||||
public EdiTransportPlugin get(String type)
|
||||
{
|
||||
try
|
||||
EdiTransportPlugin plugin = plugins.get(type);
|
||||
if(plugin == null)
|
||||
{
|
||||
Class<?> type = Class.forName("com.kingsrook.qqq.backend.core.actions.ai.DataClassification");
|
||||
Object value = type.getField(classification).get(null);
|
||||
field.getClass().getMethod("setDataClassification", type).invoke(field, value);
|
||||
}
|
||||
catch(ClassNotFoundException e)
|
||||
{
|
||||
// RC.5 keeps the explicit supplemental classification below.
|
||||
}
|
||||
catch(ReflectiveOperationException e)
|
||||
{
|
||||
throw new IllegalStateException("Update the EDI classification bridge for this core version", e);
|
||||
throw new IllegalArgumentException("Unknown EDI transport type");
|
||||
}
|
||||
return (plugin);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Registry attached to the active instance.
|
||||
*******************************************************************************/
|
||||
public static EdiTransportPluginsMetaData current()
|
||||
{
|
||||
return (QSupplementalInstanceMetaData.of(QContext.getQInstance(), "edi.transport.plugins"));
|
||||
}
|
||||
}
|
||||
Loaded 100 of 219 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user