diff --git a/CHANGELOG.md b/CHANGELOG.md index 23ff67c..61ae7e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,8 +4,17 @@ Following [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and [Semantic ## [Unreleased] +### Changed + +- X12 routing and sending honor identity `isActive`, matching EDIFACT. + ### Added +- Help bundle `help/qbit-edi.yaml` (stand-in for qqq#1198): every table and the app say what they are for and how rows get created, including the setup order, so an empty table explains itself. +- Setup status section on the EDI home (partner, identities, agreement, agreement documents, transport, certificates, guides, maps) with the next step; it counts only the active rows the runtime uses. +- "Install sample maps" action on Maps: standard streamed ETL, insert-only by name, installs the 32 bundled synthetic maps as draft, unbound maps bound to the built-in guide. +- `EdiTablesGuardrailTest`: every table is used at runtime, seeded or listed as starting empty, and has help. + - Phase f1 `edi` 1.0.0 spec'd UI API: guide tree, map target/transform metadata, encrypted base64 sample preview with unsaved rule overrides, and per-rule validation under `/qqq/v1/qbit/edi`. - Versioned `Edi...V1` schemas and generated OpenAPI fragment with the RC.6 contract/compatibility checks; real HTTP strict-mode permission, PHI, upload, isolation and validation tests. - Shared sandbox/record-action preview service, current/original guide selection, caller-session transforms, structured revision/expected diffs and value-free PHI diagnostics. diff --git a/README.md b/README.md index c8f5074..0e78343 100644 --- a/README.md +++ b/README.md @@ -167,6 +167,29 @@ synthetic EDI on the standard Review step. Browser uploads require the host's RC staging. The alias encrypts streams and persists samples; this process accepts no filesystem archive. Samples must contain synthetic data only. +## Setup and starter data + +No Liquibase changelog or host include is needed: the qbit seeds its own reference data. When the +host calls `EdiRuntime.initialize()` at startup (after the schema exists) the 41 built-in guides +load, with their segments, elements, rules, code lists and code values. It only changes a guide when +the bundled content changes, so edits to your own rows are kept. + +Everything else is host or runtime data, and every table explains itself when empty (help bundle +`help/qbit-edi.yaml`). Set up in this order: + +1. Partner. +2. Identities: one of yours (no partner) and one per partner. +3. Agreement: pairs your identity with the partner's, for a standard and test or production. +4. Agreement documents: one per transaction set and direction, with the guide and optionally a map. +5. Transport (AS2, SFTP, HTTPS), linked on the agreement. Certificates only for AS2 signing and encryption. + +The **Setup status** section on the home shows each step, whether it is done (counting only the +active rows the runtime uses) and the next step. + +Maps start empty. The **Install sample maps** action on Maps adds the 32 bundled synthetic examples +as draft maps with no partner and no agreement document, by name and never over an existing map. Point +a map at your own table, preview it, activate it, then select it on an agreement document. + ## Permissions Full table permissions include `edi.partner.read|insert|edit|delete`, `edi.map.*`, diff --git a/UPGRADE.md b/UPGRADE.md new file mode 100644 index 0000000..16b2103 --- /dev/null +++ b/UPGRADE.md @@ -0,0 +1,9 @@ +# Upgrade notes + +## Unreleased + +- No schema change and no Liquibase include. +- Behavior change: an inactive identity (`isActive` false) now stops X12 routing and sending, as it already did for EDIFACT. Inbound documents from or to it are rejected as an unknown partner, and outbound requests for its agreements wait until it is active. Identities default to active, so only identities someone switched off are affected. +- Hosts that already call `EdiRuntime.initialize()` at startup keep getting the 41 built-in guides and code lists. A host that never called it will now see "Needs setup" for Guides and code lists on the home. +- New home section "Setup status" (widget `edi_home_setup`). +- New table action "Install sample maps" on Maps, permission `edi.admin`. It is never run automatically and only adds draft maps whose name does not exist yet. diff --git a/src/main/java/com/kingsrook/qbits/edi/EdiQBitProducer.java b/src/main/java/com/kingsrook/qbits/edi/EdiQBitProducer.java index 62dbbdf..2b7c0cb 100644 --- a/src/main/java/com/kingsrook/qbits/edi/EdiQBitProducer.java +++ b/src/main/java/com/kingsrook/qbits/edi/EdiQBitProducer.java @@ -130,6 +130,7 @@ public class EdiQBitProducer implements QBitMetaDataProducer MetaDataProducerMultiOutput output = QBitMetaDataProducer.super.produce(instance); output.addSelfToInstance(instance); EdiRuntime.install(instance, qBitConfig); + com.kingsrook.qbits.edi.metadata.EdiHelp.apply(instance, qBitConfig.getAppName()); instance.withSupplementalMetaData(new com.kingsrook.qbits.edi.metadata.EdiRecordLabels()); instance.withRecordChangeListener(new com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference(com.kingsrook.qbits.edi.flow.OutboundEnqueue.class)); instance.withSupplementalMetaData(new com.kingsrook.qbits.edi.transport.EdiTransportPluginsMetaData() diff --git a/src/main/java/com/kingsrook/qbits/edi/app/EdiHome.java b/src/main/java/com/kingsrook/qbits/edi/app/EdiHome.java index 43cb853..f7cd889 100644 --- a/src/main/java/com/kingsrook/qbits/edi/app/EdiHome.java +++ b/src/main/java/com/kingsrook/qbits/edi/app/EdiHome.java @@ -69,7 +69,7 @@ public class EdiHome extends AbstractWidgetRenderer public static final List SETUP = List.of("edi_partner", "edi_agreement", "edi_transport", "edi_certificate", "edi_guide", "edi_code_list", "edi_map"); private static final Map LABELS = new LinkedHashMap<>(); private static final Map DOCUMENT_NAMES = new HashMap<>(); - private static final List TABLES = List.of("queue", "acks", "errors", "volume", "system"); + private static final List TABLES = List.of("setup", "queue", "acks", "errors", "volume", "system"); static { @@ -85,6 +85,7 @@ public class EdiHome extends AbstractWidgetRenderer LABELS.put("overdue", "Overdue acks"); LABELS.put("dead", "Dead letters"); LABELS.put("certificates", "Certificates expiring (30 days)"); + LABELS.put("setup", "Setup status"); LABELS.put("queue", "Exception queue"); LABELS.put("acks", "Overdue acknowledgements"); LABELS.put("errors", "Recent errors (24 hours)"); @@ -138,6 +139,7 @@ public class EdiHome extends AbstractWidgetRenderer case "dead" -> count("edi_exception", openExceptions().withCriteria("type", QCriteriaOperator.IN, "DEAD_LETTER", "TRANSPORT")); case "certificates" -> count("edi_certificate", new QQueryFilter().withCriteria("notAfter", QCriteriaOperator.LESS_THAN_OR_EQUALS, now.plus(30, ChronoUnit.DAYS)) .withCriteria("status", QCriteriaOperator.EQUALS, "ACTIVE")); + case "setup" -> setup(); case "queue" -> queue(now); case "acks" -> acks(now); case "errors" -> errors(now); @@ -191,6 +193,48 @@ public class EdiHome extends AbstractWidgetRenderer + /******************************************************************************* + ** The setup steps in order, with their status and the next step. Counts are + ** taken as the viewer; a table the viewer cannot read shows as not visible. + *******************************************************************************/ + private static RenderWidgetOutput setup() throws QException + { + QQueryFilter active = new QQueryFilter().withCriteria("isActive", QCriteriaOperator.EQUALS, true); + EdiSetupStatus.Counts counts = new EdiSetupStatus.Counts(safeCount("edi_partner", new QQueryFilter()), + safeCount("edi_identity", new QQueryFilter().withCriteria("isActive", QCriteriaOperator.EQUALS, true).withCriteria("partnerId", QCriteriaOperator.IS_BLANK)), + safeCount("edi_identity", new QQueryFilter().withCriteria("isActive", QCriteriaOperator.EQUALS, true).withCriteria("partnerId", QCriteriaOperator.IS_NOT_BLANK)), + safeCount("edi_agreement", new QQueryFilter()), safeCount("edi_agreement", active), + safeCount("edi_agreement_document", new QQueryFilter()), safeCount("edi_agreement_document", active), + safeCount("edi_transport", new QQueryFilter()), safeCount("edi_transport", active), + safeCount("edi_certificate", new QQueryFilter()), safeCount("edi_guide", new QQueryFilter()), safeCount("edi_code_list", new QQueryFilter()), + safeCount("edi_map", new QQueryFilter()), safeCount("edi_map", new QQueryFilter().withCriteria("status", QCriteriaOperator.EQUALS, "ACTIVE"))); + List> rows = new ArrayList<>(); + for(EdiSetupStatus.Item item : EdiSetupStatus.items(counts)) + { + rows.add(row("Step", item.step(), "Status", item.state().label() + (item.detail().isEmpty() ? "" : ": " + item.detail()), "Next step", item.nextStep().isEmpty() ? "Nothing to do." : item.nextStep())); + } + return (table(List.of("Step", "Status", "Next step"), rows, "edi_partner", new QQueryFilter(), "Open partners")); + } + + + + /******************************************************************************* + ** A count the viewer may not be allowed to read: -1 instead of an error. + *******************************************************************************/ + private static int safeCount(String table, QQueryFilter filter) + { + try + { + return (countOf(table, filter)); + } + catch(QException | RuntimeException e) + { + return (-1); + } + } + + + /******************************************************************************* ** Open exceptions, oldest first, in four narrow columns. *******************************************************************************/ diff --git a/src/main/java/com/kingsrook/qbits/edi/app/EdiSetupStatus.java b/src/main/java/com/kingsrook/qbits/edi/app/EdiSetupStatus.java new file mode 100644 index 0000000..c7f0674 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/edi/app/EdiSetupStatus.java @@ -0,0 +1,156 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.edi.app; + +import java.util.ArrayList; +import java.util.List; + +/******************************************************************************* + ** The Setup status on the EDI home: each setup step in the order it has to be + ** done (partner, identity, agreement, document, transport), whether it is + ** done, and the next step. The counts use the same conditions the runtime + ** uses to route documents (active rows), so the card never reports "set up" + ** for rows the runtime ignores. + *******************************************************************************/ +public final class EdiSetupStatus +{ + /******************************************************************************* + ** DONE: working. TODO: the exchange needs it. BUILT_IN: provided by the + ** qbit. OPTIONAL: only some setups need it. UNKNOWN: the viewer cannot see it. + *******************************************************************************/ + public enum State + { + DONE("Set up"), TODO("Needs setup"), BUILT_IN("Built in"), OPTIONAL("Optional"), UNKNOWN("Not visible to you"); + + private final String label; + + /******************************************************************************* + ** + *******************************************************************************/ + State(String label) + { + this.label = label; + } + + /******************************************************************************* + ** The label shown on the card. + *******************************************************************************/ + public String label() + { + return (label); + } + } + + /******************************************************************************* + ** One line of the card. + *******************************************************************************/ + public record Item(String step, String tableName, State state, String detail, String nextStep) + { + } + + /******************************************************************************* + ** The row counts the card is built from; -1 means the viewer cannot read it. + ** Active counts are rows with isActive true (what the runtime uses). + *******************************************************************************/ + public record Counts(int partners, int ownIdentities, int partnerIdentities, int agreements, int activeAgreements, int agreementDocuments, + int activeAgreementDocuments, int transports, int activeTransports, int certificates, int guides, int codeLists, int maps, int activeMaps) + { + } + + /******************************************************************************* + ** Utility class. + *******************************************************************************/ + private EdiSetupStatus() + { + } + + /******************************************************************************* + ** The items, in setup order. + *******************************************************************************/ + public static List items(Counts c) + { + List items = new ArrayList<>(); + items.add(item("1. Partner", "edi_partner", c.partners(), c.partners(), "partner", "Add the trading partner with New on Partners.")); + items.add(identities(c)); + items.add(item("3. Agreement", "edi_agreement", c.agreements(), c.activeAgreements(), "agreement", + "Add an agreement on Agreements: your identity, the partner's identity, the standard and test or production. Make sure Active is on.")); + items.add(item("4. Agreement documents", "edi_agreement_document", c.agreementDocuments(), c.activeAgreementDocuments(), "agreement document", + "Add one per transaction set and direction on Agreement Documents: pick the guide, and a map if it should become records. A document type with no row is not processed.")); + items.add(item("5. Transport", "edi_transport", c.transports(), c.activeTransports(), "transport", + "Add the AS2, SFTP or HTTPS connection on Transports (secrets as variable names), link it on the agreement, then run Test Connection.")); + items.add(c.certificates() < 0 ? new Item("Certificates", "edi_certificate", State.UNKNOWN, "", "") + : c.certificates() == 0 + ? new Item("Certificates", "edi_certificate", State.OPTIONAL, "None.", "Needed only for AS2 signing and encryption: add yours and the partner's on Certificates.") + : new Item("Certificates", "edi_certificate", State.DONE, c.certificates() + (c.certificates() == 1 ? " certificate." : " certificates."), "")); + items.add(c.guides() < 0 || c.codeLists() < 0 ? new Item("Guides and code lists", "edi_guide", State.UNKNOWN, "", "") + : c.guides() == 0 || c.codeLists() == 0 + ? new Item("Guides and code lists", "edi_guide", State.TODO, "The built-in guides are not loaded.", "The host must call EdiRuntime.initialize() at startup, after the schema exists.") + : new Item("Guides and code lists", "edi_guide", State.BUILT_IN, c.guides() + " guides and " + c.codeLists() + " code lists.", "")); + items.add(c.maps() < 0 ? new Item("Maps", "edi_map", State.UNKNOWN, "", "") + : c.maps() == 0 + ? new Item("Maps", "edi_map", State.OPTIONAL, "None.", "Needed to turn documents into records. Run Install sample maps on Maps to start from an example.") + : c.activeMaps() == 0 + ? new Item("Maps", "edi_map", State.TODO, c.maps() + " maps, none active.", "Point a map at your table, preview it, then run Activate Map.") + : new Item("Maps", "edi_map", State.DONE, c.activeMaps() + " active of " + c.maps() + ".", "")); + return (items); + } + + /******************************************************************************* + ** Step 2: your own identity (no partner) and the partner's. + *******************************************************************************/ + private static Item identities(Counts c) + { + String step = "2. Identities"; + if(c.ownIdentities() < 0 || c.partnerIdentities() < 0) + { + return (new Item(step, "edi_identity", State.UNKNOWN, "", "")); + } + if(c.ownIdentities() == 0) + { + return (new Item(step, "edi_identity", State.TODO, "None of yours.", "Add your own ISA or UNB qualifier and ID on Identities, with no partner.")); + } + if(c.partnerIdentities() == 0) + { + return (new Item(step, "edi_identity", State.TODO, "Yours is set; no partner has one.", "Add the partner's qualifier and ID on Identities, with the partner selected.")); + } + return (new Item(step, "edi_identity", State.DONE, c.ownIdentities() + " of yours, " + c.partnerIdentities() + " for partners.", "")); + } + + /******************************************************************************* + ** A step that needs at least one active row. + *******************************************************************************/ + private static Item item(String step, String table, int total, int active, String noun, String next) + { + if(total < 0 || active < 0) + { + return (new Item(step, table, State.UNKNOWN, "", "")); + } + if(total == 0) + { + return (new Item(step, table, State.TODO, "None.", next)); + } + if(active == 0) + { + return (new Item(step, table, State.TODO, total + " " + noun + (total == 1 ? "" : "s") + ", none active.", "Turn Active on for the one to use.")); + } + return (new Item(step, table, State.DONE, active + " active.", "")); + } +} diff --git a/src/main/java/com/kingsrook/qbits/edi/flow/InboundProcessor.java b/src/main/java/com/kingsrook/qbits/edi/flow/InboundProcessor.java index 900f5e8..e187c8c 100644 --- a/src/main/java/com/kingsrook/qbits/edi/flow/InboundProcessor.java +++ b/src/main/java/com/kingsrook/qbits/edi/flow/InboundProcessor.java @@ -220,7 +220,8 @@ public class InboundProcessor { QRecord sender = row("edi_identity", agreement.getValueInteger("partnerIdentityId")); QRecord receiver = row("edi_identity", agreement.getValueInteger("localIdentityId")); - if(sender != null && receiver != null && Objects.equals(sender.getValueInteger("partnerId"), agreement.getValueInteger("partnerId")) + if(sender != null && receiver != null && !Boolean.FALSE.equals(sender.getValueBoolean("isActive")) && !Boolean.FALSE.equals(receiver.getValueBoolean("isActive")) + && Objects.equals(sender.getValueInteger("partnerId"), agreement.getValueInteger("partnerId")) && receiver.getValue("partnerId") == null && x12.isa().value(5).equals(sender.getValueString("interchangeQualifier")) && x12.isa().value(6).trim().equals(sender.getValueString("interchangeId")) && x12.isa().value(7).equals(receiver.getValueString("interchangeQualifier")) && x12.isa().value(8).trim().equals(receiver.getValueString("interchangeId"))) diff --git a/src/main/java/com/kingsrook/qbits/edi/flow/OutboundWorker.java b/src/main/java/com/kingsrook/qbits/edi/flow/OutboundWorker.java index b816538..7b283ad 100644 --- a/src/main/java/com/kingsrook/qbits/edi/flow/OutboundWorker.java +++ b/src/main/java/com/kingsrook/qbits/edi/flow/OutboundWorker.java @@ -87,6 +87,15 @@ public class OutboundWorker { return (0); } + QRecord localIdentity = row("edi_identity", agreement.getValueInteger("localIdentityId")); + QRecord partnerIdentity = row("edi_identity", agreement.getValueInteger("partnerIdentityId")); + if(localIdentity == null || partnerIdentity == null || Boolean.FALSE.equals(localIdentity.getValueBoolean("isActive")) || Boolean.FALSE.equals(partnerIdentity.getValueBoolean("isActive"))) + { + /////////////////////////////////////////////////////////////////////////// + // an inactive identity stops sending, as it stops routing; requests wait // + /////////////////////////////////////////////////////////////////////////// + return (0); + } var requests = EdiRecords.query("edi_outbound_request", EdiRecords.equal("agreementDocumentId", id).withCriteria("status", QCriteriaOperator.EQUALS, "PENDING").withLimit(100)); if(requests.isEmpty()) { diff --git a/src/main/java/com/kingsrook/qbits/edi/map/SampleMaps.java b/src/main/java/com/kingsrook/qbits/edi/map/SampleMaps.java new file mode 100644 index 0000000..ddcb4e9 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/edi/map/SampleMaps.java @@ -0,0 +1,157 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.edi.map; + +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.kingsrook.qbits.edi.storage.EdiRecords; +import com.kingsrook.qbits.edi.support.SystemWrites; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; +import com.kingsrook.qqq.backend.core.model.data.QRecord; + +/******************************************************************************* + ** The bundled sample maps (synthetic, one per built-in document and version) + ** and their insert-only installation. Installed maps are drafts, bound to no + ** partner and to no agreement document, so installing one never changes how + ** any document is processed; the target table in each is an example name the + ** host replaces before activating. + *******************************************************************************/ +public final class SampleMaps +{ + public static final List RESOURCES = List.of("204-004010", "204-005010", "204-out-004010", "204-out-005010", "214-004010", "214-005010", + "810-004010", "810-005010", "846-004010", "846-005010", "850-004010", "850-005010", "855-004010", "855-005010", "856-004010", "856-005010", + "860-004010", "860-005010", "940-004010", "940-005010", "945-004010", "945-005010", "945-in-004010", "945-in-005010", + "DESADV-D01B", "DESADV-D96A", "INVOIC-D01B", "INVOIC-D96A", "ORDERS-D01B", "ORDERS-D96A", "ORDRSP-D01B", "ORDRSP-D96A"); + + /******************************************************************************* + ** Utility class. + *******************************************************************************/ + private SampleMaps() + { + } + + /******************************************************************************* + ** One bundled sample: the map's values and its rules. + *******************************************************************************/ + public static JsonNode read(String resource) throws IOException + { + try(InputStream input = SampleMaps.class.getResourceAsStream("/edi/maps/" + resource + "-sample.json")) + { + if(input == null) + { + throw (new IOException("Unknown sample map " + resource)); + } + return (new ObjectMapper().readTree(input)); + } + } + + /******************************************************************************* + ** The bundled samples whose map name does not exist yet (any revision, any + ** partner), in bundle order. Reads the map table as the processing user. + *******************************************************************************/ + public static List missing() throws QException, IOException + { + Set existing = new HashSet<>(); + for(QRecord map : SystemWrites.query("edi_map", new QQueryFilter())) + { + existing.add(map.getValueString("name")); + } + List missing = new ArrayList<>(); + for(String resource : RESOURCES) + { + if(!existing.contains(read(resource).path("map").path("name").asText())) + { + missing.add(resource); + } + } + return (missing); + } + + /******************************************************************************* + ** The map record a sample installs as: a draft, revision 0, no partner, + ** bound to the built-in guide for its document and version when present. + *******************************************************************************/ + public static QRecord mapRecord(String resource) throws IOException, QException + { + JsonNode map = read(resource).path("map"); + QRecord record = new QRecord(); + map.fields().forEachRemaining(e -> + { + if(!e.getValue().isNull() && !List.of("status", "revision", "partnerId", "guideId", "parentMapId").contains(e.getKey())) + { + record.withValue(e.getKey(), e.getValue().asText()); + } + }); + record.withValue("status", "DRAFT").withValue("revision", 0); + Integer guideId = null; + int revision = -1; + for(QRecord guide : SystemWrites.query("edi_guide", new QQueryFilter().withCriteria("transactionSet", QCriteriaOperator.EQUALS, map.path("transactionSet").asText()) + .withCriteria("version", QCriteriaOperator.EQUALS, map.path("version").asText()).withCriteria("source", QCriteriaOperator.EQUALS, "BUILT_IN") + .withCriteria("status", QCriteriaOperator.EQUALS, "ACTIVE"))) + { + int candidate = guide.getValueInteger("revision") == null ? 0 : guide.getValueInteger("revision"); + if(candidate > revision) + { + revision = candidate; + guideId = guide.getValueInteger("id"); + } + } + return (record.withValue("guideId", guideId)); + } + + /******************************************************************************* + ** Insert one sample and its ordered rules. Skips (returns null) a sample + ** whose map name now exists, so a repeated run adds nothing. It is an + ** administrator action; two people running it at the same moment is not + ** guarded against (edi_map has no unique name, since revisions share one). + *******************************************************************************/ + public static Integer install(String resource) throws Exception + { + JsonNode sample = read(resource); + String name = sample.path("map").path("name").asText(); + if(!EdiRecords.query("edi_map", EdiRecords.equal("name", name)).isEmpty()) + { + return (null); + } + QRecord map = SystemWrites.insert("edi_map", mapRecord(resource)); + for(JsonNode rule : sample.path("rules")) + { + QRecord row = new QRecord(); + rule.fields().forEachRemaining(e -> + { + if(!e.getValue().isNull()) + { + row.withValue(e.getKey(), e.getValue().isInt() ? (java.io.Serializable) e.getValue().intValue() : e.getValue().isBoolean() ? (java.io.Serializable) e.getValue().booleanValue() : e.getValue().asText()); + } + }); + SystemWrites.insert("edi_map_rule", row.withValue("mapId", map.getValue("id"))); + } + return (map.getValueInteger("id")); + } +} diff --git a/src/main/java/com/kingsrook/qbits/edi/metadata/EdiHelp.java b/src/main/java/com/kingsrook/qbits/edi/metadata/EdiHelp.java new file mode 100644 index 0000000..6105027 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/edi/metadata/EdiHelp.java @@ -0,0 +1,131 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.edi.metadata; + + +import java.io.IOException; +import java.io.InputStream; +import java.util.LinkedHashMap; +import java.util.Map; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import com.kingsrook.qqq.backend.core.model.metadata.help.QHelpContent; +import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; + + +/******************************************************************************* + ** Applies help/qbit-edi.yaml (the QQQ help-bundle format) to the Edi app + ** and its edi_* tables: what each table is for and how its rows get created, + ** shown on the table screen and when it is empty. + ** STAND-IN(core=QRun-IO/qqq#1198, removeBy=4.1.0-RC.8): replace with HelpBundles.register once the pinned QQQ + ** release includes it; the bundle file stays the same. + *******************************************************************************/ +public final class EdiHelp +{ + public static final String RESOURCE = "/help/qbit-edi.yaml"; + public static final String SLOT = "help"; + public static final String APP_KEY = "edi"; + + + + /******************************************************************************* + ** Utility class. + *******************************************************************************/ + private EdiHelp() + { + } + + + + /******************************************************************************* + ** The bundle's entries by key, validated (fails startup if malformed). + *******************************************************************************/ + public static Map entries() throws QException + { + try(InputStream stream = EdiHelp.class.getResourceAsStream(RESOURCE)) + { + if(stream == null) + { + throw (new IOException("missing resource")); + } + JsonNode bundle = new ObjectMapper(new YAMLFactory()).readTree(stream); + if(!"qbit-edi".equals(bundle.path("bundle").asText()) || !"QBIT".equals(bundle.path("layer").asText())) + { + throw (new IOException("invalid bundle header")); + } + Map entries = new LinkedHashMap<>(); + for(JsonNode entry : bundle.path("entries")) + { + String key = entry.path("key").asText(); + String summary = entry.path("summary").asText(); + if(key.isBlank() || summary.isBlank() || summary.length() > 120 || entry.path("content").asText().isBlank() || entries.put(key, entry) != null) + { + throw (new IOException("invalid or duplicate entry: " + key)); + } + } + return (entries); + } + catch(IOException | RuntimeException e) + { + throw (new QException("Could not load " + RESOURCE, e)); + } + } + + + + /******************************************************************************* + ** Apply the help to the app (by its configured name) and every edi_* table. A + ** host's own help on a table is kept (the bundle only fills the slot). + *******************************************************************************/ + public static void apply(QInstance qInstance, String appName) throws QException + { + Map entries = entries(); + for(QTableMetaData table : qInstance.getTables().values()) + { + JsonNode entry = entries.get("table:" + table.getName() + ";slot:" + SLOT); + if(entry != null && (table.getHelpContent() == null || !table.getHelpContent().containsKey(SLOT))) + { + table.withHelpContent(SLOT, content(entry)); + } + } + QAppMetaData app = qInstance.getApp(appName); + JsonNode appEntry = entries.get("app:" + APP_KEY + ";slot:" + SLOT); + if(app != null && appEntry != null && (app.getHelpContent() == null || !app.getHelpContent().containsKey(SLOT))) + { + app.withHelpContent(SLOT, content(appEntry)); + } + } + + + + /******************************************************************************* + ** Summary as the first paragraph, then the content, as markdown. + *******************************************************************************/ + static QHelpContent content(JsonNode entry) + { + return (new QHelpContent().withContentAsMarkdown(entry.path("summary").asText().strip() + "\n\n" + entry.path("content").asText().strip())); + } + +} diff --git a/src/main/java/com/kingsrook/qbits/edi/processes/EdiProcesses.java b/src/main/java/com/kingsrook/qbits/edi/processes/EdiProcesses.java index 1a12176..dfd3ec5 100644 --- a/src/main/java/com/kingsrook/qbits/edi/processes/EdiProcesses.java +++ b/src/main/java/com/kingsrook/qbits/edi/processes/EdiProcesses.java @@ -88,6 +88,7 @@ public final class EdiProcesses result.add(action("edi_guide", "override", "Create Partner Override", "edi.guide.override")); result.add(action("edi_guide", "activate", "Activate Guide", "edi.guide.activate")); result.add(action("edi_map", "activate", "Activate Map", "edi.map.activate")); + result.add(InstallSampleMapsProcess.process()); QProcessMetaData scheduled = new QProcessMetaData().withName("edi_inbox_worker").withLabel("Process Inbox").withIsHidden(true) .withPermissionRules(permission("edi.admin")).withStep(new QBackendStepMetaData().withName("run").withCode(new QCodeReference(WorkerStep.class))); if(config.getSchedulerName() != null) diff --git a/src/main/java/com/kingsrook/qbits/edi/processes/InstallSampleMapsProcess.java b/src/main/java/com/kingsrook/qbits/edi/processes/InstallSampleMapsProcess.java new file mode 100644 index 0000000..a7a0e88 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/edi/processes/InstallSampleMapsProcess.java @@ -0,0 +1,210 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.edi.processes; + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import com.kingsrook.qbits.edi.map.SampleMaps; +import com.kingsrook.qbits.edi.support.SystemWrites; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLine; +import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLineInterface; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput; +import com.kingsrook.qqq.backend.core.model.actions.processes.Status; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon; +import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel; +import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules; +import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData; +import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractExtractStep; +import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractLoadStep; +import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractTransformStep; +import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.ProcessSummaryProviderInterface; +import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.StreamedETLWithFrontendProcess; + +/******************************************************************************* + ** "Install sample maps": a table action on edi_map that adds the bundled + ** synthetic sample maps whose name does not exist yet. Standard streamed ETL: + ** Review lists what will be added, Result what was added. Insert-only by map + ** name, so existing maps and edits are never touched. Installed maps are + ** drafts with no partner and no agreement document, so nothing changes how a + ** document is processed until an administrator activates and binds one. + *******************************************************************************/ +public final class InstallSampleMapsProcess +{ + public static final String NAME = "edi_map_installSamples"; + + /******************************************************************************* + ** Utility class. + *******************************************************************************/ + private InstallSampleMapsProcess() + { + } + + /******************************************************************************* + ** The process: a table-level action (no records need to be selected). + *******************************************************************************/ + public static QProcessMetaData process() + { + Map values = new LinkedHashMap<>(); + values.put(StreamedETLWithFrontendProcess.FIELD_SOURCE_TABLE, "edi_map"); + values.put(StreamedETLWithFrontendProcess.FIELD_DESTINATION_TABLE, "edi_map"); + values.put(StreamedETLWithFrontendProcess.FIELD_SUPPORTS_FULL_VALIDATION, false); + values.put(StreamedETLWithFrontendProcess.FIELD_PREVIEW_MESSAGE, + "These sample maps will be added as drafts, with no partner and no agreement document. Maps that already exist are not changed."); + QProcessMetaData process = StreamedETLWithFrontendProcess.defineProcessMetaData(Extract.class, Transform.class, Load.class, values); + process.setName(NAME); + process.setLabel("Install sample maps"); + process.setIcon(new QIcon("playlist_add")); + process.setTableName("edi_map"); + process.setIsHidden(true); + process.setMinInputRecords(0); + process.setPermissionRules(new QPermissionRules().withPermissionBaseName("edi.admin").withLevel(PermissionLevel.HAS_ACCESS_PERMISSION)); + return (process); + } + + /******************************************************************************* + ** The samples still to install, as the processing user. + *******************************************************************************/ + static List missing() throws QException + { + return (SystemWrites.call(SampleMaps::missing)); + } + + /******************************************************************************* + ** A summary line with the standard four wordings. + *******************************************************************************/ + private static ProcessSummaryLine line(Status status, String future, String futurePlural, String past, String pastPlural) + { + ProcessSummaryLine line = new ProcessSummaryLine(status, 0, null); + line.setSingularFutureMessage(future); + line.setPluralFutureMessage(futurePlural); + line.setSingularPastMessage(past); + line.setPluralPastMessage(pastPlural); + return (line); + } + + /******************************************************************************* + ** Extract: the missing samples, computed (no query of selected records). + *******************************************************************************/ + public static class Extract extends AbstractExtractStep + { + /******************************************************************************* + ** How many samples will be added. + *******************************************************************************/ + @Override + public Integer doCount(RunBackendStepInput input) throws QException + { + return (missing().size()); + } + + /******************************************************************************* + ** One record per missing sample. + *******************************************************************************/ + @Override + public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException + { + for(String resource : missing()) + { + QRecord map = SystemWrites.call(() -> SampleMaps.mapRecord(resource)); + getRecordPipe().addRecord(map.withValue("sample", resource)); + } + } + } + + /******************************************************************************* + ** Transform: forward the records and count them by standard and direction. + *******************************************************************************/ + public static class Transform extends AbstractTransformStep implements ProcessSummaryProviderInterface + { + private final Map byKind = new LinkedHashMap<>(); + + /******************************************************************************* + ** Count by standard and direction. + *******************************************************************************/ + @Override + public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException + { + for(QRecord record : input.getRecords()) + { + String kind = record.getValueString("standard") + " " + record.getValueString("direction").toLowerCase(); + byKind.computeIfAbsent(kind, k -> line(Status.OK, k + " sample map will be added as a draft", k + " sample maps will be added as drafts", + k + " sample map was added as a draft", k + " sample maps were added as drafts")).incrementCount(); + output.getRecords().add(record); + } + } + + /******************************************************************************* + ** Standard Review summary. + *******************************************************************************/ + @Override + public ArrayList getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen) + { + ArrayList lines = new ArrayList<>(); + byKind.values().forEach(line -> line.addSelfToListIfAnyCount(lines)); + if(lines.isEmpty()) + { + lines.add(new ProcessSummaryLine(Status.INFO, null, "Every sample map is already installed.")); + } + return (lines); + } + } + + /******************************************************************************* + ** Load: insert each map and its rules as the processing user. + *******************************************************************************/ + public static class Load extends AbstractLoadStep implements ProcessSummaryProviderInterface + { + private final ProcessSummaryLine added = line(Status.OK, "map will be added", "maps will be added", "map was added as a draft", "maps were added as drafts"); + + /******************************************************************************* + ** One transaction per map, so a map never lands without its rules. + *******************************************************************************/ + @Override + public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException + { + for(QRecord record : input.getRecords()) + { + Integer id = SystemWrites.call(() -> SystemWrites.transaction(() -> SampleMaps.install(record.getValueString("sample")))); + if(id != null) + { + added.incrementCountAndAddPrimaryKey(id); + output.getRecords().add(new QRecord().withValue("id", id)); + } + } + } + + /******************************************************************************* + ** Standard Result summary. + *******************************************************************************/ + @Override + public ArrayList getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen) + { + ArrayList lines = new ArrayList<>(); + added.addSelfToListIfAnyCount(lines); + return (lines); + } + } +} diff --git a/src/main/resources/help/qbit-edi.yaml b/src/main/resources/help/qbit-edi.yaml new file mode 100644 index 0000000..87a47fc --- /dev/null +++ b/src/main/resources/help/qbit-edi.yaml @@ -0,0 +1,148 @@ +bundle: qbit-edi +version: 1 +layer: QBIT +# Help for the EDI app and every edi_* table, in the QQQ help-bundle format +# (qqq#1198). Each table entry says what the table is for and how its rows get +# created, so an empty table explains itself. EdiHelp applies it until the core +# HelpBundles registry ships in a QQQ release. +# Summaries stay under 120 characters (EdiTablesGuardrailTest). +entries: + - key: app:edi;slot:help + summary: "Exchange X12 and EDIFACT documents with trading partners: set up, then watch the activity." + content: | + Built-in guides and code lists load at startup. You set up the rest in this order: 1) a Partner; 2) Identities, one for your own ISA or UNB ID (no partner) and one for the partner; 3) an Agreement that pairs the two identities for a standard and usage (test or production); 4) Agreement Documents, one per transaction set and direction, with the guide and optionally a map; 5) a Transport (AS2, SFTP, HTTPS) and link it on the agreement. Certificates are only needed for AS2 signing and encryption. The Setup status section on the home shows which step is next. Everything after that (inbox, interchanges, transactions, exceptions, outbound messages) is written by the system as documents flow. + + - key: table:edi_partner;slot:help + summary: "A trading partner you exchange documents with. The first setup step: identities, agreements and transports hang off it." + content: | + **How rows get created:** an administrator adds one with New: a name and a short code. It is host data, so it starts empty. Next add the partner's identity and your own, then an agreement. + + - key: table:edi_identity;slot:help + summary: "An interchange identity: qualifier and ID (ISA or UNB). Yours has no partner; each partner has its own." + content: | + **How rows get created:** an administrator adds them with New, after the partner. Add one with no partner for your own ID, and one per partner for theirs. Inbound documents are routed by matching these qualifiers and IDs, so they must match what the partner sends exactly. It is host data and starts empty. + + - key: table:edi_agreement;slot:help + summary: "The agreement with one partner: your identity, theirs, the standard, test or production, separators and acks." + content: | + **How rows get created:** an administrator adds one with New after the partner and both identities exist: pick your identity, the partner's identity, the standard and the usage indicator. Then set the inbound and outbound transports and add its documents. Without an agreement, inbound documents from that sender are rejected as unknown. It is host data and starts empty. + + - key: table:edi_agreement_document;slot:help + summary: "One document type on an agreement: transaction set, version, direction, guide, map and acknowledgement rules." + content: | + **How rows get created:** an administrator adds one per transaction set and direction with New, after the agreement: pick the set, version and direction, then the guide (built-in guides are ready) and the map that turns it into records. A document with no row here is not processed. It is host data and starts empty. + + - key: table:edi_control_number;slot:help + summary: "The last interchange, group or document control number sent, per agreement or identity scope." + content: | + **How rows get created:** automatically, the first time a document is sent for a scope; the system then advances the number on every send. An administrator edits the last value only to resume numbering after a partner or system change. + + - key: table:edi_transport;slot:help + summary: "How documents travel to and from a partner: AS2, SFTP, HTTPS or a gateway, with secrets as variable names." + content: | + **How rows get created:** an administrator adds one with New, after the partner: pick the type and fill in its address and the NAMES of the environment variables holding passwords or keys (secrets are never stored). Then link it on the agreement as the inbound or outbound transport and run "Test Connection". It is host data and starts empty. + + - key: table:edi_certificate;slot:help + summary: "Certificates for AS2 signing and encryption: yours and your partners', with expiry tracking." + content: | + **How rows get created:** an administrator adds one with New: paste a partner's public certificate, or register your own with the environment variable NAMES that hold its private key. Only AS2 needs them. Expiring certificates raise an alert, and "Promote Certificate" switches a NEXT certificate to current. It is host data and starts empty. + + - key: table:edi_guide;slot:help + summary: "Implementation guides: the segments, elements and rules a document is validated against." + content: | + **How rows get created:** the qbit installs 41 built-in guides (X12 004010 and 005010, EDIFACT D96A and D01B, HIPAA) when the host starts, and updates them when the qbit changes. Built-ins are read-only: use "Create Partner Override" to copy one for a partner, edit the draft, then "Activate Guide". + + - key: table:edi_guide_segment;slot:help + summary: "The segments of a guide, in order, with usage, repeat limits and loop position." + content: | + **How rows get created:** automatically with the guide, when a built-in guide loads or a partner override is created. Edit them only on a draft override. + + - key: table:edi_guide_element;slot:help + summary: "The elements of a guide segment: data type, length, usage and the code list it is checked against." + content: | + **How rows get created:** automatically with the guide, when a built-in guide loads or a partner override is created. Edit them only on a draft override. + + - key: table:edi_guide_rule;slot:help + summary: "Conditional rules in a guide, such as required-if and exclusion rules, evaluated during validation." + content: | + **How rows get created:** automatically with the guide, when a built-in guide loads or a partner override is created. Edit them only on a draft override. + + - key: table:edi_code_list;slot:help + summary: "Code lists that guide elements are validated against, such as qualifiers and status codes." + content: | + **How rows get created:** the qbit installs them with its built-in guides when the host starts. An administrator can add a list for a partner override with New. + + - key: table:edi_code_list_value;slot:help + summary: "The allowed values of a code list, with descriptions." + content: | + **How rows get created:** the qbit installs them with its built-in guides when the host starts. An administrator can add values to a list on a partner override with New. + + - key: table:edi_map;slot:help + summary: "A versioned map between a document and a QQQ table: which record each document becomes, or is built from." + content: | + **How rows get created:** run "Install sample maps" to add the bundled synthetic examples (one per built-in document and version, as drafts, bound to nothing), or add your own with New. Point the map at your own table, adjust its rules, use "Preview Map" with a synthetic sample, then "Activate Map" and select it on the agreement document. Never use real PHI in samples. + + - key: table:edi_map_rule;slot:help + summary: "One field rule of a map: an EDI path, the QQQ field it fills, an optional transform and condition." + content: | + **How rows get created:** "Install sample maps" adds the rules of each sample, or an administrator adds them with New on a draft map. Active maps are read-only; make a new revision to change one. + + - key: table:edi_map_sample;slot:help + summary: "Synthetic documents used to preview a map and record the last preview result." + content: | + **How rows get created:** run "Preview Map" on a map and upload a synthetic sample document; the sample is saved here with its preview status. Never upload real PHI. It starts empty. + + - key: table:edi_inbox;slot:help + summary: "Every inbound file or message received, before it is processed, with its status and raw document." + content: | + **How rows get created:** automatically, when a transport receives a document: the AS2, HTTPS or file-gateway endpoints, or an SFTP poll. A worker then parses it into interchanges. It starts empty and is never typed in; "Reprocess" re-runs a document. + + - key: table:edi_payload;slot:help + summary: "Encrypted raw document bytes, used when payload storage is the database instead of object storage." + content: | + **How rows get created:** automatically, only when the host sets payload storage to DATABASE (demos and tests). Production hosts use object storage and this table stays empty. + + - key: table:edi_interchange;slot:help + summary: "One interchange (ISA or UNB envelope) sent or received, with its status and the agreement it matched." + content: | + **How rows get created:** automatically, when an inbound document is parsed and when an outbound document is enveloped. It starts empty; use "Reprocess", "Resend" or "Download Raw Document" on a row. + + - key: table:edi_functional_group;slot:help + summary: "A functional group (GS) inside an interchange, with the acknowledgement it expects or sent." + content: | + **How rows get created:** automatically, with each interchange. The home shows groups whose expected acknowledgement is overdue. + + - key: table:edi_transaction;slot:help + summary: "One document (ST or UNH) inside a group: its transaction set, status and the record it became." + content: | + **How rows get created:** automatically, as each document is validated and mapped (inbound) or built (outbound). It starts empty; "Reprocess" re-runs a document after a map or guide change. + + - key: table:edi_transaction_error;slot:help + summary: "A validation finding on a document: the segment, element, code and message." + content: | + **How rows get created:** automatically, when validation against the guide finds a problem in a document. They feed the 997, 999 and CONTRL acknowledgements. + + - key: table:edi_exception;slot:help + summary: "Problems that need a person: failed parses, rejected acks, dead letters and transport failures." + content: | + **How rows get created:** automatically, when processing fails or an acknowledgement is rejected or overdue. Work them from the home queue: "Assign", "Ignore" or "Mark Resolved". It starts empty, which is good. + + - key: table:edi_event;slot:help + summary: "Audit trail of what happened to a document: received, parsed, validated, sent, delivered and so on." + content: | + **How rows get created:** automatically, at each step of processing and when a raw document is downloaded. It is an append-only audit log shown on the interchange record, and is never edited. + + - key: table:edi_as2_mdn;slot:help + summary: "AS2 delivery receipts (MDNs) sent and received, with the disposition and signature check." + content: | + **How rows get created:** automatically, by the AS2 endpoint: one when an MDN arrives for a message you sent (it settles that message as delivered or failed) and one for each MDN you send. It is read-only and used to ignore duplicate receipts. It starts empty and only AS2 transports use it. + + - key: table:edi_outbound_request;slot:help + summary: "A request to send a host record as an EDI document, waiting to be built into an interchange." + content: | + **How rows get created:** automatically, when a record in a table the host lists for sending is created or changed, or when someone runs "Send As EDI" on a row. A worker builds the document and queues it as an outbound message. + + - key: table:edi_outbound_message;slot:help + summary: "The outbound queue: each interchange waiting to send, its attempts, delivery status and MDN." + content: | + **How rows get created:** automatically, when a document is built for sending. A worker sends it over the agreement's transport, retries on failure, and marks it delivered when the acknowledgement or MDN arrives. Use "Resend" or "Cancel" on a row. diff --git a/src/test/java/com/kingsrook/qbits/edi/EdiSetupStatusTest.java b/src/test/java/com/kingsrook/qbits/edi/EdiSetupStatusTest.java new file mode 100644 index 0000000..39ebe7c --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/edi/EdiSetupStatusTest.java @@ -0,0 +1,127 @@ +/* + * 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; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import com.kingsrook.qbits.edi.app.EdiSetupStatus; +import com.kingsrook.qbits.edi.support.SystemWrites; +import com.kingsrook.qqq.backend.core.actions.dashboard.RenderWidgetAction; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput; +import com.kingsrook.qqq.backend.core.model.dashboard.widgets.TableData; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThat; + +/******************************************************************************* + ** The Setup status on the EDI home: the steps in setup order, each with its + ** state and next step, counting only the rows the runtime would use. + *******************************************************************************/ +class EdiSetupStatusTest +{ + /******************************************************************************* + ** + *******************************************************************************/ + @AfterEach + void clear() + { + QContext.clear(); + } + + /******************************************************************************* + ** The pure logic: order, states and next steps. + *******************************************************************************/ + @Test + void stepsAreInSetupOrder() + { + List items = EdiSetupStatus.items(new EdiSetupStatus.Counts(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 41, 20, 0, 0)); + assertThat(items).extracting(EdiSetupStatus.Item::step).containsExactly("1. Partner", "2. Identities", "3. Agreement", "4. Agreement documents", "5. Transport", + "Certificates", "Guides and code lists", "Maps"); + assertThat(items.subList(0, 5)).allSatisfy(item -> assertThat(item.state()).isEqualTo(EdiSetupStatus.State.TODO)); + assertThat(items.get(5).state()).isEqualTo(EdiSetupStatus.State.OPTIONAL); + assertThat(items.get(6).state()).isEqualTo(EdiSetupStatus.State.BUILT_IN); + assertThat(items.get(7).state()).isEqualTo(EdiSetupStatus.State.OPTIONAL); + assertThat(items.get(7).nextStep()).contains("Install sample maps"); + + items = EdiSetupStatus.items(new EdiSetupStatus.Counts(1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 3, 0)); + assertThat(items.get(1).nextStep()).contains("partner's"); + assertThat(items.get(6).state()).isEqualTo(EdiSetupStatus.State.TODO); + assertThat(items.get(6).nextStep()).contains("EdiRuntime.initialize()"); + assertThat(items.get(7).state()).as("maps exist but none active").isEqualTo(EdiSetupStatus.State.TODO); + + items = EdiSetupStatus.items(new EdiSetupStatus.Counts(2, 1, 2, 2, 1, 3, 3, 1, 1, 1, 41, 20, 5, 2)); + assertThat(items).allSatisfy(item -> assertThat(item.state()).isIn(EdiSetupStatus.State.DONE, EdiSetupStatus.State.BUILT_IN)); + + items = EdiSetupStatus.items(new EdiSetupStatus.Counts(1, 1, 1, 2, 0, 1, 1, 1, 1, 0, 41, 20, 0, 0)); + assertThat(items.get(2).state()).as("agreements exist but none is active, so the runtime ignores them").isEqualTo(EdiSetupStatus.State.TODO); + + items = EdiSetupStatus.items(new EdiSetupStatus.Counts(-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1)); + assertThat(items).allSatisfy(item -> assertThat(item.state()).isEqualTo(EdiSetupStatus.State.UNKNOWN)); + } + + /******************************************************************************* + ** The home widget moves from "needs setup" to "set up" as the rows are added. + *******************************************************************************/ + @Test + void homeWidgetFollowsTheData() throws Exception + { + QInstance instance = H2Support.create(); + EdiRuntime.current().initialize(); + assertThat(instance.getWidget("edi_home_setup")).isNotNull(); + assertThat(instance.getApp(EdiRuntime.current().config().getAppName()).getWidgets()).contains("edi_home_setup"); + + Map status = status(instance); + assertThat(status).containsKeys("1. Partner", "5. Transport"); + assertThat(status.get("1. Partner")).startsWith("Needs setup"); + assertThat(status.get("2. Identities")).startsWith("Needs setup"); + assertThat(status.get("Guides and code lists")).startsWith("Built in: 41 guides"); + assertThat(status.get("Maps")).startsWith("Optional"); + + SystemWrites.call(() -> + { + QRecord partner = SystemWrites.insert("edi_partner", new QRecord().withValue("name", "Synthetic Retailer").withValue("code", "SYNTH")); + SystemWrites.insert("edi_identity", new QRecord().withValue("standard", "X12").withValue("interchangeQualifier", "ZZ").withValue("interchangeId", "QQQTEST") + .withValue("applicationCode", "QQQTEST").withValue("isActive", true)); + SystemWrites.insert("edi_identity", new QRecord().withValue("partnerId", partner.getValue("id")).withValue("standard", "X12").withValue("interchangeQualifier", "ZZ") + .withValue("interchangeId", "SYNTHETIC").withValue("applicationCode", "SYNTHETIC").withValue("isActive", false)); + return (null); + }); + status = status(instance); + assertThat(status.get("1. Partner")).startsWith("Set up"); + assertThat(status.get("2. Identities")).as("the partner's identity is inactive, so the runtime would ignore it").startsWith("Needs setup: Yours is set; no partner has one"); + assertThat(status.get("3. Agreement")).startsWith("Needs setup"); + } + + /******************************************************************************* + ** Render the widget and return step to "state: detail". + *******************************************************************************/ + private Map status(QInstance instance) throws Exception + { + var data = (TableData) new RenderWidgetAction().execute(new RenderWidgetInput().withWidgetMetaData(instance.getWidget("edi_home_setup"))).getWidgetData(); + Map status = new LinkedHashMap<>(); + data.getRows().forEach(row -> status.put(String.valueOf(row.get("Step")), String.valueOf(row.get("Status")))); + return (status); + } +} diff --git a/src/test/java/com/kingsrook/qbits/edi/EdiTablesGuardrailTest.java b/src/test/java/com/kingsrook/qbits/edi/EdiTablesGuardrailTest.java new file mode 100644 index 0000000..8045cc4 --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/edi/EdiTablesGuardrailTest.java @@ -0,0 +1,248 @@ +/* + * 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; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.regex.Pattern; +import java.util.stream.Stream; +import com.kingsrook.qbits.edi.guide.BuiltInGuideLoader; +import com.kingsrook.qbits.edi.metadata.EdiHelp; +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.model.actions.tables.query.QQueryFilter; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import com.kingsrook.qqq.backend.core.model.metadata.help.QHelpContent; +import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThat; + +/******************************************************************************* + ** Guardrail (owner requirement 2026-10-14: "all tables should be populated and + ** the qbit should be fully using them"). For every edi_* table: + ** - runtime code (not just its model, metadata or home screen) reads or + ** writes it; + ** - it is either seeded by the qbit at startup, or listed here with why it + ** starts empty; + ** - it has bundle help that says what it is for and how its rows get created + ** (shown on the table screen and when it is empty). + ** A new table fails this test until it is wired, seeded or explained. + *******************************************************************************/ +class EdiTablesGuardrailTest +{ + private static final Path MAIN = Path.of("src", "main", "java", "com", "kingsrook", "qbits", "edi"); + + /******************************************************************************* + ** Tables the qbit seeds itself (EdiRuntime.initialize, idempotent). + *******************************************************************************/ + static final Map SEEDED = Map.of( + "edi_guide", "BuiltInGuideLoader: 41 built-in guides", + "edi_guide_segment", "with each built-in guide", + "edi_guide_element", "with each built-in guide", + "edi_guide_rule", "with each built-in guide", + "edi_code_list", "with the built-in guides", + "edi_code_list_value", "with the built-in guides"); + + /******************************************************************************* + ** Tables that legitimately start empty, and where their rows come from. + *******************************************************************************/ + static final Map STARTS_EMPTY = Map.ofEntries( + Map.entry("edi_partner", "host data: the trading partners"), + Map.entry("edi_identity", "host data: your ISA/UNB identity and each partner's"), + Map.entry("edi_agreement", "host data: the agreement per partner"), + Map.entry("edi_agreement_document", "host data: transaction sets per agreement"), + Map.entry("edi_transport", "host connection config: addresses and secret variable names"), + Map.entry("edi_certificate", "host data: AS2 certificates (optional)"), + Map.entry("edi_map", "host data; the 'Install sample maps' action adds bundled examples as inactive, unbound drafts"), + Map.entry("edi_map_rule", "with a map; 'Install sample maps' adds the rules of each sample"), + Map.entry("edi_map_sample", "user content: synthetic documents uploaded with Preview Map"), + Map.entry("edi_control_number", "runtime: allocated on the first send for a scope"), + Map.entry("edi_inbox", "runtime: every received document"), + Map.entry("edi_payload", "runtime: raw bytes, only when payload storage is DATABASE"), + Map.entry("edi_interchange", "runtime: every envelope sent or received"), + Map.entry("edi_functional_group", "runtime: every group"), + Map.entry("edi_transaction", "runtime: every document"), + Map.entry("edi_transaction_error", "runtime: validation findings"), + Map.entry("edi_exception", "runtime: failures that need a person"), + Map.entry("edi_event", "audit log: written at each processing step (EdiJournal), shown on the interchange record"), + Map.entry("edi_as2_mdn", "runtime: AS2 receipts sent and received; read to ignore duplicates and replay a sent receipt"), + Map.entry("edi_outbound_request", "runtime: a host record queued to be sent"), + Map.entry("edi_outbound_message", "runtime: the outbound queue")); + + private QInstance instance; + + /******************************************************************************* + ** + *******************************************************************************/ + @AfterEach + void clear() + { + QContext.clear(); + } + + /******************************************************************************* + ** Every table is used by runtime code. + *******************************************************************************/ + @Test + void everyTableIsUsedAtRuntime() throws Exception + { + List runtimeSource = new ArrayList<>(); + try(Stream files = Files.walk(MAIN)) + { + files.filter(p -> p.toString().endsWith(".java") && !p.startsWith(MAIN.resolve("model")) && !p.startsWith(MAIN.resolve("metadata")) && !p.startsWith(MAIN.resolve("app"))) + .forEach(p -> runtimeSource.add(read(p))); + } + for(String table : tables()) + { + Pattern use = Pattern.compile("\\b" + table + "\\b"); + assertThat(runtimeSource.stream().anyMatch(source -> use.matcher(source).find())) + .as(table + " must be read or written by runtime code, not only declared").isTrue(); + } + } + + /******************************************************************************* + ** Every table is seeded or explained. + *******************************************************************************/ + @Test + void everyTableIsSeededOrExplained() throws Exception + { + for(String table : tables()) + { + assertThat(SEEDED.containsKey(table) || STARTS_EMPTY.containsKey(table)).as(table + " must be seeded or listed in STARTS_EMPTY").isTrue(); + assertThat(SEEDED.containsKey(table) && STARTS_EMPTY.containsKey(table)).as(table + " cannot be both").isFalse(); + } + assertThat(tables()).containsAll(STARTS_EMPTY.keySet()).containsAll(SEEDED.keySet()); + } + + /******************************************************************************* + ** The built-in guides fill the seeded tables, a second start adds nothing, and + ** an administrator's edit survives. + *******************************************************************************/ + @Test + void seedIsInsertOnly() throws Exception + { + H2Support.create(); + EdiRuntime.current().initialize(); + Map first = counts(); + assertThat(first.get("edi_guide")).isEqualTo(BuiltInGuideLoader.RESOURCES.size()).isEqualTo(41); + for(String table : SEEDED.keySet()) + { + assertThat(first.get(table)).as(table + " is seeded").isGreaterThan(0); + } + QRecord guide = SystemWrites.query("edi_guide", new QQueryFilter()).getFirst(); + SystemWrites.call(() -> + { + SystemWrites.update("edi_guide", new QRecord().withValue("id", guide.getValue("id")).withValue("description", "Edited by an administrator")); + return (null); + }); + + EdiRuntime.current().initialize(); + assertThat(counts()).isEqualTo(first); + assertThat(EdiRecords.query("edi_guide", EdiRecords.equal("id", guide.getValue("id"))).getFirst().getValueString("description")).isEqualTo("Edited by an administrator"); + } + + /******************************************************************************* + ** Every table and the app have bundle help that says how rows get created. + *******************************************************************************/ + @Test + void everyTableHasEmptyStateHelp() throws Exception + { + QInstance instance = H2Support.create(); + assertThat(EdiHelp.entries()).allSatisfy((key, entry) -> assertThat(entry.path("summary").asText().length()).as(key).isLessThanOrEqualTo(120)); + for(String table : tables()) + { + List help = instance.getTable(table).getHelpContent() == null ? null : instance.getTable(table).getHelpContent().get(EdiHelp.SLOT); + assertThat(help).as(table + " table help").isNotEmpty(); + assertThat(help.get(0).getContent()).as(table + " help says how rows get created").contains("How rows get created"); + } + assertThat(instance.getApp(EdiRuntime.current().config().getAppName()).getHelpContent()).containsKey(EdiHelp.SLOT); + assertThat(EdiHelp.entries().keySet()).allMatch(key -> key.startsWith("app:") || instance.getTable(key.substring(6, key.indexOf(';'))) != null, "help names only real tables"); + assertThat(EdiHelp.entries().get("table:edi_map;slot:help").path("content").asText()).contains("Install sample maps"); + assertThat(EdiHelp.entries().get("app:edi;slot:help").path("content").asText().indexOf("Partner")).isLessThan(EdiHelp.entries().get("app:edi;slot:help").path("content").asText().indexOf("Transport")); + } + + /******************************************************************************* + ** Help a host put in the slot first is never replaced by the bundle. + *******************************************************************************/ + @Test + void hostHelpIsKept() throws Exception + { + QInstance instance = H2Support.create(); + String appName = EdiRuntime.current().config().getAppName(); + QHelpContent host = new QHelpContent().withContentAsMarkdown("Host help for partners"); + instance.getTable("edi_partner").getHelpContent().put(EdiHelp.SLOT, new ArrayList<>(List.of(host))); + instance.getApp(appName).getHelpContent().put(EdiHelp.SLOT, new ArrayList<>(List.of(host))); + EdiHelp.apply(instance, appName); + assertThat(instance.getTable("edi_partner").getHelpContent().get(EdiHelp.SLOT)).containsExactly(host); + assertThat(instance.getApp(appName).getHelpContent().get(EdiHelp.SLOT)).containsExactly(host); + assertThat(instance.getTable("edi_identity").getHelpContent().get(EdiHelp.SLOT).get(0).getContent()).contains("How rows get created"); + } + + /******************************************************************************* + ** Row counts of the seeded tables. + *******************************************************************************/ + private Map counts() throws Exception + { + Map counts = new java.util.TreeMap<>(); + for(String table : SEEDED.keySet()) + { + counts.put(table, SystemWrites.count(table, new QQueryFilter())); + } + return (counts); + } + + /******************************************************************************* + ** The qbit's tables (the preview download backend table is not data). + *******************************************************************************/ + private List tables() throws Exception + { + if(instance == null) + { + instance = H2Support.create(); + } + List tables = instance.getTables().values().stream().map(QTableMetaData::getName) + .filter(name -> name.startsWith("edi_") && !name.equals("edi_preview_download")).sorted().toList(); + assertThat(tables).hasSize(27); + return (tables); + } + + /******************************************************************************* + ** + *******************************************************************************/ + private static String read(Path path) + { + try + { + return (Files.readString(path, StandardCharsets.UTF_8)); + } + catch(Exception e) + { + throw (new IllegalStateException(e)); + } + } +} diff --git a/src/test/java/com/kingsrook/qbits/edi/InboundTest.java b/src/test/java/com/kingsrook/qbits/edi/InboundTest.java index 1cf11ca..ac25f75 100644 --- a/src/test/java/com/kingsrook/qbits/edi/InboundTest.java +++ b/src/test/java/com/kingsrook/qbits/edi/InboundTest.java @@ -744,4 +744,32 @@ class InboundTest assertThat(rows("sample_order_line")).isEmpty(); assertThat(rows("edi_exception")).extracting(r -> r.getValueString("type")).contains("APPLY"); } + + /******************************************************************************* + ** An inactive identity stops X12 routing, as it does for EDIFACT. + *******************************************************************************/ + @Test + void inactiveIdentityStopsX12Routing() throws Exception + { + setup("004010"); + QRecord remote = rows("edi_identity").stream().filter(r -> r.getValue("partnerId") != null).findFirst().orElseThrow(); + SystemWrites.call(() -> + { + SystemWrites.update("edi_identity", new QRecord().withValue("id", remote.getValue("id")).withValue("isActive", false)); + return (null); + }); + receive(raw(), "inactive"); + new InboxWorker().runOnce(); + assertThat(rows("sample_order")).isEmpty(); + assertThat(rows("edi_exception")).extracting(r -> r.getValueString("type")).contains("UNKNOWN_PARTNER"); + + SystemWrites.call(() -> + { + SystemWrites.update("edi_identity", new QRecord().withValue("id", remote.getValue("id")).withValue("isActive", true)); + return (null); + }); + receive(Synthetic850.generate(18, "004010", X12Separators.DEFAULT), "active"); + new InboxWorker().runOnce(); + assertThat(rows("sample_order")).hasSize(1); + } } diff --git a/src/test/java/com/kingsrook/qbits/edi/InstallSampleMapsTest.java b/src/test/java/com/kingsrook/qbits/edi/InstallSampleMapsTest.java new file mode 100644 index 0000000..b3bf7aa --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/edi/InstallSampleMapsTest.java @@ -0,0 +1,189 @@ +/* + * 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; + +import java.io.File; +import java.util.Arrays; +import java.util.List; +import com.kingsrook.qbits.edi.map.SampleMaps; +import com.kingsrook.qbits.edi.processes.InstallSampleMapsProcess; +import com.kingsrook.qbits.edi.storage.EdiRecords; +import com.kingsrook.qbits.edi.support.SystemWrites; +import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThat; + +/******************************************************************************* + ** The "Install sample maps" table action on edi_map: standard streamed ETL, + ** insert-only by name, installing inactive and unbound drafts. + *******************************************************************************/ +class InstallSampleMapsTest +{ + /******************************************************************************* + ** + *******************************************************************************/ + @AfterEach + void clear() + { + QContext.clear(); + } + + /******************************************************************************* + ** Every bundled sample is listed, and every listed sample exists. + *******************************************************************************/ + @Test + void theListMatchesTheBundledFiles() + { + String[] files = new File("src/main/resources/edi/maps").list((dir, name) -> name.endsWith("-sample.json")); + assertThat(Arrays.stream(files).map(name -> name.replace("-sample.json", "")).toList()).containsExactlyInAnyOrderElementsOf(SampleMaps.RESOURCES); + } + + /******************************************************************************* + ** A standard streamed-ETL table action that needs no selected records. + *******************************************************************************/ + @Test + void isAStandardTableAction() throws Exception + { + QInstance instance = H2Support.create(); + QProcessMetaData process = instance.getProcess(InstallSampleMapsProcess.NAME); + assertThat(process).isNotNull(); + assertThat(process.getTableName()).isEqualTo("edi_map"); + assertThat(process.getMinInputRecords()).isEqualTo(0); + assertThat(process.getPermissionRules().getPermissionBaseName()).isEqualTo("edi.admin"); + assertThat(process.getStepList()).extracting(step -> step.getName()).contains("review", "result"); + } + + /******************************************************************************* + ** Installs every sample as an inactive, unbound draft with its rules; a + ** second run adds nothing; an edited map is kept; a deleted one comes back. + *******************************************************************************/ + @Test + void installsDraftsInsertOnly() throws Exception + { + H2Support.create(); + EdiRuntime.current().initialize(); + assertThat(count("edi_map")).isZero(); + + run(); + assertThat(count("edi_map")).isEqualTo(SampleMaps.RESOURCES.size()); + List maps = SystemWrites.query("edi_map", new QQueryFilter()); + assertThat(maps).allSatisfy(map -> + { + assertThat(map.getValueString("status")).as(map.getValueString("name")).isEqualTo("DRAFT"); + assertThat(map.getValueInteger("revision")).isZero(); + assertThat(map.getValue("partnerId")).as("unbound to a partner").isNull(); + assertThat(map.getValue("guideId")).as("bound to the built-in guide for its document").isNotNull(); + }); + assertThat(count("edi_agreement_document")).as("bound to no agreement document").isZero(); + + QRecord sample850 = maps.stream().filter(map -> "Sample 850 004010".equals(map.getValueString("name"))).findFirst().orElseThrow(); + int rules = count("edi_map_rule"); + assertThat(EdiRecords.query("edi_map_rule", EdiRecords.equal("mapId", sample850.getValue("id")))).hasSize(5); + assertThat(rules).isGreaterThan(SampleMaps.RESOURCES.size()); + + SystemWrites.call(() -> + { + SystemWrites.update("edi_map", new QRecord().withValue("id", sample850.getValue("id")).withValue("tableName", "my_orders").withValue("status", "ACTIVE")); + return (null); + }); + run(); + assertThat(count("edi_map")).isEqualTo(SampleMaps.RESOURCES.size()); + assertThat(count("edi_map_rule")).isEqualTo(rules); + QRecord edited = EdiRecords.query("edi_map", EdiRecords.equal("id", sample850.getValue("id"))).getFirst(); + assertThat(edited.getValueString("tableName")).isEqualTo("my_orders"); + assertThat(edited.getValueString("status")).isEqualTo("ACTIVE"); + + SystemWrites.call(() -> + { + new com.kingsrook.qqq.backend.core.actions.tables.DeleteAction().execute(new com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput("edi_map_rule") + .withQueryFilter(new QQueryFilter().withCriteria("mapId", com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator.EQUALS, sample850.getValue("id")))); + new com.kingsrook.qqq.backend.core.actions.tables.DeleteAction().execute(new com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput("edi_map") + .withPrimaryKey(sample850.getValue("id"))); + return (null); + }); + assertThat(SampleMaps.RESOURCES).hasSize(32); + assertThat(SystemWrites.call(SampleMaps::missing)).containsExactly("850-004010"); + run(); + assertThat(count("edi_map")).isEqualTo(SampleMaps.RESOURCES.size()); + assertThat(EdiRecords.query("edi_map_rule", EdiRecords.equal("mapId", EdiRecords.query("edi_map", EdiRecords.equal("name", "Sample 850 004010")).getFirst().getValue("id")))).hasSize(5); + } + + /******************************************************************************* + ** A host that already has a map of the same name keeps it, and gets no copy. + *******************************************************************************/ + @Test + void neverTouchesAMapTheHostAlreadyHas() throws Exception + { + H2Support.create(); + EdiRuntime.current().initialize(); + SystemWrites.call(() -> SystemWrites.insert("edi_map", new QRecord().withValue("name", "Sample 850 004010").withValue("direction", "INBOUND") + .withValue("transactionSet", "850").withValue("version", "004010").withValue("status", "ACTIVE").withValue("revision", 3).withValue("tableName", "host_orders"))); + run(); + List same = EdiRecords.query("edi_map", EdiRecords.equal("name", "Sample 850 004010")); + assertThat(same).hasSize(1); + assertThat(same.getFirst().getValueString("tableName")).isEqualTo("host_orders"); + assertThat(same.getFirst().getValueString("status")).isEqualTo("ACTIVE"); + assertThat(count("edi_map")).isEqualTo(SampleMaps.RESOURCES.size()); + assertThat(count("edi_map_rule")).as("no rules were added to the host's map").isEqualTo( + SampleMaps.RESOURCES.stream().filter(r -> !r.equals("850-004010")).mapToInt(InstallSampleMapsTest::ruleCount).sum()); + } + + /******************************************************************************* + ** Run the real process through the standard runner (frontend steps skipped). + *******************************************************************************/ + private void run() throws Exception + { + RunProcessInput input = new RunProcessInput(); + input.setProcessName(InstallSampleMapsProcess.NAME); + input.setFrontendStepBehavior(RunProcessInput.FrontendStepBehavior.SKIP); + new RunProcessAction().execute(input); + } + + /******************************************************************************* + ** The number of rules a bundled sample carries. + *******************************************************************************/ + private static int ruleCount(String resource) + { + try + { + return (SampleMaps.read(resource).path("rules").size()); + } + catch(java.io.IOException e) + { + throw (new IllegalStateException(e)); + } + } + + /******************************************************************************* + ** + *******************************************************************************/ + private int count(String table) throws Exception + { + return (SystemWrites.count(table, new QQueryFilter())); + } +} diff --git a/src/test/java/com/kingsrook/qbits/edi/OutboundTest.java b/src/test/java/com/kingsrook/qbits/edi/OutboundTest.java index 06ece5a..0728eb4 100644 --- a/src/test/java/com/kingsrook/qbits/edi/OutboundTest.java +++ b/src/test/java/com/kingsrook/qbits/edi/OutboundTest.java @@ -345,4 +345,44 @@ class OutboundTest var cron = com.kingsrook.qbits.edi.processes.OutboundProcesses.process("edi_test_cron", config, null, "0 0 * * * ?").getSchedule(); assertThat(cron.getCronTimeZoneId()).isEqualTo("UTC"); } + + /******************************************************************************* + ** A draft map bound to an outbound document is never used: the request fails + ** as a mapping error and nothing is queued. An inactive identity stops the + ** document from being built at all; its requests wait. + *******************************************************************************/ + @Test + void draftMapAndInactiveIdentityStopSending() throws Exception + { + setup(); + SystemWrites.call(() -> + { + SystemWrites.update("edi_map", new QRecord().withValue("id", document.getValue("mapId")).withValue("status", "DRAFT")); + return (null); + }); + SystemWrites.call(() -> SystemWrites.transaction(() -> RetailSupport.saveSource("sample_order", RetailSupport.source()))); + new OutboundWorker().runOnce(); + assertThat(rows("edi_outbound_message")).isEmpty(); + assertThat(rows("edi_outbound_request")).extracting(r -> r.getValueString("status")).containsExactly("FAILED"); + assertThat(rows("edi_exception")).extracting(r -> r.getValueString("type")).contains("MAPPING"); + + SystemWrites.call(() -> + { + SystemWrites.update("edi_map", new QRecord().withValue("id", document.getValue("mapId")).withValue("status", "ACTIVE")); + SystemWrites.update("edi_identity", new QRecord().withValue("id", agreement.getValue("partnerIdentityId")).withValue("isActive", false)); + return (null); + }); + SystemWrites.call(() -> SystemWrites.transaction(() -> RetailSupport.saveSource("sample_order", RetailSupport.source().withValue("poNumber", "DEMO-1002")))); + assertThat(new OutboundWorker().runOnce()).isZero(); + assertThat(rows("edi_outbound_message")).isEmpty(); + assertThat(rows("edi_outbound_request")).extracting(r -> r.getValueString("status")).contains("PENDING"); + + SystemWrites.call(() -> + { + SystemWrites.update("edi_identity", new QRecord().withValue("id", agreement.getValue("partnerIdentityId")).withValue("isActive", true)); + return (null); + }); + new OutboundWorker().runOnce(); + assertThat(rows("edi_outbound_message")).hasSize(1); + } }