diff --git a/core/src/main/java/de/srsoftware/umbrella/core/api/TimeService.java b/core/src/main/java/de/srsoftware/umbrella/core/api/TimeService.java index a3f044b..e7a684d 100644 --- a/core/src/main/java/de/srsoftware/umbrella/core/api/TimeService.java +++ b/core/src/main/java/de/srsoftware/umbrella/core/api/TimeService.java @@ -1,5 +1,9 @@ /* © SRSoftware 2025 */ package de.srsoftware.umbrella.core.api; +import de.srsoftware.umbrella.core.model.Time; +import java.util.Collection; + public interface TimeService { + void updateStates(Collection timeIds, Time.State timeState); } diff --git a/core/src/main/java/de/srsoftware/umbrella/core/model/Time.java b/core/src/main/java/de/srsoftware/umbrella/core/model/Time.java index deff899..43ec67f 100644 --- a/core/src/main/java/de/srsoftware/umbrella/core/model/Time.java +++ b/core/src/main/java/de/srsoftware/umbrella/core/model/Time.java @@ -132,6 +132,9 @@ public class Time implements Mappable{ var o = json.get(STATE); try { switch (o) { + case JSONObject nested: + state = State.of(nested.getInt(CODE)); + break; case String stateName: state = State.valueOf(stateName); break; diff --git a/documents/src/main/java/de/srsoftware/umbrella/documents/DocumentApi.java b/documents/src/main/java/de/srsoftware/umbrella/documents/DocumentApi.java index 6a4f7be..52106da 100644 --- a/documents/src/main/java/de/srsoftware/umbrella/documents/DocumentApi.java +++ b/documents/src/main/java/de/srsoftware/umbrella/documents/DocumentApi.java @@ -28,6 +28,7 @@ import static de.srsoftware.umbrella.documents.Constants.*; import static java.lang.System.Logger.Level.DEBUG; import static java.lang.System.Logger.Level.WARNING; import static java.net.HttpURLConnection.*; +import static java.util.function.Predicate.not; import static java.util.stream.Collectors.toMap; import com.sun.net.httpserver.HttpExchange; @@ -41,10 +42,7 @@ import de.srsoftware.document.processor.weasyprint.WeasyFactory; import de.srsoftware.document.zugferd.ZugferdFactory; import de.srsoftware.document.zugferd.data.*; import de.srsoftware.document.zugferd.data.Currency; -import de.srsoftware.tools.Pair; -import de.srsoftware.tools.Path; -import de.srsoftware.tools.SessionToken; -import de.srsoftware.tools.Tuple; +import de.srsoftware.tools.*; import de.srsoftware.umbrella.core.BaseHandler; import de.srsoftware.umbrella.core.ModuleRegistry; import de.srsoftware.umbrella.core.Paths; @@ -85,25 +83,75 @@ public class DocumentApi extends BaseHandler implements DocumentService { this.registry.documents().forEach(d -> LOG.log(DEBUG,"found template: {0}",d)); } - @Override - public boolean doDelete(Path path, HttpExchange ex) throws IOException { - addCors(ex); + private DocumentData convert(Document document) throws UmbrellaException { + var currency = switch (document.currency()){ + case "€" -> Currency.EUR; + case "$" -> Currency.USD; + default -> throw unprocessable("Unsupported currency: ",document.currency()); + }; + var typeCode = switch (document.type().name()){ + case "invoice" -> TypeCode.HANDELSRECHNUNG; + default -> throw unprocessable("Unsupported document type: ",document.type().name()); + }; + var countryID = CountryCode.DE; + LOG.log(WARNING,"countryID is hardcoded to be \"DE\", should be field of company!"); + var sender = document.sender(); + + var match = POST_CODE.matcher(sender.name()); + if (!match.find()) throw unprocessable(ERROR_ADDRESS_MISSING, SENDER); + var name = match.group(1).trim(); + var streetAddress = match.group(2).trim(); + var postCode = match.group(3); + var city = match.group(4).trim(); + var taxNumber = sender.taxNumber(); + if (!taxNumber.startsWith("DE")) throw unprocessable("Invalid sender tax number ({0})!",taxNumber); + var author = new Author(name,countryID,postCode,city,streetAddress,taxNumber); + + match = POST_CODE.matcher(document.customer().name()); + if (!match.find()) throw unprocessable(ERROR_ADDRESS_MISSING,CUSTOMER); + name = escapeHtmlEntities(match.group(1).trim()); + streetAddress = escapeHtmlEntities(match.group(2).trim()); + postCode = match.group(3); + city = match.group(4).trim(); + var customer = new de.srsoftware.document.zugferd.data.Customer(name,countryID,postCode,streetAddress,city); + var footer = document.footer(); + var head = document.head(); + + var notes = new ArrayList(); + if (!head.isBlank()) notes.add(head); + if (!footer.isBlank()) notes.add(footer); + LocalDate deliveryDate; try { - Optional token = SessionToken.from(ex).map(Token::of); - var user = userService().loadUser(token); - if (user.isEmpty()) return unauthorized(ex); - var head = path.pop(); - long docId = Long.parseLong(head); - return switch (path.pop()){ - case POSITION -> deletePosition(ex,docId,user.get()); - case null -> deleteDocument(ex,docId,user.get()); - default -> super.doDelete(path,ex); + deliveryDate = LocalDate.parse(document.delivery()); + } catch (RuntimeException ex) { + throw unprocessable("\"{0}\" is not a valid delivery date (cannot be parsed)!",document.delivery()); + } + var lineItems = new ArrayList(); + for (var entry : document.positions().entrySet()){ + var number = entry.getKey(); + var pos = entry.getValue(); + + UnitCode unit = switch (pos.unit()){ + case "d" -> per_day; + case "h", "Stunden" -> hours; + case "jährlich" -> per_year; + case "pauschal" -> fixed; + case "Stück" -> pieces; + default -> throw unprocessable("No unit code defined for {0}",pos.unit()); }; - } catch (NumberFormatException ignored) { - return super.doDelete(path,ex); - } catch (UmbrellaException e) { - return send(ex,e); + var percent = pos.tax(); + var taxType = percent == 0 ? TaxType.Z : TaxType.S; + LOG.log(WARNING,"tax type is hardcoded to be \"{0}\", should be field of document / document position!",taxType.name()); + + var taxSet = new LineItemTaxSet(percent*100L,taxType); + // bei Gutschriften soll die Menge negativ sein, nicht aber der Einheitspreis. Deshalb wird das ggf. invertiert. + var neg = pos.unitPrice() < 0; + var unitPrice = (neg ? -1 : 1) * pos.unitPrice(); + var amount = (neg ? -1 : 1) * pos.amount(); + lineItems.add(new LineItem(number,pos.itemCode(),pos.title(),pos.description(),null,unitPrice,unit,amount,taxSet)); } + String terms = footer; + return new DocumentData(document.number(),currency,typeCode,document.date(),author,customer,notes,deliveryDate,null,terms,lineItems); } private boolean deleteDocument(HttpExchange ex, long docId, UmbrellaUser user) throws IOException, UmbrellaException { @@ -125,6 +173,32 @@ public class DocumentApi extends BaseHandler implements DocumentService { return send(ex,db.loadDoc(docId).positions()); } + @Override + public Map> docsReferencedByTimes(Set timeIds) throws UmbrellaException { + return db.docReferencedByTimes(timeIds); + } + + @Override + public boolean doDelete(Path path, HttpExchange ex) throws IOException { + addCors(ex); + try { + Optional token = SessionToken.from(ex).map(Token::of); + var user = userService().loadUser(token); + if (user.isEmpty()) return unauthorized(ex); + var head = path.pop(); + long docId = Long.parseLong(head); + return switch (path.pop()){ + case POSITION -> deletePosition(ex,docId,user.get()); + case null -> deleteDocument(ex,docId,user.get()); + default -> super.doDelete(path,ex); + }; + } catch (NumberFormatException ignored) { + return super.doDelete(path,ex); + } catch (UmbrellaException e) { + return send(ex,e); + } + } + @Override public boolean doGet(Path path, HttpExchange ex) throws IOException { addCors(ex); @@ -204,14 +278,17 @@ public class DocumentApi extends BaseHandler implements DocumentService { return sendContent(ex,map); } - - private boolean getDocTypes(HttpExchange ex) throws UmbrellaException, IOException { var types = db.listTypes(); var map = types.values().stream().collect(toMap(Type::id, Type::name)); return sendContent(ex,map); } + private boolean getDocument(HttpExchange ex, long docId, UmbrellaUser user) throws IOException, UmbrellaException { + var doc = getDocumentWithCompanyData(docId,user); + return sendContent(ex,doc.renderToMap()); + } + private Tuple getDocument(long docId, UmbrellaUser user) throws UmbrellaException { var doc = db.loadDoc(docId); var companyId = doc.companyId(); @@ -220,6 +297,14 @@ public class DocumentApi extends BaseHandler implements DocumentService { return Tuple.of(doc,company); } + private boolean getDocumentSettings(HttpExchange ex, long docId, UmbrellaUser user) throws IOException, UmbrellaException { + var tuple = getDocument(docId,user); + var doc = tuple.a; + var company = tuple.b; + var settings = db.getCustomerSettings(company.id(),doc.type(),doc.customer().id()); + return sendContent(ex,settings); + } + private Document getDocumentWithCompanyData(long docId, UmbrellaUser user) throws UmbrellaException { var tuple = getDocument(docId,user); var company = tuple.b; @@ -232,88 +317,49 @@ public class DocumentApi extends BaseHandler implements DocumentService { return doc; } - private boolean getDocument(HttpExchange ex, long docId, UmbrellaUser user) throws IOException, UmbrellaException { - var doc = getDocumentWithCompanyData(docId,user); - return sendContent(ex,doc.renderToMap()); + private boolean getRenderedDocument(HttpExchange ex, long docId, UmbrellaUser user) throws IOException, UmbrellaException { + var document = getDocumentWithCompanyData(docId,user); + var content = renderDocument(document,user); + var headers = ex.getResponseHeaders(); + headers.add(CONTENT_TYPE, MIME_PDF); + headers.add(CONTENT_DISPOSITION,"attachment; filename=\""+document.number()+".pdf\""); + return sendContent(ex,content.bytes()); } - private boolean getDocumentSettings(HttpExchange ex, long docId, UmbrellaUser user) throws IOException, UmbrellaException { - var tuple = getDocument(docId,user); - var doc = tuple.a; - var company = tuple.b; - var settings = db.getCustomerSettings(company.id(),doc.type(),doc.customer().id()); - return sendContent(ex,settings); + public Map list(long companyId) throws UmbrellaException{ + return db.listDocs(companyId); } - private DocumentData convert(Document document) throws UmbrellaException { - var currency = switch (document.currency()){ - case "€" -> Currency.EUR; - case "$" -> Currency.USD; - default -> throw unprocessable("Unsupported currency: ",document.currency()); - }; - var typeCode = switch (document.type().name()){ - case "invoice" -> TypeCode.HANDELSRECHNUNG; - default -> throw unprocessable("Unsupported document type: ",document.type().name()); - }; - var countryID = CountryCode.DE; - LOG.log(WARNING,"countryID is hardcoded to be \"DE\", should be field of company!"); - var sender = document.sender(); - - var match = POST_CODE.matcher(sender.name()); - if (!match.find()) throw unprocessable(ERROR_ADDRESS_MISSING, SENDER); - var name = match.group(1).trim(); - var streetAddress = match.group(2).trim(); - var postCode = match.group(3); - var city = match.group(4).trim(); - var taxNumber = sender.taxNumber(); - if (!taxNumber.startsWith("DE")) throw unprocessable("Invalid sender tax number ({0})!",taxNumber); - var author = new Author(name,countryID,postCode,city,streetAddress,taxNumber); - - match = POST_CODE.matcher(document.customer().name()); - if (!match.find()) throw unprocessable(ERROR_ADDRESS_MISSING,CUSTOMER); - name = escapeHtmlEntities(match.group(1).trim()); - streetAddress = escapeHtmlEntities(match.group(2).trim()); - postCode = match.group(3); - city = match.group(4).trim(); - var customer = new de.srsoftware.document.zugferd.data.Customer(name,countryID,postCode,streetAddress,city); - var footer = document.footer(); - var head = document.head(); - - var notes = new ArrayList(); - if (!head.isBlank()) notes.add(head); - if (!footer.isBlank()) notes.add(footer); - LocalDate deliveryDate; + private boolean listCompaniesDocuments(HttpExchange ex, UmbrellaUser user) throws UmbrellaException { try { - deliveryDate = LocalDate.parse(document.delivery()); - } catch (RuntimeException ex) { - throw unprocessable("\"{0}\" is not a valid delivery date (cannot be parsed)!",document.delivery()); + var json = json(ex); + if (!json.has(COMPANY)) throw missingFieldException(COMPANY); + long companyId = json.getLong(COMPANY); + var company = companyService().get(companyId); + if (!companyService().membership(companyId,user.id())) throw forbidden("You are mot a member of company {0}",company); + var docs = list(companyId); + var map = new HashMap(); + for (var entry : docs.entrySet()) map.put(entry.getKey(),entry.getValue().summary()); + return sendContent(ex,new JSONObject(map).toString(2)); + } catch (IOException e) { + LOG.log(WARNING,"Failed to parse JSON data from request",e); + throw new UmbrellaException( 500,"Failed to parse JSON data from request").causedBy(e); } - var lineItems = new ArrayList(); - for (var entry : document.positions().entrySet()){ - var number = entry.getKey(); - var pos = entry.getValue(); - - UnitCode unit = switch (pos.unit()){ - case "d" -> per_day; - case "h", "Stunden" -> hours; - case "jährlich" -> per_year; - case "pauschal" -> fixed; - case "Stück" -> pieces; - default -> throw unprocessable("No unit code defined for {0}",pos.unit()); - }; - var percent = pos.tax(); - var taxType = percent == 0 ? TaxType.Z : TaxType.S; - LOG.log(WARNING,"tax type is hardcoded to be \"{0}\", should be field of document / document position!",taxType.name()); + } - var taxSet = new LineItemTaxSet(percent*100L,taxType); - // bei Gutschriften soll die Menge negativ sein, nicht aber der Einheitspreis. Deshalb wird das ggf. invertiert. - var neg = pos.unitPrice() < 0; - var unitPrice = (neg ? -1 : 1) * pos.unitPrice(); - var amount = (neg ? -1 : 1) * pos.amount(); - lineItems.add(new LineItem(number,pos.itemCode(),pos.title(),pos.description(),null,unitPrice,unit,amount,taxSet)); + private boolean patchDocument(long docId, UmbrellaUser user, HttpExchange ex) throws UmbrellaException, IOException { + var doc = getDocument(docId,user).a; + var data = json(ex); + doc.patch(data); + if (doc.isDirty(FOOTER,HEAD)) { + var settings = db.getCustomerSettings(doc.companyId(),doc.type(),doc.customer().id()); + if (settings == null) settings = CustomerSettings.empty(); + db.save(doc.companyId(),doc.type(),doc.customer().id(), settings.patch(data)); } - String terms = footer; - return new DocumentData(document.number(),currency,typeCode,document.date(),author,customer,notes,deliveryDate,null,terms,lineItems); + var stateChanged = doc.isDirty(STATE); + db.save(doc); + if (stateChanged) updateTimes(doc); + return ok(ex); } private Content renderDocument(Document document, UmbrellaUser user) throws UmbrellaException { @@ -355,54 +401,6 @@ public class DocumentApi extends BaseHandler implements DocumentService { } - private boolean getRenderedDocument(HttpExchange ex, long docId, UmbrellaUser user) throws IOException, UmbrellaException { - var document = getDocumentWithCompanyData(docId,user); - var content = renderDocument(document,user); - var headers = ex.getResponseHeaders(); - headers.add(CONTENT_TYPE, MIME_PDF); - headers.add(CONTENT_DISPOSITION,"attachment; filename=\""+document.number()+".pdf\""); - return sendContent(ex,content.bytes()); - } - - public Map list(long companyId) throws UmbrellaException{ - return db.listDocs(companyId); - } - - private boolean listCompaniesDocuments(HttpExchange ex, UmbrellaUser user) throws UmbrellaException { - try { - var json = json(ex); - if (!json.has(COMPANY)) throw missingFieldException(COMPANY); - long companyId = json.getLong(COMPANY); - var company = companyService().get(companyId); - if (!companyService().membership(companyId,user.id())) throw forbidden("You are mot a member of company {0}",company); - var docs = list(companyId); - var map = new HashMap(); - for (var entry : docs.entrySet()) map.put(entry.getKey(),entry.getValue().summary()); - return sendContent(ex,new JSONObject(map).toString(2)); - } catch (IOException e) { - LOG.log(WARNING,"Failed to parse JSON data from request",e); - throw new UmbrellaException( 500,"Failed to parse JSON data from request").causedBy(e); - } - } - - @Override - public Map> docsReferencedByTimes(Set timeIds) throws UmbrellaException { - return db.docReferencedByTimes(timeIds); - } - - private boolean patchDocument(long docId, UmbrellaUser user, HttpExchange ex) throws UmbrellaException, IOException { - var doc = getDocument(docId,user).a; - var data = json(ex); - doc.patch(data); - if (doc.isDirty(FOOTER,HEAD)) { - var settings = db.getCustomerSettings(doc.companyId(),doc.type(),doc.customer().id()); - if (settings == null) settings = CustomerSettings.empty(); - db.save(doc.companyId(),doc.type(),doc.customer().id(), settings.patch(data)); - } - db.save(doc); - return ok(ex); - } - private boolean send(HttpExchange ex,PositionList positions) throws IOException { return sendContent(ex,positions.entrySet().stream().collect(toMap(Map.Entry::getKey,entry -> entry.getValue().renderToMap()))); } @@ -567,6 +565,18 @@ public class DocumentApi extends BaseHandler implements DocumentService { var envelope = new Envelope(message,new User(doc.customer().shortName(),new EmailAddress(email),doc.customer().language())); postBox().send(envelope); db.save(doc.set(SENT)); + updateTimes(doc); return ok(ex); } + + private void updateTimes(Document doc) { + var timeState = switch (doc.state()){ + case DELAYED, SENT -> Time.State.Pending; + case ERROR -> Time.State.Open; + case DECLINED, NEW -> Time.State.Open; + case PAYED -> Time.State.Complete; + }; + var timeIds = doc.positions().values().stream().map(Position::timeId).filter(not(Optionals::is0)).toList(); + timeService().updateStates(timeIds,timeState); + } } diff --git a/time/src/main/java/de/srsoftware/umbrella/time/SqliteDb.java b/time/src/main/java/de/srsoftware/umbrella/time/SqliteDb.java index b6fe38a..786619c 100644 --- a/time/src/main/java/de/srsoftware/umbrella/time/SqliteDb.java +++ b/time/src/main/java/de/srsoftware/umbrella/time/SqliteDb.java @@ -5,6 +5,7 @@ import static de.srsoftware.tools.jdbc.Condition.*; import static de.srsoftware.tools.jdbc.Query.*; import static de.srsoftware.tools.jdbc.Query.SelectQuery.ALL; import static de.srsoftware.umbrella.core.Constants.*; +import static de.srsoftware.umbrella.core.exceptions.UmbrellaException.databaseException; import static de.srsoftware.umbrella.core.model.Time.State.Complete; import static de.srsoftware.umbrella.time.Constants.*; import static java.lang.System.Logger.Level.ERROR; @@ -85,7 +86,7 @@ CREATE TABLE IF NOT EXISTS {0} ( db.setAutoCommit(false); return timeId; } catch (SQLException e) { - throw UmbrellaException.databaseException("Failed to delete time with id = {0}",timeId); + throw databaseException("Failed to delete time with id = {0}",timeId); } } @@ -202,7 +203,21 @@ CREATE TABLE IF NOT EXISTS {0} ( return track; } catch (SQLException e){ LOG.log(ERROR,"Failed to write time to DB",e); - throw UmbrellaException.databaseException("Failed to write time to DB"); + throw databaseException("Failed to write time to DB"); + } + } + + @Override + public void updateStates(Collection timeIds, Time.State timeState) { + try { + update(TABLE_TIMES) + .set(STATE) + .where(ID,in(timeIds.toArray())) + .prepare(db) + .apply(timeState.code()) + .close(); + } catch (SQLException e) { + throw databaseException("Failed to update state of several times"); } } } diff --git a/time/src/main/java/de/srsoftware/umbrella/time/TimeDb.java b/time/src/main/java/de/srsoftware/umbrella/time/TimeDb.java index 43453fe..c24f1e3 100644 --- a/time/src/main/java/de/srsoftware/umbrella/time/TimeDb.java +++ b/time/src/main/java/de/srsoftware/umbrella/time/TimeDb.java @@ -19,4 +19,6 @@ public interface TimeDb { Time load(long timeId); Time save(Time track) throws UmbrellaException; + + void updateStates(Collection timeIds, Time.State timeState); } diff --git a/time/src/main/java/de/srsoftware/umbrella/time/TimeModule.java b/time/src/main/java/de/srsoftware/umbrella/time/TimeModule.java index b9fe38d..4b255bb 100644 --- a/time/src/main/java/de/srsoftware/umbrella/time/TimeModule.java +++ b/time/src/main/java/de/srsoftware/umbrella/time/TimeModule.java @@ -316,4 +316,8 @@ public class TimeModule extends BaseHandler implements TimeService { return sendContent(ex,track); } + @Override + public void updateStates(Collection timeIds, Time.State timeState) { + timeDb.updateStates(timeIds,timeState); + } }