Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
47c3aac1b2
|
||
|
|
bf8f40959a | ||
|
|
f7c031b793 | ||
|
|
cd9423e966 | ||
|
|
d97418447b
|
||
|
|
c15872a373
|
||
|
|
fc800b8124 | ||
|
|
30ac598952
|
||
|
|
21b0e61cbb | ||
|
|
5afeefb1c2
|
||
|
|
b9ea954e1f | ||
|
|
ea4c48ad66
|
||
|
|
09b77ec122 | ||
|
|
4f8916246a
|
||
|
|
a4dca9046b
|
||
|
|
2122fafed5 |
No files matched your search
@@ -1,7 +1,7 @@
|
||||
version: 2.1
|
||||
|
||||
orbs:
|
||||
qqq-orb: kingsrook/qqq-orb@0.6.8
|
||||
qqq-orb: kingsrook/qqq-orb@0.6.9
|
||||
|
||||
commands:
|
||||
configure-package-install:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -6,7 +6,26 @@ Following [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and [Semantic
|
||||
|
||||
### Added
|
||||
|
||||
- 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.
|
||||
@@ -31,6 +50,22 @@ Following [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and [Semantic
|
||||
- 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,19 +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 and b implement inbound/outbound retail EDI. Original baseline guides cover
|
||||
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 and email sending remain disabled.
|
||||
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.6+**, 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.
|
||||
|
||||
@@ -36,7 +57,7 @@ a least-privilege `QSession` with target and association permissions. Applying w
|
||||
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.
|
||||
|
||||
## Tables
|
||||
@@ -52,9 +73,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.0.0`,
|
||||
under `/qqq/v1/qbit/edi`. 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`; all component names are
|
||||
`Edi...V1`. Regenerate intentionally with:
|
||||
|
||||
```powershell
|
||||
./mvnw.cmd -B '-Dtest=EdiApiContractTest' '-Dedi.openapi.update=true' test
|
||||
```
|
||||
|
||||
RC.6 provides `UiApiContract.check`, used to compare generated and checked-in YAML and run
|
||||
`OpenApiCompatibilityChecker` against this initial 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
|
||||
@@ -65,10 +137,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.
|
||||
@@ -90,7 +162,7 @@ Outbound maps support nested association paths; inbound target paths retain the
|
||||
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.
|
||||
@@ -112,6 +184,8 @@ Full table permissions include `edi.partner.read|insert|edit|delete`, `edi.map.*
|
||||
| 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
|
||||
@@ -131,6 +205,7 @@ the viewing user's permissions. Processes are table/record actions, never naviga
|
||||
| `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 |
|
||||
|
||||
@@ -141,29 +216,35 @@ 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
|
||||
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` and `edi.certificate.expired` (RC.5 requires dotted names).
|
||||
`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.5 catches listener errors without failing the source write: successful enqueues roll back with
|
||||
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.5 `EsbTrigger` to invoke it with
|
||||
`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.5's consumer runner uses JMS, so broker-free acceptance calls
|
||||
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.
|
||||
@@ -180,7 +261,8 @@ TRANSPORT exception and raise `edi.send.failed`.
|
||||
| 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 / EMAIL | Settings validation only. Sending returns a permanent unavailable result until phase c / the email API. |
|
||||
| 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
|
||||
@@ -200,6 +282,104 @@ without re-running its map and links `originalInterchangeId`. Cancel accepts QUE
|
||||
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
|
||||
|
||||
+37
-2
@@ -19,7 +19,20 @@ invoices and inventory. Forty synthetic inbound documents go through intake and
|
||||
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.
|
||||
Two public self-signed certificates are generated in memory; private keys are never persisted.
|
||||
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)
|
||||
@@ -29,9 +42,17 @@ and sends three valid 940 shipping orders and one with a bad unit; the orders an
|
||||
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 alone has configured outbound documents. The in-memory gateway
|
||||
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.
|
||||
@@ -45,3 +66,17 @@ descending creation date and ID. The home exception queue puts OPEN before IN_PR
|
||||
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.
|
||||
+192
-50
@@ -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,
|
||||
@@ -247,7 +254,11 @@ One row per document, version and direction that the agreement carries.
|
||||
- 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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -894,7 +978,6 @@ 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.
|
||||
@@ -924,3 +1007,62 @@ RC.5 adaptations implemented in this worktree:
|
||||
- `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.
|
||||
+16
-3
@@ -52,9 +52,9 @@ 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 are generated by
|
||||
the mapper and group-local controls are assigned by the worker. Component-target paths are
|
||||
currently rejected explicitly; the bundled retail maps use simple elements.
|
||||
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
|
||||
|
||||
@@ -76,3 +76,16 @@ collide with the header N1 loop) and the stop sequence is mapped from S501. A 21
|
||||
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),
|
||||
@@ -388,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 (
|
||||
@@ -536,5 +565,6 @@ CREATE TABLE edi_transport (
|
||||
sign_algorithm VARCHAR(255),
|
||||
type VARCHAR(255),
|
||||
van_name VARCHAR(255),
|
||||
weak_crypto BOOLEAN,
|
||||
UNIQUE (name)
|
||||
);
|
||||
@@ -69,7 +69,8 @@
|
||||
|
||||
<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.81</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>
|
||||
@@ -212,7 +213,7 @@
|
||||
<activeByDefault>true</activeByDefault>
|
||||
</activation>
|
||||
<properties>
|
||||
<qqq.snapshot.version>4.1.0-RC.5</qqq.snapshot.version>
|
||||
<qqq.snapshot.version>4.1.0-RC.6</qqq.snapshot.version>
|
||||
</properties>
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
|
||||
@@ -55,6 +55,26 @@ public class EdiQBitConfig implements QBitConfig
|
||||
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();
|
||||
@@ -68,6 +88,7 @@ public class EdiQBitConfig implements QBitConfig
|
||||
private String appName = "edi";
|
||||
private String appLabel = "EDI";
|
||||
private String parentAppName;
|
||||
private String publicBaseUrl;
|
||||
|
||||
|
||||
|
||||
@@ -350,4 +371,23 @@ 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);
|
||||
}
|
||||
}
|
||||
@@ -135,11 +135,13 @@ public class EdiQBitProducer implements QBitMetaDataProducer<EdiQBitConfig>
|
||||
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.transport.UnavailableTransport("AS2")).withPlugin(new com.kingsrook.qbits.edi.transport.UnavailableTransport("EMAIL")));
|
||||
.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));
|
||||
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)
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
@@ -71,6 +71,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())
|
||||
|
||||
@@ -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,293 @@
|
||||
/*
|
||||
* 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.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.0.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.0.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", "EdiErrorV1"),
|
||||
new BasicResponse(HttpStatus.UNAUTHORIZED, "Sign in required", "EdiErrorV1"),
|
||||
new BasicResponse(HttpStatus.FORBIDDEN, "Permission denied", "EdiErrorV1"),
|
||||
new BasicResponse(HttpStatus.NOT_FOUND, "Missing or invisible record", "EdiErrorV1"),
|
||||
new BasicResponse(HttpStatus.CONTENT_TOO_LARGE, "Host HTTP request size limit exceeded", "EdiErrorV1")));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** 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("Core middleware error body.")
|
||||
.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));
|
||||
}
|
||||
}
|
||||
@@ -312,7 +312,7 @@ public class EdiHome extends AbstractWidgetRenderer
|
||||
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", "Not configured"));
|
||||
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())
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
+40
-36
@@ -18,65 +18,69 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.edi.security;
|
||||
package com.kingsrook.qbits.edi.as2;
|
||||
|
||||
|
||||
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;
|
||||
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;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** STAND-IN(core=QRun-IO/qqq#1142, removeBy=4.1.0-RC.6). Field classifications on RC.5.
|
||||
** 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 class EdiClassifications extends QSupplementalFieldMetaData
|
||||
public final class As2Store
|
||||
{
|
||||
private final String classification;
|
||||
public static final String TABLE = "edi_as2_mdn";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Retain classification as metadata until the core enum is available.
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
public EdiClassifications(String classification)
|
||||
private As2Store()
|
||||
{
|
||||
this.classification = classification;
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Stable supplemental metadata key.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getType()
|
||||
{
|
||||
return ("edi.classification");
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Read the classification without requiring an RC.6 class.
|
||||
** Store the raw MDN entity (headers and body) encrypted.
|
||||
*******************************************************************************/
|
||||
public String getClassification()
|
||||
public static PayloadRef storeRaw(byte[] entity) throws Exception
|
||||
{
|
||||
return (classification);
|
||||
return (EdiRuntime.current().payloads().put(PayloadKind.MDN, new ByteArrayInputStream(entity)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Bridge when a host supplies the newer field classification API.
|
||||
** The base64 value of a MIC without its algorithm.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void enrich(QInstance instance, QFieldMetaData field)
|
||||
public static String micValue(MIC mic)
|
||||
{
|
||||
try
|
||||
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)
|
||||
{
|
||||
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);
|
||||
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()));
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,7 @@ 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;
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -65,7 +66,7 @@ public class AckMonitor
|
||||
}
|
||||
count++;
|
||||
}
|
||||
for(QRecord certificate : EdiRecords.query("edi_certificate", EdiRecords.equal("status", "ACTIVE")))
|
||||
for(QRecord certificate : EdiRecords.query("edi_certificate", new QQueryFilter().withCriteria("status", QCriteriaOperator.IN, "ACTIVE", "NEXT")))
|
||||
{
|
||||
if(certificate.getValueInstant("notAfter") == null)
|
||||
{
|
||||
@@ -87,10 +88,51 @@ public class AckMonitor
|
||||
}
|
||||
}
|
||||
}
|
||||
return (count);
|
||||
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.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -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");
|
||||
@@ -261,6 +261,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 +308,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 +353,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 +383,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 +421,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 +433,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 +448,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())
|
||||
|
||||
@@ -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, () ->
|
||||
{
|
||||
|
||||
@@ -73,6 +73,11 @@ public final class OutboundActions
|
||||
*******************************************************************************/
|
||||
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"));
|
||||
|
||||
@@ -93,7 +93,7 @@ public class OutboundWorker
|
||||
return (0);
|
||||
}
|
||||
List<QRecord> accepted = new ArrayList<>();
|
||||
List<X12TransactionSet> sets = 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>();
|
||||
@@ -115,9 +115,9 @@ public class OutboundWorker
|
||||
{
|
||||
continue;
|
||||
}
|
||||
try
|
||||
try(var logging = com.kingsrook.qbits.edi.security.PhiLogScope.open(Boolean.TRUE.equals(document.getValueBoolean("containsPhi"))))
|
||||
{
|
||||
X12TransactionSet set = InboundApplier.asPartner(EdiRuntime.current().config().getServiceSessionSupplier(), agreement.getValueInteger("partnerId"), () ->
|
||||
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();
|
||||
@@ -129,7 +129,9 @@ public class OutboundWorker
|
||||
{
|
||||
throw new IllegalArgumentException("Source record or active map unavailable");
|
||||
}
|
||||
return (new OutboundMapper().build(records.getFirst(), map, rules, String.format("%04d", sets.size() + 1)));
|
||||
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())))
|
||||
{
|
||||
@@ -170,8 +172,13 @@ public class OutboundWorker
|
||||
/*******************************************************************************
|
||||
** 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<X12TransactionSet> sets, Integer original) throws Exception
|
||||
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"));
|
||||
@@ -187,7 +194,7 @@ public class OutboundWorker
|
||||
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), List.copyOf(sets), List.of())), List.of(), List.of());
|
||||
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";
|
||||
@@ -211,7 +218,7 @@ public class OutboundWorker
|
||||
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.st().value(1)).withValue("version", gs.value(8)).withValue("controlNumber", set.st().value(2)).withValue("status", "QUEUED")
|
||||
.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)
|
||||
|
||||
@@ -101,7 +101,7 @@ public class SendWorker
|
||||
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 : "Transport delivery failed")
|
||||
.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)
|
||||
{
|
||||
@@ -145,6 +145,17 @@ public class SendWorker
|
||||
}));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** 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.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -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", "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");
|
||||
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));
|
||||
|
||||
@@ -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,7 +136,7 @@ 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))
|
||||
@@ -144,6 +148,10 @@ public class InboundMapper
|
||||
{
|
||||
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()))
|
||||
{
|
||||
|
||||
@@ -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()))));
|
||||
}
|
||||
}
|
||||
@@ -62,7 +62,10 @@ public class OutboundMapper
|
||||
throw new IllegalArgumentException("An outbound map is required");
|
||||
}
|
||||
List<X12Segment> segments = new ArrayList<>();
|
||||
segments.add(X12Segment.of("ST", 1, map.getValueString("transactionSet"), control));
|
||||
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));
|
||||
@@ -72,7 +75,7 @@ public class OutboundMapper
|
||||
/*******************************************************************************
|
||||
** Walk each loop once per associated record, retaining the nearest HL parent.
|
||||
*******************************************************************************/
|
||||
private void emit(QRecord source, List<QRecord> rules, String prefix, String qPrefix, int sequence, String 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<>();
|
||||
@@ -109,13 +112,13 @@ public class OutboundMapper
|
||||
}
|
||||
continue;
|
||||
}
|
||||
List<String> elements = new ArrayList<>();
|
||||
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() || match.group(3) != null)
|
||||
if(!match.matches())
|
||||
{
|
||||
throw new IllegalArgumentException("Unsupported outbound element path");
|
||||
}
|
||||
@@ -127,7 +130,7 @@ public class OutboundMapper
|
||||
}
|
||||
while(elements.size() < position)
|
||||
{
|
||||
elements.add("");
|
||||
elements.add(new ArrayList<>());
|
||||
}
|
||||
String condition = rule.getValueString("condition");
|
||||
if(condition != null && !condition.isBlank() && !new RuleEvaluator(output, Map.of()).test(condition))
|
||||
@@ -169,7 +172,7 @@ public class OutboundMapper
|
||||
}
|
||||
else if(transform.equals("DECIMAL"))
|
||||
{
|
||||
value = new java.math.BigDecimal(raw.toString()).movePointRight(Integer.parseInt(args.getOrDefault("scale", "0"))).stripTrailingZeros().toPlainString();
|
||||
value = decimal(new java.math.BigDecimal(raw.toString()).movePointRight(Integer.parseInt(args.getOrDefault("scale", "0"))).stripTrailingZeros().toPlainString());
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -180,16 +183,35 @@ public class OutboundMapper
|
||||
{
|
||||
throw new IllegalArgumentException("Required outbound field is missing");
|
||||
}
|
||||
elements.set(position - 1, Objects.requireNonNullElse(value, ""));
|
||||
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().anyMatch(v -> !v.isEmpty()))
|
||||
if(elements.stream().flatMap(List::stream).anyMatch(v -> !v.isEmpty()))
|
||||
{
|
||||
output.add(X12Segment.of(segmentId, output.size() + 1, elements.toArray(String[]::new)));
|
||||
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();
|
||||
currentParent = elements.getFirst().getFirst();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** X12 uses a period; EDIFACT can override the numeric service character.
|
||||
*******************************************************************************/
|
||||
protected String decimal(String value)
|
||||
{
|
||||
return (value);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -51,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.
|
||||
@@ -118,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.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -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
|
||||
{
|
||||
@@ -75,7 +75,9 @@ public final class EdiContingencies
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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", "edi.send.failed", "edi.certificate.expiring", "edi.certificate.expired"))
|
||||
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,6 +80,7 @@ public class EdiRecordLabels implements TableCustomizerInterface, QSupplementalI
|
||||
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");
|
||||
|
||||
@@ -40,7 +40,7 @@ import com.kingsrook.qqq.backend.core.model.metadata.tables.Tier;
|
||||
*******************************************************************************/
|
||||
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");
|
||||
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");
|
||||
|
||||
|
||||
|
||||
@@ -76,6 +76,7 @@ public final class EdiTablePresentation
|
||||
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);
|
||||
});
|
||||
@@ -198,6 +199,7 @@ public final class EdiTablePresentation
|
||||
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,10 +51,10 @@ 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;
|
||||
@@ -118,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"));
|
||||
@@ -156,6 +159,7 @@ public final class EdiTables
|
||||
tables.add(base(config, "edi_map_sample", EdiMapSample.class, "name", false));
|
||||
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())
|
||||
@@ -228,11 +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);
|
||||
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"))
|
||||
{
|
||||
@@ -249,13 +254,14 @@ public final class EdiTables
|
||||
}
|
||||
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));
|
||||
metadata.withExposure(AiExposure.NONE);
|
||||
}
|
||||
field.withSupplementalMetaData(metadata);
|
||||
}
|
||||
EdiTablePresentation.apply(table);
|
||||
EdiRecordLabels.configure(table);
|
||||
@@ -277,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"));
|
||||
@@ -301,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"));
|
||||
@@ -333,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);
|
||||
}
|
||||
|
||||
@@ -403,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);
|
||||
}
|
||||
@@ -414,7 +424,8 @@ public final class EdiTables
|
||||
{
|
||||
return (joins().stream().map(join -> ChildRecordListRenderer.widgetMetaDataBuilder(join).withName(join.getName())
|
||||
.withLabel(joinLabel(join)).withCanAddChildRecord(false)
|
||||
.withOrderBys(childOrder(join.getRightTable())).getWidgetMetaData().withGridColumns(12)).toList());
|
||||
.withOrderBys(childOrder(join.getRightTable())).getWidgetMetaData()
|
||||
.withCodeReference(new QCodeReference(com.kingsrook.qbits.edi.app.EdiChildRecordListRenderer.class)).withGridColumns(12)).toList());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -424,7 +435,7 @@ public final class EdiTables
|
||||
{
|
||||
return (switch(table)
|
||||
{
|
||||
case "edi_transaction", "edi_interchange", "edi_exception", "edi_inbox", "edi_event", "edi_outbound_request", "edi_outbound_message" ->
|
||||
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"));
|
||||
|
||||
@@ -74,6 +74,7 @@ public class TransportValidation implements TableCustomizerInterface
|
||||
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)
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -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,6 +78,10 @@ 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"));
|
||||
@@ -95,11 +102,13 @@ public final class EdiProcesses
|
||||
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_ack_monitor", "edi_outbound_enqueue"))
|
||||
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));
|
||||
}
|
||||
@@ -128,6 +137,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)
|
||||
@@ -135,6 +146,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"))
|
||||
@@ -168,7 +192,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"))));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -204,7 +228,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")));
|
||||
@@ -265,18 +312,34 @@ public final class EdiProcesses
|
||||
.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
|
||||
{
|
||||
@@ -286,18 +349,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)
|
||||
{
|
||||
|
||||
@@ -61,7 +61,11 @@ public class OutboundProcesses implements BackendStep
|
||||
.withStep(new QBackendStepMetaData().withName("execute").withCode(new QCodeReference(OutboundProcesses.class)));
|
||||
if(config.getSchedulerName() != null && (seconds != null || cron != null))
|
||||
{
|
||||
process.withSchedule(new QScheduleMetaData().withSchedulerName(config.getSchedulerName()).withRepeatSeconds(seconds).withCronExpression(cron).withCronTimeZoneId("UTC"));
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
// 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);
|
||||
}
|
||||
@@ -106,6 +110,10 @@ public class OutboundProcesses implements BackendStep
|
||||
{
|
||||
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();
|
||||
|
||||
@@ -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)));
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -105,7 +105,7 @@ public class HttpsTransport implements EdiTransportPlugin
|
||||
bytes = stream.readAllBytes();
|
||||
}
|
||||
HttpRequest.Builder request = HttpRequest.newBuilder(URI.create(transport.getHttpsUrl())).timeout(Duration.ofSeconds(60))
|
||||
.header("Content-Type", "application/edi-x12").POST(HttpRequest.BodyPublishers.ofByteArray(bytes));
|
||||
.header("Content-Type", new String(bytes, java.nio.charset.StandardCharsets.US_ASCII).stripLeading().startsWith("UN") ? "application/EDIFACT" : "application/edi-x12").POST(HttpRequest.BodyPublishers.ofByteArray(bytes));
|
||||
if(transport.getHttpsHeadersJson() != null)
|
||||
{
|
||||
Map<String, String> headers = new ObjectMapper().readValue(transport.getHttpsHeadersJson(), new TypeReference<Map<String, String>>() {});
|
||||
|
||||
@@ -26,7 +26,8 @@ import com.kingsrook.qbits.edi.model.EdiTransport;
|
||||
import com.kingsrook.qbits.edi.storage.PayloadRef;
|
||||
|
||||
/*******************************************************************************
|
||||
** Explicit phase boundaries for AS2 and the unpublished email API.
|
||||
** Explicit boundary for the unpublished qbit-email API; AS2 is implemented by
|
||||
** As2Transport.
|
||||
*******************************************************************************/
|
||||
public record UnavailableTransport(String type) implements EdiTransportPlugin
|
||||
{
|
||||
@@ -43,7 +44,7 @@ public record UnavailableTransport(String type) implements EdiTransportPlugin
|
||||
*******************************************************************************/
|
||||
public void validate(EdiTransport transport, List<String> errors)
|
||||
{
|
||||
if("AS2".equals(type) ? transport.getAs2Url() == null : transport.getEmailTo() == null)
|
||||
if(transport.getEmailTo() == null)
|
||||
{
|
||||
errors.add("Transport destination is required");
|
||||
}
|
||||
@@ -54,6 +55,6 @@ public record UnavailableTransport(String type) implements EdiTransportPlugin
|
||||
*******************************************************************************/
|
||||
public EdiSendResult send(EdiOutboundMessage message, PayloadRef payload, EdiTransport transport)
|
||||
{
|
||||
return (new EdiSendResult("FAILED", null, "Not available until phase c / qbit-email API"));
|
||||
return (new EdiSendResult("FAILED", null, "Not available until the qbit-email API ships"));
|
||||
}
|
||||
}
|
||||
@@ -50,7 +50,8 @@ public class ControlNumberAllocator
|
||||
*******************************************************************************/
|
||||
public long allocate(Integer agreementId, Integer localIdentityId, String level, long min, long max, boolean wrap) throws SQLException
|
||||
{
|
||||
if((agreementId == null) == (localIdentityId == null) || !List.of("ISA", "GS").contains(level) || min < 1 || max < min || max > 999999999)
|
||||
long ceiling = List.of("UNB", "UNG", "UNH").contains(level) ? 99999999999999L : 999999999;
|
||||
if((agreementId == null) == (localIdentityId == null) || !List.of("ISA", "GS", "UNB", "UNG", "UNH").contains(level) || min < 1 || max < min || max > ceiling)
|
||||
{
|
||||
throw new IllegalArgumentException("Invalid control number scope or bounds");
|
||||
}
|
||||
@@ -100,7 +101,7 @@ public class ControlNumberAllocator
|
||||
return (min);
|
||||
}
|
||||
}
|
||||
if(minimum < 1 || maximum < minimum || maximum > 999999999 || previous < minimum - 1 || previous > maximum)
|
||||
if(minimum < 1 || maximum < minimum || maximum > ceiling || previous < minimum - 1 || previous > maximum)
|
||||
{
|
||||
throw new IllegalStateException("Stored control number range is invalid");
|
||||
}
|
||||
|
||||
@@ -55,6 +55,18 @@ public class DuplicateDetector
|
||||
return (!EdiRecords.query("edi_interchange", filter).isEmpty());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** EDIFACT duplicates use complete UNB identities, reference and test indicator.
|
||||
*******************************************************************************/
|
||||
public boolean edifact(Integer agreementId, com.kingsrook.qbits.edi.edifact.EdifactInterchange input, int days, Instant now) throws QException
|
||||
{
|
||||
return (!EdiRecords.query("edi_interchange", EdiRecords.equal("agreementId", agreementId).withCriteria("direction", QCriteriaOperator.EQUALS, "INBOUND")
|
||||
.withCriteria("standard", QCriteriaOperator.EQUALS, "EDIFACT").withCriteria("senderId", QCriteriaOperator.EQUALS, input.unb().value(2))
|
||||
.withCriteria("senderQualifier", QCriteriaOperator.EQUALS, input.unb().value(2, 1, 2)).withCriteria("receiverId", QCriteriaOperator.EQUALS, input.unb().value(3))
|
||||
.withCriteria("receiverQualifier", QCriteriaOperator.EQUALS, input.unb().value(3, 1, 2)).withCriteria("controlNumber", QCriteriaOperator.EQUALS, input.unb().value(5))
|
||||
.withCriteria("usageIndicator", QCriteriaOperator.EQUALS, input.usage()).withCriteria("createDate", QCriteriaOperator.GREATER_THAN_OR_EQUALS, now.minus(days, ChronoUnit.DAYS))).isEmpty());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Check GS06 for a sender in the duplicate window.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -27,6 +27,15 @@ import java.util.List;
|
||||
/*******************************************************************************
|
||||
** Buffered transaction set, including its ST and SE.
|
||||
*******************************************************************************/
|
||||
public record X12TransactionSet(X12Segment st, X12Segment se, List<X12Segment> segments, List<EnvelopeFinding> findings)
|
||||
public record X12TransactionSet(X12Segment st, X12Segment se, List<X12Segment> segments, List<EnvelopeFinding> findings) implements com.kingsrook.qbits.edi.document.EdiDocument
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Expose the header to standard-neutral validators and maps.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public X12Segment header()
|
||||
{
|
||||
return (st);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,600 @@
|
||||
{
|
||||
"name": "QQQ baseline guide 270 005010X279A1",
|
||||
"standard": "X12",
|
||||
"version": "005010X279A1",
|
||||
"transactionSet": "270",
|
||||
"functionalIdCode": "HS",
|
||||
"segments": [
|
||||
{
|
||||
"segmentId": "ST",
|
||||
"usage": "REQUIRED",
|
||||
"minUse": 1,
|
||||
"maxUse": 1,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "ID",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 3,
|
||||
"fixedValue": "270"
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "AN",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 9
|
||||
},
|
||||
{
|
||||
"position": 3,
|
||||
"dataType": "ID",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 35,
|
||||
"fixedValue": "005010X279A1"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"segmentId": "BHT",
|
||||
"usage": "REQUIRED",
|
||||
"minUse": 1,
|
||||
"maxUse": 1,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "ID",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 4
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "ID",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 2
|
||||
},
|
||||
{
|
||||
"position": 3,
|
||||
"dataType": "AN",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 50
|
||||
},
|
||||
{
|
||||
"position": 4,
|
||||
"dataType": "DT",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 8
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"segmentId": "HL",
|
||||
"usage": "REQUIRED",
|
||||
"minUse": 1,
|
||||
"maxUse": 1,
|
||||
"loopId": "2000A",
|
||||
"parentLoopId": null,
|
||||
"loopMaxRepeat": 999,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "N0",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 12
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "N0",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 12
|
||||
},
|
||||
{
|
||||
"position": 3,
|
||||
"dataType": "ID",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 2,
|
||||
"fixedValue": "20"
|
||||
},
|
||||
{
|
||||
"position": 4,
|
||||
"dataType": "ID",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 1,
|
||||
"codes": [
|
||||
"0",
|
||||
"1"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"segmentId": "NM1",
|
||||
"usage": "REQUIRED",
|
||||
"minUse": 1,
|
||||
"maxUse": 1,
|
||||
"loopId": "2100A",
|
||||
"parentLoopId": "2000A",
|
||||
"loopMaxRepeat": 999,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "ID",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 3,
|
||||
"fixedValue": "PR"
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "ID",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 1,
|
||||
"codes": [
|
||||
"1",
|
||||
"2"
|
||||
]
|
||||
},
|
||||
{
|
||||
"position": 3,
|
||||
"dataType": "AN",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 60
|
||||
},
|
||||
{
|
||||
"position": 4,
|
||||
"dataType": "AN",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 35
|
||||
},
|
||||
{
|
||||
"position": 8,
|
||||
"dataType": "ID",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 2
|
||||
},
|
||||
{
|
||||
"position": 9,
|
||||
"dataType": "AN",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 80
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"segmentId": "HL",
|
||||
"usage": "REQUIRED",
|
||||
"minUse": 1,
|
||||
"maxUse": 1,
|
||||
"loopId": "2000B",
|
||||
"parentLoopId": "2000A",
|
||||
"loopMaxRepeat": 999,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "N0",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 12
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "N0",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 12
|
||||
},
|
||||
{
|
||||
"position": 3,
|
||||
"dataType": "ID",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 2,
|
||||
"fixedValue": "21"
|
||||
},
|
||||
{
|
||||
"position": 4,
|
||||
"dataType": "ID",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 1,
|
||||
"codes": [
|
||||
"0",
|
||||
"1"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"segmentId": "NM1",
|
||||
"usage": "REQUIRED",
|
||||
"minUse": 1,
|
||||
"maxUse": 1,
|
||||
"loopId": "2100B",
|
||||
"parentLoopId": "2000B",
|
||||
"loopMaxRepeat": 999,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "ID",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 3,
|
||||
"fixedValue": "1P"
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "ID",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 1,
|
||||
"codes": [
|
||||
"1",
|
||||
"2"
|
||||
]
|
||||
},
|
||||
{
|
||||
"position": 3,
|
||||
"dataType": "AN",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 60
|
||||
},
|
||||
{
|
||||
"position": 4,
|
||||
"dataType": "AN",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 35
|
||||
},
|
||||
{
|
||||
"position": 8,
|
||||
"dataType": "ID",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 2
|
||||
},
|
||||
{
|
||||
"position": 9,
|
||||
"dataType": "AN",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 80
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"segmentId": "HL",
|
||||
"usage": "REQUIRED",
|
||||
"minUse": 1,
|
||||
"maxUse": 1,
|
||||
"loopId": "2000C",
|
||||
"parentLoopId": "2000B",
|
||||
"loopMaxRepeat": 999,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "N0",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 12
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "N0",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 12
|
||||
},
|
||||
{
|
||||
"position": 3,
|
||||
"dataType": "ID",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 2,
|
||||
"fixedValue": "22"
|
||||
},
|
||||
{
|
||||
"position": 4,
|
||||
"dataType": "ID",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 1,
|
||||
"codes": [
|
||||
"0",
|
||||
"1"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"segmentId": "TRN",
|
||||
"usage": "SITUATIONAL",
|
||||
"minUse": 0,
|
||||
"maxUse": 2,
|
||||
"loopId": "2000C",
|
||||
"parentLoopId": "2000B",
|
||||
"loopMaxRepeat": 999,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "ID",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 2
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "AN",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 50
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"segmentId": "NM1",
|
||||
"usage": "REQUIRED",
|
||||
"minUse": 1,
|
||||
"maxUse": 1,
|
||||
"loopId": "2100C",
|
||||
"parentLoopId": "2000C",
|
||||
"loopMaxRepeat": 999,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "ID",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 3,
|
||||
"fixedValue": "IL"
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "ID",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 1,
|
||||
"codes": [
|
||||
"1",
|
||||
"2"
|
||||
]
|
||||
},
|
||||
{
|
||||
"position": 3,
|
||||
"dataType": "AN",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 60
|
||||
},
|
||||
{
|
||||
"position": 4,
|
||||
"dataType": "AN",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 35
|
||||
},
|
||||
{
|
||||
"position": 8,
|
||||
"dataType": "ID",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 2
|
||||
},
|
||||
{
|
||||
"position": 9,
|
||||
"dataType": "AN",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 80
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"segmentId": "N3",
|
||||
"usage": "SITUATIONAL",
|
||||
"minUse": 0,
|
||||
"maxUse": 1,
|
||||
"loopId": "2100C",
|
||||
"parentLoopId": "2000C",
|
||||
"loopMaxRepeat": 999,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "AN",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 55
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"segmentId": "N4",
|
||||
"usage": "SITUATIONAL",
|
||||
"minUse": 0,
|
||||
"maxUse": 1,
|
||||
"loopId": "2100C",
|
||||
"parentLoopId": "2000C",
|
||||
"loopMaxRepeat": 999,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "AN",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 30
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "ID",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 2
|
||||
},
|
||||
{
|
||||
"position": 3,
|
||||
"dataType": "ID",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 15
|
||||
},
|
||||
{
|
||||
"position": 4,
|
||||
"dataType": "ID",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 3
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"segmentId": "REF",
|
||||
"usage": "SITUATIONAL",
|
||||
"minUse": 0,
|
||||
"maxUse": 5,
|
||||
"loopId": "2100C",
|
||||
"parentLoopId": "2000C",
|
||||
"loopMaxRepeat": 999,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "ID",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 3
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "AN",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 80
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"segmentId": "DMG",
|
||||
"usage": "SITUATIONAL",
|
||||
"minUse": 0,
|
||||
"maxUse": 1,
|
||||
"loopId": "2100C",
|
||||
"parentLoopId": "2000C",
|
||||
"loopMaxRepeat": 999,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "ID",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 3
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "DT",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 8
|
||||
},
|
||||
{
|
||||
"position": 3,
|
||||
"dataType": "ID",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 1
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"segmentId": "DTP",
|
||||
"usage": "SITUATIONAL",
|
||||
"minUse": 0,
|
||||
"maxUse": 20,
|
||||
"loopId": "2100C",
|
||||
"parentLoopId": "2000C",
|
||||
"loopMaxRepeat": 999,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "ID",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 3
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "ID",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 3,
|
||||
"codes": [
|
||||
"D8",
|
||||
"RD8"
|
||||
]
|
||||
},
|
||||
{
|
||||
"position": 3,
|
||||
"dataType": "AN",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 35
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"segmentId": "EQ",
|
||||
"usage": "REQUIRED",
|
||||
"minUse": 1,
|
||||
"maxUse": 1,
|
||||
"loopId": "2110C",
|
||||
"parentLoopId": "2100C",
|
||||
"loopMaxRepeat": 999,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "ID",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 2,
|
||||
"codes": [
|
||||
"30"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"segmentId": "SE",
|
||||
"usage": "REQUIRED",
|
||||
"minUse": 1,
|
||||
"maxUse": 1,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "N0",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 10
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "AN",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 9
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,699 @@
|
||||
{
|
||||
"name": "QQQ baseline guide 271 005010X279A1",
|
||||
"standard": "X12",
|
||||
"version": "005010X279A1",
|
||||
"transactionSet": "271",
|
||||
"functionalIdCode": "HB",
|
||||
"segments": [
|
||||
{
|
||||
"segmentId": "ST",
|
||||
"usage": "REQUIRED",
|
||||
"minUse": 1,
|
||||
"maxUse": 1,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "ID",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 3,
|
||||
"fixedValue": "271"
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "AN",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 9
|
||||
},
|
||||
{
|
||||
"position": 3,
|
||||
"dataType": "ID",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 35,
|
||||
"fixedValue": "005010X279A1"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"segmentId": "BHT",
|
||||
"usage": "REQUIRED",
|
||||
"minUse": 1,
|
||||
"maxUse": 1,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "ID",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 4
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "ID",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 2
|
||||
},
|
||||
{
|
||||
"position": 3,
|
||||
"dataType": "AN",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 50
|
||||
},
|
||||
{
|
||||
"position": 4,
|
||||
"dataType": "DT",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 8
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"segmentId": "HL",
|
||||
"usage": "REQUIRED",
|
||||
"minUse": 1,
|
||||
"maxUse": 1,
|
||||
"loopId": "2000A",
|
||||
"parentLoopId": null,
|
||||
"loopMaxRepeat": 999,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "N0",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 12
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "N0",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 12
|
||||
},
|
||||
{
|
||||
"position": 3,
|
||||
"dataType": "ID",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 2,
|
||||
"fixedValue": "20"
|
||||
},
|
||||
{
|
||||
"position": 4,
|
||||
"dataType": "ID",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 1,
|
||||
"codes": [
|
||||
"0",
|
||||
"1"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"segmentId": "NM1",
|
||||
"usage": "REQUIRED",
|
||||
"minUse": 1,
|
||||
"maxUse": 1,
|
||||
"loopId": "2100A",
|
||||
"parentLoopId": "2000A",
|
||||
"loopMaxRepeat": 999,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "ID",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 3,
|
||||
"fixedValue": "PR"
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "ID",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 1,
|
||||
"codes": [
|
||||
"1",
|
||||
"2"
|
||||
]
|
||||
},
|
||||
{
|
||||
"position": 3,
|
||||
"dataType": "AN",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 60
|
||||
},
|
||||
{
|
||||
"position": 4,
|
||||
"dataType": "AN",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 35
|
||||
},
|
||||
{
|
||||
"position": 8,
|
||||
"dataType": "ID",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 2
|
||||
},
|
||||
{
|
||||
"position": 9,
|
||||
"dataType": "AN",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 80
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"segmentId": "HL",
|
||||
"usage": "REQUIRED",
|
||||
"minUse": 1,
|
||||
"maxUse": 1,
|
||||
"loopId": "2000B",
|
||||
"parentLoopId": "2000A",
|
||||
"loopMaxRepeat": 999,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "N0",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 12
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "N0",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 12
|
||||
},
|
||||
{
|
||||
"position": 3,
|
||||
"dataType": "ID",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 2,
|
||||
"fixedValue": "21"
|
||||
},
|
||||
{
|
||||
"position": 4,
|
||||
"dataType": "ID",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 1,
|
||||
"codes": [
|
||||
"0",
|
||||
"1"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"segmentId": "NM1",
|
||||
"usage": "REQUIRED",
|
||||
"minUse": 1,
|
||||
"maxUse": 1,
|
||||
"loopId": "2100B",
|
||||
"parentLoopId": "2000B",
|
||||
"loopMaxRepeat": 999,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "ID",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 3,
|
||||
"fixedValue": "1P"
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "ID",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 1,
|
||||
"codes": [
|
||||
"1",
|
||||
"2"
|
||||
]
|
||||
},
|
||||
{
|
||||
"position": 3,
|
||||
"dataType": "AN",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 60
|
||||
},
|
||||
{
|
||||
"position": 4,
|
||||
"dataType": "AN",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 35
|
||||
},
|
||||
{
|
||||
"position": 8,
|
||||
"dataType": "ID",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 2
|
||||
},
|
||||
{
|
||||
"position": 9,
|
||||
"dataType": "AN",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 80
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"segmentId": "HL",
|
||||
"usage": "REQUIRED",
|
||||
"minUse": 1,
|
||||
"maxUse": 1,
|
||||
"loopId": "2000C",
|
||||
"parentLoopId": "2000B",
|
||||
"loopMaxRepeat": 999,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "N0",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 12
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "N0",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 12
|
||||
},
|
||||
{
|
||||
"position": 3,
|
||||
"dataType": "ID",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 2,
|
||||
"fixedValue": "22"
|
||||
},
|
||||
{
|
||||
"position": 4,
|
||||
"dataType": "ID",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 1,
|
||||
"codes": [
|
||||
"0",
|
||||
"1"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"segmentId": "TRN",
|
||||
"usage": "SITUATIONAL",
|
||||
"minUse": 0,
|
||||
"maxUse": 2,
|
||||
"loopId": "2000C",
|
||||
"parentLoopId": "2000B",
|
||||
"loopMaxRepeat": 999,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "ID",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 2
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "AN",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 50
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"segmentId": "NM1",
|
||||
"usage": "REQUIRED",
|
||||
"minUse": 1,
|
||||
"maxUse": 1,
|
||||
"loopId": "2100C",
|
||||
"parentLoopId": "2000C",
|
||||
"loopMaxRepeat": 999,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "ID",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 3,
|
||||
"fixedValue": "IL"
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "ID",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 1,
|
||||
"codes": [
|
||||
"1",
|
||||
"2"
|
||||
]
|
||||
},
|
||||
{
|
||||
"position": 3,
|
||||
"dataType": "AN",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 60
|
||||
},
|
||||
{
|
||||
"position": 4,
|
||||
"dataType": "AN",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 35
|
||||
},
|
||||
{
|
||||
"position": 8,
|
||||
"dataType": "ID",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 2
|
||||
},
|
||||
{
|
||||
"position": 9,
|
||||
"dataType": "AN",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 80
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"segmentId": "N3",
|
||||
"usage": "SITUATIONAL",
|
||||
"minUse": 0,
|
||||
"maxUse": 1,
|
||||
"loopId": "2100C",
|
||||
"parentLoopId": "2000C",
|
||||
"loopMaxRepeat": 999,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "AN",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 55
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"segmentId": "N4",
|
||||
"usage": "SITUATIONAL",
|
||||
"minUse": 0,
|
||||
"maxUse": 1,
|
||||
"loopId": "2100C",
|
||||
"parentLoopId": "2000C",
|
||||
"loopMaxRepeat": 999,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "AN",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 30
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "ID",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 2
|
||||
},
|
||||
{
|
||||
"position": 3,
|
||||
"dataType": "ID",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 15
|
||||
},
|
||||
{
|
||||
"position": 4,
|
||||
"dataType": "ID",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 3
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"segmentId": "REF",
|
||||
"usage": "SITUATIONAL",
|
||||
"minUse": 0,
|
||||
"maxUse": 5,
|
||||
"loopId": "2100C",
|
||||
"parentLoopId": "2000C",
|
||||
"loopMaxRepeat": 999,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "ID",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 3
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "AN",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 80
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"segmentId": "DMG",
|
||||
"usage": "SITUATIONAL",
|
||||
"minUse": 0,
|
||||
"maxUse": 1,
|
||||
"loopId": "2100C",
|
||||
"parentLoopId": "2000C",
|
||||
"loopMaxRepeat": 999,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "ID",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 3
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "DT",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 8
|
||||
},
|
||||
{
|
||||
"position": 3,
|
||||
"dataType": "ID",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 1
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"segmentId": "DTP",
|
||||
"usage": "SITUATIONAL",
|
||||
"minUse": 0,
|
||||
"maxUse": 20,
|
||||
"loopId": "2100C",
|
||||
"parentLoopId": "2000C",
|
||||
"loopMaxRepeat": 999,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "ID",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 3
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "ID",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 3,
|
||||
"codes": [
|
||||
"D8",
|
||||
"RD8"
|
||||
]
|
||||
},
|
||||
{
|
||||
"position": 3,
|
||||
"dataType": "AN",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 35
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"segmentId": "AAA",
|
||||
"usage": "SITUATIONAL",
|
||||
"minUse": 0,
|
||||
"maxUse": 1,
|
||||
"loopId": "2100C",
|
||||
"parentLoopId": "2000C",
|
||||
"loopMaxRepeat": 999
|
||||
},
|
||||
{
|
||||
"segmentId": "EB",
|
||||
"usage": "SITUATIONAL",
|
||||
"minUse": 0,
|
||||
"maxUse": 1,
|
||||
"loopId": "2110C",
|
||||
"parentLoopId": "2100C",
|
||||
"loopMaxRepeat": 999,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "ID",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 2
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "ID",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 3
|
||||
},
|
||||
{
|
||||
"position": 3,
|
||||
"dataType": "ID",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 2
|
||||
},
|
||||
{
|
||||
"position": 7,
|
||||
"dataType": "R",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 18
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"segmentId": "HSD",
|
||||
"usage": "SITUATIONAL",
|
||||
"minUse": 0,
|
||||
"maxUse": 1,
|
||||
"loopId": "2110C",
|
||||
"parentLoopId": "2100C",
|
||||
"loopMaxRepeat": 999
|
||||
},
|
||||
{
|
||||
"segmentId": "REF",
|
||||
"usage": "SITUATIONAL",
|
||||
"minUse": 0,
|
||||
"maxUse": 5,
|
||||
"loopId": "2110C",
|
||||
"parentLoopId": "2100C",
|
||||
"loopMaxRepeat": 999
|
||||
},
|
||||
{
|
||||
"segmentId": "DTP",
|
||||
"usage": "SITUATIONAL",
|
||||
"minUse": 0,
|
||||
"maxUse": 20,
|
||||
"loopId": "2110C",
|
||||
"parentLoopId": "2100C",
|
||||
"loopMaxRepeat": 999,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "ID",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 3
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "ID",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 3,
|
||||
"codes": [
|
||||
"D8",
|
||||
"RD8"
|
||||
]
|
||||
},
|
||||
{
|
||||
"position": 3,
|
||||
"dataType": "AN",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 35
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"segmentId": "AAA",
|
||||
"usage": "SITUATIONAL",
|
||||
"minUse": 0,
|
||||
"maxUse": 1,
|
||||
"loopId": "2110C",
|
||||
"parentLoopId": "2100C",
|
||||
"loopMaxRepeat": 999
|
||||
},
|
||||
{
|
||||
"segmentId": "SE",
|
||||
"usage": "REQUIRED",
|
||||
"minUse": 1,
|
||||
"maxUse": 1,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "N0",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 10
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "AN",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 9
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"rules": [
|
||||
{
|
||||
"type": "SITUATIONAL",
|
||||
"expression": "when(present(TRN02), or(present(EB), present(AAA)))",
|
||||
"snipLevel": 4,
|
||||
"severity": "ERROR",
|
||||
"message": "Benefits or rejection reason required for inquiry response"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,762 @@
|
||||
{
|
||||
"name": "QQQ baseline guide 276 005010X212",
|
||||
"standard": "X12",
|
||||
"version": "005010X212",
|
||||
"transactionSet": "276",
|
||||
"functionalIdCode": "HR",
|
||||
"segments": [
|
||||
{
|
||||
"segmentId": "ST",
|
||||
"usage": "REQUIRED",
|
||||
"minUse": 1,
|
||||
"maxUse": 1,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "ID",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 3,
|
||||
"fixedValue": "276"
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "AN",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 9
|
||||
},
|
||||
{
|
||||
"position": 3,
|
||||
"dataType": "ID",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 35,
|
||||
"fixedValue": "005010X212"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"segmentId": "BHT",
|
||||
"usage": "REQUIRED",
|
||||
"minUse": 1,
|
||||
"maxUse": 1,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "ID",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 4
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "ID",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 2
|
||||
},
|
||||
{
|
||||
"position": 3,
|
||||
"dataType": "AN",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 50
|
||||
},
|
||||
{
|
||||
"position": 4,
|
||||
"dataType": "DT",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 8
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"segmentId": "HL",
|
||||
"usage": "REQUIRED",
|
||||
"minUse": 1,
|
||||
"maxUse": 1,
|
||||
"loopId": "2000A",
|
||||
"parentLoopId": null,
|
||||
"loopMaxRepeat": 999,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "N0",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 12
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "N0",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 12
|
||||
},
|
||||
{
|
||||
"position": 3,
|
||||
"dataType": "ID",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 2,
|
||||
"fixedValue": "20"
|
||||
},
|
||||
{
|
||||
"position": 4,
|
||||
"dataType": "ID",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 1,
|
||||
"codes": [
|
||||
"0",
|
||||
"1"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"segmentId": "NM1",
|
||||
"usage": "REQUIRED",
|
||||
"minUse": 1,
|
||||
"maxUse": 1,
|
||||
"loopId": "2100A",
|
||||
"parentLoopId": "2000A",
|
||||
"loopMaxRepeat": 999,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "ID",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 3,
|
||||
"fixedValue": "PR"
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "ID",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 1,
|
||||
"codes": [
|
||||
"1",
|
||||
"2"
|
||||
]
|
||||
},
|
||||
{
|
||||
"position": 3,
|
||||
"dataType": "AN",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 60
|
||||
},
|
||||
{
|
||||
"position": 4,
|
||||
"dataType": "AN",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 35
|
||||
},
|
||||
{
|
||||
"position": 8,
|
||||
"dataType": "ID",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 2
|
||||
},
|
||||
{
|
||||
"position": 9,
|
||||
"dataType": "AN",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 80
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"segmentId": "HL",
|
||||
"usage": "REQUIRED",
|
||||
"minUse": 1,
|
||||
"maxUse": 1,
|
||||
"loopId": "2000B",
|
||||
"parentLoopId": "2000A",
|
||||
"loopMaxRepeat": 999,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "N0",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 12
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "N0",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 12
|
||||
},
|
||||
{
|
||||
"position": 3,
|
||||
"dataType": "ID",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 2,
|
||||
"fixedValue": "21"
|
||||
},
|
||||
{
|
||||
"position": 4,
|
||||
"dataType": "ID",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 1,
|
||||
"codes": [
|
||||
"0",
|
||||
"1"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"segmentId": "NM1",
|
||||
"usage": "REQUIRED",
|
||||
"minUse": 1,
|
||||
"maxUse": 1,
|
||||
"loopId": "2100B",
|
||||
"parentLoopId": "2000B",
|
||||
"loopMaxRepeat": 999,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "ID",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 3,
|
||||
"fixedValue": "1P"
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "ID",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 1,
|
||||
"codes": [
|
||||
"1",
|
||||
"2"
|
||||
]
|
||||
},
|
||||
{
|
||||
"position": 3,
|
||||
"dataType": "AN",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 60
|
||||
},
|
||||
{
|
||||
"position": 4,
|
||||
"dataType": "AN",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 35
|
||||
},
|
||||
{
|
||||
"position": 8,
|
||||
"dataType": "ID",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 2
|
||||
},
|
||||
{
|
||||
"position": 9,
|
||||
"dataType": "AN",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 80
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"segmentId": "HL",
|
||||
"usage": "REQUIRED",
|
||||
"minUse": 1,
|
||||
"maxUse": 1,
|
||||
"loopId": "2000C",
|
||||
"parentLoopId": "2000B",
|
||||
"loopMaxRepeat": 999,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "N0",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 12
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "N0",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 12
|
||||
},
|
||||
{
|
||||
"position": 3,
|
||||
"dataType": "ID",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 2,
|
||||
"fixedValue": "22"
|
||||
},
|
||||
{
|
||||
"position": 4,
|
||||
"dataType": "ID",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 1,
|
||||
"codes": [
|
||||
"0",
|
||||
"1"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"segmentId": "TRN",
|
||||
"usage": "SITUATIONAL",
|
||||
"minUse": 0,
|
||||
"maxUse": 2,
|
||||
"loopId": "2000C",
|
||||
"parentLoopId": "2000B",
|
||||
"loopMaxRepeat": 999,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "ID",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 2
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "AN",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 50
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"segmentId": "NM1",
|
||||
"usage": "REQUIRED",
|
||||
"minUse": 1,
|
||||
"maxUse": 1,
|
||||
"loopId": "2100C",
|
||||
"parentLoopId": "2000C",
|
||||
"loopMaxRepeat": 999,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "ID",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 3,
|
||||
"fixedValue": "IL"
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "ID",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 1,
|
||||
"codes": [
|
||||
"1",
|
||||
"2"
|
||||
]
|
||||
},
|
||||
{
|
||||
"position": 3,
|
||||
"dataType": "AN",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 60
|
||||
},
|
||||
{
|
||||
"position": 4,
|
||||
"dataType": "AN",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 35
|
||||
},
|
||||
{
|
||||
"position": 8,
|
||||
"dataType": "ID",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 2
|
||||
},
|
||||
{
|
||||
"position": 9,
|
||||
"dataType": "AN",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 80
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"segmentId": "N3",
|
||||
"usage": "SITUATIONAL",
|
||||
"minUse": 0,
|
||||
"maxUse": 1,
|
||||
"loopId": "2100C",
|
||||
"parentLoopId": "2000C",
|
||||
"loopMaxRepeat": 999,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "AN",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 55
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"segmentId": "N4",
|
||||
"usage": "SITUATIONAL",
|
||||
"minUse": 0,
|
||||
"maxUse": 1,
|
||||
"loopId": "2100C",
|
||||
"parentLoopId": "2000C",
|
||||
"loopMaxRepeat": 999,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "AN",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 30
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "ID",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 2
|
||||
},
|
||||
{
|
||||
"position": 3,
|
||||
"dataType": "ID",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 15
|
||||
},
|
||||
{
|
||||
"position": 4,
|
||||
"dataType": "ID",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 3
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"segmentId": "REF",
|
||||
"usage": "SITUATIONAL",
|
||||
"minUse": 0,
|
||||
"maxUse": 5,
|
||||
"loopId": "2100C",
|
||||
"parentLoopId": "2000C",
|
||||
"loopMaxRepeat": 999,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "ID",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 3
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "AN",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 80
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"segmentId": "DMG",
|
||||
"usage": "SITUATIONAL",
|
||||
"minUse": 0,
|
||||
"maxUse": 1,
|
||||
"loopId": "2100C",
|
||||
"parentLoopId": "2000C",
|
||||
"loopMaxRepeat": 999,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "ID",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 3
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "DT",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 8
|
||||
},
|
||||
{
|
||||
"position": 3,
|
||||
"dataType": "ID",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 1
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"segmentId": "DTP",
|
||||
"usage": "SITUATIONAL",
|
||||
"minUse": 0,
|
||||
"maxUse": 20,
|
||||
"loopId": "2100C",
|
||||
"parentLoopId": "2000C",
|
||||
"loopMaxRepeat": 999,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "ID",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 3
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "ID",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 3,
|
||||
"codes": [
|
||||
"D8",
|
||||
"RD8"
|
||||
]
|
||||
},
|
||||
{
|
||||
"position": 3,
|
||||
"dataType": "AN",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 35
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"segmentId": "TRN",
|
||||
"usage": "REQUIRED",
|
||||
"minUse": 1,
|
||||
"maxUse": 1,
|
||||
"loopId": "2200C",
|
||||
"parentLoopId": "2100C",
|
||||
"loopMaxRepeat": 999,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "ID",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 2
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "AN",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 50
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"segmentId": "STC",
|
||||
"usage": "SITUATIONAL",
|
||||
"minUse": 0,
|
||||
"maxUse": 1,
|
||||
"loopId": "2200C",
|
||||
"parentLoopId": "2100C",
|
||||
"loopMaxRepeat": 999,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "ID",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 3,
|
||||
"componentPosition": 1
|
||||
},
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "ID",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 5,
|
||||
"componentPosition": 2
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "DT",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 8
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"segmentId": "REF",
|
||||
"usage": "SITUATIONAL",
|
||||
"minUse": 0,
|
||||
"maxUse": 5,
|
||||
"loopId": "2200C",
|
||||
"parentLoopId": "2100C",
|
||||
"loopMaxRepeat": 999
|
||||
},
|
||||
{
|
||||
"segmentId": "AMT",
|
||||
"usage": "SITUATIONAL",
|
||||
"minUse": 0,
|
||||
"maxUse": 5,
|
||||
"loopId": "2200C",
|
||||
"parentLoopId": "2100C",
|
||||
"loopMaxRepeat": 999
|
||||
},
|
||||
{
|
||||
"segmentId": "DTP",
|
||||
"usage": "SITUATIONAL",
|
||||
"minUse": 0,
|
||||
"maxUse": 20,
|
||||
"loopId": "2200C",
|
||||
"parentLoopId": "2100C",
|
||||
"loopMaxRepeat": 999,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "ID",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 3
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "ID",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 3,
|
||||
"codes": [
|
||||
"D8",
|
||||
"RD8"
|
||||
]
|
||||
},
|
||||
{
|
||||
"position": 3,
|
||||
"dataType": "AN",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 35
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"segmentId": "SVC",
|
||||
"usage": "SITUATIONAL",
|
||||
"minUse": 0,
|
||||
"maxUse": 1,
|
||||
"loopId": "2220C",
|
||||
"parentLoopId": "2200C",
|
||||
"loopMaxRepeat": 999,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "ID",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 2,
|
||||
"componentPosition": 1
|
||||
},
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "ID",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 5,
|
||||
"componentPosition": 2
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "R",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 18
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"segmentId": "DTP",
|
||||
"usage": "SITUATIONAL",
|
||||
"minUse": 0,
|
||||
"maxUse": 20,
|
||||
"loopId": "2220C",
|
||||
"parentLoopId": "2200C",
|
||||
"loopMaxRepeat": 999,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "ID",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 3
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "ID",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 3,
|
||||
"codes": [
|
||||
"D8",
|
||||
"RD8"
|
||||
]
|
||||
},
|
||||
{
|
||||
"position": 3,
|
||||
"dataType": "AN",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 35
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"segmentId": "SE",
|
||||
"usage": "REQUIRED",
|
||||
"minUse": 1,
|
||||
"maxUse": 1,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "N0",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 10
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "AN",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 9
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,762 @@
|
||||
{
|
||||
"name": "QQQ baseline guide 277 005010X212",
|
||||
"standard": "X12",
|
||||
"version": "005010X212",
|
||||
"transactionSet": "277",
|
||||
"functionalIdCode": "HN",
|
||||
"segments": [
|
||||
{
|
||||
"segmentId": "ST",
|
||||
"usage": "REQUIRED",
|
||||
"minUse": 1,
|
||||
"maxUse": 1,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "ID",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 3,
|
||||
"fixedValue": "277"
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "AN",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 9
|
||||
},
|
||||
{
|
||||
"position": 3,
|
||||
"dataType": "ID",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 35,
|
||||
"fixedValue": "005010X212"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"segmentId": "BHT",
|
||||
"usage": "REQUIRED",
|
||||
"minUse": 1,
|
||||
"maxUse": 1,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "ID",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 4
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "ID",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 2
|
||||
},
|
||||
{
|
||||
"position": 3,
|
||||
"dataType": "AN",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 50
|
||||
},
|
||||
{
|
||||
"position": 4,
|
||||
"dataType": "DT",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 8
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"segmentId": "HL",
|
||||
"usage": "REQUIRED",
|
||||
"minUse": 1,
|
||||
"maxUse": 1,
|
||||
"loopId": "2000A",
|
||||
"parentLoopId": null,
|
||||
"loopMaxRepeat": 999,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "N0",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 12
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "N0",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 12
|
||||
},
|
||||
{
|
||||
"position": 3,
|
||||
"dataType": "ID",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 2,
|
||||
"fixedValue": "20"
|
||||
},
|
||||
{
|
||||
"position": 4,
|
||||
"dataType": "ID",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 1,
|
||||
"codes": [
|
||||
"0",
|
||||
"1"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"segmentId": "NM1",
|
||||
"usage": "REQUIRED",
|
||||
"minUse": 1,
|
||||
"maxUse": 1,
|
||||
"loopId": "2100A",
|
||||
"parentLoopId": "2000A",
|
||||
"loopMaxRepeat": 999,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "ID",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 3,
|
||||
"fixedValue": "PR"
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "ID",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 1,
|
||||
"codes": [
|
||||
"1",
|
||||
"2"
|
||||
]
|
||||
},
|
||||
{
|
||||
"position": 3,
|
||||
"dataType": "AN",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 60
|
||||
},
|
||||
{
|
||||
"position": 4,
|
||||
"dataType": "AN",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 35
|
||||
},
|
||||
{
|
||||
"position": 8,
|
||||
"dataType": "ID",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 2
|
||||
},
|
||||
{
|
||||
"position": 9,
|
||||
"dataType": "AN",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 80
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"segmentId": "HL",
|
||||
"usage": "REQUIRED",
|
||||
"minUse": 1,
|
||||
"maxUse": 1,
|
||||
"loopId": "2000B",
|
||||
"parentLoopId": "2000A",
|
||||
"loopMaxRepeat": 999,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "N0",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 12
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "N0",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 12
|
||||
},
|
||||
{
|
||||
"position": 3,
|
||||
"dataType": "ID",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 2,
|
||||
"fixedValue": "21"
|
||||
},
|
||||
{
|
||||
"position": 4,
|
||||
"dataType": "ID",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 1,
|
||||
"codes": [
|
||||
"0",
|
||||
"1"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"segmentId": "NM1",
|
||||
"usage": "REQUIRED",
|
||||
"minUse": 1,
|
||||
"maxUse": 1,
|
||||
"loopId": "2100B",
|
||||
"parentLoopId": "2000B",
|
||||
"loopMaxRepeat": 999,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "ID",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 3,
|
||||
"fixedValue": "1P"
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "ID",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 1,
|
||||
"codes": [
|
||||
"1",
|
||||
"2"
|
||||
]
|
||||
},
|
||||
{
|
||||
"position": 3,
|
||||
"dataType": "AN",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 60
|
||||
},
|
||||
{
|
||||
"position": 4,
|
||||
"dataType": "AN",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 35
|
||||
},
|
||||
{
|
||||
"position": 8,
|
||||
"dataType": "ID",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 2
|
||||
},
|
||||
{
|
||||
"position": 9,
|
||||
"dataType": "AN",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 80
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"segmentId": "HL",
|
||||
"usage": "REQUIRED",
|
||||
"minUse": 1,
|
||||
"maxUse": 1,
|
||||
"loopId": "2000C",
|
||||
"parentLoopId": "2000B",
|
||||
"loopMaxRepeat": 999,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "N0",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 12
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "N0",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 12
|
||||
},
|
||||
{
|
||||
"position": 3,
|
||||
"dataType": "ID",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 2,
|
||||
"fixedValue": "22"
|
||||
},
|
||||
{
|
||||
"position": 4,
|
||||
"dataType": "ID",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 1,
|
||||
"codes": [
|
||||
"0",
|
||||
"1"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"segmentId": "TRN",
|
||||
"usage": "SITUATIONAL",
|
||||
"minUse": 0,
|
||||
"maxUse": 2,
|
||||
"loopId": "2000C",
|
||||
"parentLoopId": "2000B",
|
||||
"loopMaxRepeat": 999,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "ID",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 2
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "AN",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 50
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"segmentId": "NM1",
|
||||
"usage": "REQUIRED",
|
||||
"minUse": 1,
|
||||
"maxUse": 1,
|
||||
"loopId": "2100C",
|
||||
"parentLoopId": "2000C",
|
||||
"loopMaxRepeat": 999,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "ID",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 3,
|
||||
"fixedValue": "IL"
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "ID",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 1,
|
||||
"codes": [
|
||||
"1",
|
||||
"2"
|
||||
]
|
||||
},
|
||||
{
|
||||
"position": 3,
|
||||
"dataType": "AN",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 60
|
||||
},
|
||||
{
|
||||
"position": 4,
|
||||
"dataType": "AN",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 35
|
||||
},
|
||||
{
|
||||
"position": 8,
|
||||
"dataType": "ID",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 2
|
||||
},
|
||||
{
|
||||
"position": 9,
|
||||
"dataType": "AN",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 80
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"segmentId": "N3",
|
||||
"usage": "SITUATIONAL",
|
||||
"minUse": 0,
|
||||
"maxUse": 1,
|
||||
"loopId": "2100C",
|
||||
"parentLoopId": "2000C",
|
||||
"loopMaxRepeat": 999,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "AN",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 55
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"segmentId": "N4",
|
||||
"usage": "SITUATIONAL",
|
||||
"minUse": 0,
|
||||
"maxUse": 1,
|
||||
"loopId": "2100C",
|
||||
"parentLoopId": "2000C",
|
||||
"loopMaxRepeat": 999,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "AN",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 30
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "ID",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 2
|
||||
},
|
||||
{
|
||||
"position": 3,
|
||||
"dataType": "ID",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 15
|
||||
},
|
||||
{
|
||||
"position": 4,
|
||||
"dataType": "ID",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 3
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"segmentId": "REF",
|
||||
"usage": "SITUATIONAL",
|
||||
"minUse": 0,
|
||||
"maxUse": 5,
|
||||
"loopId": "2100C",
|
||||
"parentLoopId": "2000C",
|
||||
"loopMaxRepeat": 999,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "ID",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 3
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "AN",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 80
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"segmentId": "DMG",
|
||||
"usage": "SITUATIONAL",
|
||||
"minUse": 0,
|
||||
"maxUse": 1,
|
||||
"loopId": "2100C",
|
||||
"parentLoopId": "2000C",
|
||||
"loopMaxRepeat": 999,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "ID",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 3
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "DT",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 8
|
||||
},
|
||||
{
|
||||
"position": 3,
|
||||
"dataType": "ID",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 1
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"segmentId": "DTP",
|
||||
"usage": "SITUATIONAL",
|
||||
"minUse": 0,
|
||||
"maxUse": 20,
|
||||
"loopId": "2100C",
|
||||
"parentLoopId": "2000C",
|
||||
"loopMaxRepeat": 999,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "ID",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 3
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "ID",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 3,
|
||||
"codes": [
|
||||
"D8",
|
||||
"RD8"
|
||||
]
|
||||
},
|
||||
{
|
||||
"position": 3,
|
||||
"dataType": "AN",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 35
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"segmentId": "TRN",
|
||||
"usage": "REQUIRED",
|
||||
"minUse": 1,
|
||||
"maxUse": 1,
|
||||
"loopId": "2200C",
|
||||
"parentLoopId": "2100C",
|
||||
"loopMaxRepeat": 999,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "ID",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 2
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "AN",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 50
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"segmentId": "STC",
|
||||
"usage": "REQUIRED",
|
||||
"minUse": 1,
|
||||
"maxUse": 1,
|
||||
"loopId": "2200C",
|
||||
"parentLoopId": "2100C",
|
||||
"loopMaxRepeat": 999,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "ID",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 3,
|
||||
"componentPosition": 1
|
||||
},
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "ID",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 5,
|
||||
"componentPosition": 2
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "DT",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 8
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"segmentId": "REF",
|
||||
"usage": "SITUATIONAL",
|
||||
"minUse": 0,
|
||||
"maxUse": 5,
|
||||
"loopId": "2200C",
|
||||
"parentLoopId": "2100C",
|
||||
"loopMaxRepeat": 999
|
||||
},
|
||||
{
|
||||
"segmentId": "AMT",
|
||||
"usage": "SITUATIONAL",
|
||||
"minUse": 0,
|
||||
"maxUse": 5,
|
||||
"loopId": "2200C",
|
||||
"parentLoopId": "2100C",
|
||||
"loopMaxRepeat": 999
|
||||
},
|
||||
{
|
||||
"segmentId": "DTP",
|
||||
"usage": "SITUATIONAL",
|
||||
"minUse": 0,
|
||||
"maxUse": 20,
|
||||
"loopId": "2200C",
|
||||
"parentLoopId": "2100C",
|
||||
"loopMaxRepeat": 999,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "ID",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 3
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "ID",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 3,
|
||||
"codes": [
|
||||
"D8",
|
||||
"RD8"
|
||||
]
|
||||
},
|
||||
{
|
||||
"position": 3,
|
||||
"dataType": "AN",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 35
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"segmentId": "SVC",
|
||||
"usage": "SITUATIONAL",
|
||||
"minUse": 0,
|
||||
"maxUse": 1,
|
||||
"loopId": "2220C",
|
||||
"parentLoopId": "2200C",
|
||||
"loopMaxRepeat": 999,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "ID",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 2,
|
||||
"componentPosition": 1
|
||||
},
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "ID",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 5,
|
||||
"componentPosition": 2
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "R",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 18
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"segmentId": "DTP",
|
||||
"usage": "SITUATIONAL",
|
||||
"minUse": 0,
|
||||
"maxUse": 20,
|
||||
"loopId": "2220C",
|
||||
"parentLoopId": "2200C",
|
||||
"loopMaxRepeat": 999,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "ID",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 3
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "ID",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 3,
|
||||
"codes": [
|
||||
"D8",
|
||||
"RD8"
|
||||
]
|
||||
},
|
||||
{
|
||||
"position": 3,
|
||||
"dataType": "AN",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 35
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"segmentId": "SE",
|
||||
"usage": "REQUIRED",
|
||||
"minUse": 1,
|
||||
"maxUse": 1,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "N0",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 10
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "AN",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 9
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,539 @@
|
||||
{
|
||||
"name": "QQQ baseline guide 834 005010X220A1",
|
||||
"standard": "X12",
|
||||
"version": "005010X220A1",
|
||||
"transactionSet": "834",
|
||||
"functionalIdCode": "BE",
|
||||
"segments": [
|
||||
{
|
||||
"segmentId": "ST",
|
||||
"usage": "REQUIRED",
|
||||
"minUse": 1,
|
||||
"maxUse": 1,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "ID",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 3,
|
||||
"fixedValue": "834"
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "AN",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 9
|
||||
},
|
||||
{
|
||||
"position": 3,
|
||||
"dataType": "ID",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 35,
|
||||
"fixedValue": "005010X220A1"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"segmentId": "BGN",
|
||||
"usage": "REQUIRED",
|
||||
"minUse": 1,
|
||||
"maxUse": 1,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "ID",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 2
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "AN",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 50
|
||||
},
|
||||
{
|
||||
"position": 3,
|
||||
"dataType": "DT",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 8
|
||||
},
|
||||
{
|
||||
"position": 4,
|
||||
"dataType": "TM",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 8
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"segmentId": "REF",
|
||||
"usage": "SITUATIONAL",
|
||||
"minUse": 0,
|
||||
"maxUse": 5
|
||||
},
|
||||
{
|
||||
"segmentId": "DTP",
|
||||
"usage": "SITUATIONAL",
|
||||
"minUse": 0,
|
||||
"maxUse": 20,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "ID",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 3
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "ID",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 3,
|
||||
"codes": [
|
||||
"D8",
|
||||
"RD8"
|
||||
]
|
||||
},
|
||||
{
|
||||
"position": 3,
|
||||
"dataType": "AN",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 35
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"segmentId": "N1",
|
||||
"usage": "SITUATIONAL",
|
||||
"minUse": 0,
|
||||
"maxUse": 1,
|
||||
"loopId": "1000A",
|
||||
"parentLoopId": null,
|
||||
"loopMaxRepeat": 999,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "ID",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 3,
|
||||
"fixedValue": "P5"
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "AN",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 60
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"segmentId": "N1",
|
||||
"usage": "SITUATIONAL",
|
||||
"minUse": 0,
|
||||
"maxUse": 1,
|
||||
"loopId": "1000B",
|
||||
"parentLoopId": null,
|
||||
"loopMaxRepeat": 999,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "ID",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 3,
|
||||
"fixedValue": "IN"
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "AN",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 60
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"segmentId": "INS",
|
||||
"usage": "REQUIRED",
|
||||
"minUse": 1,
|
||||
"maxUse": 1,
|
||||
"loopId": "2000",
|
||||
"parentLoopId": null,
|
||||
"loopMaxRepeat": 999,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "ID",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 1,
|
||||
"codes": [
|
||||
"Y",
|
||||
"N"
|
||||
]
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "ID",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 2
|
||||
},
|
||||
{
|
||||
"position": 3,
|
||||
"dataType": "ID",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 3
|
||||
}
|
||||
],
|
||||
"rules": [
|
||||
{
|
||||
"type": "SITUATIONAL",
|
||||
"expression": "when(eq(INS01, 'Y'), present(DMG))",
|
||||
"snipLevel": 4,
|
||||
"severity": "ERROR",
|
||||
"message": "Subscriber demographics required",
|
||||
"ik3Code": "8",
|
||||
"ik4Code": "2"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"segmentId": "REF",
|
||||
"usage": "SITUATIONAL",
|
||||
"minUse": 0,
|
||||
"maxUse": 10,
|
||||
"loopId": "2000",
|
||||
"parentLoopId": null,
|
||||
"loopMaxRepeat": 999,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "ID",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 3
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "AN",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 50
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"segmentId": "DTP",
|
||||
"usage": "SITUATIONAL",
|
||||
"minUse": 0,
|
||||
"maxUse": 20,
|
||||
"loopId": "2000",
|
||||
"parentLoopId": null,
|
||||
"loopMaxRepeat": 999,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "ID",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 3
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "ID",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 3,
|
||||
"codes": [
|
||||
"D8",
|
||||
"RD8"
|
||||
]
|
||||
},
|
||||
{
|
||||
"position": 3,
|
||||
"dataType": "AN",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 35
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"segmentId": "NM1",
|
||||
"usage": "REQUIRED",
|
||||
"minUse": 1,
|
||||
"maxUse": 1,
|
||||
"loopId": "2100A",
|
||||
"parentLoopId": "2000",
|
||||
"loopMaxRepeat": 999,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "ID",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 3,
|
||||
"fixedValue": "IL"
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "ID",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 1,
|
||||
"codes": [
|
||||
"1",
|
||||
"2"
|
||||
]
|
||||
},
|
||||
{
|
||||
"position": 3,
|
||||
"dataType": "AN",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 60
|
||||
},
|
||||
{
|
||||
"position": 4,
|
||||
"dataType": "AN",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 35
|
||||
},
|
||||
{
|
||||
"position": 8,
|
||||
"dataType": "ID",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 2
|
||||
},
|
||||
{
|
||||
"position": 9,
|
||||
"dataType": "AN",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 80
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"segmentId": "N3",
|
||||
"usage": "SITUATIONAL",
|
||||
"minUse": 0,
|
||||
"maxUse": 1,
|
||||
"loopId": "2100A",
|
||||
"parentLoopId": "2000",
|
||||
"loopMaxRepeat": 999,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "AN",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 55
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"segmentId": "N4",
|
||||
"usage": "SITUATIONAL",
|
||||
"minUse": 0,
|
||||
"maxUse": 1,
|
||||
"loopId": "2100A",
|
||||
"parentLoopId": "2000",
|
||||
"loopMaxRepeat": 999,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "AN",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 30
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "ID",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 2
|
||||
},
|
||||
{
|
||||
"position": 3,
|
||||
"dataType": "ID",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 15
|
||||
},
|
||||
{
|
||||
"position": 4,
|
||||
"dataType": "ID",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 3
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"segmentId": "REF",
|
||||
"usage": "SITUATIONAL",
|
||||
"minUse": 0,
|
||||
"maxUse": 5,
|
||||
"loopId": "2100A",
|
||||
"parentLoopId": "2000",
|
||||
"loopMaxRepeat": 999,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "ID",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 3
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "AN",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 80
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"segmentId": "DMG",
|
||||
"usage": "SITUATIONAL",
|
||||
"minUse": 0,
|
||||
"maxUse": 1,
|
||||
"loopId": "2100A",
|
||||
"parentLoopId": "2000",
|
||||
"loopMaxRepeat": 999,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "ID",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 3
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "DT",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 8
|
||||
},
|
||||
{
|
||||
"position": 3,
|
||||
"dataType": "ID",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 1
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"segmentId": "HD",
|
||||
"usage": "SITUATIONAL",
|
||||
"minUse": 0,
|
||||
"maxUse": 1,
|
||||
"loopId": "2300",
|
||||
"parentLoopId": "2000",
|
||||
"loopMaxRepeat": 999,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "ID",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 3
|
||||
},
|
||||
{
|
||||
"position": 3,
|
||||
"dataType": "ID",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 3
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"segmentId": "DTP",
|
||||
"usage": "SITUATIONAL",
|
||||
"minUse": 0,
|
||||
"maxUse": 20,
|
||||
"loopId": "2300",
|
||||
"parentLoopId": "2000",
|
||||
"loopMaxRepeat": 999,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "ID",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 3
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "ID",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 3,
|
||||
"codes": [
|
||||
"D8",
|
||||
"RD8"
|
||||
]
|
||||
},
|
||||
{
|
||||
"position": 3,
|
||||
"dataType": "AN",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 35
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"segmentId": "REF",
|
||||
"usage": "SITUATIONAL",
|
||||
"minUse": 0,
|
||||
"maxUse": 5,
|
||||
"loopId": "2300",
|
||||
"parentLoopId": "2000",
|
||||
"loopMaxRepeat": 999
|
||||
},
|
||||
{
|
||||
"segmentId": "SE",
|
||||
"usage": "REQUIRED",
|
||||
"minUse": 1,
|
||||
"maxUse": 1,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "N0",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 10
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "AN",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 9
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,951 @@
|
||||
{
|
||||
"name": "QQQ baseline guide 835 005010X221A1",
|
||||
"standard": "X12",
|
||||
"version": "005010X221A1",
|
||||
"transactionSet": "835",
|
||||
"functionalIdCode": "HP",
|
||||
"segments": [
|
||||
{
|
||||
"segmentId": "ST",
|
||||
"usage": "REQUIRED",
|
||||
"minUse": 1,
|
||||
"maxUse": 1,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "ID",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 3,
|
||||
"fixedValue": "835"
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "AN",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 9
|
||||
},
|
||||
{
|
||||
"position": 3,
|
||||
"dataType": "ID",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 35,
|
||||
"fixedValue": "005010X221A1"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"segmentId": "BPR",
|
||||
"usage": "REQUIRED",
|
||||
"minUse": 1,
|
||||
"maxUse": 1,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "ID",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 1
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "R",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 18
|
||||
},
|
||||
{
|
||||
"position": 3,
|
||||
"dataType": "ID",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 1
|
||||
},
|
||||
{
|
||||
"position": 4,
|
||||
"dataType": "ID",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 3
|
||||
},
|
||||
{
|
||||
"position": 16,
|
||||
"dataType": "DT",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 8
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"segmentId": "TRN",
|
||||
"usage": "REQUIRED",
|
||||
"minUse": 1,
|
||||
"maxUse": 1,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "ID",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 2
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "AN",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 50
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"segmentId": "REF",
|
||||
"usage": "SITUATIONAL",
|
||||
"minUse": 0,
|
||||
"maxUse": 5
|
||||
},
|
||||
{
|
||||
"segmentId": "DTM",
|
||||
"usage": "SITUATIONAL",
|
||||
"minUse": 0,
|
||||
"maxUse": 5
|
||||
},
|
||||
{
|
||||
"segmentId": "N1",
|
||||
"usage": "SITUATIONAL",
|
||||
"minUse": 0,
|
||||
"maxUse": 1,
|
||||
"loopId": "1000A",
|
||||
"parentLoopId": null,
|
||||
"loopMaxRepeat": 999,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "ID",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 2,
|
||||
"fixedValue": "PR"
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "AN",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 60
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"segmentId": "N3",
|
||||
"usage": "SITUATIONAL",
|
||||
"minUse": 0,
|
||||
"maxUse": 1,
|
||||
"loopId": "1000A",
|
||||
"parentLoopId": null,
|
||||
"loopMaxRepeat": 999,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "AN",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 55
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"segmentId": "N4",
|
||||
"usage": "SITUATIONAL",
|
||||
"minUse": 0,
|
||||
"maxUse": 1,
|
||||
"loopId": "1000A",
|
||||
"parentLoopId": null,
|
||||
"loopMaxRepeat": 999,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "AN",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 30
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "ID",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 2
|
||||
},
|
||||
{
|
||||
"position": 3,
|
||||
"dataType": "ID",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 15
|
||||
},
|
||||
{
|
||||
"position": 4,
|
||||
"dataType": "ID",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 3
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"segmentId": "REF",
|
||||
"usage": "SITUATIONAL",
|
||||
"minUse": 0,
|
||||
"maxUse": 5,
|
||||
"loopId": "1000A",
|
||||
"parentLoopId": null,
|
||||
"loopMaxRepeat": 999,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "ID",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 3
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "AN",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 80
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"segmentId": "N1",
|
||||
"usage": "SITUATIONAL",
|
||||
"minUse": 0,
|
||||
"maxUse": 1,
|
||||
"loopId": "1000B",
|
||||
"parentLoopId": null,
|
||||
"loopMaxRepeat": 999,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "ID",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 2,
|
||||
"fixedValue": "PE"
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "AN",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 60
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"segmentId": "N3",
|
||||
"usage": "SITUATIONAL",
|
||||
"minUse": 0,
|
||||
"maxUse": 1,
|
||||
"loopId": "1000B",
|
||||
"parentLoopId": null,
|
||||
"loopMaxRepeat": 999,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "AN",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 55
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"segmentId": "N4",
|
||||
"usage": "SITUATIONAL",
|
||||
"minUse": 0,
|
||||
"maxUse": 1,
|
||||
"loopId": "1000B",
|
||||
"parentLoopId": null,
|
||||
"loopMaxRepeat": 999,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "AN",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 30
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "ID",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 2
|
||||
},
|
||||
{
|
||||
"position": 3,
|
||||
"dataType": "ID",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 15
|
||||
},
|
||||
{
|
||||
"position": 4,
|
||||
"dataType": "ID",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 3
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"segmentId": "REF",
|
||||
"usage": "SITUATIONAL",
|
||||
"minUse": 0,
|
||||
"maxUse": 5,
|
||||
"loopId": "1000B",
|
||||
"parentLoopId": null,
|
||||
"loopMaxRepeat": 999,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "ID",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 3
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "AN",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 80
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"segmentId": "LX",
|
||||
"usage": "REQUIRED",
|
||||
"minUse": 1,
|
||||
"maxUse": 1,
|
||||
"loopId": "2000",
|
||||
"parentLoopId": null,
|
||||
"loopMaxRepeat": 999,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "N0",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 6
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"segmentId": "CLP",
|
||||
"usage": "REQUIRED",
|
||||
"minUse": 1,
|
||||
"maxUse": 1,
|
||||
"loopId": "2100",
|
||||
"parentLoopId": "2000",
|
||||
"loopMaxRepeat": 999,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "AN",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 38
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "ID",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 2
|
||||
},
|
||||
{
|
||||
"position": 3,
|
||||
"dataType": "R",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 18
|
||||
},
|
||||
{
|
||||
"position": 4,
|
||||
"dataType": "R",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 18
|
||||
},
|
||||
{
|
||||
"position": 5,
|
||||
"dataType": "R",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 18
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"segmentId": "CAS",
|
||||
"usage": "SITUATIONAL",
|
||||
"minUse": 0,
|
||||
"maxUse": 99,
|
||||
"loopId": "2100",
|
||||
"parentLoopId": "2000",
|
||||
"loopMaxRepeat": 999,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "ID",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 2
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "ID",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 5,
|
||||
"source": "EXTERNAL",
|
||||
"externalCodeSet": "CARC"
|
||||
},
|
||||
{
|
||||
"position": 3,
|
||||
"dataType": "R",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 18
|
||||
},
|
||||
{
|
||||
"position": 4,
|
||||
"dataType": "R",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 15
|
||||
},
|
||||
{
|
||||
"position": 5,
|
||||
"dataType": "ID",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 5,
|
||||
"source": "EXTERNAL",
|
||||
"externalCodeSet": "CARC"
|
||||
},
|
||||
{
|
||||
"position": 6,
|
||||
"dataType": "R",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 18
|
||||
},
|
||||
{
|
||||
"position": 7,
|
||||
"dataType": "R",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 15
|
||||
},
|
||||
{
|
||||
"position": 8,
|
||||
"dataType": "ID",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 5,
|
||||
"source": "EXTERNAL",
|
||||
"externalCodeSet": "CARC"
|
||||
},
|
||||
{
|
||||
"position": 9,
|
||||
"dataType": "R",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 18
|
||||
},
|
||||
{
|
||||
"position": 10,
|
||||
"dataType": "R",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 15
|
||||
},
|
||||
{
|
||||
"position": 11,
|
||||
"dataType": "ID",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 5,
|
||||
"source": "EXTERNAL",
|
||||
"externalCodeSet": "CARC"
|
||||
},
|
||||
{
|
||||
"position": 12,
|
||||
"dataType": "R",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 18
|
||||
},
|
||||
{
|
||||
"position": 13,
|
||||
"dataType": "R",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 15
|
||||
},
|
||||
{
|
||||
"position": 14,
|
||||
"dataType": "ID",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 5,
|
||||
"source": "EXTERNAL",
|
||||
"externalCodeSet": "CARC"
|
||||
},
|
||||
{
|
||||
"position": 15,
|
||||
"dataType": "R",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 18
|
||||
},
|
||||
{
|
||||
"position": 16,
|
||||
"dataType": "R",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 15
|
||||
},
|
||||
{
|
||||
"position": 17,
|
||||
"dataType": "ID",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 5,
|
||||
"source": "EXTERNAL",
|
||||
"externalCodeSet": "CARC"
|
||||
},
|
||||
{
|
||||
"position": 18,
|
||||
"dataType": "R",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 18
|
||||
},
|
||||
{
|
||||
"position": 19,
|
||||
"dataType": "R",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 15
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"segmentId": "NM1",
|
||||
"usage": "SITUATIONAL",
|
||||
"minUse": 0,
|
||||
"maxUse": 10,
|
||||
"loopId": "2100",
|
||||
"parentLoopId": "2000",
|
||||
"loopMaxRepeat": 999,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "ID",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 3
|
||||
},
|
||||
{
|
||||
"position": 3,
|
||||
"dataType": "AN",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 60
|
||||
},
|
||||
{
|
||||
"position": 9,
|
||||
"dataType": "AN",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 80
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"segmentId": "MIA",
|
||||
"usage": "SITUATIONAL",
|
||||
"minUse": 0,
|
||||
"maxUse": 1,
|
||||
"loopId": "2100",
|
||||
"parentLoopId": "2000",
|
||||
"loopMaxRepeat": 999
|
||||
},
|
||||
{
|
||||
"segmentId": "MOA",
|
||||
"usage": "SITUATIONAL",
|
||||
"minUse": 0,
|
||||
"maxUse": 1,
|
||||
"loopId": "2100",
|
||||
"parentLoopId": "2000",
|
||||
"loopMaxRepeat": 999
|
||||
},
|
||||
{
|
||||
"segmentId": "REF",
|
||||
"usage": "SITUATIONAL",
|
||||
"minUse": 0,
|
||||
"maxUse": 10,
|
||||
"loopId": "2100",
|
||||
"parentLoopId": "2000",
|
||||
"loopMaxRepeat": 999
|
||||
},
|
||||
{
|
||||
"segmentId": "DTM",
|
||||
"usage": "SITUATIONAL",
|
||||
"minUse": 0,
|
||||
"maxUse": 10,
|
||||
"loopId": "2100",
|
||||
"parentLoopId": "2000",
|
||||
"loopMaxRepeat": 999
|
||||
},
|
||||
{
|
||||
"segmentId": "AMT",
|
||||
"usage": "SITUATIONAL",
|
||||
"minUse": 0,
|
||||
"maxUse": 10,
|
||||
"loopId": "2100",
|
||||
"parentLoopId": "2000",
|
||||
"loopMaxRepeat": 999
|
||||
},
|
||||
{
|
||||
"segmentId": "SVC",
|
||||
"usage": "SITUATIONAL",
|
||||
"minUse": 0,
|
||||
"maxUse": 1,
|
||||
"loopId": "2110",
|
||||
"parentLoopId": "2100",
|
||||
"loopMaxRepeat": 999,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "ID",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 2,
|
||||
"componentPosition": 1
|
||||
},
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "ID",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 5,
|
||||
"componentPosition": 2,
|
||||
"source": "EXTERNAL",
|
||||
"externalCodeSet": "HCPCS"
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "R",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 18
|
||||
},
|
||||
{
|
||||
"position": 3,
|
||||
"dataType": "R",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 18
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"segmentId": "DTM",
|
||||
"usage": "SITUATIONAL",
|
||||
"minUse": 0,
|
||||
"maxUse": 5,
|
||||
"loopId": "2110",
|
||||
"parentLoopId": "2100",
|
||||
"loopMaxRepeat": 999
|
||||
},
|
||||
{
|
||||
"segmentId": "CAS",
|
||||
"usage": "SITUATIONAL",
|
||||
"minUse": 0,
|
||||
"maxUse": 99,
|
||||
"loopId": "2110",
|
||||
"parentLoopId": "2100",
|
||||
"loopMaxRepeat": 999,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "ID",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 2
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "ID",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 5,
|
||||
"source": "EXTERNAL",
|
||||
"externalCodeSet": "CARC"
|
||||
},
|
||||
{
|
||||
"position": 3,
|
||||
"dataType": "R",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 18
|
||||
},
|
||||
{
|
||||
"position": 4,
|
||||
"dataType": "R",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 15
|
||||
},
|
||||
{
|
||||
"position": 5,
|
||||
"dataType": "ID",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 5,
|
||||
"source": "EXTERNAL",
|
||||
"externalCodeSet": "CARC"
|
||||
},
|
||||
{
|
||||
"position": 6,
|
||||
"dataType": "R",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 18
|
||||
},
|
||||
{
|
||||
"position": 7,
|
||||
"dataType": "R",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 15
|
||||
},
|
||||
{
|
||||
"position": 8,
|
||||
"dataType": "ID",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 5,
|
||||
"source": "EXTERNAL",
|
||||
"externalCodeSet": "CARC"
|
||||
},
|
||||
{
|
||||
"position": 9,
|
||||
"dataType": "R",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 18
|
||||
},
|
||||
{
|
||||
"position": 10,
|
||||
"dataType": "R",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 15
|
||||
},
|
||||
{
|
||||
"position": 11,
|
||||
"dataType": "ID",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 5,
|
||||
"source": "EXTERNAL",
|
||||
"externalCodeSet": "CARC"
|
||||
},
|
||||
{
|
||||
"position": 12,
|
||||
"dataType": "R",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 18
|
||||
},
|
||||
{
|
||||
"position": 13,
|
||||
"dataType": "R",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 15
|
||||
},
|
||||
{
|
||||
"position": 14,
|
||||
"dataType": "ID",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 5,
|
||||
"source": "EXTERNAL",
|
||||
"externalCodeSet": "CARC"
|
||||
},
|
||||
{
|
||||
"position": 15,
|
||||
"dataType": "R",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 18
|
||||
},
|
||||
{
|
||||
"position": 16,
|
||||
"dataType": "R",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 15
|
||||
},
|
||||
{
|
||||
"position": 17,
|
||||
"dataType": "ID",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 5,
|
||||
"source": "EXTERNAL",
|
||||
"externalCodeSet": "CARC"
|
||||
},
|
||||
{
|
||||
"position": 18,
|
||||
"dataType": "R",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 18
|
||||
},
|
||||
{
|
||||
"position": 19,
|
||||
"dataType": "R",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 15
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"segmentId": "REF",
|
||||
"usage": "SITUATIONAL",
|
||||
"minUse": 0,
|
||||
"maxUse": 10,
|
||||
"loopId": "2110",
|
||||
"parentLoopId": "2100",
|
||||
"loopMaxRepeat": 999
|
||||
},
|
||||
{
|
||||
"segmentId": "AMT",
|
||||
"usage": "SITUATIONAL",
|
||||
"minUse": 0,
|
||||
"maxUse": 5,
|
||||
"loopId": "2110",
|
||||
"parentLoopId": "2100",
|
||||
"loopMaxRepeat": 999
|
||||
},
|
||||
{
|
||||
"segmentId": "LQ",
|
||||
"usage": "SITUATIONAL",
|
||||
"minUse": 0,
|
||||
"maxUse": 20,
|
||||
"loopId": "2110",
|
||||
"parentLoopId": "2100",
|
||||
"loopMaxRepeat": 999
|
||||
},
|
||||
{
|
||||
"segmentId": "PLB",
|
||||
"usage": "SITUATIONAL",
|
||||
"minUse": 0,
|
||||
"maxUse": 99,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "AN",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 50
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "DT",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 8
|
||||
},
|
||||
{
|
||||
"position": 4,
|
||||
"dataType": "R",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 18
|
||||
},
|
||||
{
|
||||
"position": 6,
|
||||
"dataType": "R",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 18
|
||||
},
|
||||
{
|
||||
"position": 8,
|
||||
"dataType": "R",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 18
|
||||
},
|
||||
{
|
||||
"position": 10,
|
||||
"dataType": "R",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 18
|
||||
},
|
||||
{
|
||||
"position": 12,
|
||||
"dataType": "R",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 18
|
||||
},
|
||||
{
|
||||
"position": 14,
|
||||
"dataType": "R",
|
||||
"usage": "SITUATIONAL",
|
||||
"minLength": 1,
|
||||
"maxLength": 18
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"segmentId": "SE",
|
||||
"usage": "REQUIRED",
|
||||
"minUse": 1,
|
||||
"maxUse": 1,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "N0",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 10
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "AN",
|
||||
"usage": "REQUIRED",
|
||||
"minLength": 1,
|
||||
"maxLength": 9
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "QQQ baseline 999 005010X231A1",
|
||||
"name": "QQQ baseline guide 999 005010X231A1",
|
||||
"standard": "X12",
|
||||
"version": "005010X231A1",
|
||||
"transactionSet": "999",
|
||||
|
||||
@@ -0,0 +1,474 @@
|
||||
{
|
||||
"name": "QQQ baseline CONTRL D01B",
|
||||
"standard": "EDIFACT",
|
||||
"version": "D01B",
|
||||
"transactionSet": "CONTRL",
|
||||
"functionalIdCode": "CONTRL",
|
||||
"segments": [
|
||||
{
|
||||
"segmentId": "UNH",
|
||||
"usage": "REQUIRED",
|
||||
"minUse": 1,
|
||||
"maxUse": 1,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "AN",
|
||||
"minLength": 1,
|
||||
"maxLength": 14,
|
||||
"usage": "REQUIRED"
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "ID",
|
||||
"minLength": 1,
|
||||
"maxLength": 6,
|
||||
"usage": "REQUIRED",
|
||||
"componentPosition": 1,
|
||||
"codes": [
|
||||
"CONTRL"
|
||||
]
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "ID",
|
||||
"minLength": 1,
|
||||
"maxLength": 1,
|
||||
"usage": "REQUIRED",
|
||||
"componentPosition": 2,
|
||||
"codes": [
|
||||
"D"
|
||||
]
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "ID",
|
||||
"minLength": 1,
|
||||
"maxLength": 3,
|
||||
"usage": "REQUIRED",
|
||||
"componentPosition": 3,
|
||||
"codes": [
|
||||
"01B"
|
||||
]
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "ID",
|
||||
"minLength": 1,
|
||||
"maxLength": 2,
|
||||
"usage": "REQUIRED",
|
||||
"componentPosition": 4,
|
||||
"codes": [
|
||||
"UN"
|
||||
]
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "AN",
|
||||
"minLength": 1,
|
||||
"maxLength": 6,
|
||||
"usage": "SITUATIONAL",
|
||||
"componentPosition": 5
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"segmentId": "UCI",
|
||||
"usage": "REQUIRED",
|
||||
"minUse": 1,
|
||||
"maxUse": 1,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "AN",
|
||||
"minLength": 1,
|
||||
"maxLength": 14,
|
||||
"usage": "REQUIRED"
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "AN",
|
||||
"minLength": 1,
|
||||
"maxLength": 35,
|
||||
"usage": "REQUIRED",
|
||||
"componentPosition": 1
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "AN",
|
||||
"minLength": 1,
|
||||
"maxLength": 4,
|
||||
"usage": "SITUATIONAL",
|
||||
"componentPosition": 2
|
||||
},
|
||||
{
|
||||
"position": 3,
|
||||
"dataType": "AN",
|
||||
"minLength": 1,
|
||||
"maxLength": 35,
|
||||
"usage": "REQUIRED",
|
||||
"componentPosition": 1
|
||||
},
|
||||
{
|
||||
"position": 3,
|
||||
"dataType": "AN",
|
||||
"minLength": 1,
|
||||
"maxLength": 4,
|
||||
"usage": "SITUATIONAL",
|
||||
"componentPosition": 2
|
||||
},
|
||||
{
|
||||
"position": 4,
|
||||
"dataType": "ID",
|
||||
"minLength": 1,
|
||||
"maxLength": 1,
|
||||
"usage": "REQUIRED",
|
||||
"codes": [
|
||||
"4",
|
||||
"7",
|
||||
"8"
|
||||
]
|
||||
},
|
||||
{
|
||||
"position": 5,
|
||||
"dataType": "AN",
|
||||
"minLength": 1,
|
||||
"maxLength": 3,
|
||||
"usage": "SITUATIONAL"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"segmentId": "UCM",
|
||||
"usage": "SITUATIONAL",
|
||||
"minUse": 0,
|
||||
"maxUse": 1,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "AN",
|
||||
"minLength": 1,
|
||||
"maxLength": 14,
|
||||
"usage": "REQUIRED"
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "AN",
|
||||
"minLength": 1,
|
||||
"maxLength": 35,
|
||||
"usage": "REQUIRED",
|
||||
"componentPosition": 1
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "AN",
|
||||
"minLength": 1,
|
||||
"maxLength": 35,
|
||||
"usage": "REQUIRED",
|
||||
"componentPosition": 2
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "AN",
|
||||
"minLength": 1,
|
||||
"maxLength": 35,
|
||||
"usage": "REQUIRED",
|
||||
"componentPosition": 3
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "AN",
|
||||
"minLength": 1,
|
||||
"maxLength": 35,
|
||||
"usage": "REQUIRED",
|
||||
"componentPosition": 4
|
||||
},
|
||||
{
|
||||
"position": 3,
|
||||
"dataType": "ID",
|
||||
"minLength": 1,
|
||||
"maxLength": 1,
|
||||
"usage": "REQUIRED",
|
||||
"codes": [
|
||||
"4",
|
||||
"7",
|
||||
"8"
|
||||
]
|
||||
},
|
||||
{
|
||||
"position": 4,
|
||||
"dataType": "AN",
|
||||
"minLength": 1,
|
||||
"maxLength": 3,
|
||||
"usage": "SITUATIONAL"
|
||||
}
|
||||
],
|
||||
"loopId": "SG1",
|
||||
"loopMaxRepeat": 999
|
||||
},
|
||||
{
|
||||
"segmentId": "UCS",
|
||||
"usage": "SITUATIONAL",
|
||||
"minUse": 0,
|
||||
"maxUse": 1,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "N0",
|
||||
"minLength": 1,
|
||||
"maxLength": 6,
|
||||
"usage": "REQUIRED"
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "AN",
|
||||
"minLength": 1,
|
||||
"maxLength": 3,
|
||||
"usage": "SITUATIONAL"
|
||||
}
|
||||
],
|
||||
"loopId": "SG2",
|
||||
"loopMaxRepeat": 999,
|
||||
"parentLoopId": "SG1"
|
||||
},
|
||||
{
|
||||
"segmentId": "UCD",
|
||||
"usage": "SITUATIONAL",
|
||||
"minUse": 0,
|
||||
"maxUse": 99,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "ID",
|
||||
"minLength": 1,
|
||||
"maxLength": 3,
|
||||
"usage": "REQUIRED",
|
||||
"codes": [
|
||||
"12",
|
||||
"13",
|
||||
"14",
|
||||
"15"
|
||||
]
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "N0",
|
||||
"minLength": 1,
|
||||
"maxLength": 3,
|
||||
"usage": "REQUIRED",
|
||||
"componentPosition": 1
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "N0",
|
||||
"minLength": 1,
|
||||
"maxLength": 3,
|
||||
"usage": "SITUATIONAL",
|
||||
"componentPosition": 2
|
||||
}
|
||||
],
|
||||
"loopId": "SG2",
|
||||
"loopMaxRepeat": 999,
|
||||
"parentLoopId": "SG1"
|
||||
},
|
||||
{
|
||||
"segmentId": "UCF",
|
||||
"usage": "SITUATIONAL",
|
||||
"minUse": 0,
|
||||
"maxUse": 1,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "AN",
|
||||
"minLength": 1,
|
||||
"maxLength": 14,
|
||||
"usage": "REQUIRED"
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "AN",
|
||||
"minLength": 1,
|
||||
"maxLength": 35,
|
||||
"usage": "REQUIRED"
|
||||
},
|
||||
{
|
||||
"position": 3,
|
||||
"dataType": "AN",
|
||||
"minLength": 1,
|
||||
"maxLength": 35,
|
||||
"usage": "REQUIRED"
|
||||
},
|
||||
{
|
||||
"position": 4,
|
||||
"dataType": "ID",
|
||||
"minLength": 1,
|
||||
"maxLength": 1,
|
||||
"usage": "REQUIRED",
|
||||
"codes": [
|
||||
"4",
|
||||
"7",
|
||||
"8"
|
||||
]
|
||||
}
|
||||
],
|
||||
"loopId": "SG3",
|
||||
"loopMaxRepeat": 999
|
||||
},
|
||||
{
|
||||
"segmentId": "UCM",
|
||||
"usage": "SITUATIONAL",
|
||||
"minUse": 0,
|
||||
"maxUse": 1,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "AN",
|
||||
"minLength": 1,
|
||||
"maxLength": 14,
|
||||
"usage": "REQUIRED"
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "AN",
|
||||
"minLength": 1,
|
||||
"maxLength": 35,
|
||||
"usage": "REQUIRED",
|
||||
"componentPosition": 1
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "AN",
|
||||
"minLength": 1,
|
||||
"maxLength": 35,
|
||||
"usage": "REQUIRED",
|
||||
"componentPosition": 2
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "AN",
|
||||
"minLength": 1,
|
||||
"maxLength": 35,
|
||||
"usage": "REQUIRED",
|
||||
"componentPosition": 3
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "AN",
|
||||
"minLength": 1,
|
||||
"maxLength": 35,
|
||||
"usage": "REQUIRED",
|
||||
"componentPosition": 4
|
||||
},
|
||||
{
|
||||
"position": 3,
|
||||
"dataType": "ID",
|
||||
"minLength": 1,
|
||||
"maxLength": 1,
|
||||
"usage": "REQUIRED",
|
||||
"codes": [
|
||||
"4",
|
||||
"7",
|
||||
"8"
|
||||
]
|
||||
},
|
||||
{
|
||||
"position": 4,
|
||||
"dataType": "AN",
|
||||
"minLength": 1,
|
||||
"maxLength": 3,
|
||||
"usage": "SITUATIONAL"
|
||||
}
|
||||
],
|
||||
"loopId": "SG4",
|
||||
"loopMaxRepeat": 999,
|
||||
"parentLoopId": "SG3"
|
||||
},
|
||||
{
|
||||
"segmentId": "UCS",
|
||||
"usage": "SITUATIONAL",
|
||||
"minUse": 0,
|
||||
"maxUse": 1,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "N0",
|
||||
"minLength": 1,
|
||||
"maxLength": 6,
|
||||
"usage": "REQUIRED"
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "AN",
|
||||
"minLength": 1,
|
||||
"maxLength": 3,
|
||||
"usage": "SITUATIONAL"
|
||||
}
|
||||
],
|
||||
"loopId": "SG5",
|
||||
"loopMaxRepeat": 999,
|
||||
"parentLoopId": "SG4"
|
||||
},
|
||||
{
|
||||
"segmentId": "UCD",
|
||||
"usage": "SITUATIONAL",
|
||||
"minUse": 0,
|
||||
"maxUse": 99,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "ID",
|
||||
"minLength": 1,
|
||||
"maxLength": 3,
|
||||
"usage": "REQUIRED",
|
||||
"codes": [
|
||||
"12",
|
||||
"13",
|
||||
"14",
|
||||
"15"
|
||||
]
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "N0",
|
||||
"minLength": 1,
|
||||
"maxLength": 3,
|
||||
"usage": "REQUIRED",
|
||||
"componentPosition": 1
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "N0",
|
||||
"minLength": 1,
|
||||
"maxLength": 3,
|
||||
"usage": "SITUATIONAL",
|
||||
"componentPosition": 2
|
||||
}
|
||||
],
|
||||
"loopId": "SG5",
|
||||
"loopMaxRepeat": 999,
|
||||
"parentLoopId": "SG4"
|
||||
},
|
||||
{
|
||||
"segmentId": "UNT",
|
||||
"usage": "REQUIRED",
|
||||
"minUse": 1,
|
||||
"maxUse": 1,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "N0",
|
||||
"minLength": 1,
|
||||
"maxLength": 10,
|
||||
"usage": "REQUIRED"
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "AN",
|
||||
"minLength": 1,
|
||||
"maxLength": 14,
|
||||
"usage": "REQUIRED"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,474 @@
|
||||
{
|
||||
"name": "QQQ baseline CONTRL D96A",
|
||||
"standard": "EDIFACT",
|
||||
"version": "D96A",
|
||||
"transactionSet": "CONTRL",
|
||||
"functionalIdCode": "CONTRL",
|
||||
"segments": [
|
||||
{
|
||||
"segmentId": "UNH",
|
||||
"usage": "REQUIRED",
|
||||
"minUse": 1,
|
||||
"maxUse": 1,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "AN",
|
||||
"minLength": 1,
|
||||
"maxLength": 14,
|
||||
"usage": "REQUIRED"
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "ID",
|
||||
"minLength": 1,
|
||||
"maxLength": 6,
|
||||
"usage": "REQUIRED",
|
||||
"componentPosition": 1,
|
||||
"codes": [
|
||||
"CONTRL"
|
||||
]
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "ID",
|
||||
"minLength": 1,
|
||||
"maxLength": 1,
|
||||
"usage": "REQUIRED",
|
||||
"componentPosition": 2,
|
||||
"codes": [
|
||||
"D"
|
||||
]
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "ID",
|
||||
"minLength": 1,
|
||||
"maxLength": 3,
|
||||
"usage": "REQUIRED",
|
||||
"componentPosition": 3,
|
||||
"codes": [
|
||||
"96A"
|
||||
]
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "ID",
|
||||
"minLength": 1,
|
||||
"maxLength": 2,
|
||||
"usage": "REQUIRED",
|
||||
"componentPosition": 4,
|
||||
"codes": [
|
||||
"UN"
|
||||
]
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "AN",
|
||||
"minLength": 1,
|
||||
"maxLength": 6,
|
||||
"usage": "SITUATIONAL",
|
||||
"componentPosition": 5
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"segmentId": "UCI",
|
||||
"usage": "REQUIRED",
|
||||
"minUse": 1,
|
||||
"maxUse": 1,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "AN",
|
||||
"minLength": 1,
|
||||
"maxLength": 14,
|
||||
"usage": "REQUIRED"
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "AN",
|
||||
"minLength": 1,
|
||||
"maxLength": 35,
|
||||
"usage": "REQUIRED",
|
||||
"componentPosition": 1
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "AN",
|
||||
"minLength": 1,
|
||||
"maxLength": 4,
|
||||
"usage": "SITUATIONAL",
|
||||
"componentPosition": 2
|
||||
},
|
||||
{
|
||||
"position": 3,
|
||||
"dataType": "AN",
|
||||
"minLength": 1,
|
||||
"maxLength": 35,
|
||||
"usage": "REQUIRED",
|
||||
"componentPosition": 1
|
||||
},
|
||||
{
|
||||
"position": 3,
|
||||
"dataType": "AN",
|
||||
"minLength": 1,
|
||||
"maxLength": 4,
|
||||
"usage": "SITUATIONAL",
|
||||
"componentPosition": 2
|
||||
},
|
||||
{
|
||||
"position": 4,
|
||||
"dataType": "ID",
|
||||
"minLength": 1,
|
||||
"maxLength": 1,
|
||||
"usage": "REQUIRED",
|
||||
"codes": [
|
||||
"4",
|
||||
"7",
|
||||
"8"
|
||||
]
|
||||
},
|
||||
{
|
||||
"position": 5,
|
||||
"dataType": "AN",
|
||||
"minLength": 1,
|
||||
"maxLength": 3,
|
||||
"usage": "SITUATIONAL"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"segmentId": "UCM",
|
||||
"usage": "SITUATIONAL",
|
||||
"minUse": 0,
|
||||
"maxUse": 1,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "AN",
|
||||
"minLength": 1,
|
||||
"maxLength": 14,
|
||||
"usage": "REQUIRED"
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "AN",
|
||||
"minLength": 1,
|
||||
"maxLength": 35,
|
||||
"usage": "REQUIRED",
|
||||
"componentPosition": 1
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "AN",
|
||||
"minLength": 1,
|
||||
"maxLength": 35,
|
||||
"usage": "REQUIRED",
|
||||
"componentPosition": 2
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "AN",
|
||||
"minLength": 1,
|
||||
"maxLength": 35,
|
||||
"usage": "REQUIRED",
|
||||
"componentPosition": 3
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "AN",
|
||||
"minLength": 1,
|
||||
"maxLength": 35,
|
||||
"usage": "REQUIRED",
|
||||
"componentPosition": 4
|
||||
},
|
||||
{
|
||||
"position": 3,
|
||||
"dataType": "ID",
|
||||
"minLength": 1,
|
||||
"maxLength": 1,
|
||||
"usage": "REQUIRED",
|
||||
"codes": [
|
||||
"4",
|
||||
"7",
|
||||
"8"
|
||||
]
|
||||
},
|
||||
{
|
||||
"position": 4,
|
||||
"dataType": "AN",
|
||||
"minLength": 1,
|
||||
"maxLength": 3,
|
||||
"usage": "SITUATIONAL"
|
||||
}
|
||||
],
|
||||
"loopId": "SG1",
|
||||
"loopMaxRepeat": 999
|
||||
},
|
||||
{
|
||||
"segmentId": "UCS",
|
||||
"usage": "SITUATIONAL",
|
||||
"minUse": 0,
|
||||
"maxUse": 1,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "N0",
|
||||
"minLength": 1,
|
||||
"maxLength": 6,
|
||||
"usage": "REQUIRED"
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "AN",
|
||||
"minLength": 1,
|
||||
"maxLength": 3,
|
||||
"usage": "SITUATIONAL"
|
||||
}
|
||||
],
|
||||
"loopId": "SG2",
|
||||
"loopMaxRepeat": 999,
|
||||
"parentLoopId": "SG1"
|
||||
},
|
||||
{
|
||||
"segmentId": "UCD",
|
||||
"usage": "SITUATIONAL",
|
||||
"minUse": 0,
|
||||
"maxUse": 99,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "ID",
|
||||
"minLength": 1,
|
||||
"maxLength": 3,
|
||||
"usage": "REQUIRED",
|
||||
"codes": [
|
||||
"12",
|
||||
"13",
|
||||
"14",
|
||||
"15"
|
||||
]
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "N0",
|
||||
"minLength": 1,
|
||||
"maxLength": 3,
|
||||
"usage": "REQUIRED",
|
||||
"componentPosition": 1
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "N0",
|
||||
"minLength": 1,
|
||||
"maxLength": 3,
|
||||
"usage": "SITUATIONAL",
|
||||
"componentPosition": 2
|
||||
}
|
||||
],
|
||||
"loopId": "SG2",
|
||||
"loopMaxRepeat": 999,
|
||||
"parentLoopId": "SG1"
|
||||
},
|
||||
{
|
||||
"segmentId": "UCF",
|
||||
"usage": "SITUATIONAL",
|
||||
"minUse": 0,
|
||||
"maxUse": 1,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "AN",
|
||||
"minLength": 1,
|
||||
"maxLength": 14,
|
||||
"usage": "REQUIRED"
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "AN",
|
||||
"minLength": 1,
|
||||
"maxLength": 35,
|
||||
"usage": "REQUIRED"
|
||||
},
|
||||
{
|
||||
"position": 3,
|
||||
"dataType": "AN",
|
||||
"minLength": 1,
|
||||
"maxLength": 35,
|
||||
"usage": "REQUIRED"
|
||||
},
|
||||
{
|
||||
"position": 4,
|
||||
"dataType": "ID",
|
||||
"minLength": 1,
|
||||
"maxLength": 1,
|
||||
"usage": "REQUIRED",
|
||||
"codes": [
|
||||
"4",
|
||||
"7",
|
||||
"8"
|
||||
]
|
||||
}
|
||||
],
|
||||
"loopId": "SG3",
|
||||
"loopMaxRepeat": 999
|
||||
},
|
||||
{
|
||||
"segmentId": "UCM",
|
||||
"usage": "SITUATIONAL",
|
||||
"minUse": 0,
|
||||
"maxUse": 1,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "AN",
|
||||
"minLength": 1,
|
||||
"maxLength": 14,
|
||||
"usage": "REQUIRED"
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "AN",
|
||||
"minLength": 1,
|
||||
"maxLength": 35,
|
||||
"usage": "REQUIRED",
|
||||
"componentPosition": 1
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "AN",
|
||||
"minLength": 1,
|
||||
"maxLength": 35,
|
||||
"usage": "REQUIRED",
|
||||
"componentPosition": 2
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "AN",
|
||||
"minLength": 1,
|
||||
"maxLength": 35,
|
||||
"usage": "REQUIRED",
|
||||
"componentPosition": 3
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "AN",
|
||||
"minLength": 1,
|
||||
"maxLength": 35,
|
||||
"usage": "REQUIRED",
|
||||
"componentPosition": 4
|
||||
},
|
||||
{
|
||||
"position": 3,
|
||||
"dataType": "ID",
|
||||
"minLength": 1,
|
||||
"maxLength": 1,
|
||||
"usage": "REQUIRED",
|
||||
"codes": [
|
||||
"4",
|
||||
"7",
|
||||
"8"
|
||||
]
|
||||
},
|
||||
{
|
||||
"position": 4,
|
||||
"dataType": "AN",
|
||||
"minLength": 1,
|
||||
"maxLength": 3,
|
||||
"usage": "SITUATIONAL"
|
||||
}
|
||||
],
|
||||
"loopId": "SG4",
|
||||
"loopMaxRepeat": 999,
|
||||
"parentLoopId": "SG3"
|
||||
},
|
||||
{
|
||||
"segmentId": "UCS",
|
||||
"usage": "SITUATIONAL",
|
||||
"minUse": 0,
|
||||
"maxUse": 1,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "N0",
|
||||
"minLength": 1,
|
||||
"maxLength": 6,
|
||||
"usage": "REQUIRED"
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "AN",
|
||||
"minLength": 1,
|
||||
"maxLength": 3,
|
||||
"usage": "SITUATIONAL"
|
||||
}
|
||||
],
|
||||
"loopId": "SG5",
|
||||
"loopMaxRepeat": 999,
|
||||
"parentLoopId": "SG4"
|
||||
},
|
||||
{
|
||||
"segmentId": "UCD",
|
||||
"usage": "SITUATIONAL",
|
||||
"minUse": 0,
|
||||
"maxUse": 99,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "ID",
|
||||
"minLength": 1,
|
||||
"maxLength": 3,
|
||||
"usage": "REQUIRED",
|
||||
"codes": [
|
||||
"12",
|
||||
"13",
|
||||
"14",
|
||||
"15"
|
||||
]
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "N0",
|
||||
"minLength": 1,
|
||||
"maxLength": 3,
|
||||
"usage": "REQUIRED",
|
||||
"componentPosition": 1
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "N0",
|
||||
"minLength": 1,
|
||||
"maxLength": 3,
|
||||
"usage": "SITUATIONAL",
|
||||
"componentPosition": 2
|
||||
}
|
||||
],
|
||||
"loopId": "SG5",
|
||||
"loopMaxRepeat": 999,
|
||||
"parentLoopId": "SG4"
|
||||
},
|
||||
{
|
||||
"segmentId": "UNT",
|
||||
"usage": "REQUIRED",
|
||||
"minUse": 1,
|
||||
"maxUse": 1,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "N0",
|
||||
"minLength": 1,
|
||||
"maxLength": 10,
|
||||
"usage": "REQUIRED"
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "AN",
|
||||
"minLength": 1,
|
||||
"maxLength": 14,
|
||||
"usage": "REQUIRED"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,410 @@
|
||||
{
|
||||
"name": "QQQ baseline DESADV D01B",
|
||||
"standard": "EDIFACT",
|
||||
"version": "D01B",
|
||||
"transactionSet": "DESADV",
|
||||
"functionalIdCode": "DESADV",
|
||||
"segments": [
|
||||
{
|
||||
"segmentId": "UNH",
|
||||
"usage": "REQUIRED",
|
||||
"minUse": 1,
|
||||
"maxUse": 1,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "AN",
|
||||
"minLength": 1,
|
||||
"maxLength": 14,
|
||||
"usage": "REQUIRED"
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "ID",
|
||||
"minLength": 1,
|
||||
"maxLength": 6,
|
||||
"usage": "REQUIRED",
|
||||
"componentPosition": 1,
|
||||
"codes": [
|
||||
"DESADV"
|
||||
]
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "ID",
|
||||
"minLength": 1,
|
||||
"maxLength": 1,
|
||||
"usage": "REQUIRED",
|
||||
"componentPosition": 2,
|
||||
"codes": [
|
||||
"D"
|
||||
]
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "ID",
|
||||
"minLength": 1,
|
||||
"maxLength": 3,
|
||||
"usage": "REQUIRED",
|
||||
"componentPosition": 3,
|
||||
"codes": [
|
||||
"01B"
|
||||
]
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "ID",
|
||||
"minLength": 1,
|
||||
"maxLength": 2,
|
||||
"usage": "REQUIRED",
|
||||
"componentPosition": 4,
|
||||
"codes": [
|
||||
"UN"
|
||||
]
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "AN",
|
||||
"minLength": 1,
|
||||
"maxLength": 6,
|
||||
"usage": "SITUATIONAL",
|
||||
"componentPosition": 5
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"segmentId": "BGM",
|
||||
"usage": "REQUIRED",
|
||||
"minUse": 1,
|
||||
"maxUse": 1,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "ID",
|
||||
"minLength": 1,
|
||||
"maxLength": 3,
|
||||
"usage": "REQUIRED",
|
||||
"componentPosition": 1,
|
||||
"codes": [
|
||||
"351"
|
||||
]
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "AN",
|
||||
"minLength": 1,
|
||||
"maxLength": 35,
|
||||
"usage": "REQUIRED"
|
||||
},
|
||||
{
|
||||
"position": 3,
|
||||
"dataType": "ID",
|
||||
"minLength": 1,
|
||||
"maxLength": 3,
|
||||
"usage": "REQUIRED",
|
||||
"codes": [
|
||||
"9",
|
||||
"29"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"segmentId": "DTM",
|
||||
"usage": "REQUIRED",
|
||||
"minUse": 1,
|
||||
"maxUse": 5,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "ID",
|
||||
"minLength": 1,
|
||||
"maxLength": 3,
|
||||
"usage": "REQUIRED",
|
||||
"componentPosition": 1,
|
||||
"codes": [
|
||||
"137",
|
||||
"11"
|
||||
]
|
||||
},
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "DT",
|
||||
"minLength": 1,
|
||||
"maxLength": 8,
|
||||
"usage": "REQUIRED",
|
||||
"componentPosition": 2
|
||||
},
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "ID",
|
||||
"minLength": 1,
|
||||
"maxLength": 3,
|
||||
"usage": "REQUIRED",
|
||||
"componentPosition": 3,
|
||||
"codes": [
|
||||
"102"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"segmentId": "RFF",
|
||||
"usage": "SITUATIONAL",
|
||||
"minUse": 0,
|
||||
"maxUse": 1,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "ID",
|
||||
"minLength": 1,
|
||||
"maxLength": 3,
|
||||
"usage": "REQUIRED",
|
||||
"componentPosition": 1,
|
||||
"codes": [
|
||||
"ON"
|
||||
]
|
||||
},
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "AN",
|
||||
"minLength": 1,
|
||||
"maxLength": 35,
|
||||
"usage": "REQUIRED",
|
||||
"componentPosition": 2
|
||||
}
|
||||
],
|
||||
"loopId": "SG1",
|
||||
"loopMaxRepeat": 10
|
||||
},
|
||||
{
|
||||
"segmentId": "NAD",
|
||||
"usage": "SITUATIONAL",
|
||||
"minUse": 0,
|
||||
"maxUse": 1,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "ID",
|
||||
"minLength": 1,
|
||||
"maxLength": 3,
|
||||
"usage": "REQUIRED",
|
||||
"codes": [
|
||||
"BY",
|
||||
"SU",
|
||||
"DP"
|
||||
]
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "AN",
|
||||
"minLength": 1,
|
||||
"maxLength": 35,
|
||||
"usage": "REQUIRED",
|
||||
"componentPosition": 1
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "AN",
|
||||
"minLength": 1,
|
||||
"maxLength": 3,
|
||||
"usage": "SITUATIONAL",
|
||||
"componentPosition": 2
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "ID",
|
||||
"minLength": 1,
|
||||
"maxLength": 3,
|
||||
"usage": "SITUATIONAL",
|
||||
"componentPosition": 3,
|
||||
"codes": [
|
||||
"9"
|
||||
]
|
||||
}
|
||||
],
|
||||
"loopId": "SG2",
|
||||
"loopMaxRepeat": 10
|
||||
},
|
||||
{
|
||||
"segmentId": "CPS",
|
||||
"usage": "REQUIRED",
|
||||
"minUse": 1,
|
||||
"maxUse": 1,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "N0",
|
||||
"minLength": 1,
|
||||
"maxLength": 12,
|
||||
"usage": "REQUIRED"
|
||||
}
|
||||
],
|
||||
"loopId": "SG10",
|
||||
"loopMaxRepeat": 999
|
||||
},
|
||||
{
|
||||
"segmentId": "PAC",
|
||||
"usage": "SITUATIONAL",
|
||||
"minUse": 0,
|
||||
"maxUse": 1,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "N0",
|
||||
"minLength": 1,
|
||||
"maxLength": 8,
|
||||
"usage": "REQUIRED"
|
||||
}
|
||||
],
|
||||
"loopId": "SG10",
|
||||
"loopMaxRepeat": 999
|
||||
},
|
||||
{
|
||||
"segmentId": "LIN",
|
||||
"usage": "REQUIRED",
|
||||
"minUse": 1,
|
||||
"maxUse": 1,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "N0",
|
||||
"minLength": 1,
|
||||
"maxLength": 6,
|
||||
"usage": "REQUIRED"
|
||||
},
|
||||
{
|
||||
"position": 3,
|
||||
"dataType": "AN",
|
||||
"minLength": 1,
|
||||
"maxLength": 35,
|
||||
"usage": "REQUIRED",
|
||||
"componentPosition": 1
|
||||
},
|
||||
{
|
||||
"position": 3,
|
||||
"dataType": "ID",
|
||||
"minLength": 1,
|
||||
"maxLength": 3,
|
||||
"usage": "REQUIRED",
|
||||
"componentPosition": 2,
|
||||
"codes": [
|
||||
"EN",
|
||||
"SA"
|
||||
]
|
||||
}
|
||||
],
|
||||
"loopId": "SG17",
|
||||
"loopMaxRepeat": 9999,
|
||||
"parentLoopId": "SG10"
|
||||
},
|
||||
{
|
||||
"segmentId": "QTY",
|
||||
"usage": "REQUIRED",
|
||||
"minUse": 1,
|
||||
"maxUse": 1,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "ID",
|
||||
"minLength": 1,
|
||||
"maxLength": 3,
|
||||
"usage": "REQUIRED",
|
||||
"componentPosition": 1,
|
||||
"codes": [
|
||||
"12"
|
||||
]
|
||||
},
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "R",
|
||||
"minLength": 1,
|
||||
"maxLength": 15,
|
||||
"usage": "REQUIRED",
|
||||
"componentPosition": 2
|
||||
},
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "ID",
|
||||
"minLength": 1,
|
||||
"maxLength": 3,
|
||||
"usage": "REQUIRED",
|
||||
"componentPosition": 3,
|
||||
"codes": [
|
||||
"PCE"
|
||||
]
|
||||
}
|
||||
],
|
||||
"loopId": "SG17",
|
||||
"loopMaxRepeat": 9999,
|
||||
"parentLoopId": "SG10"
|
||||
},
|
||||
{
|
||||
"segmentId": "UNS",
|
||||
"usage": "SITUATIONAL",
|
||||
"minUse": 0,
|
||||
"maxUse": 1,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "ID",
|
||||
"minLength": 1,
|
||||
"maxLength": 1,
|
||||
"usage": "REQUIRED",
|
||||
"codes": [
|
||||
"S"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"segmentId": "CNT",
|
||||
"usage": "SITUATIONAL",
|
||||
"minUse": 0,
|
||||
"maxUse": 1,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "ID",
|
||||
"minLength": 1,
|
||||
"maxLength": 3,
|
||||
"usage": "REQUIRED",
|
||||
"componentPosition": 1,
|
||||
"codes": [
|
||||
"2"
|
||||
]
|
||||
},
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "N0",
|
||||
"minLength": 1,
|
||||
"maxLength": 10,
|
||||
"usage": "REQUIRED",
|
||||
"componentPosition": 2
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"segmentId": "UNT",
|
||||
"usage": "REQUIRED",
|
||||
"minUse": 1,
|
||||
"maxUse": 1,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "N0",
|
||||
"minLength": 1,
|
||||
"maxLength": 10,
|
||||
"usage": "REQUIRED"
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "AN",
|
||||
"minLength": 1,
|
||||
"maxLength": 14,
|
||||
"usage": "REQUIRED"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,410 @@
|
||||
{
|
||||
"name": "QQQ baseline DESADV D96A",
|
||||
"standard": "EDIFACT",
|
||||
"version": "D96A",
|
||||
"transactionSet": "DESADV",
|
||||
"functionalIdCode": "DESADV",
|
||||
"segments": [
|
||||
{
|
||||
"segmentId": "UNH",
|
||||
"usage": "REQUIRED",
|
||||
"minUse": 1,
|
||||
"maxUse": 1,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "AN",
|
||||
"minLength": 1,
|
||||
"maxLength": 14,
|
||||
"usage": "REQUIRED"
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "ID",
|
||||
"minLength": 1,
|
||||
"maxLength": 6,
|
||||
"usage": "REQUIRED",
|
||||
"componentPosition": 1,
|
||||
"codes": [
|
||||
"DESADV"
|
||||
]
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "ID",
|
||||
"minLength": 1,
|
||||
"maxLength": 1,
|
||||
"usage": "REQUIRED",
|
||||
"componentPosition": 2,
|
||||
"codes": [
|
||||
"D"
|
||||
]
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "ID",
|
||||
"minLength": 1,
|
||||
"maxLength": 3,
|
||||
"usage": "REQUIRED",
|
||||
"componentPosition": 3,
|
||||
"codes": [
|
||||
"96A"
|
||||
]
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "ID",
|
||||
"minLength": 1,
|
||||
"maxLength": 2,
|
||||
"usage": "REQUIRED",
|
||||
"componentPosition": 4,
|
||||
"codes": [
|
||||
"UN"
|
||||
]
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "AN",
|
||||
"minLength": 1,
|
||||
"maxLength": 6,
|
||||
"usage": "SITUATIONAL",
|
||||
"componentPosition": 5
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"segmentId": "BGM",
|
||||
"usage": "REQUIRED",
|
||||
"minUse": 1,
|
||||
"maxUse": 1,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "ID",
|
||||
"minLength": 1,
|
||||
"maxLength": 3,
|
||||
"usage": "REQUIRED",
|
||||
"componentPosition": 1,
|
||||
"codes": [
|
||||
"351"
|
||||
]
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "AN",
|
||||
"minLength": 1,
|
||||
"maxLength": 35,
|
||||
"usage": "REQUIRED"
|
||||
},
|
||||
{
|
||||
"position": 3,
|
||||
"dataType": "ID",
|
||||
"minLength": 1,
|
||||
"maxLength": 3,
|
||||
"usage": "REQUIRED",
|
||||
"codes": [
|
||||
"9",
|
||||
"29"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"segmentId": "DTM",
|
||||
"usage": "REQUIRED",
|
||||
"minUse": 1,
|
||||
"maxUse": 5,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "ID",
|
||||
"minLength": 1,
|
||||
"maxLength": 3,
|
||||
"usage": "REQUIRED",
|
||||
"componentPosition": 1,
|
||||
"codes": [
|
||||
"137",
|
||||
"11"
|
||||
]
|
||||
},
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "DT",
|
||||
"minLength": 1,
|
||||
"maxLength": 8,
|
||||
"usage": "REQUIRED",
|
||||
"componentPosition": 2
|
||||
},
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "ID",
|
||||
"minLength": 1,
|
||||
"maxLength": 3,
|
||||
"usage": "REQUIRED",
|
||||
"componentPosition": 3,
|
||||
"codes": [
|
||||
"102"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"segmentId": "RFF",
|
||||
"usage": "SITUATIONAL",
|
||||
"minUse": 0,
|
||||
"maxUse": 1,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "ID",
|
||||
"minLength": 1,
|
||||
"maxLength": 3,
|
||||
"usage": "REQUIRED",
|
||||
"componentPosition": 1,
|
||||
"codes": [
|
||||
"ON"
|
||||
]
|
||||
},
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "AN",
|
||||
"minLength": 1,
|
||||
"maxLength": 35,
|
||||
"usage": "REQUIRED",
|
||||
"componentPosition": 2
|
||||
}
|
||||
],
|
||||
"loopId": "SG1",
|
||||
"loopMaxRepeat": 10
|
||||
},
|
||||
{
|
||||
"segmentId": "NAD",
|
||||
"usage": "SITUATIONAL",
|
||||
"minUse": 0,
|
||||
"maxUse": 1,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "ID",
|
||||
"minLength": 1,
|
||||
"maxLength": 3,
|
||||
"usage": "REQUIRED",
|
||||
"codes": [
|
||||
"BY",
|
||||
"SU",
|
||||
"DP"
|
||||
]
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "AN",
|
||||
"minLength": 1,
|
||||
"maxLength": 35,
|
||||
"usage": "REQUIRED",
|
||||
"componentPosition": 1
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "AN",
|
||||
"minLength": 1,
|
||||
"maxLength": 3,
|
||||
"usage": "SITUATIONAL",
|
||||
"componentPosition": 2
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "ID",
|
||||
"minLength": 1,
|
||||
"maxLength": 3,
|
||||
"usage": "SITUATIONAL",
|
||||
"componentPosition": 3,
|
||||
"codes": [
|
||||
"9"
|
||||
]
|
||||
}
|
||||
],
|
||||
"loopId": "SG2",
|
||||
"loopMaxRepeat": 10
|
||||
},
|
||||
{
|
||||
"segmentId": "CPS",
|
||||
"usage": "REQUIRED",
|
||||
"minUse": 1,
|
||||
"maxUse": 1,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "N0",
|
||||
"minLength": 1,
|
||||
"maxLength": 12,
|
||||
"usage": "REQUIRED"
|
||||
}
|
||||
],
|
||||
"loopId": "SG10",
|
||||
"loopMaxRepeat": 999
|
||||
},
|
||||
{
|
||||
"segmentId": "PAC",
|
||||
"usage": "SITUATIONAL",
|
||||
"minUse": 0,
|
||||
"maxUse": 1,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "N0",
|
||||
"minLength": 1,
|
||||
"maxLength": 8,
|
||||
"usage": "REQUIRED"
|
||||
}
|
||||
],
|
||||
"loopId": "SG10",
|
||||
"loopMaxRepeat": 999
|
||||
},
|
||||
{
|
||||
"segmentId": "LIN",
|
||||
"usage": "REQUIRED",
|
||||
"minUse": 1,
|
||||
"maxUse": 1,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "N0",
|
||||
"minLength": 1,
|
||||
"maxLength": 6,
|
||||
"usage": "REQUIRED"
|
||||
},
|
||||
{
|
||||
"position": 3,
|
||||
"dataType": "AN",
|
||||
"minLength": 1,
|
||||
"maxLength": 35,
|
||||
"usage": "REQUIRED",
|
||||
"componentPosition": 1
|
||||
},
|
||||
{
|
||||
"position": 3,
|
||||
"dataType": "ID",
|
||||
"minLength": 1,
|
||||
"maxLength": 3,
|
||||
"usage": "REQUIRED",
|
||||
"componentPosition": 2,
|
||||
"codes": [
|
||||
"EN",
|
||||
"SA"
|
||||
]
|
||||
}
|
||||
],
|
||||
"loopId": "SG17",
|
||||
"loopMaxRepeat": 9999,
|
||||
"parentLoopId": "SG10"
|
||||
},
|
||||
{
|
||||
"segmentId": "QTY",
|
||||
"usage": "REQUIRED",
|
||||
"minUse": 1,
|
||||
"maxUse": 1,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "ID",
|
||||
"minLength": 1,
|
||||
"maxLength": 3,
|
||||
"usage": "REQUIRED",
|
||||
"componentPosition": 1,
|
||||
"codes": [
|
||||
"12"
|
||||
]
|
||||
},
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "R",
|
||||
"minLength": 1,
|
||||
"maxLength": 15,
|
||||
"usage": "REQUIRED",
|
||||
"componentPosition": 2
|
||||
},
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "ID",
|
||||
"minLength": 1,
|
||||
"maxLength": 3,
|
||||
"usage": "REQUIRED",
|
||||
"componentPosition": 3,
|
||||
"codes": [
|
||||
"PCE"
|
||||
]
|
||||
}
|
||||
],
|
||||
"loopId": "SG17",
|
||||
"loopMaxRepeat": 9999,
|
||||
"parentLoopId": "SG10"
|
||||
},
|
||||
{
|
||||
"segmentId": "UNS",
|
||||
"usage": "SITUATIONAL",
|
||||
"minUse": 0,
|
||||
"maxUse": 1,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "ID",
|
||||
"minLength": 1,
|
||||
"maxLength": 1,
|
||||
"usage": "REQUIRED",
|
||||
"codes": [
|
||||
"S"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"segmentId": "CNT",
|
||||
"usage": "SITUATIONAL",
|
||||
"minUse": 0,
|
||||
"maxUse": 1,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "ID",
|
||||
"minLength": 1,
|
||||
"maxLength": 3,
|
||||
"usage": "REQUIRED",
|
||||
"componentPosition": 1,
|
||||
"codes": [
|
||||
"2"
|
||||
]
|
||||
},
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "N0",
|
||||
"minLength": 1,
|
||||
"maxLength": 10,
|
||||
"usage": "REQUIRED",
|
||||
"componentPosition": 2
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"segmentId": "UNT",
|
||||
"usage": "REQUIRED",
|
||||
"minUse": 1,
|
||||
"maxUse": 1,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "N0",
|
||||
"minLength": 1,
|
||||
"maxLength": 10,
|
||||
"usage": "REQUIRED"
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "AN",
|
||||
"minLength": 1,
|
||||
"maxLength": 14,
|
||||
"usage": "REQUIRED"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,430 @@
|
||||
{
|
||||
"name": "QQQ baseline INVOIC D01B",
|
||||
"standard": "EDIFACT",
|
||||
"version": "D01B",
|
||||
"transactionSet": "INVOIC",
|
||||
"functionalIdCode": "INVOIC",
|
||||
"segments": [
|
||||
{
|
||||
"segmentId": "UNH",
|
||||
"usage": "REQUIRED",
|
||||
"minUse": 1,
|
||||
"maxUse": 1,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "AN",
|
||||
"minLength": 1,
|
||||
"maxLength": 14,
|
||||
"usage": "REQUIRED"
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "ID",
|
||||
"minLength": 1,
|
||||
"maxLength": 6,
|
||||
"usage": "REQUIRED",
|
||||
"componentPosition": 1,
|
||||
"codes": [
|
||||
"INVOIC"
|
||||
]
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "ID",
|
||||
"minLength": 1,
|
||||
"maxLength": 1,
|
||||
"usage": "REQUIRED",
|
||||
"componentPosition": 2,
|
||||
"codes": [
|
||||
"D"
|
||||
]
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "ID",
|
||||
"minLength": 1,
|
||||
"maxLength": 3,
|
||||
"usage": "REQUIRED",
|
||||
"componentPosition": 3,
|
||||
"codes": [
|
||||
"01B"
|
||||
]
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "ID",
|
||||
"minLength": 1,
|
||||
"maxLength": 2,
|
||||
"usage": "REQUIRED",
|
||||
"componentPosition": 4,
|
||||
"codes": [
|
||||
"UN"
|
||||
]
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "AN",
|
||||
"minLength": 1,
|
||||
"maxLength": 6,
|
||||
"usage": "SITUATIONAL",
|
||||
"componentPosition": 5
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"segmentId": "BGM",
|
||||
"usage": "REQUIRED",
|
||||
"minUse": 1,
|
||||
"maxUse": 1,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "ID",
|
||||
"minLength": 1,
|
||||
"maxLength": 3,
|
||||
"usage": "REQUIRED",
|
||||
"componentPosition": 1,
|
||||
"codes": [
|
||||
"380"
|
||||
]
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "AN",
|
||||
"minLength": 1,
|
||||
"maxLength": 35,
|
||||
"usage": "REQUIRED"
|
||||
},
|
||||
{
|
||||
"position": 3,
|
||||
"dataType": "ID",
|
||||
"minLength": 1,
|
||||
"maxLength": 3,
|
||||
"usage": "REQUIRED",
|
||||
"codes": [
|
||||
"9",
|
||||
"29"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"segmentId": "DTM",
|
||||
"usage": "REQUIRED",
|
||||
"minUse": 1,
|
||||
"maxUse": 5,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "ID",
|
||||
"minLength": 1,
|
||||
"maxLength": 3,
|
||||
"usage": "REQUIRED",
|
||||
"componentPosition": 1,
|
||||
"codes": [
|
||||
"137",
|
||||
"11"
|
||||
]
|
||||
},
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "DT",
|
||||
"minLength": 1,
|
||||
"maxLength": 8,
|
||||
"usage": "REQUIRED",
|
||||
"componentPosition": 2
|
||||
},
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "ID",
|
||||
"minLength": 1,
|
||||
"maxLength": 3,
|
||||
"usage": "REQUIRED",
|
||||
"componentPosition": 3,
|
||||
"codes": [
|
||||
"102"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"segmentId": "RFF",
|
||||
"usage": "SITUATIONAL",
|
||||
"minUse": 0,
|
||||
"maxUse": 1,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "ID",
|
||||
"minLength": 1,
|
||||
"maxLength": 3,
|
||||
"usage": "REQUIRED",
|
||||
"componentPosition": 1,
|
||||
"codes": [
|
||||
"ON"
|
||||
]
|
||||
},
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "AN",
|
||||
"minLength": 1,
|
||||
"maxLength": 35,
|
||||
"usage": "REQUIRED",
|
||||
"componentPosition": 2
|
||||
}
|
||||
],
|
||||
"loopId": "SG1",
|
||||
"loopMaxRepeat": 10
|
||||
},
|
||||
{
|
||||
"segmentId": "NAD",
|
||||
"usage": "SITUATIONAL",
|
||||
"minUse": 0,
|
||||
"maxUse": 1,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "ID",
|
||||
"minLength": 1,
|
||||
"maxLength": 3,
|
||||
"usage": "REQUIRED",
|
||||
"codes": [
|
||||
"BY",
|
||||
"SU",
|
||||
"DP"
|
||||
]
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "AN",
|
||||
"minLength": 1,
|
||||
"maxLength": 35,
|
||||
"usage": "REQUIRED",
|
||||
"componentPosition": 1
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "AN",
|
||||
"minLength": 1,
|
||||
"maxLength": 3,
|
||||
"usage": "SITUATIONAL",
|
||||
"componentPosition": 2
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "ID",
|
||||
"minLength": 1,
|
||||
"maxLength": 3,
|
||||
"usage": "SITUATIONAL",
|
||||
"componentPosition": 3,
|
||||
"codes": [
|
||||
"9"
|
||||
]
|
||||
}
|
||||
],
|
||||
"loopId": "SG2",
|
||||
"loopMaxRepeat": 10
|
||||
},
|
||||
{
|
||||
"segmentId": "LIN",
|
||||
"usage": "REQUIRED",
|
||||
"minUse": 1,
|
||||
"maxUse": 1,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "N0",
|
||||
"minLength": 1,
|
||||
"maxLength": 6,
|
||||
"usage": "REQUIRED"
|
||||
},
|
||||
{
|
||||
"position": 3,
|
||||
"dataType": "AN",
|
||||
"minLength": 1,
|
||||
"maxLength": 35,
|
||||
"usage": "REQUIRED",
|
||||
"componentPosition": 1
|
||||
},
|
||||
{
|
||||
"position": 3,
|
||||
"dataType": "ID",
|
||||
"minLength": 1,
|
||||
"maxLength": 3,
|
||||
"usage": "REQUIRED",
|
||||
"componentPosition": 2,
|
||||
"codes": [
|
||||
"EN",
|
||||
"SA"
|
||||
]
|
||||
}
|
||||
],
|
||||
"loopId": "SG25",
|
||||
"loopMaxRepeat": 9999
|
||||
},
|
||||
{
|
||||
"segmentId": "QTY",
|
||||
"usage": "REQUIRED",
|
||||
"minUse": 1,
|
||||
"maxUse": 1,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "ID",
|
||||
"minLength": 1,
|
||||
"maxLength": 3,
|
||||
"usage": "REQUIRED",
|
||||
"componentPosition": 1,
|
||||
"codes": [
|
||||
"47"
|
||||
]
|
||||
},
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "R",
|
||||
"minLength": 1,
|
||||
"maxLength": 15,
|
||||
"usage": "REQUIRED",
|
||||
"componentPosition": 2
|
||||
},
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "ID",
|
||||
"minLength": 1,
|
||||
"maxLength": 3,
|
||||
"usage": "REQUIRED",
|
||||
"componentPosition": 3,
|
||||
"codes": [
|
||||
"PCE"
|
||||
]
|
||||
}
|
||||
],
|
||||
"loopId": "SG25",
|
||||
"loopMaxRepeat": 9999
|
||||
},
|
||||
{
|
||||
"segmentId": "PRI",
|
||||
"usage": "SITUATIONAL",
|
||||
"minUse": 0,
|
||||
"maxUse": 1,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "ID",
|
||||
"minLength": 1,
|
||||
"maxLength": 3,
|
||||
"usage": "REQUIRED",
|
||||
"componentPosition": 1,
|
||||
"codes": [
|
||||
"AAA"
|
||||
]
|
||||
},
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "R",
|
||||
"minLength": 1,
|
||||
"maxLength": 15,
|
||||
"usage": "REQUIRED",
|
||||
"componentPosition": 2
|
||||
}
|
||||
],
|
||||
"loopId": "SG25",
|
||||
"loopMaxRepeat": 9999
|
||||
},
|
||||
{
|
||||
"segmentId": "UNS",
|
||||
"usage": "SITUATIONAL",
|
||||
"minUse": 0,
|
||||
"maxUse": 1,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "ID",
|
||||
"minLength": 1,
|
||||
"maxLength": 1,
|
||||
"usage": "REQUIRED",
|
||||
"codes": [
|
||||
"S"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"segmentId": "CNT",
|
||||
"usage": "SITUATIONAL",
|
||||
"minUse": 0,
|
||||
"maxUse": 1,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "ID",
|
||||
"minLength": 1,
|
||||
"maxLength": 3,
|
||||
"usage": "REQUIRED",
|
||||
"componentPosition": 1,
|
||||
"codes": [
|
||||
"2"
|
||||
]
|
||||
},
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "N0",
|
||||
"minLength": 1,
|
||||
"maxLength": 10,
|
||||
"usage": "REQUIRED",
|
||||
"componentPosition": 2
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"segmentId": "MOA",
|
||||
"usage": "SITUATIONAL",
|
||||
"minUse": 0,
|
||||
"maxUse": 1,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "ID",
|
||||
"minLength": 1,
|
||||
"maxLength": 3,
|
||||
"usage": "REQUIRED",
|
||||
"componentPosition": 1,
|
||||
"codes": [
|
||||
"77"
|
||||
]
|
||||
},
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "R",
|
||||
"minLength": 1,
|
||||
"maxLength": 18,
|
||||
"usage": "REQUIRED",
|
||||
"componentPosition": 2
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"segmentId": "UNT",
|
||||
"usage": "REQUIRED",
|
||||
"minUse": 1,
|
||||
"maxUse": 1,
|
||||
"elements": [
|
||||
{
|
||||
"position": 1,
|
||||
"dataType": "N0",
|
||||
"minLength": 1,
|
||||
"maxLength": 10,
|
||||
"usage": "REQUIRED"
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"dataType": "AN",
|
||||
"minLength": 1,
|
||||
"maxLength": 14,
|
||||
"usage": "REQUIRED"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
Loaded 100 of 140 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user