Skip to content

Commit 034e336

Browse files
delchevclaude
andauthored
feat(intent): generateSnapshot delegate — render + store a document copy on issue (#6377)
* feat(intent): function: Snapshot — generated read-only versioned record copies A sibling of function: Attachment (#6371) for SYSTEM-GENERATED copies (e.g. the printed invoice stored on issue) rather than user-uploaded files. Same composition-child + CMS + file-metadata machinery, but read-only and versioned. - IntentParser: `snapshot` added to ENTITY_FUNCTIONS. - EntityIntent: isSnapshot() + isFileChild() (attachment or snapshot — shared injection). - EdmIntentGenerator: a file-child gets the injected metadata columns + synthesized Id + audit (now keyed on isFileChild). A Snapshot additionally gets a read-only, major `Version` column carrying the new DOCUMENT_VERSION widget (the copy's sequence within its master, for special treatment in forms/print), and is marked attachmentReadOnly="true" alongside attachmentEntity. - EntityController template: the `/upload` verb is gated off when attachmentReadOnly — a Snapshot controller exposes download + list only (no upload; copies are created server-side by the forthcoming generateSnapshot serviceTask). The read-only Harmonia Files panel (#6373) already keys on attachmentReadOnly (download only, no upload/remove). Verified: EdmIntentGeneratorTest (metadata + Id + Version/DOCUMENT_VERSION + attachmentReadOnly); a generated CompanySnapshotController has @get /{id}/download and NO @post /upload, and the model carries Version(DOCUMENT_VERSION) + attachmentReadOnly=true. Part A of the Sales-Invoice snapshot flow. Follow-ups: C (server-side print render) and D (generateSnapshot BPM serviceTask + delegate, mint on issue). Depends on #6371. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(intent): generateSnapshot delegate — render + store a document copy on issue Part D of the Sales-Invoice snapshot flow: generates the JavaDelegate a process wires as a `delegate:` service task to mint an immutable printed copy of a document on issue (the number stays across amendments; only the snapshot Version increments). - SnapshotSupport builds the `snapshots` glue collection — one descriptor per function: Snapshot child of a DOCUMENT master (which has a PrintFeeder to render from): master, master PK (the process variable), the snapshot entity/perspective/master-FK, language. - GlueIntentGenerator emits the `snapshots` collection into the .glue. - generateUtils adds the `snapshots` case (sanitizes the perspective → javaPerspective, its own loop like printFeeders/settlements). - Snapshot.java.template generates gen/events/<Master>SnapshotGenerator.java: reads the master id from the process variable, reuses the master's generated PrintFeeder (same gen.events package) for the {document, items} payload, renders server-side via sdk.print.Print (#6376), stores the PDF via sdk.cms.Attachments (#6370), and writes a <Snapshot> row with Version = max existing + 1 (gap-free — snapshots are immutable). Bound by the BPMN via the existing `delegate:` (flowable:class) path — no new parser/BPMN wiring, and ServiceTaskHandlerGenerator already skips a delegate: task. Verified: on the SalesInvoice document master with a function: Snapshot child + a `generateSnapshot` delegate step, the .glue carries the snapshots descriptor, gen/events/SalesInvoiceSnapshotGenerator .java is emitted, and it compiles cleanly into the client bean container (538 beans, no javac errors). Depends on: #6374 (function: Snapshot type — the PR base), #6376 (sdk.print.Print), #6371/#6370 (sdk.cms.Attachments). Follow-up: KF adoption wires generateSnapshot into the SalesInvoice process after markIssued + the Confirm/Amend loop; a full process-run e2e mints the first snapshot on issue. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 44a85f1 commit 034e336

5 files changed

Lines changed: 190 additions & 1 deletion

File tree

components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/GlueIntentGenerator.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,11 +100,12 @@ public void generate(IntentGenerationContext context) {
100100
List<Map<String, Object>> transitions = buildTransitions(model, byName, compositionParents, settings);
101101
List<Map<String, Object>> postings = buildPostings(model, byName, compositionParents, settings, context);
102102
List<Map<String, Object>> printFeeders = PrintFeederSupport.buildPrintFeeders(model, byName, compositionParents, context);
103+
List<Map<String, Object>> snapshots = SnapshotSupport.buildSnapshots(model, byName, compositionParents);
103104

104105
if (triggers.isEmpty() && resolvers.isEmpty() && fieldLoaders.isEmpty() && timerLoaders.isEmpty() && waits.isEmpty()
105106
&& aborts.isEmpty() && writers.isEmpty() && setters.isEmpty() && notifications.isEmpty() && schedules.isEmpty()
106107
&& integrations.isEmpty() && inbound.isEmpty() && rollups.isEmpty() && expansions.isEmpty() && settlements.isEmpty()
107-
&& generates.isEmpty() && transitions.isEmpty() && printFeeders.isEmpty() && postings.isEmpty()) {
108+
&& generates.isEmpty() && transitions.isEmpty() && printFeeders.isEmpty() && postings.isEmpty() && snapshots.isEmpty()) {
108109
// No process glue for this intent - any stale .glue is removed by the post-pass scrub.
109110
return;
110111
}
@@ -129,6 +130,7 @@ public void generate(IntentGenerationContext context) {
129130
glue.put("transitions", transitions);
130131
glue.put("postings", postings);
131132
glue.put("printFeeders", printFeeders);
133+
glue.put("snapshots", snapshots);
132134
context.writeModelFile(IntentNaming.baseName(context) + ".glue", JsonHelper.toJson(glue));
133135
LOGGER.debug(
134136
"Wrote glue with [{}] trigger(s), [{}] resolver(s), [{}] writer(s), [{}] setter(s),"
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
/*
2+
* Copyright (c) 2010-2026 Eclipse Dirigible contributors
3+
*
4+
* All rights reserved. This program and the accompanying materials are made available under the
5+
* terms of the Eclipse Public License v2.0 which accompanies this distribution, and is available at
6+
* http://www.eclipse.org/legal/epl-v20.html
7+
*
8+
* SPDX-FileCopyrightText: Eclipse Dirigible contributors SPDX-License-Identifier: EPL-2.0
9+
*/
10+
package org.eclipse.dirigible.components.intent.generator;
11+
12+
import java.util.ArrayList;
13+
import java.util.LinkedHashMap;
14+
import java.util.LinkedHashSet;
15+
import java.util.List;
16+
import java.util.Map;
17+
import java.util.Set;
18+
19+
import org.eclipse.dirigible.components.intent.generator.print.PrintIntentGenerator;
20+
import org.eclipse.dirigible.components.intent.model.EntityIntent;
21+
import org.eclipse.dirigible.components.intent.model.IntentModel;
22+
import org.eclipse.dirigible.components.intent.model.RelationIntent;
23+
24+
/**
25+
* Builds the {@code snapshots} glue collection: one descriptor per {@code function: Snapshot} child
26+
* whose master is a document (header-items) master, driving the generated
27+
* {@code gen/events/<Master>SnapshotGenerator.java} delegate. Wired into a process as a
28+
* {@code delegate:} service task ({@code delegate: gen.events.<Master>SnapshotGenerator}) so an
29+
* immutable printed copy of the document is rendered and stored on issue - the number stays across
30+
* amendments, only the snapshot {@code Version} increments.
31+
*
32+
* <p>
33+
* The delegate reuses the master's generated {@code PrintFeeder} (same {@code gen.events} package)
34+
* to assemble the {@code {document, items}} payload, renders it server-side via
35+
* {@code sdk.print.Print}, and stores the PDF via {@code sdk.cms.Attachments} - so only a document
36+
* master (which has a feeder) can carry a snapshot child.
37+
*/
38+
final class SnapshotSupport {
39+
40+
private SnapshotSupport() {}
41+
42+
/**
43+
* One snapshot descriptor per {@code function: Snapshot} child of a document master.
44+
*
45+
* @param model the parsed intent model
46+
* @param byName entities indexed by name
47+
* @param compositionParents each entity's transitive composition parent (perspective resolution)
48+
* @return the {@code snapshots} collection (possibly empty)
49+
*/
50+
static List<Map<String, Object>> buildSnapshots(IntentModel model, Map<String, EntityIntent> byName,
51+
Map<String, String> compositionParents) {
52+
Set<String> documentMasters = new LinkedHashSet<>();
53+
for (EntityIntent master : PrintIntentGenerator.documentMasters(model)
54+
.keySet()) {
55+
documentMasters.add(master.getName());
56+
}
57+
List<Map<String, Object>> snapshots = new ArrayList<>();
58+
for (EntityIntent entity : model.getEntities()) {
59+
if (!entity.isSnapshot()) {
60+
continue;
61+
}
62+
RelationIntent masterRelation = compositionMaster(entity);
63+
if (masterRelation == null || !documentMasters.contains(masterRelation.getTo())) {
64+
continue; // a snapshot needs a document master (with a PrintFeeder) to render from
65+
}
66+
EntityIntent master = byName.get(masterRelation.getTo());
67+
if (master == null) {
68+
continue;
69+
}
70+
Map<String, Object> snapshot = new LinkedHashMap<>();
71+
snapshot.put("master", master.getName());
72+
snapshot.put("masterPk", IntentEntities.keyFieldName(master));
73+
snapshot.put("language", "en");
74+
snapshot.put("snapshotEntity", entity.getName());
75+
snapshot.put("snapshotPerspective", IntentEntities.resolvePerspective(entity.getName(), compositionParents));
76+
snapshot.put("snapshotMasterFk", IntentNaming.pascalCase(masterRelation.getName()));
77+
snapshots.add(snapshot);
78+
}
79+
return snapshots;
80+
}
81+
82+
/** The snapshot's composition to-one relation back to its master (the owning document). */
83+
private static RelationIntent compositionMaster(EntityIntent snapshot) {
84+
for (RelationIntent relation : snapshot.getRelations()) {
85+
boolean toOne = "manyToOne".equals(relation.getKind()) || "oneToOne".equals(relation.getKind());
86+
if (toOne && relation.isComposition() && relation.getTo() != null) {
87+
return relation;
88+
}
89+
}
90+
return null;
91+
}
92+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
package gen.events;
2+
3+
import org.eclipse.dirigible.components.data.store.java.repository.Criteria;
4+
import org.flowable.engine.delegate.DelegateExecution;
5+
import org.flowable.engine.delegate.JavaDelegate;
6+
7+
import gen.${javaGenFolderName}.data.${snapshotJavaPerspective}.${snapshotEntity}Entity;
8+
import gen.${javaGenFolderName}.data.${snapshotJavaPerspective}.${snapshotEntity}Repository;
9+
10+
/**
11+
* Snapshot generator for ${master}: on the service task it is wired to (e.g. right after Issue),
12+
* renders the document to an immutable PDF copy and stores it as a ${snapshotEntity} row. The number
13+
* stays across amendments - each generation only increments the copy's Version.
14+
*
15+
* Generated from the intent snapshots glue - do not edit; it is re-generated with the application.
16+
* Wired as a `delegate:` service task (delegate: gen.events.${master}SnapshotGenerator). Data assembly
17+
* reuses the generated ${master}PrintFeeder (same gen.events package); the PDF is rendered server-side
18+
* via sdk.print.Print and stored in the tenant CMS via sdk.cms.Attachments; the ${snapshotEntity} row
19+
* is written through its generated repository (validations / events / audit).
20+
*/
21+
public class ${master}SnapshotGenerator implements JavaDelegate {
22+
23+
@Override
24+
public void execute(DelegateExecution execution) {
25+
Object key = execution.getVariable("${masterPk}");
26+
if (!(key instanceof Number)) {
27+
return;
28+
}
29+
int id = ((Number) key).intValue();
30+
31+
// Assemble the { document, items } payload the print template binds, then render server-side.
32+
String payload = new ${master}PrintFeeder().feed(id);
33+
byte[] pdf = org.eclipse.dirigible.sdk.print.Print.render("${master}", "${language}", payload);
34+
35+
${snapshotEntity}Repository repository = new ${snapshotEntity}Repository();
36+
// Next version = max existing + 1. Snapshots are immutable (never deleted), so this is gap-free;
37+
// an amend re-issue mints the next version while the document number is unchanged.
38+
int version = 1;
39+
for (${snapshotEntity}Entity existing : repository.findAll(Criteria.create()
40+
.eq("${snapshotMasterFk}", id))) {
41+
if (existing.Version != null && existing.Version >= version) {
42+
version = existing.Version + 1;
43+
}
44+
}
45+
46+
String fileName = "${master} " + id + " v" + version + ".pdf";
47+
org.eclipse.dirigible.sdk.cms.Attachments.Attachment stored =
48+
org.eclipse.dirigible.sdk.cms.Attachments.store("${snapshotEntity}", fileName, "application/pdf", pdf);
49+
50+
${snapshotEntity}Entity snapshot = new ${snapshotEntity}Entity();
51+
snapshot.FileName = stored.fileName();
52+
snapshot.ContentType = stored.contentType();
53+
snapshot.FileSize = stored.size();
54+
snapshot.StoragePath = stored.path();
55+
snapshot.Uuid = stored.uuid();
56+
snapshot.Version = version;
57+
snapshot.${snapshotMasterFk} = id;
58+
repository.save(snapshot);
59+
}
60+
}

components/template/template-application-events-java/src/main/resources/META-INF/dirigible/template-application-events-java/template/template.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,13 @@ export function getTemplate(parameters) {
128128
engine: "velocity",
129129
collection: "printFeeders"
130130
},
131+
{
132+
location: "/template-application-events-java/events/Snapshot.java.template",
133+
action: "generate",
134+
rename: "gen/events/{{master}}SnapshotGenerator.java",
135+
engine: "velocity",
136+
collection: "snapshots"
137+
},
131138
{
132139
location: "/template-application-events-java/events/SettlementOnPayment.java.template",
133140
action: "generate",

components/ui/service-generate/src/main/resources/META-INF/dirigible/service-generate/template/generateUtils.js

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1106,6 +1106,34 @@ export function generateFiles(model, parameters, templateSources) {
11061106
}
11071107
}
11081108
break;
1109+
case "snapshots":
1110+
// Snapshot generators (intent layer): one JavaDelegate per function: Snapshot child of
1111+
// a document master, wired as a delegate: service task. It reuses the master's
1112+
// PrintFeeder (same gen.events package) to assemble the payload, renders via
1113+
// sdk.print.Print and stores via sdk.cms.Attachments. Not entity-shaped, so its own
1114+
// loop; the snapshot repo lives in this project (javaGenFolderName), its Java package
1115+
// segment is the sanitized perspective.
1116+
if (model.snapshots) {
1117+
for (let s = 0; s < model.snapshots.length; s++) {
1118+
const snapshot = model.snapshots[s];
1119+
const snapshotParameters = {
1120+
...parameters,
1121+
master: snapshot.master,
1122+
masterPk: snapshot.masterPk,
1123+
language: snapshot.language,
1124+
snapshotEntity: snapshot.snapshotEntity,
1125+
snapshotJavaPerspective: sanitizeJavaIdentifier(snapshot.snapshotPerspective),
1126+
snapshotMasterFk: snapshot.snapshotMasterFk
1127+
};
1128+
const cleanSnapshotParameters = cleanData(snapshotParameters);
1129+
generatedFiles.push({
1130+
location: location,
1131+
content: getGenerationEngine(template).generate(location, content, cleanSnapshotParameters),
1132+
path: templateEngines.getMustacheEngine().generate(location, template.rename, cleanSnapshotParameters)
1133+
});
1134+
}
1135+
}
1136+
break;
11091137
default:
11101138
// No collection
11111139
parameters.models = model.entities;

0 commit comments

Comments
 (0)