11 changed files with 611 additions and 12 deletions
@ -0,0 +1,6 @@ |
|||||||
|
/* © SRSoftware 2025 */ |
||||||
|
package de.srsoftware.umbrella.documents; |
||||||
|
|
||||||
|
public interface PriceFormat { |
||||||
|
String format(long val); |
||||||
|
} |
||||||
@ -0,0 +1,188 @@ |
|||||||
|
/* © SRSoftware 2025 */ |
||||||
|
package de.srsoftware.umbrella.documents; |
||||||
|
|
||||||
|
import de.srsoftware.configuration.JsonConfig; |
||||||
|
import de.srsoftware.document.api.*; |
||||||
|
import de.srsoftware.umbrella.core.model.UmbrellaUser; |
||||||
|
import org.json.JSONObject; |
||||||
|
|
||||||
|
import java.util.*; |
||||||
|
import java.util.regex.Pattern; |
||||||
|
|
||||||
|
import static de.srsoftware.tools.MimeType.*; |
||||||
|
import static de.srsoftware.umbrella.core.Constants.ERROR_MISSING_FIELD; |
||||||
|
import static de.srsoftware.umbrella.core.Constants.USER; |
||||||
|
import static de.srsoftware.umbrella.documents.Constants.FIELD_PRICE_FORMAT; |
||||||
|
import static java.lang.System.Logger.Level.TRACE; |
||||||
|
import static java.nio.charset.StandardCharsets.UTF_8; |
||||||
|
import static java.text.MessageFormat.format; |
||||||
|
|
||||||
|
public abstract class TemplateDoc implements Document { |
||||||
|
private static final Pattern TOKEN_PATTERN = Pattern.compile("<\\? (([^?]|\\?[^>])+) \\?>"); |
||||||
|
private static final Pattern POSITION_PATTERN = Pattern.compile("<!-- positions -->(.*)<!-- positions -->",Pattern.DOTALL); |
||||||
|
private static final Pattern TAXES_PATTERN = Pattern.compile("<!-- tax list -->(.*)<!-- tax list -->",Pattern.DOTALL); |
||||||
|
private String id, mime; |
||||||
|
|
||||||
|
@Override |
||||||
|
public String description() { |
||||||
|
return format("File created by TemplateProcessor from \"{0}\"",source().name()); |
||||||
|
} |
||||||
|
|
||||||
|
@Override |
||||||
|
public String id() { |
||||||
|
if (id == null) { |
||||||
|
id = source().name(); |
||||||
|
if (id.endsWith(".template")) id = id.substring(0,id.length()-9); |
||||||
|
} |
||||||
|
return id; |
||||||
|
} |
||||||
|
|
||||||
|
@Override |
||||||
|
public String name() { |
||||||
|
return "processed "+id(); |
||||||
|
} |
||||||
|
|
||||||
|
@Override |
||||||
|
public String mimeType() { |
||||||
|
if (mime == null) { |
||||||
|
mime = MIME_UNKNOWN; |
||||||
|
if (id().endsWith("html")) mime = MIME_HTML; |
||||||
|
if (id().endsWith("tex")) mime = MIME_LATEX; |
||||||
|
} |
||||||
|
return mime; |
||||||
|
} |
||||||
|
|
||||||
|
@Override |
||||||
|
public RenderResult render(Map<String, Object> data) { |
||||||
|
data = new HashMap<>(data); |
||||||
|
if (!(data.get(FIELD_PRICE_FORMAT) instanceof PriceFormat price)) return RenderError.of(ERROR_MISSING_FIELD,FIELD_PRICE_FORMAT); |
||||||
|
if (data.get(USER) instanceof UmbrellaUser user) data.put(USER,user.toMap()); |
||||||
|
var precursor = source().render(data); |
||||||
|
if (precursor instanceof Content content){ |
||||||
|
var source = new String(content.bytes(), UTF_8); |
||||||
|
|
||||||
|
var config = new JsonConfig(new JSONObject(data)); |
||||||
|
source = renderPositions(source,config.subset("document.positions"),price); |
||||||
|
source = renderTaxes(source,config.subset("document.taxes"),price); |
||||||
|
var tokens = findTokensIn(source); |
||||||
|
for (var token : tokens){ |
||||||
|
var val = config.get(token).map(Object::toString); |
||||||
|
if (val.isPresent()) { |
||||||
|
var value = val.get().trim(); |
||||||
|
value = switch (token){ |
||||||
|
case "document.net_sum","document.gross_sum" -> price.format(Long.parseLong(value)); |
||||||
|
default -> value; |
||||||
|
}; |
||||||
|
if (MIME_HTML.equals(mimeType())) value = value.replace("\n","<span class=\"break\"></span>\n"); |
||||||
|
source = source.replace("<? "+token+" ?>",value); |
||||||
|
} |
||||||
|
} |
||||||
|
tokens = findTokensIn(source); |
||||||
|
for (var token : tokens){ |
||||||
|
var value = config.get("translations."+token).map(Object::toString).orElse(token).trim(); |
||||||
|
if (MIME_HTML.equals(mimeType())) value = value.replace("\n","<span class=\"break\"></span>\n"); |
||||||
|
source = source.replace("<? "+token+" ?>",value); |
||||||
|
} |
||||||
|
return new StringContent(source); |
||||||
|
} |
||||||
|
return precursor; |
||||||
|
} |
||||||
|
|
||||||
|
private String renderPositions(String source, Optional<JsonConfig> opt, PriceFormat price) { |
||||||
|
if (opt.isPresent()){ |
||||||
|
var positions = opt.get(); |
||||||
|
System.getLogger(id()).log(TRACE,"Positions: {0}",positions); |
||||||
|
|
||||||
|
var matcher = POSITION_PATTERN.matcher(source); |
||||||
|
if (matcher.find()){ |
||||||
|
var posCode = matcher.group(1).trim(); |
||||||
|
var start = matcher.start(1); |
||||||
|
var end = matcher.end(1); |
||||||
|
var tokens = findTokensIn(posCode); |
||||||
|
StringBuilder sb = new StringBuilder(); |
||||||
|
sb.append(source, 0, start); |
||||||
|
for (var pos : positions.keys().stream().sorted(this::byIntValue).toList()){ |
||||||
|
var copy = posCode; |
||||||
|
for (var token : tokens){ |
||||||
|
var key = pos+"."+token; |
||||||
|
var val = positions.get(key).orElse(token); |
||||||
|
String value = switch (token){ |
||||||
|
case "amount" -> ""+(Math.round(1000d*(double)val)/1000d); |
||||||
|
case "net_price", "unit_price" -> price.format((long)val); |
||||||
|
case "optional" -> val == Boolean.TRUE ? "optional" : ""; |
||||||
|
case null -> ""; |
||||||
|
default -> val+""; |
||||||
|
}; |
||||||
|
copy = copy.replace("<? "+token+" ?>",value); |
||||||
|
} |
||||||
|
sb.append(copy).append("\n"); |
||||||
|
} |
||||||
|
sb.append(source.substring(end)); |
||||||
|
return sb.toString(); |
||||||
|
} |
||||||
|
} |
||||||
|
return source; |
||||||
|
} |
||||||
|
|
||||||
|
private String renderTaxes(String source, Optional<JsonConfig> opt, PriceFormat price) { |
||||||
|
if (opt.isPresent()){ |
||||||
|
var taxes = opt.get(); |
||||||
|
System.getLogger(id()).log(TRACE,"Taxes: {0}",taxes); |
||||||
|
|
||||||
|
var matcher = TAXES_PATTERN.matcher(source); |
||||||
|
if (matcher.find()){ |
||||||
|
var taxCode = matcher.group(1).trim(); |
||||||
|
var start = matcher.start(1); |
||||||
|
var end = matcher.end(1); |
||||||
|
var tokens = findTokensIn(taxCode); |
||||||
|
StringBuilder sb = new StringBuilder(); |
||||||
|
sb.append(source, 0, start); |
||||||
|
|
||||||
|
for (var rate : taxes.keys()){ |
||||||
|
var percent = Integer.parseInt(rate); |
||||||
|
Optional<Long> o = taxes.get(rate); |
||||||
|
long netSum = o.orElse(0L); |
||||||
|
long tax = Math.round(percent * netSum / 100d); |
||||||
|
long grossSum = netSum + tax; |
||||||
|
|
||||||
|
var copy = taxCode; |
||||||
|
|
||||||
|
for (var token : tokens){ |
||||||
|
var value = switch (token){ |
||||||
|
case "net_sum" -> price.format(netSum); |
||||||
|
case "rate" -> rate+" %"; |
||||||
|
case "amount" -> price.format(tax); |
||||||
|
case "gross_sum" -> price.format(grossSum); |
||||||
|
default -> null; |
||||||
|
}; |
||||||
|
if (value != null) copy = copy.replace("<? "+token+" ?>",value); |
||||||
|
} |
||||||
|
sb.append(copy).append("\n"); |
||||||
|
} |
||||||
|
|
||||||
|
sb.append(source.substring(end)); |
||||||
|
return sb.toString(); |
||||||
|
} |
||||||
|
} |
||||||
|
return source; |
||||||
|
} |
||||||
|
|
||||||
|
private int byIntValue(String a, String b) { |
||||||
|
try { |
||||||
|
var i1 = Integer.parseInt(a); |
||||||
|
var i2 = Integer.parseInt(b); |
||||||
|
return i1-i2; |
||||||
|
} catch (NumberFormatException ignored){ |
||||||
|
return a.compareTo(b); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
private static Set<String> findTokensIn(String content) { |
||||||
|
var matcher = TOKEN_PATTERN.matcher(content); |
||||||
|
var token = new HashSet<String>(); |
||||||
|
while (matcher.find()) token.add(matcher.group(1)); |
||||||
|
return token; |
||||||
|
} |
||||||
|
|
||||||
|
protected abstract Document source(); |
||||||
|
} |
||||||
@ -0,0 +1,37 @@ |
|||||||
|
/* © SRSoftware 2025 */ |
||||||
|
package de.srsoftware.umbrella.documents; |
||||||
|
|
||||||
|
import de.srsoftware.document.api.Document; |
||||||
|
import de.srsoftware.document.api.DocumentFactory; |
||||||
|
import de.srsoftware.document.api.DocumentRegistry; |
||||||
|
|
||||||
|
import java.util.stream.Stream; |
||||||
|
|
||||||
|
public class TemplateProcessor implements DocumentFactory { |
||||||
|
|
||||||
|
private DocumentRegistry registry; |
||||||
|
|
||||||
|
private Document createGenerator(final Document source) { |
||||||
|
return new TemplateDoc() { |
||||||
|
protected Document source() { |
||||||
|
return source; |
||||||
|
} |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
@Override |
||||||
|
public String description() { |
||||||
|
return "Creates documents from template files"; |
||||||
|
} |
||||||
|
|
||||||
|
@Override |
||||||
|
public Stream<Document> documents() { |
||||||
|
return this.registry.documents().filter(document -> document.name().endsWith(".template")).map(this::createGenerator); |
||||||
|
} |
||||||
|
|
||||||
|
@Override |
||||||
|
public DocumentFactory setRegistry(DocumentRegistry registry) { |
||||||
|
this.registry = registry; |
||||||
|
return this; |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,224 @@ |
|||||||
|
<html> |
||||||
|
<head> |
||||||
|
<meta charset="UTF-8"> |
||||||
|
<style> |
||||||
|
body { |
||||||
|
font-family: Arial, Helvetica, sans-serif; |
||||||
|
font-size: 14px; |
||||||
|
} |
||||||
|
header{ |
||||||
|
width: 100%; |
||||||
|
height: 4cm; |
||||||
|
position: relative; |
||||||
|
} |
||||||
|
header .receiver{ |
||||||
|
position: absolute; |
||||||
|
left: 0; |
||||||
|
bottom: 0; |
||||||
|
} |
||||||
|
header .sender{ |
||||||
|
position: absolute; |
||||||
|
right: 0; |
||||||
|
text-align: right; |
||||||
|
} |
||||||
|
header .company{ |
||||||
|
font-size: 20px; |
||||||
|
font-weight: bold; |
||||||
|
} |
||||||
|
header .address .small{ |
||||||
|
font-size: 10px; |
||||||
|
font-weight: bold; |
||||||
|
padding-bottom: 5px; |
||||||
|
} |
||||||
|
header .address .small .break{ |
||||||
|
display: inline; |
||||||
|
} |
||||||
|
header .address .small .break:after{ |
||||||
|
content: ' / '; |
||||||
|
} |
||||||
|
span.break{ |
||||||
|
display: block; |
||||||
|
} |
||||||
|
h2 { |
||||||
|
position: relative; |
||||||
|
text-align: center; |
||||||
|
font-size: 14px; |
||||||
|
font-weight: bold; |
||||||
|
} |
||||||
|
.bottom .company, |
||||||
|
h2 .left { |
||||||
|
position: absolute; |
||||||
|
left: 0; |
||||||
|
} |
||||||
|
.bottom .bank_account, |
||||||
|
h2 .right { |
||||||
|
position: absolute; |
||||||
|
right: 0; |
||||||
|
} |
||||||
|
.right{ |
||||||
|
text-align: right; |
||||||
|
} |
||||||
|
.center{ |
||||||
|
text-align: center; |
||||||
|
} |
||||||
|
tr { |
||||||
|
vertical-align: baseline; |
||||||
|
} |
||||||
|
.foot, |
||||||
|
.head{ |
||||||
|
margin: 15px 0; |
||||||
|
} |
||||||
|
td.description{ |
||||||
|
font-size: 12px; |
||||||
|
color: #333; |
||||||
|
} |
||||||
|
header .logo{ |
||||||
|
width: 150px; |
||||||
|
position: absolute; |
||||||
|
left: 37%; |
||||||
|
top: -60px; |
||||||
|
} |
||||||
|
.bottom{ |
||||||
|
width: 100%; |
||||||
|
position: absolute; |
||||||
|
bottom: 0; |
||||||
|
text-align: center; |
||||||
|
} |
||||||
|
.bottom div{ |
||||||
|
display: inline; |
||||||
|
text-align: left; |
||||||
|
top: 0; |
||||||
|
} |
||||||
|
th { |
||||||
|
background: paleGreen; |
||||||
|
} |
||||||
|
table:not(.bottom) tr:nth-child(4n+1), |
||||||
|
tr:nth-child(4n){ |
||||||
|
background: #eaffea; |
||||||
|
} |
||||||
|
.ad{ |
||||||
|
font-size: 10px; |
||||||
|
margin-top: 20px; |
||||||
|
} |
||||||
|
table{ |
||||||
|
width: 100%; |
||||||
|
} |
||||||
|
table.bottom td:nth-child(2n+1){ |
||||||
|
text-align: left; |
||||||
|
} |
||||||
|
</style> |
||||||
|
</head> |
||||||
|
<body> |
||||||
|
<header> |
||||||
|
<svg width="42mm" height="37mm" version="1.1" viewBox="0 0 840 727" xmlns="http://www.w3.org/2000/svg" class="logo"> |
||||||
|
<path d="m420 20c-200 0-400 120-400 300" fill="none" stroke="#1E8449" stroke-width="40"/> |
||||||
|
<path d="m420 20c200 0 400 120 400 300" fill="none" stroke="#1E8449" stroke-width="40"/> |
||||||
|
<path d="m380 160c-100-160-300 60-100 160" fill="none" stroke="#1E8449" stroke-width="40"/> |
||||||
|
<path d="m276 318c194 102 94 332-104 238" fill="none" stroke="#1E8449" stroke-width="40"/> |
||||||
|
<path d="m20 320c0 100 50 180 160 240" fill="none" stroke="#1E8449" stroke-width="40"/> |
||||||
|
<line x1="440" x2="440" y1="80" y2="442" fill="none" stroke="#1E8449" stroke-width="40"/> |
||||||
|
<path d="m440 320c180 0 152 92 232 192" fill="none" stroke="#1E8449" stroke-width="40"/> |
||||||
|
<path d="m440 100c200 0 280 220 0 220" fill="none" stroke="#1E8449" stroke-width="40"/> |
||||||
|
<path d="m626 578c-80 30-186 62-186-138" fill="none" stroke="#1E8449" stroke-width="40"/> |
||||||
|
<path d="m620 580c116-40 200-150 200-260" fill="none" stroke="#1E8449" stroke-width="40"/> |
||||||
|
<text x="100" y="700" fill="#1E8449" font-family="sans-serif" font-size="112.89px"><tspan x="100" y="700">SRSoftware</tspan></text> |
||||||
|
</svg> |
||||||
|
|
||||||
|
<div class="company"><? document.company.name ?></div> |
||||||
|
<div class="receiver address"> |
||||||
|
<div class="small"><? document.sender.name ?></div> |
||||||
|
<? document.customer.name ?> |
||||||
|
</div> |
||||||
|
<div class="sender data"> |
||||||
|
<? document.sender.name ?><br/> |
||||||
|
<br/> |
||||||
|
<? Delivery date ?>: <? document.delivery ?> |
||||||
|
</div> |
||||||
|
</header> |
||||||
|
<h1><? document.type ?></h1> |
||||||
|
<h2> |
||||||
|
<span class="left"><? document.type ?> <? document.number ?></span> |
||||||
|
<span class="center"><? customer number ?>: <? document.customer.id ?></span> |
||||||
|
<span class="right"><? document.date ?></span> |
||||||
|
</h2> |
||||||
|
<hr/> |
||||||
|
<div class="head"><? document.head ?></div> |
||||||
|
<table> |
||||||
|
<tr> |
||||||
|
<th><? Position ?></th> |
||||||
|
<th><? Description ?></th> |
||||||
|
<th><? Price per unit ?></th> |
||||||
|
<th><? Amount ?></th> |
||||||
|
<th><? Price ?></th> |
||||||
|
<th><? Tax rate ?></th> |
||||||
|
</tr> |
||||||
|
|
||||||
|
<!-- positions --> |
||||||
|
|
||||||
|
<tr> |
||||||
|
<td class="center"><? number ?></td> |
||||||
|
<td><b><? title ?></b></td> |
||||||
|
<td class="right"><? unit_price ?></td> |
||||||
|
<td class="right"><? amount ?> <? unit ?></td> |
||||||
|
<td class="right"><? net_price ?></td> |
||||||
|
<td class="right"><? tax ?> %</td> |
||||||
|
</tr> |
||||||
|
<tr> |
||||||
|
<td><? optional ?></td> |
||||||
|
<td colspan="5" class="description"><? description ?></td> |
||||||
|
</tr> |
||||||
|
|
||||||
|
<!-- positions --> |
||||||
|
|
||||||
|
<tr> |
||||||
|
<th class="right" colspan="5"><? Net sum ?>:</th> |
||||||
|
<th class="right"><? document.net_sum ?></th> |
||||||
|
</tr> |
||||||
|
|
||||||
|
<!-- tax list --> |
||||||
|
|
||||||
|
<tr class="right"> |
||||||
|
<td></td> |
||||||
|
<td><? net_sum ?> + <? rate ?> =</td> |
||||||
|
<td><? gross_sum ?></td> |
||||||
|
<td colspan="2"><? contained tax: ?></td> |
||||||
|
<td><? amount ?></td> |
||||||
|
</tr> |
||||||
|
|
||||||
|
<!-- tax list --> |
||||||
|
<tr> |
||||||
|
<th class="right" colspan="5"><? Gross sum ?>:</th> |
||||||
|
<th class="right"><? document.gross_sum ?></th> |
||||||
|
</tr> |
||||||
|
</table> |
||||||
|
<div class="foot"><? document.footer ?></div> |
||||||
|
<table class="bottom"> |
||||||
|
<tr> |
||||||
|
<td><? document.sender.name ?></td> |
||||||
|
<td> |
||||||
|
<? Tax id ?>: <? document.sender.tax_id ?><br/> |
||||||
|
<? local court ?>: <? document.sender.court ?><br/><br/> |
||||||
|
<div class="ad"><? document.type ?> <? created with ?> <a href="https://umbrella.srsoftware.de">Umbrella</a> <? by ?> <a href="https://srsoftware.de">SRSoftware</a></div> |
||||||
|
</td> |
||||||
|
<td> |
||||||
|
<? Bank account ?>:<br/> |
||||||
|
<? document.sender.bank_account ?> |
||||||
|
</td> |
||||||
|
</tr> |
||||||
|
</table> |
||||||
|
<!-- <div class="bottom"> |
||||||
|
<div class="company"> |
||||||
|
<? document.sender.name ?> |
||||||
|
</div> |
||||||
|
<div class="tax_data"> |
||||||
|
<? Tax id ?>: <? document.sender.tax_id ?><br/> |
||||||
|
<? local court ?>: <? document.sender.court ?><br/><br/> |
||||||
|
<div class="ad"><? document.type ?> <? created with ?> <a href="https://umbrella.srsoftware.de">Umbrella</a> <? by ?> <a href="https://srsoftware.de">SRSoftware</a></div> |
||||||
|
</div> |
||||||
|
<div class="bank_account"> |
||||||
|
<? Bank account ?>:<br/> |
||||||
|
<? document.sender.bank_account ?> |
||||||
|
</div> |
||||||
|
</div> --> |
||||||
|
</body> |
||||||
|
</html> |
||||||
Loading…
Reference in new issue